diff --git a/.github/scripts/bump-versions.mjs b/.github/scripts/bump-versions.mjs index da45c92a80..c1c110272c 100644 --- a/.github/scripts/bump-versions.mjs +++ b/.github/scripts/bump-versions.mjs @@ -28,7 +28,10 @@ for (let { name, path, version, private: isPrivate, dependencies } of packages) packageMap[name] = { path, isDirty, version }; } -assert.ok(packageMap['n8n'].isDirty, 'No changes found since the last release'); +assert.ok( + Object.values(packageMap).some(({ isDirty }) => isDirty), + 'No changes found since the last release', +); // Keep the monorepo version up to date with the released version packageMap['monorepo-root'].version = packageMap['n8n'].version; diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index e3ef37b180..b672cb127c 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -35,6 +35,11 @@ jobs: - name: Test run: pnpm test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml + - name: Lint env: CI_LINT_MASTER: true diff --git a/.github/workflows/ci-pull-requests.yml b/.github/workflows/ci-pull-requests.yml index c567d1baea..281aa36e6d 100644 --- a/.github/workflows/ci-pull-requests.yml +++ b/.github/workflows/ci-pull-requests.yml @@ -67,6 +67,11 @@ jobs: - name: Test run: pnpm test + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: packages/cli/coverage/cobertura-coverage.xml,packages/core/coverage/cobertura-coverage.xml,packages/design-system/coverage/cobertura-coverage.xml,packages/editor-ui/coverage/cobertura-coverage.xml,packages/nodes-base/coverage/cobertura-coverage.xml,packages/workflow/coverage/cobertura-coverage.xml + lint: name: Lint changes runs-on: ubuntu-latest @@ -108,7 +113,7 @@ jobs: uses: ./.github/workflows/e2e-reusable.yml if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-e2e') }} with: - branch: ${{ github.event.pull_request.base.ref }} + branch: ${{ github.event.pull_request.head.ref }} user: ${{ github.event.inputs.user || 'PR User' }} spec: ${{ github.event.inputs.spec || 'e2e/0-smoke.cy.ts' }} run-env: base:16.18.1 @@ -117,3 +122,15 @@ jobs: containers: '[1]' secrets: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} + + checklist_job: + runs-on: ubuntu-latest + name: Checklist job + steps: + - name: Checkout + uses: actions/checkout@v1 + - name: Checklist + uses: wyozi/contextual-qa-checklist-action@master + with: + gh-token: ${{ secrets.GITHUB_TOKEN }} + comment-footer: Make sure to check off this list before asking for review. diff --git a/.gitignore b/.gitignore index cc307542c3..b25164b547 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules .tmp tmp dist +coverage npm-debug.log* yarn.lock google-generated-credentials.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 2241325816..63647ccc1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,151 @@ +## [0.221.2](https://github.com/n8n-io/n8n/compare/n8n@0.221.1...n8n@0.221.2) (2023-03-24) + + +### Bug Fixes + +* **core:** Assign properties.success earlier to set executionStatus correctly ([6c7772a](https://github.com/n8n-io/n8n/commit/6c7772a0b3276ffe9e5ab8029e17a0ed743ee5a7)) + + + +## [0.221.1](https://github.com/n8n-io/n8n/compare/n8n@0.221.0...n8n@0.221.1) (2023-03-23) + + +### Bug Fixes + +* **core:** Initialize queue in the webhook server as well ([163859b](https://github.com/n8n-io/n8n/commit/163859b87a19f3795e2689ce8eba2e2ce6766684)) + + + +# [0.221.0](https://github.com/n8n-io/n8n/compare/n8n@0.220.0...n8n@0.221.0) (2023-03-23) + + +### Bug Fixes + +* **core:** Fix calling error workflows in main mode recovery ([#5698](https://github.com/n8n-io/n8n/issues/5698)) ([e0ea97a](https://github.com/n8n-io/n8n/commit/e0ea97af8d7aaa014680f5f9d5702d1cafd49757)) +* **core:** Fix telemetry execution status for manual workflows executions ([#5712](https://github.com/n8n-io/n8n/issues/5712)) ([a28396e](https://github.com/n8n-io/n8n/commit/a28396ee91bfbccc6596812606c237a8e2c34088)) +* **core:** Return saml attributes after connection test ([#5717](https://github.com/n8n-io/n8n/issues/5717)) ([be172cb](https://github.com/n8n-io/n8n/commit/be172cb720c8a44ebd1f0b86dddab321e1e3c9fd)) +* **editor:** Disable tooltip for display modes that don't support mapping ([#5715](https://github.com/n8n-io/n8n/issues/5715)) ([fb8755e](https://github.com/n8n-io/n8n/commit/fb8755ea3c720c98f002a6756c39b8fed11482c0)) +* **editor:** Fix execution list item selection ([#5606](https://github.com/n8n-io/n8n/issues/5606)) ([7a352ef](https://github.com/n8n-io/n8n/commit/7a352efff944c52062412e53ea2c1a034a25f908)) +* **editor:** Fix for large notifications being cut off ([#5705](https://github.com/n8n-io/n8n/issues/5705)) ([c07f838](https://github.com/n8n-io/n8n/commit/c07f838ce60dc33261fe3e1b5e7dd6fe05f1d63b)) +* **editor:** Fix redo in code and expression editor ([#5708](https://github.com/n8n-io/n8n/issues/5708)) ([cd7a55b](https://github.com/n8n-io/n8n/commit/cd7a55ba5aeb83d1e540a65b5c6b2c74fd742461)) +* **editor:** Fix the canvas node distance when automatically injecting manual trigger ([#5716](https://github.com/n8n-io/n8n/issues/5716)) ([cb2ba97](https://github.com/n8n-io/n8n/commit/cb2ba97f3837b572e237da1256b9f2ee376767a9)) +* **HTTP Request Node:** Fix AWS credentials to automatically deconstruct the url ([#5751](https://github.com/n8n-io/n8n/issues/5751)) ([4ac944a](https://github.com/n8n-io/n8n/commit/4ac944af3028b70ae600000300c16de77c1af1d5)) +* **Split In Batches Node:** Roll back changes in v1 and create v2 ([#5747](https://github.com/n8n-io/n8n/issues/5747)) ([cefec77](https://github.com/n8n-io/n8n/commit/cefec7739b6da820d64f9476476e1901d4f386bf)) + + +### Features + +* **core:** Augment data instead of copying it ([#5487](https://github.com/n8n-io/n8n/issues/5487)) ([0876c38](https://github.com/n8n-io/n8n/commit/0876c38aaeb8355141fecbc14e84cdda0b2c737b)) +* **editor:** Move canvas by holding Space or Middle mouse button ([#5719](https://github.com/n8n-io/n8n/issues/5719)) ([19dded1](https://github.com/n8n-io/n8n/commit/19dded18c9a588a30b9ac1fc274dcd967e9b7b6b)) +* **editor:** Recommend and pre-select auth type with overrides ([#5684](https://github.com/n8n-io/n8n/issues/5684)) ([f59b591](https://github.com/n8n-io/n8n/commit/f59b591c93ecd7cbd279668abe6494ef2b88c831)) +* **editor:** SSO login button ([#5615](https://github.com/n8n-io/n8n/issues/5615)) ([6916628](https://github.com/n8n-io/n8n/commit/6916628a9f11e07cbcdf390f747f396fb0ef9e3c)) +* **QuickChart Node:** Add QuickChart node ([#3572](https://github.com/n8n-io/n8n/issues/3572)) ([233f1fa](https://github.com/n8n-io/n8n/commit/233f1fa7ec230e92e868de0247e315aa6a705ead)) + + + +## [0.220.1](https://github.com/n8n-io/n8n/compare/n8n@0.220.0...n8n@0.220.1) (2023-03-22) + + +### Bug Fixes + +* **Split In Batches Node:** Roll back changes in v1 and create v2 ([#5747](https://github.com/n8n-io/n8n/issues/5747)) ([6d1c88e](https://github.com/n8n-io/n8n/commit/6d1c88ea8c2e5dc72c6e6edeeeef52dc1fba4075)) + + + +# [0.220.0](https://github.com/n8n-io/n8n/compare/n8n@0.219.1...n8n@0.220.0) (2023-03-16) + + +### Bug Fixes + +* **core:** Initialize License and LDAP in the correct order ([#5673](https://github.com/n8n-io/n8n/issues/5673)) ([90afa5e](https://github.com/n8n-io/n8n/commit/90afa5e55f96fbe46417f4be8f764795fb5c2225)) +* **editor:** Display correct error message for env access ([#5634](https://github.com/n8n-io/n8n/issues/5634)) ([5f238ea](https://github.com/n8n-io/n8n/commit/5f238ea6413d25704a5865d339401117e81dbbab)) +* **editor:** Fix autocomplete for complex expresions ([#5695](https://github.com/n8n-io/n8n/issues/5695)) ([11bf260](https://github.com/n8n-io/n8n/commit/11bf260bf164c6c9dffe71b875fde139c93f905d)) +* **editor:** Fix owner set-up checkbox wording ([#5697](https://github.com/n8n-io/n8n/issues/5697)) ([58232be](https://github.com/n8n-io/n8n/commit/58232bec618edd403f18527913c489bfa11f570b)) +* **editor:** Properly handle mapping of dragged expression if it contains hyphen ([#5703](https://github.com/n8n-io/n8n/issues/5703)) ([7025efe](https://github.com/n8n-io/n8n/commit/7025efe8654a8a55ff10e2105ddc6ce2dc5a89d6)) +* **Metabase Node:** Fix issue with question results not correctly being returned ([#5665](https://github.com/n8n-io/n8n/issues/5665)) ([d1e3c19](https://github.com/n8n-io/n8n/commit/d1e3c192ba9e2dfd852e570e88f6135d42d2ed45)) + + +### Features + +* **core:** Improve SAML connection test ([#5680](https://github.com/n8n-io/n8n/issues/5680)) ([ef07528](https://github.com/n8n-io/n8n/commit/ef07528cc21f06ee52f93bafb34ac54a244609f9)) +* **editor:** Add basic Datatable and Pagination components ([#5652](https://github.com/n8n-io/n8n/issues/5652)) ([29f2629](https://github.com/n8n-io/n8n/commit/29f2629716e3693372ec9a4572113a3f3721ff5e)) +* **editor:** Add support for schema view in the NDV output ([#5688](https://github.com/n8n-io/n8n/issues/5688)) ([541850f](https://github.com/n8n-io/n8n/commit/541850f95f1c42fc16d9aeee3a3fef68a4b77082)) +* **editor:** Do not show actions panel for single-action nodes ([#5683](https://github.com/n8n-io/n8n/issues/5683)) ([de1db92](https://github.com/n8n-io/n8n/commit/de1db927cbdc5fc8ef7d697cccbd8603f66391ea)) +* **Item Lists Node:** Update actions ([#5648](https://github.com/n8n-io/n8n/issues/5648)) ([332d50c](https://github.com/n8n-io/n8n/commit/332d50c5f1f8ba63b87325799360adecdbaa06bf)) +* **OpenAI Node:** Add support for gpt4 on chat completion ([#5692](https://github.com/n8n-io/n8n/issues/5692)) ([ba73fff](https://github.com/n8n-io/n8n/commit/ba73fff27d2972093746acc3f7016c7420e23459)) +* **Split In Batches Node:** Make it easy to combine processed data ([#5655](https://github.com/n8n-io/n8n/issues/5655)) ([2f7639e](https://github.com/n8n-io/n8n/commit/2f7639e9e4b10f08c5cb1c4916fc6ae031375cf3)) + + + +## [0.215.4](https://github.com/n8n-io/n8n/compare/n8n@0.215.3...n8n@0.215.4) (2023-03-14) + + +### Bug Fixes + +* **core:** Revert `isPending` check on the user entity ([#5571](https://github.com/n8n-io/n8n/issues/5571)) ([6d2c50d](https://github.com/n8n-io/n8n/commit/6d2c50dfed0aeffa2afdb09f5aac80c0e25a6a06)) + + + +## [0.214.5](https://github.com/n8n-io/n8n/compare/n8n@0.214.4...n8n@0.214.5) (2023-03-14) + + +### Bug Fixes + +* **core:** Revert `isPending` check on the user entity ([#5571](https://github.com/n8n-io/n8n/issues/5571)) ([b94af03](https://github.com/n8n-io/n8n/commit/b94af0384243d634683212d5199316067956f628)) + + + +## [0.219.1](https://github.com/n8n-io/n8n/compare/n8n@0.219.0...n8n@0.219.1) (2023-03-10) + + +### Bug Fixes + +* **HTTP Request Node:** Remove streaming response ([#5663](https://github.com/n8n-io/n8n/issues/5663)) ([974d57d](https://github.com/n8n-io/n8n/commit/974d57dfed78489d3f22c8c63e0ea624c637bfe0)) + + + +# [0.219.0](https://github.com/n8n-io/n8n/compare/n8n@0.218.0...n8n@0.219.0) (2023-03-09) + + +### Bug Fixes + +* **core:** Allow serving icons for custom nodes with npm scoped names ([#5626](https://github.com/n8n-io/n8n/issues/5626)) ([45ccdd3](https://github.com/n8n-io/n8n/commit/45ccdd3bb5d5601ccc60438d96aadb40cb87588b)) +* **core:** Rename advancedFilters to advancedExecutionFilters ([#5643](https://github.com/n8n-io/n8n/issues/5643)) ([419969c](https://github.com/n8n-io/n8n/commit/419969c0d761b1ac7870e7821c0398ecfca1f0ce)) +* **editor:** Fix ElButton overrides ([#5605](https://github.com/n8n-io/n8n/issues/5605)) ([2eba050](https://github.com/n8n-io/n8n/commit/2eba05046141bd13145f95c6a1ec1e6fb95b37c2)) +* **editor:** Only fetch new versions at app launch ([#5647](https://github.com/n8n-io/n8n/issues/5647)) ([5b9c521](https://github.com/n8n-io/n8n/commit/5b9c521d04bc34a9f84be966a4646f23c56ca3da)) +* Fetch credentials on workflows view to include in duplicated workflows ([#5532](https://github.com/n8n-io/n8n/issues/5532)) ([493f7a1](https://github.com/n8n-io/n8n/commit/493f7a1c92d77d3c75fc311892e53f43e1fb367f)) +* Fix color discrepancies for executions list items ([#5640](https://github.com/n8n-io/n8n/issues/5640)) ([c81656d](https://github.com/n8n-io/n8n/commit/c81656d149764dc398b93d3eb8626a402eddb0ef)) +* **OpenAI Node:** Fix issue with expressions not working with chat complete ([#5609](https://github.com/n8n-io/n8n/issues/5609)) ([e949db3](https://github.com/n8n-io/n8n/commit/e949db352526033394083476077519598dd8061c)) +* **OpenAI Node:** Simplify code ([#5618](https://github.com/n8n-io/n8n/issues/5618)) ([1c65bff](https://github.com/n8n-io/n8n/commit/1c65bff31d86ea76ff5fca10341e71389d4de7b5)) + + +### Features + +* **Cal Trigger Node:** Update to support v2 webhooks ([#5331](https://github.com/n8n-io/n8n/issues/5331)) ([2889e53](https://github.com/n8n-io/n8n/commit/2889e53b3767f2a61ee7fb3ea9fe1db7c65aaf70)) +* **core:** Add advancedFilters feature flag ([#5638](https://github.com/n8n-io/n8n/issues/5638)) ([0b5ef09](https://github.com/n8n-io/n8n/commit/0b5ef09e7cbd0c80a4b79311976b5c06c2be8747)) +* **core:** Add SAML post and test endpoints ([#5595](https://github.com/n8n-io/n8n/issues/5595)) ([523fa71](https://github.com/n8n-io/n8n/commit/523fa71705c0408c6c60c7cb5135323e3488e8c9)) +* **core:** Add SAML XML validation ([#5600](https://github.com/n8n-io/n8n/issues/5600)) ([ca66ec8](https://github.com/n8n-io/n8n/commit/ca66ec8f4d5ab0e427390b1f1874fb668bc53479)) +* **core:** Limit user changes when saml is enabled ([#5577](https://github.com/n8n-io/n8n/issues/5577)) ([b517959](https://github.com/n8n-io/n8n/commit/b5179597f3ec4ae468b2eb91969fa56322088e6f)) +* **core:** Refactor and add SAML preferences for service provider instance ([#5637](https://github.com/n8n-io/n8n/issues/5637)) ([6f27b44](https://github.com/n8n-io/n8n/commit/6f27b445ca2307b94ffc7d4eeb24e76d63516a19)) +* **editor:** Do not automatically add manual trigger on node plus ([#5644](https://github.com/n8n-io/n8n/issues/5644)) ([ac2f89a](https://github.com/n8n-io/n8n/commit/ac2f89a18a4c25ef1b39bcacc624884de9197fdf)) +* **editor:** Redirect users to canvas if they don't have any workflows ([#5629](https://github.com/n8n-io/n8n/issues/5629)) ([354edf6](https://github.com/n8n-io/n8n/commit/354edf6886fa3cc5a59d317dcab59cc75e62dc2d)) +* **HTTP Request Node:** Move from Binary Buffer to Binary streaming ([#5610](https://github.com/n8n-io/n8n/issues/5610)) ([ce0d9d2](https://github.com/n8n-io/n8n/commit/ce0d9d2bede7d87b97e18c45b63ea31ecf592255)) +* **Mattermost Node:** Add self signed certificate support ([#5630](https://github.com/n8n-io/n8n/issues/5630)) ([01a2160](https://github.com/n8n-io/n8n/commit/01a2160b3b8d36509f4b2871249a3f45358cf692)) +* **Microsoft SQL Node:** Add support for self signed certificates ([#5160](https://github.com/n8n-io/n8n/issues/5160)) ([971d5ae](https://github.com/n8n-io/n8n/commit/971d5ae8f5d2bfe319fc700fee2bcf597ea4c07e)) +* **Mindee Node:** Add support for v4 API ([#5559](https://github.com/n8n-io/n8n/issues/5559)) ([e56fbfe](https://github.com/n8n-io/n8n/commit/e56fbfef3ebb50706f24ab07505a0031f361d9b1)) +* **Slack Node:** Move from Binary Buffer to Binary streaming ([#5612](https://github.com/n8n-io/n8n/issues/5612)) ([9420b0f](https://github.com/n8n-io/n8n/commit/9420b0fdf8ccccb95780c8c97e2b5d14cc4d513e)) + + + +## [0.217.1](https://github.com/n8n-io/n8n/compare/n8n@0.217.0...n8n@0.217.1) (2023-02-24) + + +### Bug Fixes + +* **core:** Revert `isPending` check on the user entity ([#5571](https://github.com/n8n-io/n8n/issues/5571)) ([5282fd2](https://github.com/n8n-io/n8n/commit/5282fd266c26e7053ceb887addceed26b741f995)) + + + # [0.218.0](https://github.com/n8n-io/n8n/compare/n8n@0.217.2...n8n@0.218.0) (2023-03-02) diff --git a/CHECKLIST.yml b/CHECKLIST.yml new file mode 100644 index 0000000000..7da0dcb08c --- /dev/null +++ b/CHECKLIST.yml @@ -0,0 +1,48 @@ +paths: + "packages/**": + - If fixing bug, added test to cover scenario. + - If addressing forum or Github issue, added link to description. + "packages/**/*.ts": + - Added unit tests to cover new or updated functionality. + "**/*.vue": + - Used composition API for all new components. + - Added component or unit tests to cover functionality. + + # cli + "packages/cli/src/databases/migrations/**": + - Requested review from at least two engineers on migration. + - Avoided irreversible data migrations. + - Avoided deleting or updating data keys. + - Wrote 'down' migration if possible. + "n8n/packages/cli/src/api/**": + - Added integration tests for new endpoints. + + # editor ui + "packages/editor-ui/**/*.vue": + - Added E2E if adding new features. + - Used design system tokens (colors, spacings...) where possible. + "packages/editor-ui/src/mixins/restApi.ts": + - Avoided adding new methods. Only deleted from here. + "packages/editor-ui/src/mixins/**": + - Avoided adding new mixins (use composables instead). Only removed code from here. + "packages/editor-ui/src/views/NodeView.vue": + - Avoided adding code here. Only refactored to make it smaller. + "packages/editor-ui/src/hooks/**": + - Avoided adding new hooks. Only refactored to move hooks to relevant store instead. + + # nodes-base + "packages/nodes-base/nodes/**": + - Added workflow tests for nodes if possible. + + # design-system + "packages/design-system/**/*.vue": + - Used design system tokens (colors, spacings...) where possible. + - Updated Storybook with new component or updated functionality. + + # e2e + "cypress/e2e/**": + - Avoided chaining commands more than two or three times (to avoid flakiness because only last one will be retried). + - Spoofed endpoints that are not critical for the test (to avoid flakiness). + - Picked most efficient path to start the test (for example skipped account setup and starting at /workflow/new for a canvas test). + - Avoided adding waits on time (use request intercepts instead). + - Ensured each spec does not depend on any another spec to pass. \ No newline at end of file diff --git a/cypress.config.js b/cypress.config.js index c38bcbc111..d1451fcd06 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -11,6 +11,8 @@ module.exports = defineConfig({ }, defaultCommandTimeout: 10000, requestTimeout: 12000, + numTestsKeptInMemory: 0, + experimentalMemoryManagement: true, e2e: { baseUrl: BASE_URL, video: true, diff --git a/cypress/e2e/1-workflows.cy.ts b/cypress/e2e/1-workflows.cy.ts index d341a762f2..d10f3ac850 100644 --- a/cypress/e2e/1-workflows.cy.ts +++ b/cypress/e2e/1-workflows.cy.ts @@ -1,13 +1,7 @@ -import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from '../constants'; -import { randFirstName, randLastName } from '@ngneat/falso'; import { WorkflowsPage as WorkflowsPageClass } from '../pages/workflows'; import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { v4 as uuid } from 'uuid'; -const email = DEFAULT_USER_EMAIL; -const password = DEFAULT_USER_PASSWORD; -const firstName = randFirstName(); -const lastName = randLastName(); const WorkflowsPage = new WorkflowsPageClass(); const WorkflowPage = new WorkflowPageClass(); @@ -16,23 +10,17 @@ const multipleWorkflowsCount = 5; describe('Workflows', () => { before(() => { cy.resetAll(); - cy.setup({ email, firstName, lastName, password }); + cy.skipSetup(); }); beforeEach(() => { - cy.on('uncaught:exception', (err, runnable) => { - expect(err.message).to.include('Not logged in'); - - return false; - }); - - cy.signin({ email, password }); - cy.visit('/'); - cy.waitForLoad(); + cy.visit(WorkflowsPage.url); }); - it('should land on empty canvas after registration', () => { - cy.url().should('include', WorkflowPage.url); + it('should create a new workflow using empty state card', () => { + WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible'); + WorkflowsPage.getters.newWorkflowButtonCard().click(); + cy.createFixtureWorkflow('Test_workflow_1.json', `Empty State Card Workflow ${uuid()}`); WorkflowPage.getters.workflowTags().should('contain.text', 'some-tag-1'); @@ -89,13 +77,5 @@ describe('Workflows', () => { }); WorkflowsPage.getters.newWorkflowButtonCard().should('be.visible'); - WorkflowsPage.getters.newWorkflowTemplateCard().should('be.visible'); - }); - - it('should redirect to new canvas if no workflows', () => { - cy.wait(1000); - cy.visit(WorkflowsPage.url); - cy.wait(1000); - cy.url().should('include', WorkflowPage.url); }); }); diff --git a/cypress/e2e/12-canvas-actions.cy.ts b/cypress/e2e/12-canvas-actions.cy.ts new file mode 100644 index 0000000000..518857106f --- /dev/null +++ b/cypress/e2e/12-canvas-actions.cy.ts @@ -0,0 +1,172 @@ +import { + MANUAL_TRIGGER_NODE_NAME, + MANUAL_TRIGGER_NODE_DISPLAY_NAME, + CODE_NODE_NAME, + SCHEDULE_TRIGGER_NODE_NAME, + SET_NODE_NAME, + IF_NODE_NAME, + HTTP_REQUEST_NODE_NAME, +} from './../constants'; +import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; + +const WorkflowPage = new WorkflowPageClass(); +describe('Canvas Actions', () => { + before(() => { + cy.resetAll(); + cy.skipSetup(); + }); + + beforeEach(() => { + WorkflowPage.actions.visit(); + }); + + it('should render canvas', () => { + WorkflowPage.getters.nodeViewRoot().should('be.visible'); + WorkflowPage.getters.canvasPlusButton().should('be.visible'); + WorkflowPage.getters.zoomToFitButton().should('be.visible'); + WorkflowPage.getters.zoomInButton().should('be.visible'); + WorkflowPage.getters.zoomOutButton().should('be.visible'); + WorkflowPage.getters.executeWorkflowButton().should('be.visible'); + }); + + it('should connect and disconnect a simple node', () => { + WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); + WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true }); + cy.get('.jtk-connector').should('have.length', 1); + WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); + + // Change connection from Set to Set1 + cy.draganddrop( + WorkflowPage.getters.getEndpointSelector('input', SET_NODE_NAME), + WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), + ); + + WorkflowPage.getters + .canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`) + .should('have.class', 'jtk-endpoint-connected'); + + cy.get('.jtk-connector').should('have.length', 1); + // Disconnect Set1 + cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100]); + cy.get('.jtk-connector').should('have.length', 0); + }); + + it('should add first step', () => { + WorkflowPage.getters.canvasPlusButton().should('be.visible'); + WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodes().should('have.length', 1); + }); + + it('should add a node via plus endpoint drag', () => { + WorkflowPage.getters.canvasPlusButton().should('be.visible'); + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true); + + cy.drag( + WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME), + [100, 100], + ); + + WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false); + WorkflowPage.getters.nodeViewBackground().click({ force: true }); + }); + + + it('should add a connected node using plus endpoint', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + cy.get('.plus-endpoint').should('be.visible').click(); + WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); + WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); + WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}'); + cy.get('body').type('{esc}'); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + WorkflowPage.getters.nodeConnections().should('have.length', 1); + }); + + it('should add disconnected node if nothing is selected', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + // Deselect nodes + WorkflowPage.getters.nodeViewBackground().click({ force: true }); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + WorkflowPage.getters.nodeConnections().should('have.length', 0); + }); + + it('should add node between two connected nodes', () => { + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); + WorkflowPage.actions.zoomToFit(); + WorkflowPage.actions.addNodeBetweenNodes(CODE_NODE_NAME, SET_NODE_NAME, HTTP_REQUEST_NODE_NAME); + WorkflowPage.getters.canvasNodes().should('have.length', 4); + WorkflowPage.getters.nodeConnections().should('have.length', 3); + // And last node should be pushed to the right + WorkflowPage.getters + .canvasNodes() + .last() + .should('have.attr', 'style', 'left: 860px; top: 260px;'); + }); + + it('should delete connections by pressing the delete button', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.getters.nodeConnections().first().realHover(); + cy.get('.connection-actions .delete').first().click({ force: true }); + WorkflowPage.getters.nodeConnections().should('have.length', 0); + }); + + it('should delete a connection by moving it away from endpoint', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]); + WorkflowPage.getters.nodeConnections().should('have.length', 0); + }); + + it('should execute node', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.getters + .canvasNodes() + .last() + .find('[data-test-id="execute-node-button"]') + .click({ force: true }); + WorkflowPage.getters.successToast().should('contain', 'Node executed successfully'); + }); + + it('should copy selected nodes', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.selectAll(); + WorkflowPage.actions.hitCopy(); + WorkflowPage.getters.successToast().should('contain', 'Copied!'); + }); + + it('should select all nodes', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + WorkflowPage.actions.selectAll(); + WorkflowPage.getters.selectedNodes().should('have.length', 2); + }); + + it('should select nodes using arrow keys', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + cy.wait(500); + cy.get('body').type('{leftArrow}'); + WorkflowPage.getters.canvasNodes().first().should('have.class', 'jtk-drag-selected'); + cy.get('body').type('{rightArrow}'); + WorkflowPage.getters.canvasNodes().last().should('have.class', 'jtk-drag-selected'); + }); + + it('should select nodes using shift and arrow keys', () => { + WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); + WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); + WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); + cy.wait(500); + cy.get('body').type('{shift}', { release: false }).type('{leftArrow}'); + WorkflowPage.getters.selectedNodes().should('have.length', 2); + }); +}); diff --git a/cypress/e2e/12-canvas.cy.ts b/cypress/e2e/12-canvas.cy.ts index 3f78e49590..5f2fe8db07 100644 --- a/cypress/e2e/12-canvas.cy.ts +++ b/cypress/e2e/12-canvas.cy.ts @@ -20,7 +20,7 @@ const ZOOM_OUT_X1_FACTOR = 0.8; const ZOOM_OUT_X2_FACTOR = 0.64; const RENAME_NODE_NAME = 'Something else'; -describe('Canvas Actions', () => { +describe('Canvas Node Manipulation and Navigation', () => { before(() => { cy.resetAll(); cy.skipSetup(); @@ -30,56 +30,6 @@ describe('Canvas Actions', () => { WorkflowPage.actions.visit(); }); - it('should render canvas', () => { - WorkflowPage.getters.nodeViewRoot().should('be.visible'); - WorkflowPage.getters.canvasPlusButton().should('be.visible'); - WorkflowPage.getters.zoomToFitButton().should('be.visible'); - WorkflowPage.getters.zoomInButton().should('be.visible'); - WorkflowPage.getters.zoomOutButton().should('be.visible'); - WorkflowPage.getters.executeWorkflowButton().should('be.visible'); - }); - - it('should connect and disconnect a simple node', () => { - WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); - WorkflowPage.getters.nodeViewBackground().click(600, 200, { force: true }); - cy.get('.jtk-connector').should('have.length', 1); - WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); - - // Change connection from Set to Set1 - cy.draganddrop( - WorkflowPage.getters.getEndpointSelector('input', SET_NODE_NAME), - WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), - ); - - WorkflowPage.getters - .canvasNodeInputEndpointByName(`${SET_NODE_NAME}1`) - .should('have.class', 'jtk-endpoint-connected'); - - cy.get('.jtk-connector').should('have.length', 1); - // Disconnect Set1 - cy.drag(WorkflowPage.getters.getEndpointSelector('input', `${SET_NODE_NAME}1`), [-200, 100]); - cy.get('.jtk-connector').should('have.length', 0); - }); - - it('should add first step', () => { - WorkflowPage.getters.canvasPlusButton().should('be.visible'); - WorkflowPage.actions.addInitialNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodes().should('have.length', 1); - }); - - it('should add a node via plus endpoint drag', () => { - WorkflowPage.getters.canvasPlusButton().should('be.visible'); - WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME, true); - - cy.drag( - WorkflowPage.getters.getEndpointSelector('plus', SCHEDULE_TRIGGER_NODE_NAME), - [100, 100], - ); - - WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - WorkflowPage.actions.addNodeToCanvas(IF_NODE_NAME, false); - WorkflowPage.getters.nodeViewBackground().click({ force: true }); - }); it('should add switch node and test connections', () => { WorkflowPage.actions.addNodeToCanvas(SWITCH_NODE_NAME, true); @@ -142,6 +92,8 @@ describe('Canvas Actions', () => { cy.get('.rect-input-endpoint.jtk-endpoint-connected').should('have.length', 4); WorkflowPage.actions.executeWorkflow(); + WorkflowPage.getters.stopExecutionButton().should('not.exist'); + // If the merged set nodes are connected and executed correctly, there should be 2 items in the output of merge node cy.get('[data-label="2 items"]').should('be.visible'); }); @@ -167,41 +119,6 @@ describe('Canvas Actions', () => { cy.get('.jtk-connector').should('have.length', 4); }); - it('should add a connected node using plus endpoint', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - cy.get('.plus-endpoint').should('be.visible').click(); - WorkflowPage.getters.nodeCreatorSearchBar().should('be.visible'); - WorkflowPage.getters.nodeCreatorSearchBar().type(CODE_NODE_NAME); - WorkflowPage.getters.nodeCreatorSearchBar().type('{enter}'); - cy.get('body').type('{esc}'); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - WorkflowPage.getters.nodeConnections().should('have.length', 1); - }); - - it('should add disconnected node if nothing is selected', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - // Deselect nodes - WorkflowPage.getters.nodeViewBackground().click({ force: true }); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.canvasNodes().should('have.length', 2); - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - - it('should add note between two connected nodes', () => { - WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(SET_NODE_NAME); - WorkflowPage.actions.zoomToFit(); - WorkflowPage.actions.addNodeBetweenNodes(CODE_NODE_NAME, SET_NODE_NAME, HTTP_REQUEST_NODE_NAME); - WorkflowPage.getters.canvasNodes().should('have.length', 4); - WorkflowPage.getters.nodeConnections().should('have.length', 3); - // And last node should be pushed to the right - WorkflowPage.getters - .canvasNodes() - .last() - .should('have.attr', 'style', 'left: 860px; top: 260px;'); - }); - it('should delete node using node action button', () => { WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); @@ -319,50 +236,6 @@ describe('Canvas Actions', () => { WorkflowPage.getters.canvasNodes().last().should('be.visible'); }); - it('should select all nodes', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.selectAll(); - WorkflowPage.getters.selectedNodes().should('have.length', 2); - }); - - it('should select nodes using arrow keys', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - cy.wait(500); - cy.get('body').type('{leftArrow}'); - WorkflowPage.getters.canvasNodes().first().should('have.class', 'jtk-drag-selected'); - cy.get('body').type('{rightArrow}'); - WorkflowPage.getters.canvasNodes().last().should('have.class', 'jtk-drag-selected'); - }); - - it('should select nodes using shift and arrow keys', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - cy.wait(500); - cy.get('body').type('{shift}', { release: false }).type('{leftArrow}'); - WorkflowPage.getters.selectedNodes().should('have.length', 2); - }); - - it('should delete connections by pressing the delete button', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters.nodeConnections().first().realHover(); - cy.get('.connection-actions .delete').first().click({ force: true }); - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - - it('should delete a connection by moving it away from endpoint', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - cy.drag(WorkflowPage.getters.getEndpointSelector('input', CODE_NODE_NAME), [0, -100]); - WorkflowPage.getters.nodeConnections().should('have.length', 0); - }); - it('should disable node by pressing the disable button', () => { WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); WorkflowPage.getters.canvasNodeByName(MANUAL_TRIGGER_NODE_DISPLAY_NAME).click(); @@ -418,23 +291,4 @@ describe('Canvas Actions', () => { WorkflowPage.getters.canvasNodes().should('have.length', 3); WorkflowPage.getters.nodeConnections().should('have.length', 1); }); - - it('should execute node', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.getters - .canvasNodes() - .last() - .find('[data-test-id="execute-node-button"]') - .click({ force: true }); - WorkflowPage.getters.successToast().should('contain', 'Node executed successfully'); - }); - - it('should copy selected nodes', () => { - WorkflowPage.actions.addNodeToCanvas(MANUAL_TRIGGER_NODE_NAME); - WorkflowPage.actions.addNodeToCanvas(CODE_NODE_NAME); - WorkflowPage.actions.selectAll(); - WorkflowPage.actions.hitCopy(); - WorkflowPage.getters.successToast().should('contain', 'Copied!'); - }); }); diff --git a/cypress/e2e/14-data-transformation-expressions.cy.ts b/cypress/e2e/14-data-transformation-expressions.cy.ts index 161f02fe1f..9e64043fcd 100644 --- a/cypress/e2e/14-data-transformation-expressions.cy.ts +++ b/cypress/e2e/14-data-transformation-expressions.cy.ts @@ -11,7 +11,6 @@ describe('Data transformation expressions', () => { beforeEach(() => { wf.actions.visit(); - cy.waitForLoad(); cy.window() // @ts-ignore diff --git a/cypress/e2e/2-credentials.cy.ts b/cypress/e2e/2-credentials.cy.ts index e281512e09..17d51ebb0d 100644 --- a/cypress/e2e/2-credentials.cy.ts +++ b/cypress/e2e/2-credentials.cy.ts @@ -16,6 +16,9 @@ import { } from '../constants'; import { randFirstName, randLastName } from '@ngneat/falso'; import { CredentialsPage, CredentialsModal, WorkflowPage, NDV } from '../pages'; +import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json'; +import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json'; +import CustomCredential from '../fixtures/Custom_credential.json'; const email = DEFAULT_USER_EMAIL; const password = DEFAULT_USER_PASSWORD; @@ -31,25 +34,10 @@ const NEW_CREDENTIAL_NAME = 'Something else'; describe('Credentials', () => { before(() => { cy.resetAll(); - cy.setup({ email, firstName, lastName, password }); - - // Always intercept the request to test credentials and return a success - cy.intercept('POST', '/rest/credentials/test', { - statusCode: 200, - body: { - data: { status: 'success', message: 'Tested successfully' }, - } - }); + cy.skipSetup(); }); beforeEach(() => { - cy.on('uncaught:exception', (err, runnable) => { - expect(err.message).to.include('Not logged in'); - - return false; - }); - - cy.signin({ email, password }); cy.visit(credentialsPage.url); }); @@ -250,24 +238,4 @@ describe('Credentials', () => { credentialsModal.actions.fillCredentialsForm(); workflowPage.getters.nodeCredentialsSelect().should('contain', NEW_QUERY_AUTH_ACCOUNT_NAME); }); - - it('should render custom node with n8n credential', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas('Manual'); - workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true); - workflowPage.getters.nodeCredentialsLabel().click(); - cy.contains('Create New Credential').click(); - credentialsModal.getters.editCredentialModal().should('be.visible'); - credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API'); - }); - - it('should render custom node with custom credential', () => { - workflowPage.actions.visit(); - workflowPage.actions.addNodeToCanvas('Manual'); - workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true); - workflowPage.getters.nodeCredentialsLabel().click(); - cy.contains('Create New Credential').click(); - credentialsModal.getters.editCredentialModal().should('be.visible'); - credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential'); - }); }); diff --git a/cypress/e2e/21-community-nodes.cy.ts b/cypress/e2e/21-community-nodes.cy.ts new file mode 100644 index 0000000000..f46a829ac0 --- /dev/null +++ b/cypress/e2e/21-community-nodes.cy.ts @@ -0,0 +1,101 @@ +import { NodeCreator } from '../pages/features/node-creator'; +import CustomNodeFixture from '../fixtures/Custom_node.json'; +import { CredentialsModal, WorkflowPage } from '../pages'; +import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json'; +import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json'; +import CustomCredential from '../fixtures/Custom_credential.json'; + +const credentialsModal = new CredentialsModal(); +const nodeCreatorFeature = new NodeCreator(); +const workflowPage = new WorkflowPage(); + +// We separate-out the custom nodes because they require injecting nodes and credentials +// so the /nodes and /credentials endpoints are intercepted and non-cached. +// We want to keep the other tests as fast as possible so we don't want to break the cache in those. +describe('Community Nodes', () => { + before(() => { + cy.resetAll(); + cy.skipSetup(); + }) + beforeEach(() => { + cy.intercept('/types/nodes.json', { middleware: true }, (req) => { + req.headers['cache-control'] = 'no-cache, no-store'; + + req.on('response', (res) => { + const nodes = res.body || []; + + nodes.push(CustomNodeFixture, CustomNodeWithN8nCredentialFixture, CustomNodeWithCustomCredentialFixture); + }); + }) + + cy.intercept('/types/credentials.json', { middleware: true }, (req) => { + req.headers['cache-control'] = 'no-cache, no-store'; + + req.on('response', (res) => { + const credentials = res.body || []; + + credentials.push(CustomCredential); + }) + }) + workflowPage.actions.visit(); + }); + + it('should render and select community node', () => { + const customNode = 'E2E Node'; + + nodeCreatorFeature.actions.openNodeCreator(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode); + + nodeCreatorFeature.getters + .getCreatorItem(customNode) + .findChildByTestId('node-creator-item-tooltip') + .should('exist'); + nodeCreatorFeature.actions.selectNode(customNode); + + // TODO: Replace once we have canvas feature utils + cy.get('.data-display .node-name').contains(customNode).should('exist'); + + const nodeParameters = () => cy.getByTestId('node-parameters'); + const firstParameter = () => nodeParameters().find('.parameter-item').eq(0); + const secondParameter = () => nodeParameters().find('.parameter-item').eq(1); + + // Check correct fields are rendered + nodeParameters().should('exist'); + // Test property text input + firstParameter().contains('Test property').should('exist'); + firstParameter().find('input.el-input__inner').should('have.value', 'Some default'); + // Resource select input + secondParameter().find('label').contains('Resource').should('exist'); + secondParameter().find('input.el-input__inner').should('have.value', 'option2'); + secondParameter().find('.el-select').click(); + secondParameter().find('.el-select-dropdown__list').should('exist'); + // Check if all options are rendered and select the fourth one + secondParameter().find('.el-select-dropdown__list').children().should('have.length', 4); + secondParameter() + .find('.el-select-dropdown__list') + .children() + .eq(3) + .contains('option4') + .should('exist') + .click(); + secondParameter().find('input.el-input__inner').should('have.value', 'option4'); + }); + + it('should render custom node with n8n credential', () => { + workflowPage.actions.addNodeToCanvas('Manual'); + workflowPage.actions.addNodeToCanvas('E2E Node with native n8n credential', true, true); + workflowPage.getters.nodeCredentialsLabel().click(); + cy.contains('Create New Credential').click(); + credentialsModal.getters.editCredentialModal().should('be.visible'); + credentialsModal.getters.editCredentialModal().should('contain.text', 'Notion API'); + }); + + it('should render custom node with custom credential', () => { + workflowPage.actions.addNodeToCanvas('Manual'); + workflowPage.actions.addNodeToCanvas('E2E Node with custom credential', true, true); + workflowPage.getters.nodeCredentialsLabel().click(); + cy.contains('Create New Credential').click(); + credentialsModal.getters.editCredentialModal().should('be.visible'); + credentialsModal.getters.editCredentialModal().should('contain.text', 'Custom E2E Credential'); + }); +}); diff --git a/cypress/e2e/4-node-creator.cy.ts b/cypress/e2e/4-node-creator.cy.ts index 2054209f4f..0057afd93c 100644 --- a/cypress/e2e/4-node-creator.cy.ts +++ b/cypress/e2e/4-node-creator.cy.ts @@ -6,6 +6,7 @@ const nodeCreatorFeature = new NodeCreator(); const WorkflowPage = new WorkflowPageClass(); const NDVModal = new NDV(); + describe('Node Creator', () => { before(() => { cy.resetAll(); @@ -104,44 +105,75 @@ describe('Node Creator', () => { NDVModal.getters.parameterInput('operation').should('contain.text', 'Rename'); }) - it('should render and select community node', () => { - const customNode = 'E2E Node'; + it('should not show actions for single action nodes', () => { + const singleActionNodes = [ + 'DHL', + 'iCalendar', + 'LingvaNex', + 'Mailcheck', + 'MSG91', + 'OpenThesaurus', + 'Spontit', + 'Vonage', + 'Send Email', + 'Toggl Trigger' + ] + const doubleActionNode = 'OpenWeatherMap' nodeCreatorFeature.actions.openNodeCreator(); - nodeCreatorFeature.getters.searchBar().find('input').clear().type(customNode); + singleActionNodes.forEach((node) => { + nodeCreatorFeature.getters.searchBar().find('input').clear().type(node); + nodeCreatorFeature.getters.getCreatorItem(node).find('button[class*="panelIcon"]').should('not.exist'); + }) + nodeCreatorFeature.getters.searchBar().find('input').clear().type(doubleActionNode); + nodeCreatorFeature.getters.getCreatorItem(doubleActionNode).click(); + nodeCreatorFeature.getters.creatorItem().should('have.length', 2); + }) - nodeCreatorFeature.getters - .getCreatorItem(customNode) - .findChildByTestId('node-creator-item-tooltip') - .should('exist'); - nodeCreatorFeature.actions.selectNode(customNode); + describe('should correctly append manual trigger for regular actions', () => { + // For these sources, manual node should be added + const sourcesWithAppend = [ + { + name: 'canvas add button', + handler: () => nodeCreatorFeature.getters.canvasAddButton().click(), + }, { + name: 'plus button', + handler: () => nodeCreatorFeature.getters.plusButton().click(), + }, + // We can't test this one because it's not possible to trigger tab key in Cypress + // only way is to use `realPress` which is hanging the tests in Electron for some reason + // { + // name: 'tab key', + // handler: () => cy.realPress('Tab'), + // }, + ] + sourcesWithAppend.forEach((source) => { + it(`should append manual trigger when source is ${source.name}`, () => { + source.handler() + nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); + nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); + NDVModal.actions.close(); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + }); + }); - // TODO: Replace once we have canvas feature utils - cy.get('.data-display .node-name').contains(customNode).should('exist'); - - const nodeParameters = () => cy.getByTestId('node-parameters'); - const firstParameter = () => nodeParameters().find('.parameter-item').eq(0); - const secondParameter = () => nodeParameters().find('.parameter-item').eq(1); - - // Check correct fields are rendered - nodeParameters().should('exist'); - // Test property text input - firstParameter().contains('Test property').should('exist'); - firstParameter().find('input.el-input__inner').should('have.value', 'Some default'); - // Resource select input - secondParameter().find('label').contains('Resource').should('exist'); - secondParameter().find('input.el-input__inner').should('have.value', 'option2'); - secondParameter().find('.el-select').click(); - secondParameter().find('.el-select-dropdown__list').should('exist'); - // Check if all options are rendered and select the fourth one - secondParameter().find('.el-select-dropdown__list').children().should('have.length', 4); - secondParameter() - .find('.el-select-dropdown__list') - .children() - .eq(3) - .contains('option4') - .should('exist') - .click(); - secondParameter().find('input.el-input__inner').should('have.value', 'option4'); + it('should not append manual trigger when source is canvas related', () => { + nodeCreatorFeature.getters.canvasAddButton().click(); + nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); + nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); + NDVModal.actions.close(); + WorkflowPage.actions.deleteNode('When clicking "Execute Workflow"') + WorkflowPage.getters.canvasNodePlusEndpointByName('n8n').click() + nodeCreatorFeature.getters.searchBar().find('input').clear().type('n8n'); + nodeCreatorFeature.getters.getCreatorItem('n8n').click(); + nodeCreatorFeature.getters.getCreatorItem('Create a credential').click(); + NDVModal.actions.close(); + WorkflowPage.getters.canvasNodes().should('have.length', 2); + WorkflowPage.actions.zoomToFit(); + WorkflowPage.actions.addNodeBetweenNodes('n8n', 'n8n1', 'Item Lists') + WorkflowPage.getters.canvasNodes().should('have.length', 3); + }) }); }); diff --git a/cypress/e2e/5-ndv.cy.ts b/cypress/e2e/5-ndv.cy.ts index a8f0d4cc9c..2c5c770a72 100644 --- a/cypress/e2e/5-ndv.cy.ts +++ b/cypress/e2e/5-ndv.cy.ts @@ -89,4 +89,42 @@ describe('NDV', () => { cy.get('[class*=hasIssues]').should('have.length', 1); }); }); + + describe('test output schema view', () => { + const schemaKeys = ['id', 'name', 'email', 'notes', 'country', 'created', 'objectValue', 'prop1', 'prop2']; + beforeEach(() => { + cy.createFixtureWorkflow('Test_workflow_schema_test.json', `NDV test schema view ${uuid()}`); + workflowPage.actions.zoomToFit(); + workflowPage.actions.openNode('Set'); + ndv.actions.execute(); + }); + it('should switch to output schema view and validate it', () => { + ndv.getters.outputDisplayMode().children().should('have.length', 3); + ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Table'); + ndv.getters.outputDisplayMode().contains('Schema').click(); + ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Schema'); + + schemaKeys.forEach((key) => { + ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('exist'); + }); + }); + it('should preserve schema view after execution', () => { + ndv.getters.outputDisplayMode().contains('Schema').click(); + ndv.actions.execute(); + ndv.getters.outputDisplayMode().find('[class*=active]').should('contain', 'Schema'); + }) + it('should collapse and expand nested schema object', () => { + const expandedObjectProps = ['prop1', 'prop2'];; + const getObjectValueItem = () => ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').filter(':contains("objectValue")'); + ndv.getters.outputDisplayMode().contains('Schema').click(); + + expandedObjectProps.forEach((key) => { + ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('be.visible'); + }); + getObjectValueItem().find('label').click(); + expandedObjectProps.forEach((key) => { + ndv.getters.outputPanel().find('[data-test-id=run-data-schema-item]').contains(key).should('not.be.visible'); + }); + }) + }) }); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index a56ff58622..cfce3cc9b0 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -7,7 +7,7 @@ import { import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; const NEW_WORKFLOW_NAME = 'Something else'; -const IMPORT_WORKFLOW_URL = 'https://www.jsonkeeper.com/b/FNB0#.json'; +const IMPORT_WORKFLOW_URL = 'https://gist.githubusercontent.com/OlegIvaniv/010bd3f45c8a94f8eb7012e663a8b671/raw/3afea1aec15573cc168d9af7e79395bd76082906/test-workflow.json'; const DUPLICATE_WORKFLOW_NAME = 'Duplicated workflow'; const DUPLICATE_WORKFLOW_TAG = 'Duplicate'; @@ -94,7 +94,7 @@ describe('Workflow Actions', () => { cy.get('.el-message-box').should('be.visible'); cy.get('.el-message-box').find('input').type(IMPORT_WORKFLOW_URL); cy.get('body').type('{enter}'); - cy.waitForLoad(); + cy.waitForLoad(false) WorkflowPage.actions.zoomToFit(); WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.nodeConnections().should('have.length', 1); @@ -104,7 +104,7 @@ describe('Workflow Actions', () => { WorkflowPage.getters .workflowImportInput() .selectFile('cypress/fixtures/Test_workflow-actions_paste-data.json', { force: true }); - cy.waitForLoad(); + cy.waitForLoad(false) WorkflowPage.actions.zoomToFit(); WorkflowPage.getters.canvasNodes().should('have.length', 2); WorkflowPage.getters.nodeConnections().should('have.length', 1); diff --git a/cypress/fixtures/Test_workflow_schema_test.json b/cypress/fixtures/Test_workflow_schema_test.json new file mode 100644 index 0000000000..0db43a5ea4 --- /dev/null +++ b/cypress/fixtures/Test_workflow_schema_test.json @@ -0,0 +1,92 @@ +{ + "name": "My workflow 8", + "nodes": [ + { + "parameters": { + "operation": "getAllPeople", + "limit": 10 + }, + "id": "39cd80ce-5a8f-4339-b3d5-c4af969dd330", + "name": "Customer Datastore (n8n training)", + "type": "n8n-nodes-base.n8nTrainingCustomerDatastore", + "typeVersion": 1, + "position": [ + 940, + 680 + ] + }, + { + "parameters": { + "values": { + "number": [ + { + "name": "objectValue.prop1", + "value": 123 + } + ], + "string": [ + { + "name": "objectValue.prop2", + "value": "someText" + } + ] + }, + "options": { + "dotNotation": true + } + }, + "id": "6e4490f6-ba95-4400-beec-2caefdd4895a", + "name": "Set", + "type": "n8n-nodes-base.set", + "typeVersion": 1, + "position": [ + 1300, + 680 + ] + }, + { + "parameters": {}, + "id": "58512a93-dabf-4584-817f-27c608c1bdd5", + "name": "When clicking \"Execute Workflow\"", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 720, + 680 + ] + } + ], + "pinData": {}, + "connections": { + "Customer Datastore (n8n training)": { + "main": [ + [ + { + "node": "Set", + "type": "main", + "index": 0 + } + ] + ] + }, + "When clicking \"Execute Workflow\"": { + "main": [ + [ + { + "node": "Customer Datastore (n8n training)", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": {}, + "versionId": "4a4f292a-92be-427c-848a-9582527f5ed3", + "id": "8", + "meta": { + "instanceId": "032eceae7493054b723340499be69ecbf4cbe28a7ec6df676b759000750b968d" + }, + "tags": [] +} diff --git a/cypress/pages/features/node-creator.ts b/cypress/pages/features/node-creator.ts index 0899b188d5..8ebe6db702 100644 --- a/cypress/pages/features/node-creator.ts +++ b/cypress/pages/features/node-creator.ts @@ -24,7 +24,6 @@ export class NodeCreator extends BasePage { }; actions = { openNodeCreator: () => { - cy.waitForLoad(); this.getters.plusButton().click(); this.getters.nodeCreator().should('be.visible'); }, diff --git a/cypress/pages/ndv.ts b/cypress/pages/ndv.ts index 500aec4c85..5a45f2df2d 100644 --- a/cypress/pages/ndv.ts +++ b/cypress/pages/ndv.ts @@ -13,10 +13,9 @@ export class NDV extends BasePage { outputPanel: () => cy.getByTestId('output-panel'), executingLoader: () => cy.getByTestId('ndv-executing'), inputDataContainer: () => this.getters.inputPanel().findChildByTestId('ndv-data-container'), - inputDisplayMode: () => this.getters.inputPanel().getByTestId('ndv-run-data-display-mode'), + inputDisplayMode: () => this.getters.inputPanel().findChildByTestId('ndv-run-data-display-mode').first(), outputDataContainer: () => this.getters.outputPanel().findChildByTestId('ndv-data-container'), - outputDisplayMode: () => this.getters.outputPanel().getByTestId('ndv-run-data-display-mode'), - digital: () => cy.getByTestId('ndv-run-data-display-mode'), + outputDisplayMode: () => this.getters.outputPanel().findChildByTestId('ndv-run-data-display-mode').first(), pinDataButton: () => cy.getByTestId('ndv-pin-data'), editPinnedDataButton: () => cy.getByTestId('ndv-edit-pinned-data'), pinnedDataEditor: () => this.getters.outputPanel().find('.monaco-editor[role=code]'), diff --git a/cypress/pages/settings-users.ts b/cypress/pages/settings-users.ts index 4d6903c858..41ae187114 100644 --- a/cypress/pages/settings-users.ts +++ b/cypress/pages/settings-users.ts @@ -1,9 +1,11 @@ import { SettingsSidebar } from './sidebar/settings-sidebar'; import { MainSidebar } from './sidebar/main-sidebar'; import { WorkflowPage } from './workflow'; +import { WorkflowsPage } from './workflows'; import { BasePage } from './base'; const workflowPage = new WorkflowPage(); +const workflowsPage = new WorkflowsPage(); const mainSidebar = new MainSidebar(); const settingsSidebar = new SettingsSidebar(); @@ -39,7 +41,7 @@ export class SettingsUsersPage extends BasePage { settingsSidebar.getters.menuItem('Users').should('not.exist'); // Should be redirected to workflows page if trying to access UM url cy.visit('/settings/users'); - cy.url().should('match', new RegExp(workflowPage.url)); + cy.url().should('match', new RegExp(workflowsPage.url)); } }, opedDeleteDialog: (email: string) => { diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index 2dc647e4bf..4ad4b753a9 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -174,6 +174,10 @@ export class WorkflowPage extends BasePage { saveWorkflowUsingKeyboardShortcut: () => { cy.get('body').type('{meta}', { release: false }).type('s'); }, + deleteNode: (name: string) => { + this.getters.canvasNodeByName(name).first().click(); + cy.get('body').type('{del}'); + }, setWorkflowName: (name: string) => { this.getters.workflowNameInput().should('be.disabled'); this.getters.workflowNameInput().parent().click(); diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 233c74ee55..e096dc0aaa 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -26,7 +26,6 @@ import 'cypress-real-events'; import { WorkflowsPage, SigninPage, SignupPage, SettingsUsersPage, WorkflowPage } from '../pages'; import { N8N_AUTH_COOKIE } from '../constants'; -import { WorkflowPage as WorkflowPageClass } from '../pages/workflow'; import { MessageBox } from '../pages/modals/message-box'; Cypress.Commands.add('getByTestId', (selector, ...args) => { @@ -34,15 +33,17 @@ Cypress.Commands.add('getByTestId', (selector, ...args) => { }); Cypress.Commands.add('createFixtureWorkflow', (fixtureKey, workflowName) => { - const WorkflowPage = new WorkflowPageClass(); + const workflowPage = new WorkflowPage(); // We need to force the click because the input is hidden - WorkflowPage.getters + workflowPage.getters .workflowImportInput() .selectFile(`cypress/fixtures/${fixtureKey}`, { force: true }); - WorkflowPage.actions.setWorkflowName(workflowName); - WorkflowPage.getters.saveButton().should('contain', 'Saved'); + cy.waitForLoad(false); + workflowPage.actions.setWorkflowName(workflowName); + + workflowPage.getters.saveButton().should('contain', 'Saved'); }); Cypress.Commands.add( @@ -53,14 +54,20 @@ Cypress.Commands.add( }, ); -Cypress.Commands.add('waitForLoad', () => { +Cypress.Commands.add('waitForLoad', (waitForIntercepts = true) => { + // These aliases are set-up before each test in cypress/support/e2e.ts + // we can't set them up here because at this point it would be too late + // and the requests would already have been made + if(waitForIntercepts) { + cy.wait(['@loadSettings', '@loadLogin']) + } cy.getByTestId('node-view-loader', { timeout: 20000 }).should('not.exist'); cy.get('.el-loading-mask', { timeout: 20000 }).should('not.exist'); }); Cypress.Commands.add('signin', ({ email, password }) => { const signinPage = new SigninPage(); - const workflowPage = new WorkflowPage(); + const workflowsPage = new WorkflowsPage(); cy.session( [email, password], @@ -74,10 +81,7 @@ Cypress.Commands.add('signin', ({ email, password }) => { }); // we should be redirected to /workflows - cy.visit(workflowPage.url); - cy.url().should('include', workflowPage.url); - cy.intercept('GET', '/rest/workflows/new').as('loading'); - cy.wait('@loading'); + cy.url().should('include', workflowsPage.url); }, { validate() { @@ -212,7 +216,9 @@ Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => { } }); -Cypress.Commands.add('readClipboard', () => cy.window().then(win => win.navigator.clipboard.readText())) +Cypress.Commands.add('readClipboard', () => + cy.window().then((win) => win.navigator.clipboard.readText()), +); Cypress.Commands.add('paste', { prevSubject: true }, (selector, pastePayload) => { // https://developer.mozilla.org/en-US/docs/Web/API/Element/paste_event diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 21236be4f1..ee534b4dc5 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -14,28 +14,17 @@ // *********************************************************** import './commands'; -import CustomNodeFixture from '../fixtures/Custom_node.json'; -import CustomNodeWithN8nCredentialFixture from '../fixtures/Custom_node_n8n_credential.json'; -import CustomNodeWithCustomCredentialFixture from '../fixtures/Custom_node_custom_credential.json'; -import CustomCredential from '../fixtures/Custom_credential.json'; // Load custom nodes and credentials fixtures beforeEach(() => { - cy.intercept('GET', '/types/nodes.json', (req) => { - req.on('response', (res) => { - const nodes = res.body || []; + cy.intercept('GET', '/rest/settings').as('loadSettings'); + cy.intercept('GET', '/rest/login').as('loadLogin'); - res.headers['cache-control'] = 'no-cache, no-store'; - nodes.push(CustomNodeFixture, CustomNodeWithN8nCredentialFixture, CustomNodeWithCustomCredentialFixture); - }); - }).as('nodesIntercept'); - - cy.intercept('GET', '/types/credentials.json', (req) => { - req.on('response', (res) => { - const credentials = res.body || []; - - res.headers['cache-control'] = 'no-cache, no-store'; - credentials.push(CustomCredential); - }) - }).as('credentialsIntercept'); + // Always intercept the request to test credentials and return a success + cy.intercept('POST', '/rest/credentials/test', { + statusCode: 200, + body: { + data: { status: 'success', message: 'Tested successfully' }, + } + }); }) diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 93927c98e8..b42db7e717 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -43,7 +43,7 @@ declare global { skipSetup(): void; resetAll(): void; enableFeature(feature: string): void; - waitForLoad(): void; + waitForLoad(waitForIntercepts?: boolean): void; grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; paste(pastePayload: string): void; diff --git a/docker/compose/subfolderWithSSL/docker-compose.yml b/docker/compose/subfolderWithSSL/docker-compose.yml index f6ce218bff..37a4b25f18 100644 --- a/docker/compose/subfolderWithSSL/docker-compose.yml +++ b/docker/compose/subfolderWithSSL/docker-compose.yml @@ -20,7 +20,7 @@ services: - ${DATA_FOLDER}/letsencrypt:/letsencrypt - /var/run/docker.sock:/var/run/docker.sock:ro n8n: - image: n8nio/n8n + image: docker.n8n.io/n8nio/n8n ports: - '127.0.0.1:5678:5678' labels: diff --git a/docker/compose/withMariaDB/docker-compose.yml b/docker/compose/withMariaDB/docker-compose.yml index 14e478683e..e7a1db74e0 100644 --- a/docker/compose/withMariaDB/docker-compose.yml +++ b/docker/compose/withMariaDB/docker-compose.yml @@ -23,7 +23,7 @@ services: retries: 10 n8n: - image: n8nio/n8n + image: docker.n8n.io/n8nio/n8n restart: always environment: - DB_TYPE=mariadb diff --git a/docker/compose/withPostgres/docker-compose.yml b/docker/compose/withPostgres/docker-compose.yml index c102d81837..9b3ab5b83e 100644 --- a/docker/compose/withPostgres/docker-compose.yml +++ b/docker/compose/withPostgres/docker-compose.yml @@ -24,7 +24,7 @@ services: retries: 10 n8n: - image: n8nio/n8n + image: docker.n8n.io/n8nio/n8n restart: always environment: - DB_TYPE=postgresdb diff --git a/docker/compose/withPostgresAndWorker/docker-compose.yml b/docker/compose/withPostgresAndWorker/docker-compose.yml index 308e5abc47..9f947d735d 100644 --- a/docker/compose/withPostgresAndWorker/docker-compose.yml +++ b/docker/compose/withPostgresAndWorker/docker-compose.yml @@ -63,14 +63,14 @@ services: n8n: <<: *shared - image: n8nio/n8n + image: docker.n8n.io/n8nio/n8n command: /bin/sh -c "n8n start --tunnel" ports: - 5678:5678 n8n-worker: <<: *shared - image: n8nio/n8n + image: docker.n8n.io/n8nio/n8n command: /bin/sh -c "sleep 5; n8n worker" depends_on: - n8n diff --git a/docker/images/n8n-custom/Dockerfile b/docker/images/n8n-custom/Dockerfile index 76ee6ded63..f6cc050711 100644 --- a/docker/images/n8n-custom/Dockerfile +++ b/docker/images/n8n-custom/Dockerfile @@ -3,7 +3,7 @@ ARG NODE_VERSION=16 # 1. Create an image to build n8n FROM n8nio/base:${NODE_VERSION} as builder -COPY turbo.json package.json .npmrc pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ +COPY turbo.json package.json .npmrc pnpm-lock.yaml pnpm-workspace.yaml jest.config.js tsconfig.json ./ COPY scripts ./scripts COPY packages ./packages COPY patches ./patches diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index 4cec9c4cc2..376c4494ab 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -40,12 +40,12 @@ Additional information and example workflows on the n8n.io website: [https://n8n ## Start n8n in Docker -``` +```bash docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/home/node/.n8n \ + docker.n8n.io/n8nio/n8n ``` You can then access n8n by opening: @@ -62,13 +62,13 @@ n8n instance. To use it simply start n8n with `--tunnel` -``` +```bash docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n \ - n8n start --tunnel + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/home/node/.n8n \ + docker.n8n.io/n8nio/n8n \ + n8n start --tunnel ``` ## Securing n8n @@ -79,7 +79,7 @@ to make sure that n8n is protected! Right now we have very basic protection via basic-auth in place. It can be activated by setting the following environment variables: -``` +```text N8N_BASIC_AUTH_ACTIVE=true N8N_BASIC_AUTH_USER= N8N_BASIC_AUTH_PASSWORD= @@ -91,12 +91,12 @@ The workflow data gets by default saved in an SQLite database in the user folder (`/home/node/.n8n`). That folder also additionally contains the settings like webhook URL and encryption key. -``` +```bash docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + -v ~/.n8n:/home/node/.n8n \ + docker.n8n.io/n8nio/n8n ``` ### Start with other Database @@ -123,20 +123,20 @@ Replace the following placeholders with the actual data: - POSTGRES_USER - POSTGRES_SCHEMA -``` +```bash docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -e DB_TYPE=postgresdb \ - -e DB_POSTGRESDB_DATABASE= \ - -e DB_POSTGRESDB_HOST= \ - -e DB_POSTGRESDB_PORT= \ - -e DB_POSTGRESDB_USER= \ - -e DB_POSTGRESDB_SCHEMA= \ - -e DB_POSTGRESDB_PASSWORD= \ - -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n \ - n8n start + --name n8n \ + -p 5678:5678 \ + -e DB_TYPE=postgresdb \ + -e DB_POSTGRESDB_DATABASE= \ + -e DB_POSTGRESDB_HOST= \ + -e DB_POSTGRESDB_PORT= \ + -e DB_POSTGRESDB_USER= \ + -e DB_POSTGRESDB_SCHEMA= \ + -e DB_POSTGRESDB_PASSWORD= \ + -v ~/.n8n:/home/node/.n8n \ + docker.n8n.io/n8nio/n8n \ + n8n start ``` A full working setup with docker-compose can be found [here](https://github.com/n8n-io/n8n/blob/master/docker/compose/withPostgres/README.md) @@ -151,19 +151,19 @@ Replace the following placeholders with the actual data: - MYSQLDB_PORT - MYSQLDB_USER -``` +```bash docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -e DB_TYPE=mysqldb \ - -e DB_MYSQLDB_DATABASE= \ - -e DB_MYSQLDB_HOST= \ - -e DB_MYSQLDB_PORT= \ - -e DB_MYSQLDB_USER= \ - -e DB_MYSQLDB_PASSWORD= \ - -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n \ - n8n start + --name n8n \ + -p 5678:5678 \ + -e DB_TYPE=mysqldb \ + -e DB_MYSQLDB_DATABASE= \ + -e DB_MYSQLDB_HOST= \ + -e DB_MYSQLDB_PORT= \ + -e DB_MYSQLDB_USER= \ + -e DB_MYSQLDB_PASSWORD= \ + -v ~/.n8n:/home/node/.n8n \ + docker.n8n.io/n8nio/n8n \ + n8n start ``` ## Passing Sensitive Data via File @@ -191,16 +191,21 @@ A basic step by step example setup of n8n with docker-compose and Lets Encrypt i ## Updating a running docker-compose instance -``` -# Pull down the latest version from dockerhub -docker pull n8nio/n8n -# Stop current setup -sudo docker-compose stop -# Delete it (will only delete the docker-containers, data is stored separately) -sudo docker-compose rm -# Then start it again -sudo docker-compose up -d -``` +1. Pull the latest version from the registry + + `docker pull docker.n8n.io/n8nio/n8n` + +2. Stop the current setup + + `sudo docker-compose stop` + +3. Delete it (will only delete the docker-containers, data is stored separately) + + `sudo docker-compose rm` + +4. Then start it again + + `sudo docker-compose up -d` ## Setting Timezone @@ -212,22 +217,22 @@ the environment variable `TZ`. Example to use the same timezone for both: -``` +```bash docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -e GENERIC_TIMEZONE="Europe/Berlin" \ - -e TZ="Europe/Berlin" \ - n8nio/n8n + --name n8n \ + -p 5678:5678 \ + -e GENERIC_TIMEZONE="Europe/Berlin" \ + -e TZ="Europe/Berlin" \ + docker.n8n.io/n8nio/n8n ``` ## Build Docker-Image -``` -docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION= -t n8nio/n8n: . +```bash +docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION= -t n8n: . # For example: -docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8nio/n8n:0.114.0 . +docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --build-arg N8N_VERSION=0.114.0 -t n8n:0.114.0 . ``` ## What does n8n mean and how do you pronounce it? diff --git a/jest.config.js b/jest.config.js index 6aefc55ee1..e34e01fa22 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,11 +22,14 @@ const config = { moduleNameMapper: { '^@/(.*)$': '/src/$1', }, + collectCoverage: true, + coverageReporters: [process.env.COVERAGE_REPORT === 'true' ? 'text' : 'text-summary'], + collectCoverageFrom: ['src/**/*.ts'], }; if (process.env.CI === 'true') { - config.maxWorkers = 2; - config.workerIdleMemoryLimit = 2048; + config.workerIdleMemoryLimit = 1024; + config.coverageReporters = ['cobertura']; } module.exports = config; diff --git a/package.json b/package.json index 7600fd9f0d..4a3c4ada51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.218.0", + "version": "0.221.2", "private": true, "homepage": "https://n8n.io", "engines": { @@ -39,15 +39,15 @@ "devDependencies": { "@n8n_io/eslint-config": "workspace:*", "@ngneat/falso": "^6.1.0", - "@types/jest": "^29.2.2", + "@types/jest": "^29.5.0", "@types/supertest": "^2.0.12", "cross-env": "^7.0.3", - "cypress": "^12.7.0", + "cypress": "^12.8.1", "cypress-real-events": "^1.7.6", - "jest": "^29.4.2", - "jest-environment-jsdom": "^29.4.2", - "jest-mock": "^29.4.2", - "jest-mock-extended": "^3.0.1", + "jest": "^29.5.0", + "jest-environment-jsdom": "^29.5.0", + "jest-mock": "^29.5.0", + "jest-mock-extended": "^3.0.3", "nock": "^13.2.9", "node-fetch": "^2.6.7", "p-limit": "^3.1.0", @@ -69,8 +69,10 @@ "@types/node": "^16.18.12", "browserslist": "^4.21.4", "chokidar": "3.5.2", + "decode-uri-component": "0.2.2", "ejs": "^3.1.8", "fork-ts-checker-webpack-plugin": "^6.0.4", + "http-cache-semantics": "4.1.1", "jsonwebtoken": "9.0.0", "prettier": "^2.8.3", "ts-node": "^10.9.1", diff --git a/packages/cli/README.md b/packages/cli/README.md index b7043db915..6641c7d12a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -58,7 +58,7 @@ To play around with n8n, you can also start it using Docker: docker run -it --rm \ --name n8n \ -p 5678:5678 \ - n8nio/n8n + docker.n8n.io/n8nio/n8n ``` Be aware that all the data will be lost once the Docker container gets removed. To persist the data mount the `~/.n8n` folder: @@ -68,11 +68,9 @@ docker run -it --rm \ --name n8n \ -p 5678:5678 \ -v ~/.n8n:/home/node/.n8n \ - n8nio/n8n + docker.n8n.io/n8nio/n8n ``` -n8n also offers a Docker image for Raspberry Pi: `n8nio/n8n:latest-rpi`. - Refer to the [documentation](https://github.com/n8n-io/n8n/blob/master/docker/images/n8n/README.md) for more information on the Docker setup. ### Install with npm diff --git a/packages/cli/jest.config.js b/packages/cli/jest.config.js index 993c4301f4..8f61a80caa 100644 --- a/packages/cli/jest.config.js +++ b/packages/cli/jest.config.js @@ -11,4 +11,5 @@ module.exports = { '^@/(.*)$': '/src/$1', '^@db/(.*)$': '/src/databases/$1', }, + coveragePathIgnorePatterns: ['/src/databases/migrations/'], }; diff --git a/packages/cli/package.json b/packages/cli/package.json index 3cb13ea7be..73d107672b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "0.218.0", + "version": "0.221.2", "description": "n8n Workflow Automation Tool", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -70,7 +70,7 @@ "@types/body-parser-xml": "^2.0.2", "@types/compression": "1.0.1", "@types/connect-history-api-fallback": "^1.3.1", - "@types/convict": "^4.2.1", + "@types/convict": "^6.1.1", "@types/cookie-parser": "^1.4.2", "@types/express": "^4.17.6", "@types/json-diff": "^0.5.1", @@ -114,7 +114,7 @@ "tsconfig-paths": "^4.1.2" }, "dependencies": { - "@n8n_io/license-sdk": "^1.8.0", + "@n8n_io/license-sdk": "^1.9.1", "@oclif/command": "^1.8.16", "@oclif/core": "^1.16.4", "@oclif/errors": "^1.3.6", @@ -134,13 +134,14 @@ "client-oauth2": "^4.2.5", "compression": "^1.7.4", "connect-history-api-fallback": "^1.6.0", - "convict": "^6.0.1", + "convict": "^6.2.4", "cookie-parser": "^1.4.6", "crypto-js": "~4.1.1", "csrf": "^3.1.0", "curlconverter": "^3.0.0", "dotenv": "^8.0.0", "express": "^4.18.2", + "express-handlebars": "^7.0.2", "express-async-errors": "^3.1.1", "express-openapi-validator": "^4.13.6", "express-prom-bundle": "^6.6.0", @@ -169,7 +170,7 @@ "lodash.uniq": "^4.5.0", "lodash.uniqby": "^4.7.0", "lodash.unset": "^4.5.2", - "luxon": "^3.1.0", + "luxon": "^3.3.0", "mysql2": "~2.3.3", "n8n-core": "workspace:*", "n8n-editor-ui": "workspace:*", @@ -195,7 +196,7 @@ "semver": "^7.3.8", "shelljs": "^0.8.5", "source-map-support": "^0.5.21", - "sqlite3": "^5.1.4", + "sqlite3": "^5.1.6", "sse-channel": "^4.0.0", "swagger-ui-express": "^4.3.0", "syslog-client": "^1.1.1", diff --git a/packages/cli/src/AbstractServer.ts b/packages/cli/src/AbstractServer.ts index 1ce52759aa..cde2ff3fdd 100644 --- a/packages/cli/src/AbstractServer.ts +++ b/packages/cli/src/AbstractServer.ts @@ -302,7 +302,7 @@ export abstract class AbstractServer { // ---------------------------------------- protected setupWaitingWebhookEndpoint() { const endpoint = this.endpointWebhookWaiting; - const waitingWebhooks = new WaitingWebhooks(); + const waitingWebhooks = Container.get(WaitingWebhooks); // Register all webhook-waiting requests this.app.all(`/${endpoint}/*`, async (req, res) => { diff --git a/packages/cli/src/ActiveExecutions.ts b/packages/cli/src/ActiveExecutions.ts index d73f6255ba..590cba36cc 100644 --- a/packages/cli/src/ActiveExecutions.ts +++ b/packages/cli/src/ActiveExecutions.ts @@ -1,7 +1,4 @@ -/* eslint-disable prefer-template */ -/* eslint-disable @typescript-eslint/restrict-plus-operands */ /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ -/* eslint-disable no-param-reassign */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ @@ -25,7 +22,7 @@ import type { IWorkflowExecutionDataProcess, } from '@/Interfaces'; import * as ResponseHelper from '@/ResponseHelper'; -import * as WorkflowHelpers from '@/WorkflowHelpers'; +import { isWorkflowIdValid } from '@/utils'; import { Service } from 'typedi'; @Service() @@ -60,7 +57,7 @@ export class ActiveExecutions { } const workflowId = executionData.workflowData.id; - if (workflowId !== undefined && WorkflowHelpers.isWorkflowIdValid(workflowId)) { + if (workflowId !== undefined && isWorkflowIdValid(workflowId)) { fullExecutionData.workflowId = workflowId; } diff --git a/packages/cli/src/ActiveWorkflowRunner.ts b/packages/cli/src/ActiveWorkflowRunner.ts index b632a104f3..2cd770291e 100644 --- a/packages/cli/src/ActiveWorkflowRunner.ts +++ b/packages/cli/src/ActiveWorkflowRunner.ts @@ -9,7 +9,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Container, Service } from 'typedi'; +import { Service } from 'typedi'; import { ActiveWorkflows, NodeExecuteFunctions } from 'n8n-core'; import type { @@ -82,7 +82,11 @@ export class ActiveWorkflowRunner { [key: string]: IQueuedWorkflowActivations; } = {}; - constructor(private externalHooks: ExternalHooks) {} + constructor( + private activeExecutions: ActiveExecutions, + private externalHooks: ExternalHooks, + private nodeTypes: NodeTypes, + ) {} // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types async init() { @@ -271,14 +275,13 @@ export class ActiveWorkflowRunner { ); } - const nodeTypes = Container.get(NodeTypes); const workflow = new Workflow({ id: webhook.workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, - nodeTypes, + nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, }); @@ -514,14 +517,13 @@ export class ActiveWorkflowRunner { throw new Error(`Could not find workflow with id "${workflowId}"`); } - const nodeTypes = Container.get(NodeTypes); const workflow = new Workflow({ id: workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, - nodeTypes, + nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, }); @@ -638,7 +640,7 @@ export class ActiveWorkflowRunner { if (donePromise) { executePromise.then((executionId) => { - Container.get(ActiveExecutions) + this.activeExecutions .getPostExecutePromise(executionId) .then(donePromise.resolve) .catch(donePromise.reject); @@ -695,7 +697,7 @@ export class ActiveWorkflowRunner { if (donePromise) { executePromise.then((executionId) => { - Container.get(ActiveExecutions) + this.activeExecutions .getPostExecutePromise(executionId) .then(donePromise.resolve) .catch(donePromise.reject); @@ -782,14 +784,13 @@ export class ActiveWorkflowRunner { if (!workflowData) { throw new Error(`Could not find workflow with id "${workflowId}".`); } - const nodeTypes = Container.get(NodeTypes); workflowInstance = new Workflow({ id: workflowId, name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, - nodeTypes, + nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, }); diff --git a/packages/cli/src/CommunityNodes/helpers.ts b/packages/cli/src/CommunityNodes/helpers.ts index 295f6a1072..6cbe4134c4 100644 --- a/packages/cli/src/CommunityNodes/helpers.ts +++ b/packages/cli/src/CommunityNodes/helpers.ts @@ -207,7 +207,7 @@ export function hasPackageLoaded(packageName: string): boolean { export function removePackageFromMissingList(packageName: string): void { try { - const failedPackages = (config.get('nodes.packagesMissing') as string).split(' '); + const failedPackages = config.get('nodes.packagesMissing').split(' '); const packageFailedToLoad = failedPackages.filter( (packageNameAndVersion) => diff --git a/packages/cli/src/CredentialsHelper.ts b/packages/cli/src/CredentialsHelper.ts index 643b12f8e1..4fd8295ce1 100644 --- a/packages/cli/src/CredentialsHelper.ts +++ b/packages/cli/src/CredentialsHelper.ts @@ -393,8 +393,7 @@ export class CredentialsHelper extends ICredentialsHelper { } if (expressionResolveValues) { - const timezone = - (expressionResolveValues.workflow.settings.timezone as string) || defaultTimezone; + const timezone = expressionResolveValues.workflow.settings.timezone ?? defaultTimezone; try { decryptedData = expressionResolveValues.workflow.expression.getParameterValue( @@ -452,7 +451,6 @@ export class CredentialsHelper extends ICredentialsHelper { type: string, data: ICredentialDataDecryptedObject, ): Promise { - // eslint-disable-next-line @typescript-eslint/await-thenable const credentials = await this.getCredentials(nodeCredentials, type); if (!Db.isInitialized) { diff --git a/packages/cli/src/Db.ts b/packages/cli/src/Db.ts index 68ff6a0ada..28b760fd2e 100644 --- a/packages/cli/src/Db.ts +++ b/packages/cli/src/Db.ts @@ -169,6 +169,8 @@ export async function init( collections.InstalledPackages = linkRepository(entities.InstalledPackages); collections.InstalledNodes = linkRepository(entities.InstalledNodes); collections.WorkflowStatistics = linkRepository(entities.WorkflowStatistics); + collections.ExecutionMetadata = linkRepository(entities.ExecutionMetadata); + collections.EventDestinations = linkRepository(entities.EventDestinations); isInitialized = true; diff --git a/packages/cli/src/Interfaces.ts b/packages/cli/src/Interfaces.ts index d801e5e3cc..02f15ea518 100644 --- a/packages/cli/src/Interfaces.ts +++ b/packages/cli/src/Interfaces.ts @@ -23,6 +23,7 @@ import type { ExecutionStatus, IExecutionsSummary, FeatureFlags, + WorkflowSettings, } from 'n8n-workflow'; import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -48,6 +49,7 @@ import type { WebhookEntity } from '@db/entities/WebhookEntity'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; import type { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; import type { EventDestinations } from '@db/entities/MessageEventBusDestinationEntity'; +import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata'; export interface IActivationError { time: number; @@ -88,6 +90,7 @@ export interface IDatabaseCollections { InstalledNodes: Repository; WorkflowStatistics: Repository; EventDestinations: Repository; + ExecutionMetadata: Repository; } // ---------------------------------- @@ -453,16 +456,12 @@ export interface IVersionNotificationSettings { export interface IN8nUISettings { endpointWebhook: string; endpointWebhookTest: string; - saveDataErrorExecution: 'all' | 'none'; - saveDataSuccessExecution: 'all' | 'none'; + saveDataErrorExecution: WorkflowSettings.SaveDataExecution; + saveDataSuccessExecution: WorkflowSettings.SaveDataExecution; saveManualExecutions: boolean; executionTimeout: number; maxExecutionTimeout: number; - workflowCallerPolicyDefaultOption: - | 'any' - | 'none' - | 'workflowsFromAList' - | 'workflowsFromSameOwner'; + workflowCallerPolicyDefaultOption: WorkflowSettings.CallerPolicy; oauthCallbackUrls: { oauth1: string; oauth2: string; diff --git a/packages/cli/src/InternalHooks.ts b/packages/cli/src/InternalHooks.ts index 5661c7ca6e..ea36dc04b8 100644 --- a/packages/cli/src/InternalHooks.ts +++ b/packages/cli/src/InternalHooks.ts @@ -284,9 +284,19 @@ export class InternalHooks implements IInternalHooksClass { properties.user_id = userId; } + properties.success = !!runData?.finished; + + let executionStatus: ExecutionStatus; + if (runData?.status === 'crashed') { + executionStatus = 'crashed'; + } else if (runData?.status === 'waiting' || runData?.data?.waitTill) { + executionStatus = 'waiting'; + } else { + executionStatus = properties.success ? 'success' : 'failed'; + } + if (runData !== undefined) { properties.execution_mode = runData.mode; - properties.success = !!runData.finished; properties.is_manual = runData.mode === 'manual'; let nodeGraphResult: INodesGraphResult | null = null; @@ -342,7 +352,7 @@ export class InternalHooks implements IInternalHooksClass { const manualExecEventProperties: ITelemetryTrackProperties = { user_id: userId, workflow_id: workflow.id, - status: properties.success ? 'success' : 'failed', + status: executionStatus, executionStatus: runData?.status ?? 'unknown', error_message: properties.error_message as string, error_node_type: properties.error_node_type, @@ -392,15 +402,6 @@ export class InternalHooks implements IInternalHooksClass { } } - let executionStatus: ExecutionStatus; - if (runData?.status === 'crashed') { - executionStatus = 'crashed'; - } else if (runData?.status === 'waiting' || runData?.data?.waitTill) { - executionStatus = 'waiting'; - } else { - executionStatus = properties.success ? 'success' : 'failed'; - } - promises.push( Db.collections.Execution.update(executionId, { status: executionStatus, diff --git a/packages/cli/src/Ldap/constants.ts b/packages/cli/src/Ldap/constants.ts index 3b7b369b80..c70159e4e0 100644 --- a/packages/cli/src/Ldap/constants.ts +++ b/packages/cli/src/Ldap/constants.ts @@ -2,8 +2,6 @@ import type { LdapConfig } from './types'; export const LDAP_FEATURE_NAME = 'features.ldap'; -export const LDAP_ENABLED = 'enterprise.features.ldap'; - export const LDAP_LOGIN_LABEL = 'sso.ldap.loginLabel'; export const LDAP_LOGIN_ENABLED = 'sso.ldap.loginEnabled'; diff --git a/packages/cli/src/Ldap/helpers.ts b/packages/cli/src/Ldap/helpers.ts index 654734abcc..0785f7fdd1 100644 --- a/packages/cli/src/Ldap/helpers.ts +++ b/packages/cli/src/Ldap/helpers.ts @@ -2,6 +2,7 @@ import { AES, enc } from 'crypto-js'; import type { Entry as LdapUser } from 'ldapts'; import { Filter } from 'ldapts/filters/Filter'; +import { Container } from 'typedi'; import { UserSettings } from 'n8n-core'; import { validate } from 'jsonschema'; import * as Db from '@/Db'; @@ -16,23 +17,26 @@ import { LdapManager } from './LdapManager.ee'; import { BINARY_AD_ATTRIBUTES, LDAP_CONFIG_SCHEMA, - LDAP_ENABLED, LDAP_FEATURE_NAME, LDAP_LOGIN_ENABLED, LDAP_LOGIN_LABEL, } from './constants'; import type { ConnectionSecurity, LdapConfig } from './types'; import { jsonParse, LoggerProxy as Logger } from 'n8n-workflow'; -import { getLicense } from '@/License'; -import { Container } from 'typedi'; +import { License } from '@/License'; import { InternalHooks } from '@/InternalHooks'; +import { + isEmailCurrentAuthenticationMethod, + isLdapCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '@/sso/ssoHelpers'; /** * Check whether the LDAP feature is disabled in the instance */ export const isLdapEnabled = (): boolean => { - const license = getLicense(); - return isUserManagementEnabled() && (config.getEnv(LDAP_ENABLED) || license.isLdapEnabled()); + const license = Container.get(License); + return isUserManagementEnabled() && license.isLdapEnabled(); }; /** @@ -50,8 +54,24 @@ export const setLdapLoginLabel = (value: string): void => { /** * Set the LDAP login enabled to the configuration object */ -export const setLdapLoginEnabled = (value: boolean): void => { - config.set(LDAP_LOGIN_ENABLED, value); +export const setLdapLoginEnabled = async (value: boolean): Promise => { + if (config.get(LDAP_LOGIN_ENABLED) === value) { + return; + } + // only one auth method can be active at a time, with email being the default + if (value && isEmailCurrentAuthenticationMethod()) { + // enable ldap login and disable email login, but only if email is the current auth method + config.set(LDAP_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('ldap'); + } else if (!value && isLdapCurrentAuthenticationMethod()) { + // disable ldap login, but only if ldap is the current auth method + config.set(LDAP_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } else { + Logger.warn( + 'Cannot switch LDAP login enabled state when an authentication method other than email is active', + ); + } }; /** @@ -126,8 +146,8 @@ export const getLdapConfig = async (): Promise => { /** * Take the LDAP configuration and set login enabled and login label to the config object */ -export const setGlobalLdapConfigVariables = (ldapConfig: LdapConfig): void => { - setLdapLoginEnabled(ldapConfig.loginEnabled); +export const setGlobalLdapConfigVariables = async (ldapConfig: LdapConfig): Promise => { + await setLdapLoginEnabled(ldapConfig.loginEnabled); setLdapLoginLabel(ldapConfig.loginLabel); }; @@ -175,7 +195,7 @@ export const updateLdapConfig = async (ldapConfig: LdapConfig): Promise => { key: LDAP_FEATURE_NAME }, { value: JSON.stringify(ldapConfig), loadOnStartup: true }, ); - setGlobalLdapConfigVariables(ldapConfig); + await setGlobalLdapConfigVariables(ldapConfig); }; /** @@ -197,7 +217,7 @@ export const handleLdapInit = async (): Promise => { const ldapConfig = await getLdapConfig(); - setGlobalLdapConfigVariables(ldapConfig); + await setGlobalLdapConfigVariables(ldapConfig); // init LDAP manager with the current // configuration diff --git a/packages/cli/src/Ldap/routes/ldap.controller.ee.ts b/packages/cli/src/Ldap/routes/ldap.controller.ee.ts deleted file mode 100644 index aac5938415..0000000000 --- a/packages/cli/src/Ldap/routes/ldap.controller.ee.ts +++ /dev/null @@ -1,77 +0,0 @@ -import express from 'express'; -import { LdapManager } from '../LdapManager.ee'; -import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '../helpers'; -import type { LdapConfiguration } from '../types'; -import pick from 'lodash.pick'; -import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '../constants'; -import { InternalHooks } from '@/InternalHooks'; -import { Container } from 'typedi'; - -export const ldapController = express.Router(); - -/** - * GET /ldap/config - */ -ldapController.get('/config', async (req: express.Request, res: express.Response) => { - const data = await getLdapConfig(); - return res.status(200).json({ data }); -}); -/** - * POST /ldap/test-connection - */ -ldapController.post('/test-connection', async (req: express.Request, res: express.Response) => { - try { - await LdapManager.getInstance().service.testConnection(); - } catch (error) { - const errorObject = error as { message: string }; - return res.status(400).json({ message: errorObject.message }); - } - return res.status(200).json(); -}); - -/** - * PUT /ldap/config - */ -ldapController.put('/config', async (req: LdapConfiguration.Update, res: express.Response) => { - try { - await updateLdapConfig(req.body); - } catch (e) { - if (e instanceof Error) { - return res.status(400).json({ message: e.message }); - } - } - - const data = await getLdapConfig(); - - void Container.get(InternalHooks).onUserUpdatedLdapSettings({ - user_id: req.user.id, - ...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES), - }); - - return res.status(200).json({ data }); -}); - -/** - * POST /ldap/sync - */ -ldapController.post('/sync', async (req: LdapConfiguration.Sync, res: express.Response) => { - const runType = req.body.type; - - try { - await LdapManager.getInstance().sync.run(runType); - } catch (e) { - if (e instanceof Error) { - return res.status(400).json({ message: e.message }); - } - } - return res.status(200).json({}); -}); - -/** - * GET /ldap/sync - */ -ldapController.get('/sync', async (req: LdapConfiguration.GetSync, res: express.Response) => { - const { page = '0', perPage = '20' } = req.query; - const data = await getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); - return res.status(200).json({ data }); -}); diff --git a/packages/cli/src/License.ts b/packages/cli/src/License.ts index 35ed836060..de9d30e29e 100644 --- a/packages/cli/src/License.ts +++ b/packages/cli/src/License.ts @@ -5,8 +5,14 @@ import { getLogger } from './Logger'; import config from '@/config'; import * as Db from '@/Db'; import { LICENSE_FEATURES, N8N_VERSION, SETTINGS_LICENSE_CERT_KEY } from './constants'; +import { Service } from 'typedi'; async function loadCertStr(): Promise { + // if we have an ephemeral license, we don't want to load it from the database + const ephemeralLicense = config.get('license.cert'); + if (ephemeralLicense) { + return ephemeralLicense; + } const databaseSettings = await Db.collections.Settings.findOne({ where: { key: SETTINGS_LICENSE_CERT_KEY, @@ -17,6 +23,8 @@ async function loadCertStr(): Promise { } async function saveCertStr(value: TLicenseContainerStr): Promise { + // if we have an ephemeral license, we don't want to save it to the database + if (config.get('license.cert')) return; await Db.collections.Settings.upsert( { key: SETTINGS_LICENSE_CERT_KEY, @@ -27,6 +35,7 @@ async function saveCertStr(value: TLicenseContainerStr): Promise { ); } +@Service() export class License { private logger: ILogger; @@ -160,13 +169,3 @@ export class License { return (this.getFeatureValue('planName') ?? 'Community') as string; } } - -let licenseInstance: License | undefined; - -export function getLicense(): License { - if (licenseInstance === undefined) { - licenseInstance = new License(); - } - - return licenseInstance; -} diff --git a/packages/cli/src/LoadNodesAndCredentials.ts b/packages/cli/src/LoadNodesAndCredentials.ts index f25cef5f3b..8048673425 100644 --- a/packages/cli/src/LoadNodesAndCredentials.ts +++ b/packages/cli/src/LoadNodesAndCredentials.ts @@ -1,4 +1,5 @@ import uniq from 'lodash.uniq'; +import glob from 'fast-glob'; import type { DirectoryLoader, Types } from 'n8n-core'; import { CUSTOM_EXTENSION_ENV, @@ -18,18 +19,18 @@ import type { import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow'; import { createWriteStream } from 'fs'; -import { access as fsAccess, mkdir, readdir as fsReaddir, stat as fsStat } from 'fs/promises'; +import { mkdir } from 'fs/promises'; import path from 'path'; import config from '@/config'; import type { InstalledPackages } from '@db/entities/InstalledPackages'; import { executeCommand } from '@/CommunityNodes/helpers'; import { - CLI_DIR, GENERATED_STATIC_DIR, RESPONSE_ERROR_MESSAGES, CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_NAME, inTest, + CLI_DIR, } from '@/constants'; import { CredentialsOverwrites } from '@/CredentialsOverwrites'; import { Service } from 'typedi'; @@ -52,6 +53,8 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { logger: ILogger; + private downloadFolder: string; + async init() { // Make sure the imported modules can resolve dependencies fine. const delimiter = process.platform === 'win32' ? ';' : ':'; @@ -61,8 +64,13 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { // eslint-disable-next-line @typescript-eslint/no-unsafe-call if (!inTest) module.constructor._initPaths(); - await this.loadNodesFromBasePackages(); - await this.loadNodesFromDownloadedPackages(); + this.downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); + + // Load nodes from `n8n-nodes-base` and any other `n8n-nodes-*` package in the main `node_modules` + await this.loadNodesFromNodeModules(CLI_DIR); + // Load nodes from installed community packages + await this.loadNodesFromNodeModules(this.downloadFolder); + await this.loadNodesFromCustomDirectories(); await this.postProcessLoaders(); this.injectCustomApiCallOptions(); @@ -109,32 +117,20 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { await writeStaticJSON('credentials', this.types.credentials); } - private async loadNodesFromBasePackages() { - const nodeModulesPath = await this.getNodeModulesPath(); - const nodePackagePaths = await this.getN8nNodePackages(nodeModulesPath); + private async loadNodesFromNodeModules(scanDir: string): Promise { + const nodeModulesDir = path.join(scanDir, 'node_modules'); + const globOptions = { cwd: nodeModulesDir, onlyDirectories: true }; + const installedPackagePaths = [ + ...(await glob('n8n-nodes-*', { ...globOptions, deep: 1 })), + ...(await glob('@*/n8n-nodes-*', { ...globOptions, deep: 2 })), + ]; - for (const packagePath of nodePackagePaths) { - await this.runDirectoryLoader(LazyPackageDirectoryLoader, packagePath); - } - } - - private async loadNodesFromDownloadedPackages(): Promise { - const nodePackages = []; - try { - // Read downloaded nodes and credentials - const downloadedNodesDir = UserSettings.getUserN8nFolderDownloadedNodesPath(); - const downloadedNodesDirModules = path.join(downloadedNodesDir, 'node_modules'); - await fsAccess(downloadedNodesDirModules); - const downloadedPackages = await this.getN8nNodePackages(downloadedNodesDirModules); - nodePackages.push(...downloadedPackages); - } catch (error) { - // Folder does not exist so ignore and return - return; - } - - for (const packagePath of nodePackages) { + for (const packagePath of installedPackagePaths) { try { - await this.runDirectoryLoader(PackageDirectoryLoader, packagePath); + await this.runDirectoryLoader( + LazyPackageDirectoryLoader, + path.join(nodeModulesDir, packagePath), + ); } catch (error) { ErrorReporter.error(error); } @@ -158,49 +154,45 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { } } - /** - * Returns all the names of the packages which could contain n8n nodes - */ - private async getN8nNodePackages(baseModulesPath: string): Promise { - const getN8nNodePackagesRecursive = async (relativePath: string): Promise => { - const results: string[] = []; - const nodeModulesPath = `${baseModulesPath}/${relativePath}`; - const nodeModules = await fsReaddir(nodeModulesPath); - for (const nodeModule of nodeModules) { - const isN8nNodesPackage = nodeModule.indexOf('n8n-nodes-') === 0; - const isNpmScopedPackage = nodeModule.indexOf('@') === 0; - if (!isN8nNodesPackage && !isNpmScopedPackage) { - continue; - } - if (!(await fsStat(nodeModulesPath)).isDirectory()) { - continue; - } - if (isN8nNodesPackage) { - results.push(`${baseModulesPath}/${relativePath}${nodeModule}`); - } - if (isNpmScopedPackage) { - results.push(...(await getN8nNodePackagesRecursive(`${relativePath}${nodeModule}/`))); - } + private async installOrUpdateNpmModule( + packageName: string, + options: { version?: string } | { installedPackage: InstalledPackages }, + ) { + const isUpdate = 'installedPackage' in options; + const command = isUpdate + ? `npm update ${packageName}` + : `npm install ${packageName}${options.version ? `@${options.version}` : ''}`; + + try { + await executeCommand(command); + } catch (error) { + if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { + throw new Error(`The npm package "${packageName}" could not be found.`); } - return results; - }; - return getN8nNodePackagesRecursive(''); - } + throw error; + } - async loadNpmModule(packageName: string, version?: string): Promise { - const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); - const command = `npm install ${packageName}${version ? `@${version}` : ''}`; + const finalNodeUnpackedPath = path.join(this.downloadFolder, 'node_modules', packageName); - await executeCommand(command); - - const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName); - - const loader = await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath); + let loader: PackageDirectoryLoader; + try { + loader = await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath); + } catch (error) { + // Remove this package since loading it failed + const removeCommand = `npm remove ${packageName}`; + try { + await executeCommand(removeCommand); + } catch {} + throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_LOADING_FAILED, { cause: error }); + } if (loader.loadedNodes.length > 0) { // Save info to DB try { - const { persistInstalledPackageData } = await import('@/CommunityNodes/packageModel'); + const { persistInstalledPackageData, removePackageFromDatabase } = await import( + '@/CommunityNodes/packageModel' + ); + if (isUpdate) await removePackageFromDatabase(options.installedPackage); const installedPackage = await persistInstalledPackageData(loader); await this.postProcessLoaders(); await this.generateTypesForFrontend(); @@ -223,6 +215,10 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { } } + async installNpmModule(packageName: string, version?: string): Promise { + return this.installOrUpdateNpmModule(packageName, { version }); + } + async removeNpmModule(packageName: string, installedPackage: InstalledPackages): Promise { const command = `npm remove ${packageName}`; @@ -244,49 +240,7 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { packageName: string, installedPackage: InstalledPackages, ): Promise { - const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath(); - - const command = `npm i ${packageName}@latest`; - - try { - await executeCommand(command); - } catch (error) { - if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) { - throw new Error(`The npm package "${packageName}" could not be found.`); - } - throw error; - } - - const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName); - - const loader = await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath); - - if (loader.loadedNodes.length > 0) { - // Save info to DB - try { - const { persistInstalledPackageData, removePackageFromDatabase } = await import( - '@/CommunityNodes/packageModel' - ); - await removePackageFromDatabase(installedPackage); - const newlyInstalledPackage = await persistInstalledPackageData(loader); - await this.postProcessLoaders(); - await this.generateTypesForFrontend(); - return newlyInstalledPackage; - } catch (error) { - LoggerProxy.error('Failed to save installed packages and nodes', { - error: error as Error, - packageName, - }); - throw error; - } - } else { - // Remove this package since it contains no loadable nodes - const removeCommand = `npm remove ${packageName}`; - try { - await executeCommand(removeCommand); - } catch {} - throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES); - } + return this.installOrUpdateNpmModule(packageName, { installedPackage }); } /** @@ -399,27 +353,4 @@ export class LoadNodesAndCredentials implements INodesAndCredentials { } } } - - private async getNodeModulesPath(): Promise { - // Get the path to the node-modules folder to be later able - // to load the credentials and nodes - const checkPaths = [ - // In case "n8n" package is in same node_modules folder. - path.join(CLI_DIR, '..', 'n8n-workflow'), - // In case "n8n" package is the root and the packages are - // in the "node_modules" folder underneath it. - path.join(CLI_DIR, 'node_modules', 'n8n-workflow'), - // In case "n8n" package is installed using npm/yarn workspaces - // the node_modules folder is in the root of the workspace. - path.join(CLI_DIR, '..', '..', 'node_modules', 'n8n-workflow'), - ]; - for (const checkPath of checkPaths) { - try { - await fsAccess(checkPath); - // Folder exists, so use it. - return path.dirname(checkPath); - } catch {} // Folder does not exist so get next one - } - throw new Error('Could not find "node_modules" folder!'); - } } diff --git a/packages/cli/src/PublicApi/types.d.ts b/packages/cli/src/PublicApi/types.d.ts index b908f07b85..b1bd59d6dc 100644 --- a/packages/cli/src/PublicApi/types.d.ts +++ b/packages/cli/src/PublicApi/types.d.ts @@ -7,7 +7,7 @@ import type { Role } from '@db/entities/Role'; import type { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import type * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; +import type { UserManagementMailer } from '@/UserManagement/email'; import type { Risk } from '@/audit/types'; @@ -26,10 +26,10 @@ export type AuthenticatedRequest< > = express.Request & { user: User; globalMemberRole?: Role; - mailer?: UserManagementMailer.UserManagementMailer; + mailer?: UserManagementMailer; }; -export type PaginatatedRequest = AuthenticatedRequest< +export type PaginatedRequest = AuthenticatedRequest< {}, {}, {}, diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts index 4c7e0906a8..1dbfe72da2 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.handler.ts @@ -1,6 +1,6 @@ import type express from 'express'; import { Container } from 'typedi'; -import type { FindManyOptions, FindOptionsWhere } from 'typeorm'; +import type { FindOptionsWhere } from 'typeorm'; import { In } from 'typeorm'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; @@ -20,12 +20,11 @@ import { updateWorkflow, hasStartNode, getStartNode, - getWorkflows, getSharedWorkflows, - getWorkflowsCount, createWorkflow, getWorkflowIdsViaTags, parseTagNames, + getWorkflowsAndCount, } from './workflows.service'; import { WorkflowsService } from '@/workflows/workflows.services'; import { InternalHooks } from '@/InternalHooks'; @@ -98,28 +97,15 @@ export = { async (req: WorkflowRequest.GetAll, res: express.Response): Promise => { const { offset = 0, limit = 100, active = undefined, tags = undefined } = req.query; - let workflows: WorkflowEntity[]; - let count: number; - const where: FindOptionsWhere = { ...(active !== undefined && { active }), }; - const query: FindManyOptions = { - skip: offset, - take: limit, - where, - ...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }), - }; if (isInstanceOwner(req.user)) { if (tags) { const workflowIds = await getWorkflowIdsViaTags(parseTagNames(tags)); - Object.assign(where, { id: In(workflowIds) }); + where.id = In(workflowIds); } - - workflows = await getWorkflows(query); - - count = await getWorkflowsCount(query); } else { const options: { workflowIds?: string[] } = {}; @@ -137,14 +123,16 @@ export = { } const workflowsIds = sharedWorkflows.map((shareWorkflow) => shareWorkflow.workflowId); - - Object.assign(where, { id: In(workflowsIds) }); - - workflows = await getWorkflows(query); - - count = await getWorkflowsCount(query); + where.id = In(workflowsIds); } + const [workflows, count] = await getWorkflowsAndCount({ + skip: offset, + take: limit, + where, + ...(!config.getEnv('workflowTagsDisabled') && { relations: ['tags'] }), + }); + void Container.get(InternalHooks).onUserRetrievedAllWorkflows({ user_id: req.user.id, public_api: true, diff --git a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts index 62f6d74438..d727fd8805 100644 --- a/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts +++ b/packages/cli/src/PublicApi/v1/handlers/workflows/workflows.service.ts @@ -109,14 +109,10 @@ export async function deleteWorkflow(workflow: WorkflowEntity): Promise, -): Promise { - return Db.collections.Workflow.find(options); -} - -export async function getWorkflowsCount(options: FindManyOptions): Promise { - return Db.collections.Workflow.count(options); +): Promise<[WorkflowEntity[], number]> { + return Db.collections.Workflow.findAndCount(options); } export async function updateWorkflow( diff --git a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts index 5007c6ef99..1fe122e8f6 100644 --- a/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts +++ b/packages/cli/src/PublicApi/v1/shared/middlewares/global.middleware.ts @@ -2,7 +2,7 @@ import type express from 'express'; -import type { AuthenticatedRequest, PaginatatedRequest } from '../../../types'; +import type { AuthenticatedRequest, PaginatedRequest } from '../../../types'; import { decodeCursor } from '../services/pagination.service'; export const authorize = @@ -22,7 +22,7 @@ export const authorize = }; export const validCursor = ( - req: PaginatatedRequest, + req: PaginatedRequest, res: express.Response, next: express.NextFunction, ): express.Response | void => { diff --git a/packages/cli/src/Queue.ts b/packages/cli/src/Queue.ts index fb494ba8d3..1bd7fb5fcf 100644 --- a/packages/cli/src/Queue.ts +++ b/packages/cli/src/Queue.ts @@ -1,10 +1,10 @@ import type Bull from 'bull'; import type { RedisOptions } from 'ioredis'; +import { Service } from 'typedi'; import type { IExecuteResponsePromiseData } from 'n8n-workflow'; import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; import * as WebhookHelpers from '@/WebhookHelpers'; -import { Container } from 'typedi'; export type JobId = Bull.JobId; export type Job = Bull.Job; @@ -24,6 +24,7 @@ export interface WebhookResponse { response: IExecuteResponsePromiseData; } +@Service() export class Queue { private jobQueue: JobQueue; @@ -91,14 +92,3 @@ export class Queue { return false; } } - -let activeQueueInstance: Queue | undefined; - -export async function getInstance(): Promise { - if (activeQueueInstance === undefined) { - activeQueueInstance = new Queue(Container.get(ActiveExecutions)); - await activeQueueInstance.init(); - } - - return activeQueueInstance; -} diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 92e81161d3..1609b3ba28 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -19,6 +19,7 @@ import { createHmac } from 'crypto'; import { promisify } from 'util'; import cookieParser from 'cookie-parser'; import express from 'express'; +import { engine as expressHandlebars } from 'express-handlebars'; import type { ServeStaticOptions } from 'serve-static'; import type { FindManyOptions } from 'typeorm'; import { Not, In } from 'typeorm'; @@ -56,10 +57,9 @@ import timezones from 'google-timezones-json'; import history from 'connect-history-api-fallback'; import config from '@/config'; -import * as Queue from '@/Queue'; +import { Queue } from '@/Queue'; import { getSharedWorkflowIds } from '@/WorkflowHelpers'; -import { nodesController } from '@/api/nodes.api'; import { workflowsController } from '@/workflows/workflows.controller'; import { EDITOR_UI_DIST_DIR, @@ -83,16 +83,18 @@ import type { import { registerController } from '@/decorators'; import { AuthController, + LdapController, MeController, + NodesController, + NodeTypesController, OwnerController, PasswordResetController, + TagsController, TranslationController, UsersController, } from '@/controllers'; import { executionsController } from '@/executions/executions.controller'; -import { nodeTypesController } from '@/api/nodeTypes.api'; -import { tagsController } from '@/api/tags.api'; import { workflowStatsController } from '@/api/workflowStats.api'; import { loadPublicApiVersions } from '@/PublicApi'; import { @@ -102,7 +104,7 @@ import { isUserManagementEnabled, whereClause, } from '@/UserManagement/UserManagementHelper'; -import { getInstance as getMailerInstance } from '@/UserManagement/email'; +import { UserManagementMailer } from '@/UserManagement/email'; import * as Db from '@/Db'; import type { ICredentialsDb, @@ -127,15 +129,18 @@ import { WaitTracker } from '@/WaitTracker'; import * as WebhookHelpers from '@/WebhookHelpers'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { toHttpNodeParameters } from '@/CurlConverterHelper'; -import { eventBusRouter } from '@/eventbus/eventBusRoutes'; +import { EventBusController } from '@/eventbus/eventBus.controller'; import { isLogStreamingEnabled } from '@/eventbus/MessageEventBus/MessageEventBusHelper'; -import { getLicense } from '@/License'; import { licenseController } from './license/license.controller'; import { Push, setupPushServer, setupPushHandler } from '@/push'; import { setupAuthMiddlewares } from './middlewares'; import { initEvents } from './events'; -import { ldapController } from './Ldap/routes/ldap.controller.ee'; -import { getLdapLoginLabel, isLdapEnabled, isLdapLoginEnabled } from './Ldap/helpers'; +import { + getLdapLoginLabel, + handleLdapInit, + isLdapEnabled, + isLdapLoginEnabled, +} from './Ldap/helpers'; import { AbstractServer } from './AbstractServer'; import { configureMetrics } from './metrics'; import { setupBasicAuth } from './middlewares/basicAuth'; @@ -149,9 +154,9 @@ import { isAdvancedExecutionFiltersEnabled, } from './executions/executionHelpers'; import { getSamlLoginLabel, isSamlLoginEnabled, isSamlLicensed } from './sso/saml/samlHelpers'; -import { samlControllerPublic } from './sso/saml/routes/saml.controller.public.ee'; +import { SamlController } from './sso/saml/routes/saml.controller.ee'; import { SamlService } from './sso/saml/saml.service.ee'; -import { samlControllerProtected } from './sso/saml/routes/saml.controller.protected.ee'; +import { LdapManager } from './Ldap/LdapManager.ee'; const exec = promisify(callbackExec); @@ -179,6 +184,10 @@ class Server extends AbstractServer { constructor() { super(); + this.app.engine('handlebars', expressHandlebars({ defaultLayout: false })); + this.app.set('view engine', 'handlebars'); + this.app.set('views', TEMPLATES_DIR); + this.loadNodesAndCredentials = Container.get(LoadNodesAndCredentials); this.credentialTypes = Container.get(CredentialTypes); this.nodeTypes = Container.get(NodeTypes); @@ -302,8 +311,8 @@ class Server extends AbstractServer { sharing: false, ldap: false, saml: false, - logStreaming: config.getEnv('enterprise.features.logStreaming'), - advancedExecutionFilters: config.getEnv('enterprise.features.advancedExecutionFilters'), + logStreaming: false, + advancedExecutionFilters: false, }, hideUsagePage: config.getEnv('hideUsagePage'), license: { @@ -356,35 +365,32 @@ class Server extends AbstractServer { return this.frontendSettings; } - async initLicense(): Promise { - const license = getLicense(); - await license.init(this.frontendSettings.instanceId); - - const activationKey = config.getEnv('license.activationKey'); - if (activationKey) { - try { - await license.activate(activationKey); - } catch (e) { - LoggerProxy.error('Could not activate license', e); - } - } - } - private registerControllers(ignoredEndpoints: Readonly) { - const { app, externalHooks, activeWorkflowRunner } = this; + const { app, externalHooks, activeWorkflowRunner, nodeTypes } = this; const repositories = Db.collections; setupAuthMiddlewares(app, ignoredEndpoints, this.restEndpoint, repositories.User); const logger = LoggerProxy; const internalHooks = Container.get(InternalHooks); - const mailer = getMailerInstance(); + const mailer = Container.get(UserManagementMailer); const postHog = this.postHog; + const samlService = Container.get(SamlService); - const controllers = [ + const controllers: object[] = [ + new EventBusController(), new AuthController({ config, internalHooks, repositories, logger, postHog }), new OwnerController({ config, internalHooks, repositories, logger }), new MeController({ externalHooks, internalHooks, repositories, logger }), - new PasswordResetController({ config, externalHooks, internalHooks, repositories, logger }), + new NodeTypesController({ config, nodeTypes }), + new PasswordResetController({ + config, + externalHooks, + internalHooks, + mailer, + repositories, + logger, + }), + new TagsController({ config, repositories, externalHooks }), new TranslationController(config, this.credentialTypes), new UsersController({ config, @@ -396,7 +402,20 @@ class Server extends AbstractServer { logger, postHog, }), + new SamlController(samlService), ]; + + if (isLdapEnabled()) { + const { service, sync } = LdapManager.getInstance(); + controllers.push(new LdapController(service, sync, internalHooks)); + } + + if (config.getEnv('nodes.communityPackages.enabled')) { + controllers.push( + new NodesController(config, this.loadNodesAndCredentials, this.push, internalHooks), + ); + } + controllers.forEach((controller) => registerController(app, config, controller)); } @@ -413,7 +432,6 @@ class Server extends AbstractServer { await this.externalHooks.run('frontend.settings', [this.frontendSettings]); - await this.initLicense(); await this.postHog.init(this.frontendSettings.instanceId); const publicApiEndpoint = config.getEnv('publicApi.path'); @@ -423,8 +441,6 @@ class Server extends AbstractServer { 'assets', 'healthz', 'metrics', - 'icons', - 'types', 'e2e', this.endpointWebhook, this.endpointWebhookTest, @@ -475,20 +491,16 @@ class Server extends AbstractServer { }), ); - // ---------------------------------------- - // User Management - // ---------------------------------------- + if (config.getEnv('executions.mode') === 'queue') { + await Container.get(Queue).init(); + } + + await handleLdapInit(); + this.registerControllers(ignoredEndpoints); this.app.use(`/${this.restEndpoint}/credentials`, credentialsController); - // ---------------------------------------- - // Packages and nodes management - // ---------------------------------------- - if (config.getEnv('nodes.communityPackages.enabled')) { - this.app.use(`/${this.restEndpoint}/nodes`, nodesController); - } - // ---------------------------------------- // Workflow // ---------------------------------------- @@ -504,18 +516,6 @@ class Server extends AbstractServer { // ---------------------------------------- this.app.use(`/${this.restEndpoint}/workflow-stats`, workflowStatsController); - // ---------------------------------------- - // Tags - // ---------------------------------------- - this.app.use(`/${this.restEndpoint}/tags`, tagsController); - - // ---------------------------------------- - // LDAP - // ---------------------------------------- - if (isLdapEnabled()) { - this.app.use(`/${this.restEndpoint}/ldap`, ldapController); - } - // ---------------------------------------- // SAML // ---------------------------------------- @@ -524,17 +524,13 @@ class Server extends AbstractServer { // set up the initial environment if (isSamlLicensed()) { try { - await SamlService.getInstance().init(); + await Container.get(SamlService).init(); } catch (error) { LoggerProxy.error(`SAML initialization failed: ${error.message}`); } } - this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerPublic); - this.app.use(`/${this.restEndpoint}/sso/saml`, samlControllerProtected); - // ---------------------------------------- - // Returns parameter values which normally get loaded from an external API or // get generated dynamically this.app.get( @@ -645,12 +641,6 @@ class Server extends AbstractServer { ), ); - // ---------------------------------------- - // Node-Types - // ---------------------------------------- - - this.app.use(`/${this.restEndpoint}/node-types`, nodeTypesController); - // ---------------------------------------- // Active Workflows // ---------------------------------------- @@ -978,7 +968,7 @@ class Server extends AbstractServer { ResponseHelper.send( async (req: ExecutionRequest.GetAllCurrent): Promise => { if (config.getEnv('executions.mode') === 'queue') { - const queue = await Queue.getInstance(); + const queue = Container.get(Queue); const currentJobs = await queue.getJobs(['active', 'waiting']); const currentlyRunningQueueIds = currentJobs.map((job) => job.data.executionId); @@ -1121,7 +1111,7 @@ class Server extends AbstractServer { } as IExecutionsStopData; } - const queue = await Queue.getInstance(); + const queue = Container.get(Queue); const currentJobs = await queue.getJobs(['active', 'waiting']); const job = currentJobs.find((job) => job.data.executionId === req.params.id); @@ -1240,8 +1230,6 @@ class Server extends AbstractServer { if (!eventBus.isInitialized) { await eventBus.initialize(); } - // add Event Bus REST endpoints - this.app.use(`/${this.restEndpoint}/eventbus`, eventBusRouter); // ---------------------------------------- // Webhooks diff --git a/packages/cli/src/UserManagement/PermissionChecker.ts b/packages/cli/src/UserManagement/PermissionChecker.ts index 8949156587..fb2e7b19e1 100644 --- a/packages/cli/src/UserManagement/PermissionChecker.ts +++ b/packages/cli/src/UserManagement/PermissionChecker.ts @@ -116,7 +116,7 @@ export class PermissionChecker { if (parentWorkflowId === undefined) { throw errorToThrow; } - const allowedCallerIds = (subworkflow.settings.callerIds as string | undefined) + const allowedCallerIds = subworkflow.settings.callerIds ?.split(',') .map((id) => id.trim()) .filter((id) => id !== ''); diff --git a/packages/cli/src/UserManagement/UserManagementHelper.ts b/packages/cli/src/UserManagement/UserManagementHelper.ts index bd1c2d3881..2e4682db2e 100644 --- a/packages/cli/src/UserManagement/UserManagementHelper.ts +++ b/packages/cli/src/UserManagement/UserManagementHelper.ts @@ -3,6 +3,7 @@ import { In } from 'typeorm'; import type express from 'express'; import { compare, genSaltSync, hash } from 'bcryptjs'; +import Container from 'typedi'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; @@ -13,7 +14,7 @@ import type { Role } from '@db/entities/Role'; import type { AuthenticatedRequest } from '@/requests'; import config from '@/config'; import { getWebhookBaseUrl } from '@/WebhookHelpers'; -import { getLicense } from '@/License'; +import { License } from '@/License'; import { RoleService } from '@/role/role.service'; import type { PostHogClient } from '@/posthog'; @@ -55,11 +56,8 @@ export function isUserManagementEnabled(): boolean { } export function isSharingEnabled(): boolean { - const license = getLicense(); - return ( - isUserManagementEnabled() && - (config.getEnv('enterprise.features.sharing') || license.isSharingEnabled()) - ); + const license = Container.get(License); + return isUserManagementEnabled() && license.isSharingEnabled(); } export async function getRoleId(scope: Role['scope'], name: Role['name']): Promise { diff --git a/packages/cli/src/UserManagement/email/Interfaces.ts b/packages/cli/src/UserManagement/email/Interfaces.ts index 0bbeba781b..5116434998 100644 --- a/packages/cli/src/UserManagement/email/Interfaces.ts +++ b/packages/cli/src/UserManagement/email/Interfaces.ts @@ -1,9 +1,3 @@ -export interface UserManagementMailerImplementation { - init: () => Promise; - sendMail: (mailData: MailData) => Promise; - verifyConnection: () => Promise; -} - export type InviteEmailData = { email: string; firstName?: string; diff --git a/packages/cli/src/UserManagement/email/NodeMailer.ts b/packages/cli/src/UserManagement/email/NodeMailer.ts index 98da46effe..32ee1b0238 100644 --- a/packages/cli/src/UserManagement/email/NodeMailer.ts +++ b/packages/cli/src/UserManagement/email/NodeMailer.ts @@ -3,9 +3,9 @@ import type { Transporter } from 'nodemailer'; import { createTransport } from 'nodemailer'; import { ErrorReporterProxy as ErrorReporter, LoggerProxy as Logger } from 'n8n-workflow'; import config from '@/config'; -import type { MailData, SendEmailResult, UserManagementMailerImplementation } from './Interfaces'; +import type { MailData, SendEmailResult } from './Interfaces'; -export class NodeMailer implements UserManagementMailerImplementation { +export class NodeMailer { private transport?: Transporter; async init(): Promise { diff --git a/packages/cli/src/UserManagement/email/UserManagementMailer.ts b/packages/cli/src/UserManagement/email/UserManagementMailer.ts index 2c9ef67198..4a140184cf 100644 --- a/packages/cli/src/UserManagement/email/UserManagementMailer.ts +++ b/packages/cli/src/UserManagement/email/UserManagementMailer.ts @@ -2,13 +2,9 @@ import { existsSync } from 'fs'; import { readFile } from 'fs/promises'; import Handlebars from 'handlebars'; import { join as pathJoin } from 'path'; +import { Service } from 'typedi'; import config from '@/config'; -import type { - InviteEmailData, - PasswordResetData, - SendEmailResult, - UserManagementMailerImplementation, -} from './Interfaces'; +import type { InviteEmailData, PasswordResetData, SendEmailResult } from './Interfaces'; import { NodeMailer } from './NodeMailer'; type Template = HandlebarsTemplateDelegate; @@ -36,8 +32,9 @@ async function getTemplate( return template; } +@Service() export class UserManagementMailer { - private mailer: UserManagementMailerImplementation | undefined; + private mailer: NodeMailer | undefined; constructor() { // Other implementations can be used in the future. @@ -81,12 +78,3 @@ export class UserManagementMailer { return result ?? { emailSent: false }; } } - -let mailerInstance: UserManagementMailer | undefined; - -export function getInstance(): UserManagementMailer { - if (mailerInstance === undefined) { - mailerInstance = new UserManagementMailer(); - } - return mailerInstance; -} diff --git a/packages/cli/src/UserManagement/email/index.ts b/packages/cli/src/UserManagement/email/index.ts index e577648f8e..8c94805eb6 100644 --- a/packages/cli/src/UserManagement/email/index.ts +++ b/packages/cli/src/UserManagement/email/index.ts @@ -1,3 +1,3 @@ -import { getInstance, UserManagementMailer } from './UserManagementMailer'; +import { UserManagementMailer } from './UserManagementMailer'; -export { getInstance, UserManagementMailer }; +export { UserManagementMailer }; diff --git a/packages/cli/src/WaitTracker.ts b/packages/cli/src/WaitTracker.ts index b36d49856f..4441cb8e31 100644 --- a/packages/cli/src/WaitTracker.ts +++ b/packages/cli/src/WaitTracker.ts @@ -10,6 +10,7 @@ import { LoggerProxy as Logger, WorkflowOperationError, } from 'n8n-workflow'; +import { Service } from 'typedi'; import type { FindManyOptions, ObjectLiteral } from 'typeorm'; import { Not, LessThanOrEqual } from 'typeorm'; import { DateUtils } from 'typeorm/util/DateUtils'; @@ -17,7 +18,6 @@ import { DateUtils } from 'typeorm/util/DateUtils'; import config from '@/config'; import * as Db from '@/Db'; import * as ResponseHelper from '@/ResponseHelper'; -import { ActiveExecutions } from '@/ActiveExecutions'; import type { IExecutionFlattedDb, IExecutionsStopData, @@ -25,12 +25,9 @@ import type { } from '@/Interfaces'; import { WorkflowRunner } from '@/WorkflowRunner'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; -import { Container, Service } from 'typedi'; @Service() export class WaitTracker { - activeExecutionsInstance: ActiveExecutions; - private waitingExecutions: { [key: string]: { executionId: string; @@ -41,8 +38,6 @@ export class WaitTracker { mainTimer: NodeJS.Timeout; constructor() { - this.activeExecutionsInstance = Container.get(ActiveExecutions); - // Poll every 60 seconds a list of upcoming executions this.mainTimer = setInterval(() => { this.getWaitingExecutions(); diff --git a/packages/cli/src/WaitingWebhooks.ts b/packages/cli/src/WaitingWebhooks.ts index 262ff28963..f5e9ac6344 100644 --- a/packages/cli/src/WaitingWebhooks.ts +++ b/packages/cli/src/WaitingWebhooks.ts @@ -3,7 +3,7 @@ /* eslint-disable no-param-reassign */ import type { INode, WebhookHttpMethod } from 'n8n-workflow'; import { NodeHelpers, Workflow, LoggerProxy as Logger } from 'n8n-workflow'; - +import { Service } from 'typedi'; import type express from 'express'; import * as Db from '@/Db'; @@ -13,9 +13,11 @@ import { NodeTypes } from '@/NodeTypes'; import type { IExecutionResponse, IResponseCallbackData, IWorkflowDb } from '@/Interfaces'; import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; -import { Container } from 'typedi'; +@Service() export class WaitingWebhooks { + constructor(private nodeTypes: NodeTypes) {} + async executeWebhook( httpMethod: WebhookHttpMethod, fullPath: string, @@ -79,14 +81,13 @@ export class WaitingWebhooks { const { workflowData } = fullExecutionData; - const nodeTypes = Container.get(NodeTypes); const workflow = new Workflow({ id: workflowData.id!.toString(), name: workflowData.name, nodes: workflowData.nodes, connections: workflowData.connections, active: workflowData.active, - nodeTypes, + nodeTypes: this.nodeTypes, staticData: workflowData.staticData, settings: workflowData.settings, }); diff --git a/packages/cli/src/WebhookHelpers.ts b/packages/cli/src/WebhookHelpers.ts index aa73abd3a3..e399f10beb 100644 --- a/packages/cli/src/WebhookHelpers.ts +++ b/packages/cli/src/WebhookHelpers.ts @@ -16,7 +16,7 @@ import type express from 'express'; import get from 'lodash.get'; -import { BINARY_ENCODING, BinaryDataManager, NodeExecuteFunctions, eventEmitter } from 'n8n-core'; +import { BinaryDataManager, NodeExecuteFunctions, eventEmitter } from 'n8n-core'; import type { IBinaryKeyData, @@ -35,6 +35,7 @@ import type { WorkflowExecuteMode, } from 'n8n-workflow'; import { + BINARY_ENCODING, createDeferredPromise, ErrorReporterProxy as ErrorReporter, LoggerProxy as Logger, diff --git a/packages/cli/src/WorkflowExecuteAdditionalData.ts b/packages/cli/src/WorkflowExecuteAdditionalData.ts index 4488af126c..85597074b4 100644 --- a/packages/cli/src/WorkflowExecuteAdditionalData.ts +++ b/packages/cli/src/WorkflowExecuteAdditionalData.ts @@ -66,11 +66,12 @@ import * as ResponseHelper from '@/ResponseHelper'; import * as WebhookHelpers from '@/WebhookHelpers'; import * as WorkflowHelpers from '@/WorkflowHelpers'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; -import { findSubworkflowStart } from '@/utils'; +import { findSubworkflowStart, isWorkflowIdValid } from '@/utils'; import { PermissionChecker } from './UserManagement/PermissionChecker'; import { WorkflowsService } from './workflows/workflows.services'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; +import type { ExecutionMetadata } from './databases/entities/ExecutionMetadata'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -135,17 +136,11 @@ export function executeErrorWorkflow( // Run the error workflow // To avoid an infinite loop do not run the error workflow again if the error-workflow itself failed and it is its own error-workflow. - if ( - workflowData.settings?.errorWorkflow && - !( - mode === 'error' && - workflowId && - workflowData.settings.errorWorkflow.toString() === workflowId - ) - ) { + const { errorWorkflow } = workflowData.settings ?? {}; + if (errorWorkflow && !(mode === 'error' && workflowId && errorWorkflow === workflowId)) { Logger.verbose('Start external error workflow', { executionId, - errorWorkflowId: workflowData.settings.errorWorkflow.toString(), + errorWorkflowId: errorWorkflow, workflowId, }); // If a specific error workflow is set run only that one @@ -159,11 +154,7 @@ export function executeErrorWorkflow( } getWorkflowOwner(workflowId) .then((user) => { - void WorkflowHelpers.executeErrorWorkflow( - workflowData.settings!.errorWorkflow as string, - workflowErrorData, - user, - ); + void WorkflowHelpers.executeErrorWorkflow(errorWorkflow, workflowErrorData, user); }) .catch((error: Error) => { ErrorReporter.error(error); @@ -171,7 +162,7 @@ export function executeErrorWorkflow( `Could not execute ErrorWorkflow for execution ID ${this.executionId} because of error querying the workflow owner`, { executionId, - errorWorkflowId: workflowData.settings!.errorWorkflow!.toString(), + errorWorkflowId: errorWorkflow, workflowId, error, workflowErrorData, @@ -264,6 +255,22 @@ async function pruneExecutionData(this: WorkflowHooks): Promise { } } +export async function saveExecutionMetadata( + executionId: string, + executionMetadata: Record, +): Promise { + const metadataRows = []; + for (const [key, value] of Object.entries(executionMetadata)) { + metadataRows.push({ + execution: { id: executionId }, + key, + value, + }); + } + + return Db.collections.ExecutionMetadata.save(metadataRows); +} + /** * Returns hook functions to push data to Editor-UI * @@ -404,21 +411,21 @@ export function hookFunctionsPreExecute(parentProcessMode?: string): IWorkflowEx ], nodeExecuteAfter: [ async function ( + this: WorkflowHooks, nodeName: string, data: ITaskData, executionData: IRunExecutionData, ): Promise { - if (this.workflowData.settings !== undefined) { - if (this.workflowData.settings.saveExecutionProgress === false) { + const saveExecutionProgress = config.getEnv('executions.saveExecutionProgress'); + const workflowSettings = this.workflowData.settings; + if (workflowSettings !== undefined) { + if (workflowSettings.saveExecutionProgress === false) { return; } - if ( - this.workflowData.settings.saveExecutionProgress !== true && - !config.getEnv('executions.saveExecutionProgress') - ) { + if (workflowSettings.saveExecutionProgress !== true && !saveExecutionProgress) { return; } - } else if (!config.getEnv('executions.saveExecutionProgress')) { + } else if (!saveExecutionProgress) { return; } @@ -530,11 +537,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { const isManualMode = [this.mode, parentProcessMode].includes('manual'); try { - if ( - !isManualMode && - WorkflowHelpers.isWorkflowIdValid(this.workflowData.id) && - newStaticData - ) { + if (!isManualMode && isWorkflowIdValid(this.workflowData.id) && newStaticData) { // Workflow is saved so update in database try { await WorkflowHelpers.saveStaticDataById( @@ -550,13 +553,11 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { } } + const workflowSettings = this.workflowData.settings; let saveManualExecutions = config.getEnv('executions.saveDataManualExecutions'); - if ( - this.workflowData.settings !== undefined && - this.workflowData.settings.saveManualExecutions !== undefined - ) { + if (workflowSettings?.saveManualExecutions !== undefined) { // Apply to workflow override - saveManualExecutions = this.workflowData.settings.saveManualExecutions as boolean; + saveManualExecutions = workflowSettings.saveManualExecutions as boolean; } if (isManualMode && !saveManualExecutions && !fullRunData.waitTill) { @@ -641,7 +642,7 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { } const workflowId = this.workflowData.id; - if (WorkflowHelpers.isWorkflowIdValid(workflowId)) { + if (isWorkflowIdValid(workflowId)) { fullExecutionData.workflowId = workflowId; } @@ -661,6 +662,14 @@ function hookFunctionsSave(parentProcessMode?: string): IWorkflowExecuteHooks { executionData as IExecutionFlattedDb, ); + try { + if (fullRunData.data.resultData.metadata) { + await saveExecutionMetadata(this.executionId, fullRunData.data.resultData.metadata); + } + } catch (e) { + Logger.error(`Failed to save metadata for execution ID ${this.executionId}`, e); + } + if (fullRunData.finished === true && this.retryOf !== undefined) { // If the retry was successful save the reference it on the original execution // await Db.collections.Execution.save(executionData as IExecutionFlattedDb); @@ -729,7 +738,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { newStaticData: IDataObject, ): Promise { try { - if (WorkflowHelpers.isWorkflowIdValid(this.workflowData.id) && newStaticData) { + if (isWorkflowIdValid(this.workflowData.id) && newStaticData) { // Workflow is saved so update in database try { await WorkflowHelpers.saveStaticDataById( @@ -776,7 +785,7 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { } const workflowId = this.workflowData.id; - if (WorkflowHelpers.isWorkflowIdValid(workflowId)) { + if (isWorkflowIdValid(workflowId)) { fullExecutionData.workflowId = workflowId; } @@ -793,6 +802,14 @@ function hookFunctionsSaveWorker(): IWorkflowExecuteHooks { status: executionData.status, }); + try { + if (fullRunData.data.resultData.metadata) { + await saveExecutionMetadata(this.executionId, fullRunData.data.resultData.metadata); + } + } catch (e) { + Logger.error(`Failed to save metadata for execution ID ${this.executionId}`, e); + } + if (fullRunData.finished === true && this.retryOf !== undefined) { // If the retry was successful save the reference it on the original execution await Db.collections.Execution.update(this.retryOf, { @@ -995,16 +1012,14 @@ async function executeWorkflow( additionalDataIntegrated.executeWorkflow = additionalData.executeWorkflow; let subworkflowTimeout = additionalData.executionTimeoutTimestamp; - if ( - workflowData.settings?.executionTimeout !== undefined && - workflowData.settings.executionTimeout > 0 - ) { + const workflowSettings = workflowData.settings; + if (workflowSettings?.executionTimeout !== undefined && workflowSettings.executionTimeout > 0) { // We might have received a max timeout timestamp from the parent workflow // If we did, then we get the minimum time between the two timeouts // If no timeout was given from the parent, then we use our timeout. subworkflowTimeout = Math.min( additionalData.executionTimeoutTimestamp || Number.MAX_SAFE_INTEGER, - Date.now() + (workflowData.settings.executionTimeout as number) * 1000, + Date.now() + workflowSettings.executionTimeout * 1000, ); } diff --git a/packages/cli/src/WorkflowHelpers.ts b/packages/cli/src/WorkflowHelpers.ts index b2d01cd0ea..f201abd40c 100644 --- a/packages/cli/src/WorkflowHelpers.ts +++ b/packages/cli/src/WorkflowHelpers.ts @@ -1,4 +1,5 @@ import { In } from 'typeorm'; +import { Container } from 'typedi'; import type { IDataObject, IExecuteData, @@ -32,7 +33,7 @@ import type { User } from '@db/entities/User'; import { whereClause } from '@/UserManagement/UserManagementHelper'; import omit from 'lodash.omit'; import { PermissionChecker } from './UserManagement/PermissionChecker'; -import { Container } from 'typedi'; +import { isWorkflowIdValid } from './utils'; const ERROR_TRIGGER_TYPE = config.getEnv('nodes.errorTriggerType'); @@ -74,15 +75,6 @@ export function getDataLastExecutedNodeData(inputData: IRun): ITaskData | undefi return lastNodeRunData; } -/** - * Returns if the given id is a valid workflow id - * - * @param {(string | null | undefined)} id The id to check - */ -export function isWorkflowIdValid(id: string | null | undefined): boolean { - return !(typeof id === 'string' && isNaN(parseInt(id, 10))); -} - /** * Executes the error workflow * diff --git a/packages/cli/src/WorkflowRunner.ts b/packages/cli/src/WorkflowRunner.ts index 53b2a566a4..16683b57c9 100644 --- a/packages/cli/src/WorkflowRunner.ts +++ b/packages/cli/src/WorkflowRunner.ts @@ -21,6 +21,7 @@ import type { IRun, WorkflowExecuteMode, WorkflowHooks, + WorkflowSettings, } from 'n8n-workflow'; import { ErrorReporterProxy as ErrorReporter, @@ -44,7 +45,8 @@ import type { IWorkflowExecutionDataProcessWithExecution, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; -import * as Queue from '@/Queue'; +import type { Job, JobData, JobQueue, JobResponse } from '@/Queue'; +import { Queue } from '@/Queue'; import * as ResponseHelper from '@/ResponseHelper'; import * as WebhookHelpers from '@/WebhookHelpers'; import * as WorkflowHelpers from '@/WorkflowHelpers'; @@ -63,7 +65,7 @@ export class WorkflowRunner { push: Push; - jobQueue: Queue.JobQueue; + jobQueue: JobQueue; constructor() { this.push = Container.get(Push); @@ -167,7 +169,7 @@ export class WorkflowRunner { await initErrorHandling(); if (executionsMode === 'queue') { - const queue = await Queue.getInstance(); + const queue = Container.get(Queue); this.jobQueue = queue.getBullObjectInstance(); } @@ -247,11 +249,9 @@ export class WorkflowRunner { // Changes were made by adding the `workflowTimeout` to the `additionalData` // So that the timeout will also work for executions with nested workflows. let executionTimeout: NodeJS.Timeout; - let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default - if (data.workflowData.settings && data.workflowData.settings.executionTimeout) { - workflowTimeout = data.workflowData.settings.executionTimeout as number; // preference on workflow setting - } + const workflowSettings = data.workflowData.settings ?? {}; + let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default if (workflowTimeout > 0) { workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); } @@ -264,7 +264,7 @@ export class WorkflowRunner { active: data.workflowData.active, nodeTypes, staticData: data.workflowData.staticData, - settings: data.workflowData.settings, + settings: workflowSettings, }); const additionalData = await WorkflowExecuteAdditionalData.getBase( data.userId, @@ -434,7 +434,7 @@ export class WorkflowRunner { this.activeExecutions.attachResponsePromise(executionId, responsePromise); } - const jobData: Queue.JobData = { + const jobData: JobData = { executionId, loadStaticData: !!loadStaticData, }; @@ -451,7 +451,7 @@ export class WorkflowRunner { removeOnComplete: true, removeOnFail: true, }; - let job: Queue.Job; + let job: Job; let hooks: WorkflowHooks; try { job = await this.jobQueue.add(jobData, jobOptions); @@ -485,7 +485,7 @@ export class WorkflowRunner { async (resolve, reject, onCancel) => { onCancel.shouldReject = false; onCancel(async () => { - const queue = await Queue.getInstance(); + const queue = Container.get(Queue); await queue.stopJob(job); // We use "getWorkflowHooksWorkerExecuter" as "getWorkflowHooksWorkerMain" does not contain the @@ -503,11 +503,11 @@ export class WorkflowRunner { reject(error); }); - const jobData: Promise = job.finished(); + const jobData: Promise = job.finished(); const queueRecoveryInterval = config.getEnv('queue.bull.queueRecoveryInterval'); - const racingPromises: Array> = [jobData]; + const racingPromises: Array> = [jobData]; let clearWatchdogInterval; if (queueRecoveryInterval > 0) { @@ -589,16 +589,12 @@ export class WorkflowRunner { try { // Check if this execution data has to be removed from database // based on workflow settings. - let saveDataErrorExecution = config.getEnv('executions.saveDataOnError') as string; - let saveDataSuccessExecution = config.getEnv('executions.saveDataOnSuccess') as string; - if (data.workflowData.settings !== undefined) { - saveDataErrorExecution = - (data.workflowData.settings.saveDataErrorExecution as string) || - saveDataErrorExecution; - saveDataSuccessExecution = - (data.workflowData.settings.saveDataSuccessExecution as string) || - saveDataSuccessExecution; - } + const workflowSettings = data.workflowData.settings ?? {}; + const saveDataErrorExecution = + workflowSettings.saveDataErrorExecution ?? config.getEnv('executions.saveDataOnError'); + const saveDataSuccessExecution = + workflowSettings.saveDataSuccessExecution ?? + config.getEnv('executions.saveDataOnSuccess'); const workflowDidSucceed = !runData.data.resultData.error; if ( @@ -665,10 +661,9 @@ export class WorkflowRunner { // Start timeout for the execution let executionTimeout: NodeJS.Timeout; - let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default - if (data.workflowData.settings && data.workflowData.settings.executionTimeout) { - workflowTimeout = data.workflowData.settings.executionTimeout as number; // preference on workflow setting - } + + const workflowSettings = data.workflowData.settings ?? {}; + let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default const processTimeoutFunction = (timeout: number) => { this.activeExecutions.stopExecution(executionId, 'timeout'); diff --git a/packages/cli/src/WorkflowRunnerProcess.ts b/packages/cli/src/WorkflowRunnerProcess.ts index 60c9bc1cf0..32c0d6c93c 100644 --- a/packages/cli/src/WorkflowRunnerProcess.ts +++ b/packages/cli/src/WorkflowRunnerProcess.ts @@ -54,9 +54,9 @@ import config from '@/config'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { initErrorHandling } from '@/ErrorReporting'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; -import { getLicense } from './License'; -import { InternalHooks } from './InternalHooks'; -import { PostHogClient } from './posthog'; +import { License } from '@/License'; +import { InternalHooks } from '@/InternalHooks'; +import { PostHogClient } from '@/posthog'; class WorkflowRunnerProcess { data: IWorkflowExecutionDataProcessWithExecution | undefined; @@ -127,16 +127,13 @@ class WorkflowRunnerProcess { // Init db since we need to read the license. await Db.init(); - const license = getLicense(); + const license = Container.get(License); await license.init(instanceId); - // Start timeout for the execution - let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - if (this.data.workflowData.settings && this.data.workflowData.settings.executionTimeout) { - workflowTimeout = this.data.workflowData.settings.executionTimeout as number; // preference on workflow setting - } + const workflowSettings = this.data.workflowData.settings ?? {}; + // Start timeout for the execution + let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default if (workflowTimeout > 0) { workflowTimeout = Math.min(workflowTimeout, config.getEnv('executions.maxTimeout')); } diff --git a/packages/cli/src/api/e2e.api.ts b/packages/cli/src/api/e2e.api.ts index befd913114..fb9097da61 100644 --- a/packages/cli/src/api/e2e.api.ts +++ b/packages/cli/src/api/e2e.api.ts @@ -5,6 +5,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/naming-convention */ import { Router } from 'express'; +import type { Request } from 'express'; import bodyParser from 'body-parser'; import { v4 as uuid } from 'uuid'; import config from '@/config'; @@ -12,12 +13,26 @@ import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; import { hashPassword } from '@/UserManagement/UserManagementHelper'; import { eventBus } from '@/eventbus/MessageEventBus/MessageEventBus'; +import Container from 'typedi'; +import { License } from '../License'; if (process.env.E2E_TESTS !== 'true') { console.error('E2E endpoints only allowed during E2E tests'); process.exit(1); } +const enabledFeatures = { + sharing: true, //default to true here instead of setting it in config/index.ts for e2e + ldap: false, + saml: false, + logStreaming: false, + advancedExecutionFilters: false, +}; + +type Feature = keyof typeof enabledFeatures; + +Container.get(License).isFeatureEnabled = (feature: Feature) => enabledFeatures[feature] ?? false; + const tablesToTruncate = [ 'auth_identity', 'auth_provider_sync_history', @@ -78,7 +93,7 @@ const setupUserManagement = async () => { }; const resetLogStreaming = async () => { - config.set('enterprise.features.logStreaming', false); + enabledFeatures.logStreaming = false; for (const id in eventBus.destinations) { await eventBus.removeDestination(id); } @@ -127,7 +142,8 @@ e2eController.post('/db/setup-owner', bodyParser.json(), async (req, res) => { res.writeHead(204).end(); }); -e2eController.post('/enable-feature/:feature', async (req, res) => { - config.set(`enterprise.features.${req.params.feature}`, true); +e2eController.post('/enable-feature/:feature', async (req: Request<{ feature: Feature }>, res) => { + const { feature } = req.params; + enabledFeatures[feature] = true; res.writeHead(204).end(); }); diff --git a/packages/cli/src/api/tags.api.ts b/packages/cli/src/api/tags.api.ts deleted file mode 100644 index 6c13c44b51..0000000000 --- a/packages/cli/src/api/tags.api.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare */ -/* eslint-disable @typescript-eslint/no-invalid-void-type */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable no-param-reassign */ - -import express from 'express'; - -import * as Db from '@/Db'; -import { ExternalHooks } from '@/ExternalHooks'; -import type { ITagWithCountDb } from '@/Interfaces'; -import * as ResponseHelper from '@/ResponseHelper'; -import config from '@/config'; -import * as TagHelpers from '@/TagHelpers'; -import { validateEntity } from '@/GenericHelpers'; -import { TagEntity } from '@db/entities/TagEntity'; -import type { TagsRequest } from '@/requests'; -import { Container } from 'typedi'; - -export const tagsController = express.Router(); - -const workflowsEnabledMiddleware: express.RequestHandler = (req, res, next) => { - if (config.getEnv('workflowTagsDisabled')) { - throw new ResponseHelper.BadRequestError('Workflow tags are disabled'); - } - next(); -}; - -// Retrieves all tags, with or without usage count -tagsController.get( - '/', - workflowsEnabledMiddleware, - ResponseHelper.send(async (req: express.Request): Promise => { - if (req.query.withUsageCount === 'true') { - const tablePrefix = config.getEnv('database.tablePrefix'); - return TagHelpers.getTagsWithCountDb(tablePrefix); - } - - return Db.collections.Tag.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] }); - }), -); - -// Creates a tag -tagsController.post( - '/', - workflowsEnabledMiddleware, - ResponseHelper.send(async (req: express.Request): Promise => { - const newTag = new TagEntity(); - newTag.name = req.body.name.trim(); - - await Container.get(ExternalHooks).run('tag.beforeCreate', [newTag]); - - await validateEntity(newTag); - const tag = await Db.collections.Tag.save(newTag); - - await Container.get(ExternalHooks).run('tag.afterCreate', [tag]); - - return tag; - }), -); - -// Updates a tag -tagsController.patch( - '/:id(\\d+)', - workflowsEnabledMiddleware, - ResponseHelper.send(async (req: express.Request): Promise => { - const { name } = req.body; - const { id } = req.params; - - const newTag = new TagEntity(); - // @ts-ignore - newTag.id = id; - newTag.name = name.trim(); - - await Container.get(ExternalHooks).run('tag.beforeUpdate', [newTag]); - - await validateEntity(newTag); - const tag = await Db.collections.Tag.save(newTag); - - await Container.get(ExternalHooks).run('tag.afterUpdate', [tag]); - - return tag; - }), -); - -tagsController.delete( - '/:id(\\d+)', - workflowsEnabledMiddleware, - ResponseHelper.send(async (req: TagsRequest.Delete): Promise => { - if ( - config.getEnv('userManagement.isInstanceOwnerSetUp') === true && - req.user.globalRole.name !== 'owner' - ) { - throw new ResponseHelper.UnauthorizedError( - 'You are not allowed to perform this action', - 'Only owners can remove tags', - ); - } - const id = req.params.id; - - await Container.get(ExternalHooks).run('tag.beforeDelete', [id]); - - await Db.collections.Tag.delete({ id }); - - await Container.get(ExternalHooks).run('tag.afterDelete', [id]); - - return true; - }), -); diff --git a/packages/cli/src/commands/BaseCommand.ts b/packages/cli/src/commands/BaseCommand.ts index 96e19f1d16..d50846cf34 100644 --- a/packages/cli/src/commands/BaseCommand.ts +++ b/packages/cli/src/commands/BaseCommand.ts @@ -34,6 +34,8 @@ export abstract class BaseCommand extends Command { protected userSettings: IUserSettings; + protected instanceId: string; + async init(): Promise { await initErrorHandling(); @@ -49,9 +51,9 @@ export abstract class BaseCommand extends Command { const credentialTypes = Container.get(CredentialTypes); CredentialsOverwrites(credentialTypes); - const instanceId = this.userSettings.instanceId ?? ''; - await Container.get(PostHogClient).init(instanceId); - await Container.get(InternalHooks).init(instanceId); + this.instanceId = this.userSettings.instanceId ?? ''; + await Container.get(PostHogClient).init(this.instanceId); + await Container.get(InternalHooks).init(this.instanceId); await Db.init().catch(async (error: Error) => this.exitWithCrash('There was an error initializing DB', error), diff --git a/packages/cli/src/commands/execute.ts b/packages/cli/src/commands/execute.ts index 63bbb863ed..16631a962e 100644 --- a/packages/cli/src/commands/execute.ts +++ b/packages/cli/src/commands/execute.ts @@ -6,11 +6,10 @@ import { ExecutionBaseError } from 'n8n-workflow'; import { ActiveExecutions } from '@/ActiveExecutions'; import * as Db from '@/Db'; -import * as WorkflowHelpers from '@/WorkflowHelpers'; import { WorkflowRunner } from '@/WorkflowRunner'; import type { IWorkflowExecutionDataProcess } from '@/Interfaces'; import { getInstanceOwner } from '@/UserManagement/UserManagementHelper'; -import { findCliWorkflowStart } from '@/utils'; +import { findCliWorkflowStart, isWorkflowIdValid } from '@/utils'; import { initEvents } from '@/events'; import { BaseCommand } from './BaseCommand'; import { Container } from 'typedi'; @@ -101,7 +100,7 @@ export class Execute extends BaseCommand { throw new Error('Failed to retrieve workflow data for requested workflow'); } - if (!WorkflowHelpers.isWorkflowIdValid(workflowId)) { + if (!isWorkflowIdValid(workflowId)) { workflowId = undefined; } diff --git a/packages/cli/src/commands/executeBatch.ts b/packages/cli/src/commands/executeBatch.ts index 8a441592ad..f0c60bf20f 100644 --- a/packages/cli/src/commands/executeBatch.ts +++ b/packages/cli/src/commands/executeBatch.ts @@ -144,12 +144,16 @@ export class ExecuteBatch extends BaseCommand { 'econnrefused', 'missing a required parameter', 'insufficient credit balance', + 'internal server error', + '503', + '502', + '504', + 'insufficient balance', 'request timed out', 'status code 401', ]; errorMessage = errorMessage.toLowerCase(); - for (let i = 0; i < warningStrings.length; i++) { if (errorMessage.includes(warningStrings[i])) { return true; diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index 7b555fc52e..db276c40c1 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -1,5 +1,5 @@ import * as Db from '@/Db'; -import { LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; import { In } from 'typeorm'; import { BaseCommand } from '../BaseCommand'; @@ -17,6 +17,11 @@ export class Reset extends BaseCommand { await AuthIdentity.delete({ providerType: 'ldap' }); await User.delete({ id: In(ldapIdentities.map((i) => i.userId)) }); await Settings.delete({ key: LDAP_FEATURE_NAME }); + await Settings.insert({ + key: LDAP_FEATURE_NAME, + value: JSON.stringify(LDAP_DEFAULT_CONFIGURATION), + loadOnStartup: true, + }); this.logger.info('Successfully reset the database to default ldap state.'); } diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index ada08e48ec..db53900de2 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -24,11 +24,11 @@ import * as GenericHelpers from '@/GenericHelpers'; import * as Server from '@/Server'; import { TestWebhooks } from '@/TestWebhooks'; import { getAllInstalledPackages } from '@/CommunityNodes/packageModel'; -import { handleLdapInit } from '@/Ldap/helpers'; import { EDITOR_UI_DIST_DIR, GENERATED_STATIC_DIR } from '@/constants'; import { eventBus } from '@/eventbus'; import { BaseCommand } from './BaseCommand'; import { InternalHooks } from '@/InternalHooks'; +import { License } from '@/License'; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const open = require('open'); @@ -60,7 +60,7 @@ export class Start extends BaseCommand { }), }; - protected activeWorkflowRunner = Container.get(ActiveWorkflowRunner); + protected activeWorkflowRunner: ActiveWorkflowRunner; /** * Opens the UI in browser @@ -182,11 +182,27 @@ export class Start extends BaseCommand { await Promise.all(files.map(compileFile)); } + async initLicense(): Promise { + const license = Container.get(License); + await license.init(this.instanceId); + + const activationKey = config.getEnv('license.activationKey'); + if (activationKey) { + try { + await license.activate(activationKey); + } catch (e) { + LoggerProxy.error('Could not activate license', e as Error); + } + } + } + async init() { await this.initCrashJournal(); await super.init(); this.logger.info('Initializing n8n process'); + this.activeWorkflowRunner = Container.get(ActiveWorkflowRunner); + await this.initLicense(); await this.initBinaryManager(); await this.initExternalHooks(); @@ -252,11 +268,10 @@ export class Start extends BaseCommand { // Optimistic approach - stop if any installation fails // eslint-disable-next-line no-restricted-syntax for (const missingPackage of missingPackages) { - // eslint-disable-next-line no-await-in-loop - void (await this.loadNodesAndCredentials.loadNpmModule( + await this.loadNodesAndCredentials.installNpmModule( missingPackage.packageName, missingPackage.version, - )); + ); missingPackages.delete(missingPackage); } LoggerProxy.info('Packages reinstalled successfully. Resuming regular initialization.'); @@ -331,8 +346,6 @@ export class Start extends BaseCommand { // Start to get active workflows and run their triggers await this.activeWorkflowRunner.init(); - await handleLdapInit(); - const editorUrl = GenericHelpers.getBaseUrl(); this.log(`\nEditor is now accessible via:\n${editorUrl}`); diff --git a/packages/cli/src/commands/webhook.ts b/packages/cli/src/commands/webhook.ts index aada27f3cd..cc1ac53b35 100644 --- a/packages/cli/src/commands/webhook.ts +++ b/packages/cli/src/commands/webhook.ts @@ -3,6 +3,7 @@ import { LoggerProxy, sleep } from 'n8n-workflow'; import config from '@/config'; import { ActiveExecutions } from '@/ActiveExecutions'; import { WebhookServer } from '@/WebhookServer'; +import { Queue } from '@/Queue'; import { BaseCommand } from './BaseCommand'; import { Container } from 'typedi'; @@ -79,6 +80,7 @@ export class Webhook extends BaseCommand { } async run() { + await Container.get(Queue).init(); await new WebhookServer().start(); this.logger.info('Webhook listener waiting for requests.'); diff --git a/packages/cli/src/commands/worker.ts b/packages/cli/src/commands/worker.ts index 87d7b55bff..48d2269d0e 100644 --- a/packages/cli/src/commands/worker.ts +++ b/packages/cli/src/commands/worker.ts @@ -1,6 +1,7 @@ import express from 'express'; import http from 'http'; import type PCancelable from 'p-cancelable'; +import { Container } from 'typedi'; import { flags } from '@oclif/command'; import { WorkflowExecute } from 'n8n-core'; @@ -15,7 +16,8 @@ import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData' import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import config from '@/config'; -import * as Queue from '@/Queue'; +import type { Job, JobId, JobQueue, JobResponse, WebhookResponse } from '@/Queue'; +import { Queue } from '@/Queue'; import { getWorkflowOwner } from '@/UserManagement/UserManagementHelper'; import { generateFailedExecutionFromError } from '@/WorkflowHelpers'; import { N8N_VERSION } from '@/constants'; @@ -38,7 +40,7 @@ export class Worker extends BaseCommand { [key: string]: PCancelable; } = {}; - static jobQueue: Queue.JobQueue; + static jobQueue: JobQueue; /** * Stop n8n in a graceful way. @@ -86,7 +88,7 @@ export class Worker extends BaseCommand { await this.exitSuccessFully(); } - async runJob(job: Queue.Job, nodeTypes: INodeTypes): Promise { + async runJob(job: Job, nodeTypes: INodeTypes): Promise { const { executionId, loadStaticData } = job.data; const executionDb = await Db.collections.Execution.findOneBy({ id: executionId }); @@ -125,14 +127,9 @@ export class Worker extends BaseCommand { staticData = workflowData.staticData; } - let workflowTimeout = config.getEnv('executions.timeout'); // initialize with default - if ( - // eslint-disable-next-line @typescript-eslint/prefer-optional-chain - currentExecutionDb.workflowData.settings && - currentExecutionDb.workflowData.settings.executionTimeout - ) { - workflowTimeout = currentExecutionDb.workflowData.settings.executionTimeout as number; // preference on workflow setting - } + const workflowSettings = currentExecutionDb.workflowData.settings ?? {}; + + let workflowTimeout = workflowSettings.executionTimeout ?? config.getEnv('executions.timeout'); // initialize with default let executionTimeoutTimestamp: number | undefined; if (workflowTimeout > 0) { @@ -179,7 +176,7 @@ export class Worker extends BaseCommand { additionalData.hooks.hookFunctions.sendResponse = [ async (response: IExecuteResponsePromiseData): Promise => { - const progress: Queue.WebhookResponse = { + const progress: WebhookResponse = { executionId, response: WebhookHelpers.encodeWebhookResponse(response), }; @@ -238,7 +235,8 @@ export class Worker extends BaseCommand { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const redisConnectionTimeoutLimit = config.getEnv('queue.bull.redis.timeoutThreshold'); - const queue = await Queue.getInstance(); + const queue = Container.get(Queue); + await queue.init(); Worker.jobQueue = queue.getBullObjectInstance(); // eslint-disable-next-line @typescript-eslint/no-floating-promises Worker.jobQueue.process(flags.concurrency, async (job) => this.runJob(job, this.nodeTypes)); @@ -248,7 +246,7 @@ export class Worker extends BaseCommand { this.logger.info(` * Concurrency: ${flags.concurrency}`); this.logger.info(''); - Worker.jobQueue.on('global:progress', (jobId: Queue.JobId, progress) => { + Worker.jobQueue.on('global:progress', (jobId: JobId, progress) => { // Progress of a job got updated which does get used // to communicate that a job got canceled. diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index b2ed5a245e..4b87524611 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -24,11 +24,7 @@ if (inE2ETests) { dotenv.config(); } -const config = convict(schema); - -if (inE2ETests) { - config.set('enterprise.features.sharing', true); -} +const config = convict(schema, { args: [] }); // eslint-disable-next-line @typescript-eslint/unbound-method config.getEnv = config.get; diff --git a/packages/cli/src/config/schema.ts b/packages/cli/src/config/schema.ts index 5d9cb40045..74f7ab64fc 100644 --- a/packages/cli/src/config/schema.ts +++ b/packages/cli/src/config/schema.ts @@ -990,31 +990,6 @@ export const schema = { }, }, - enterprise: { - features: { - sharing: { - format: Boolean, - default: false, - }, - ldap: { - format: Boolean, - default: false, - }, - saml: { - format: Boolean, - default: false, - }, - logStreaming: { - format: Boolean, - default: false, - }, - advancedExecutionFilters: { - format: Boolean, - default: false, - }, - }, - }, - sso: { justInTimeProvisioning: { format: Boolean, @@ -1166,6 +1141,12 @@ export const schema = { env: 'N8N_LICENSE_TENANT_ID', doc: 'Tenant id used by the license manager', }, + cert: { + format: String, + default: '', + env: 'N8N_LICENSE_CERT', + doc: 'Ephemeral license certificate', + }, }, hideUsagePage: { diff --git a/packages/cli/src/constants.ts b/packages/cli/src/constants.ts index 2a984afb54..82f87dc13c 100644 --- a/packages/cli/src/constants.ts +++ b/packages/cli/src/constants.ts @@ -18,7 +18,7 @@ export const CUSTOM_API_CALL_KEY = '__CUSTOM_API_CALL__'; export const CLI_DIR = resolve(__dirname, '..'); export const TEMPLATES_DIR = join(CLI_DIR, 'templates'); -export const NODES_BASE_DIR = join(CLI_DIR, '..', 'nodes-base'); +export const NODES_BASE_DIR = dirname(require.resolve('n8n-nodes-base')); export const GENERATED_STATIC_DIR = join(UserSettings.getUserHome(), '.cache/n8n/public'); export const EDITOR_UI_DIST_DIR = join(dirname(require.resolve('n8n-editor-ui')), 'dist'); @@ -45,6 +45,7 @@ export const RESPONSE_ERROR_MESSAGES = { PACKAGE_NOT_FOUND: 'Package not found in npm', PACKAGE_VERSION_NOT_FOUND: 'The specified package version was not found', PACKAGE_DOES_NOT_CONTAIN_NODES: 'The specified package does not contain any nodes', + PACKAGE_LOADING_FAILED: 'The specified package could not be loaded', DISK_IS_FULL: 'There appears to be insufficient disk space', }; diff --git a/packages/cli/src/controllers/auth.controller.ts b/packages/cli/src/controllers/auth.controller.ts index 5092d1ea66..7a6405de36 100644 --- a/packages/cli/src/controllers/auth.controller.ts +++ b/packages/cli/src/controllers/auth.controller.ts @@ -19,8 +19,10 @@ import type { } from '@/Interfaces'; import { handleEmailLogin, handleLdapLogin } from '@/auth'; import type { PostHogClient } from '@/posthog'; -import { isSamlCurrentAuthenticationMethod } from '../sso/ssoHelpers'; -import { SamlUrls } from '../sso/saml/constants'; +import { + isLdapCurrentAuthenticationMethod, + isSamlCurrentAuthenticationMethod, +} from '@/sso/ssoHelpers'; @RestController() export class AuthController { @@ -73,19 +75,12 @@ export class AuthController { if (preliminaryUser?.globalRole?.name === 'owner') { user = preliminaryUser; } else { - // TODO:SAML - uncomment this block when we have a way to redirect users to the SSO flow - // if (doRedirectUsersFromLoginToSsoFlow()) { - res.redirect(SamlUrls.restInitSSO); - return; - // return withFeatureFlags(this.postHog, sanitizeUser(preliminaryUser)); - // } else { - // throw new AuthError( - // 'Login with username and password is disabled due to SAML being the default authentication method. Please use SAML to log in.', - // ); - // } + throw new AuthError('SAML is enabled, please log in with SAML'); } + } else if (isLdapCurrentAuthenticationMethod()) { + user = await handleLdapLogin(email, password); } else { - user = (await handleLdapLogin(email, password)) ?? (await handleEmailLogin(email, password)); + user = await handleEmailLogin(email, password); } if (user) { await issueCookie(res, user); diff --git a/packages/cli/src/controllers/index.ts b/packages/cli/src/controllers/index.ts index 37ce548a54..04b46af5f1 100644 --- a/packages/cli/src/controllers/index.ts +++ b/packages/cli/src/controllers/index.ts @@ -1,6 +1,10 @@ export { AuthController } from './auth.controller'; +export { LdapController } from './ldap.controller'; export { MeController } from './me.controller'; +export { NodesController } from './nodes.controller'; +export { NodeTypesController } from './nodeTypes.controller'; export { OwnerController } from './owner.controller'; export { PasswordResetController } from './passwordReset.controller'; +export { TagsController } from './tags.controller'; export { TranslationController } from './translation.controller'; export { UsersController } from './users.controller'; diff --git a/packages/cli/src/controllers/ldap.controller.ts b/packages/cli/src/controllers/ldap.controller.ts new file mode 100644 index 0000000000..619a70db28 --- /dev/null +++ b/packages/cli/src/controllers/ldap.controller.ts @@ -0,0 +1,65 @@ +import pick from 'lodash.pick'; +import { Get, Post, Put, RestController } from '@/decorators'; +import { getLdapConfig, getLdapSynchronizations, updateLdapConfig } from '@/Ldap/helpers'; +import { LdapService } from '@/Ldap/LdapService.ee'; +import { LdapSync } from '@/Ldap/LdapSync.ee'; +import { LdapConfiguration } from '@/Ldap/types'; +import { BadRequestError } from '@/ResponseHelper'; +import { NON_SENSIBLE_LDAP_CONFIG_PROPERTIES } from '@/Ldap/constants'; +import { InternalHooks } from '@/InternalHooks'; + +@RestController('/ldap') +export class LdapController { + constructor( + private ldapService: LdapService, + private ldapSync: LdapSync, + private internalHooks: InternalHooks, + ) {} + + @Get('/config') + async getConfig() { + return getLdapConfig(); + } + + @Post('/test-connection') + async testConnection() { + try { + await this.ldapService.testConnection(); + } catch (error) { + throw new BadRequestError((error as { message: string }).message); + } + } + + @Put('/config') + async updateConfig(req: LdapConfiguration.Update) { + try { + await updateLdapConfig(req.body); + } catch (error) { + throw new BadRequestError((error as { message: string }).message); + } + + const data = await getLdapConfig(); + + void this.internalHooks.onUserUpdatedLdapSettings({ + user_id: req.user.id, + ...pick(data, NON_SENSIBLE_LDAP_CONFIG_PROPERTIES), + }); + + return data; + } + + @Get('/sync') + async getLdapSync(req: LdapConfiguration.GetSync) { + const { page = '0', perPage = '20' } = req.query; + return getLdapSynchronizations(parseInt(page, 10), parseInt(perPage, 10)); + } + + @Post('/sync') + async syncLdap(req: LdapConfiguration.Sync) { + try { + await this.ldapSync.run(req.body.type); + } catch (error) { + throw new BadRequestError((error as { message: string }).message); + } + } +} diff --git a/packages/cli/src/api/nodeTypes.api.ts b/packages/cli/src/controllers/nodeTypes.controller.ts similarity index 60% rename from packages/cli/src/api/nodeTypes.api.ts rename to packages/cli/src/controllers/nodeTypes.controller.ts index de37b549f5..5f8af5a909 100644 --- a/packages/cli/src/api/nodeTypes.api.ts +++ b/packages/cli/src/controllers/nodeTypes.controller.ts @@ -1,39 +1,43 @@ -import express from 'express'; import { readFile } from 'fs/promises'; import get from 'lodash.get'; - +import { Request } from 'express'; import type { INodeTypeDescription, INodeTypeNameVersion } from 'n8n-workflow'; - -import config from '@/config'; -import { NodeTypes } from '@/NodeTypes'; -import * as ResponseHelper from '@/ResponseHelper'; +import { Post, RestController } from '@/decorators'; import { getNodeTranslationPath } from '@/TranslationHelpers'; -import { Container } from 'typedi'; +import type { Config } from '@/config'; +import type { NodeTypes } from '@/NodeTypes'; -export const nodeTypesController = express.Router(); +@RestController('/node-types') +export class NodeTypesController { + private readonly config: Config; -// Returns node information based on node names and versions -nodeTypesController.post( - '/', - ResponseHelper.send(async (req: express.Request): Promise => { + private readonly nodeTypes: NodeTypes; + + constructor({ config, nodeTypes }: { config: Config; nodeTypes: NodeTypes }) { + this.config = config; + this.nodeTypes = nodeTypes; + } + + @Post('/') + async getNodeInfo(req: Request) { const nodeInfos = get(req, 'body.nodeInfos', []) as INodeTypeNameVersion[]; - const defaultLocale = config.getEnv('defaultLocale'); + const defaultLocale = this.config.getEnv('defaultLocale'); if (defaultLocale === 'en') { return nodeInfos.reduce((acc, { name, version }) => { - const { description } = Container.get(NodeTypes).getByNameAndVersion(name, version); + const { description } = this.nodeTypes.getByNameAndVersion(name, version); acc.push(description); return acc; }, []); } - async function populateTranslation( + const populateTranslation = async ( name: string, version: number, nodeTypes: INodeTypeDescription[], - ) { - const { description, sourcePath } = Container.get(NodeTypes).getWithSourcePath(name, version); + ) => { + const { description, sourcePath } = this.nodeTypes.getWithSourcePath(name, version); const translationPath = await getNodeTranslationPath({ nodeSourcePath: sourcePath, longNodeType: description.name, @@ -44,12 +48,12 @@ nodeTypesController.post( const translation = await readFile(translationPath, 'utf8'); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment description.translation = JSON.parse(translation); - } catch (error) { + } catch { // ignore - no translation exists at path } nodeTypes.push(description); - } + }; const nodeTypes: INodeTypeDescription[] = []; @@ -60,5 +64,5 @@ nodeTypesController.post( await Promise.all(promises); return nodeTypes; - }), -); + } +} diff --git a/packages/cli/src/api/nodes.api.ts b/packages/cli/src/controllers/nodes.controller.ts similarity index 63% rename from packages/cli/src/api/nodes.api.ts rename to packages/cli/src/controllers/nodes.controller.ts index 70264cdab5..e82bd3e589 100644 --- a/packages/cli/src/api/nodes.api.ts +++ b/packages/cli/src/controllers/nodes.controller.ts @@ -1,10 +1,12 @@ -import express from 'express'; -import type { PublicInstalledPackage } from 'n8n-workflow'; - -import config from '@/config'; -import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -import * as ResponseHelper from '@/ResponseHelper'; - +import { Request, Response, NextFunction } from 'express'; +import { + RESPONSE_ERROR_MESSAGES, + STARTER_TEMPLATE_NAME, + UNKNOWN_FAILURE_REASON, +} from '@/constants'; +import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; +import { NodeRequest } from '@/requests'; +import { BadRequestError, InternalServerError } from '@/ResponseHelper'; import { checkNpmPackageStatus, executeCommand, @@ -22,57 +24,50 @@ import { getAllInstalledPackages, isPackageInstalled, } from '@/CommunityNodes/packageModel'; -import { - RESPONSE_ERROR_MESSAGES, - STARTER_TEMPLATE_NAME, - UNKNOWN_FAILURE_REASON, -} from '@/constants'; -import { isAuthenticatedRequest } from '@/UserManagement/UserManagementHelper'; - import type { InstalledPackages } from '@db/entities/InstalledPackages'; import type { CommunityPackages } from '@/Interfaces'; -import type { NodeRequest } from '@/requests'; -import { Push } from '@/push'; -import { Container } from 'typedi'; +import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { InternalHooks } from '@/InternalHooks'; +import { Push } from '@/push'; +import { Config } from '@/config'; +import { isAuthenticatedRequest } from '@/UserManagement/UserManagementHelper'; const { PACKAGE_NOT_INSTALLED, PACKAGE_NAME_NOT_PROVIDED } = RESPONSE_ERROR_MESSAGES; -export const nodesController = express.Router(); +@RestController('/nodes') +export class NodesController { + constructor( + private config: Config, + private loadNodesAndCredentials: LoadNodesAndCredentials, + private push: Push, + private internalHooks: InternalHooks, + ) {} -nodesController.use((req, res, next) => { - if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') { - res.status(403).json({ status: 'error', message: 'Unauthorized' }); - return; + // TODO: move this into a new decorator `@Authorized` + @Middleware() + checkIfOwner(req: Request, res: Response, next: NextFunction) { + if (!isAuthenticatedRequest(req) || req.user.globalRole.name !== 'owner') + res.status(403).json({ status: 'error', message: 'Unauthorized' }); + else next(); } - next(); -}); - -nodesController.use((req, res, next) => { - if (config.getEnv('executions.mode') === 'queue' && req.method !== 'GET') { - res.status(400).json({ - status: 'error', - message: 'Package management is disabled when running in "queue" mode', - }); - return; + // TODO: move this into a new decorator `@IfConfig('executions.mode', 'queue')` + @Middleware() + checkIfCommunityNodesEnabled(req: Request, res: Response, next: NextFunction) { + if (this.config.getEnv('executions.mode') === 'queue' && req.method !== 'GET') + res.status(400).json({ + status: 'error', + message: 'Package management is disabled when running in "queue" mode', + }); + else next(); } - next(); -}); - -/** - * POST /nodes - * - * Install an n8n community package - */ -nodesController.post( - '/', - ResponseHelper.send(async (req: NodeRequest.Post) => { + @Post('/') + async installPackage(req: NodeRequest.Post) { const { name } = req.body; if (!name) { - throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED); + throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); } let parsed: CommunityPackages.ParsedPackageName; @@ -80,13 +75,13 @@ nodesController.post( try { parsed = parseNpmPackageName(name); } catch (error) { - throw new ResponseHelper.BadRequestError( + throw new BadRequestError( error instanceof Error ? error.message : 'Failed to parse package name', ); } if (parsed.packageName === STARTER_TEMPLATE_NAME) { - throw new ResponseHelper.BadRequestError( + throw new BadRequestError( [ `Package "${parsed.packageName}" is only a template`, 'Please enter an actual package to install', @@ -98,7 +93,7 @@ nodesController.post( const hasLoaded = hasPackageLoaded(name); if (isInstalled && hasLoaded) { - throw new ResponseHelper.BadRequestError( + throw new BadRequestError( [ `Package "${parsed.packageName}" is already installed`, 'To update it, click the corresponding button in the UI', @@ -109,22 +104,19 @@ nodesController.post( const packageStatus = await checkNpmPackageStatus(name); if (packageStatus.status !== 'OK') { - throw new ResponseHelper.BadRequestError( - `Package "${name}" is banned so it cannot be installed`, - ); + throw new BadRequestError(`Package "${name}" is banned so it cannot be installed`); } let installedPackage: InstalledPackages; - try { - installedPackage = await Container.get(LoadNodesAndCredentials).loadNpmModule( + installedPackage = await this.loadNodesAndCredentials.installNpmModule( parsed.packageName, parsed.version, ); } catch (error) { const errorMessage = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; - void Container.get(InternalHooks).onCommunityPackageInstallFinished({ + void this.internalHooks.onCommunityPackageInstallFinished({ user: req.user, input_string: name, package_name: parsed.packageName, @@ -133,26 +125,26 @@ nodesController.post( failure_reason: errorMessage, }); - const message = [`Error loading package "${name}"`, errorMessage].join(':'); + let message = [`Error loading package "${name}" `, errorMessage].join(':'); + if (error instanceof Error && error.cause instanceof Error) { + message += `\nCause: ${error.cause.message}`; + } const clientError = error instanceof Error ? isClientError(error) : false; - - throw new ResponseHelper[clientError ? 'BadRequestError' : 'InternalServerError'](message); + throw new (clientError ? BadRequestError : InternalServerError)(message); } if (!hasLoaded) removePackageFromMissingList(name); - const pushInstance = Container.get(Push); - // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { - pushInstance.send('reloadNodeType', { + this.push.send('reloadNodeType', { name: node.type, version: node.latestVersion, }); }); - void Container.get(InternalHooks).onCommunityPackageInstallFinished({ + void this.internalHooks.onCommunityPackageInstallFinished({ user: req.user, input_string: name, package_name: parsed.packageName, @@ -164,17 +156,10 @@ nodesController.post( }); return installedPackage; - }), -); + } -/** - * GET /nodes - * - * Retrieve list of installed n8n community packages - */ -nodesController.get( - '/', - ResponseHelper.send(async (): Promise => { + @Get('/') + async getInstalledPackages() { const installedPackages = await getAllInstalledPackages(); if (installedPackages.length === 0) return []; @@ -188,7 +173,6 @@ nodesController.get( // when there are updates, npm exits with code 1 // when there are no updates, command succeeds // https://github.com/npm/rfcs/issues/473 - if (isNpmError(error) && error.code === 1) { pendingUpdates = JSON.parse(error.stdout) as CommunityPackages.AvailableUpdates; } @@ -197,31 +181,21 @@ nodesController.get( let hydratedPackages = matchPackagesWithUpdates(installedPackages, pendingUpdates); try { - const missingPackages = config.get('nodes.packagesMissing') as string | undefined; - + const missingPackages = this.config.get('nodes.packagesMissing') as string | undefined; if (missingPackages) { hydratedPackages = matchMissingPackages(hydratedPackages, missingPackages); } - } catch { - // Do nothing if setting is missing - } + } catch {} return hydratedPackages; - }), -); + } -/** - * DELETE /nodes - * - * Uninstall an installed n8n community package - */ -nodesController.delete( - '/', - ResponseHelper.send(async (req: NodeRequest.Delete) => { + @Delete('/') + async uninstallPackage(req: NodeRequest.Delete) { const { name } = req.query; if (!name) { - throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED); + throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); } try { @@ -229,37 +203,35 @@ nodesController.delete( } catch (error) { const message = error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON; - throw new ResponseHelper.BadRequestError(message); + throw new BadRequestError(message); } const installedPackage = await findInstalledPackage(name); if (!installedPackage) { - throw new ResponseHelper.BadRequestError(PACKAGE_NOT_INSTALLED); + throw new BadRequestError(PACKAGE_NOT_INSTALLED); } try { - await Container.get(LoadNodesAndCredentials).removeNpmModule(name, installedPackage); + await this.loadNodesAndCredentials.removeNpmModule(name, installedPackage); } catch (error) { const message = [ `Error removing package "${name}"`, error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON, ].join(':'); - throw new ResponseHelper.InternalServerError(message); + throw new InternalServerError(message); } - const pushInstance = Container.get(Push); - // broadcast to connected frontends that node list has been updated installedPackage.installedNodes.forEach((node) => { - pushInstance.send('removeNodeType', { + this.push.send('removeNodeType', { name: node.type, version: node.latestVersion, }); }); - void Container.get(InternalHooks).onCommunityPackageDeleteFinished({ + void this.internalHooks.onCommunityPackageDeleteFinished({ user: req.user, package_name: name, package_version: installedPackage.installedVersion, @@ -267,53 +239,44 @@ nodesController.delete( package_author: installedPackage.authorName, package_author_email: installedPackage.authorEmail, }); - }), -); + } -/** - * PATCH /nodes - * - * Update an installed n8n community package - */ -nodesController.patch( - '/', - ResponseHelper.send(async (req: NodeRequest.Update) => { + @Patch('/') + async updatePackage(req: NodeRequest.Update) { const { name } = req.body; if (!name) { - throw new ResponseHelper.BadRequestError(PACKAGE_NAME_NOT_PROVIDED); + throw new BadRequestError(PACKAGE_NAME_NOT_PROVIDED); } const previouslyInstalledPackage = await findInstalledPackage(name); if (!previouslyInstalledPackage) { - throw new ResponseHelper.BadRequestError(PACKAGE_NOT_INSTALLED); + throw new BadRequestError(PACKAGE_NOT_INSTALLED); } try { - const newInstalledPackage = await Container.get(LoadNodesAndCredentials).updateNpmModule( + const newInstalledPackage = await this.loadNodesAndCredentials.updateNpmModule( parseNpmPackageName(name).packageName, previouslyInstalledPackage, ); - const pushInstance = Container.get(Push); - // broadcast to connected frontends that node list has been updated previouslyInstalledPackage.installedNodes.forEach((node) => { - pushInstance.send('removeNodeType', { + this.push.send('removeNodeType', { name: node.type, version: node.latestVersion, }); }); newInstalledPackage.installedNodes.forEach((node) => { - pushInstance.send('reloadNodeType', { + this.push.send('reloadNodeType', { name: node.name, version: node.latestVersion, }); }); - void Container.get(InternalHooks).onCommunityPackageUpdateFinished({ + void this.internalHooks.onCommunityPackageUpdateFinished({ user: req.user, package_name: name, package_version_current: previouslyInstalledPackage.installedVersion, @@ -326,8 +289,7 @@ nodesController.patch( return newInstalledPackage; } catch (error) { previouslyInstalledPackage.installedNodes.forEach((node) => { - const pushInstance = Container.get(Push); - pushInstance.send('removeNodeType', { + this.push.send('removeNodeType', { name: node.type, version: node.latestVersion, }); @@ -338,7 +300,7 @@ nodesController.patch( error instanceof Error ? error.message : UNKNOWN_FAILURE_REASON, ].join(':'); - throw new ResponseHelper.InternalServerError(message); + throw new InternalServerError(message); } - }), -); + } +} diff --git a/packages/cli/src/controllers/passwordReset.controller.ts b/packages/cli/src/controllers/passwordReset.controller.ts index b78a57620d..4b0ec6c9df 100644 --- a/packages/cli/src/controllers/passwordReset.controller.ts +++ b/packages/cli/src/controllers/passwordReset.controller.ts @@ -14,7 +14,7 @@ import { hashPassword, validatePassword, } from '@/UserManagement/UserManagementHelper'; -import * as UserManagementMailer from '@/UserManagement/email'; +import type { UserManagementMailer } from '@/UserManagement/email'; import { Response } from 'express'; import type { ILogger } from 'n8n-workflow'; @@ -35,6 +35,8 @@ export class PasswordResetController { private readonly internalHooks: IInternalHooksClass; + private readonly mailer: UserManagementMailer; + private readonly userRepository: Repository; constructor({ @@ -42,18 +44,21 @@ export class PasswordResetController { logger, externalHooks, internalHooks, + mailer, repositories, }: { config: Config; logger: ILogger; externalHooks: IExternalHooksClass; internalHooks: IInternalHooksClass; + mailer: UserManagementMailer; repositories: Pick; }) { this.config = config; this.logger = logger; this.externalHooks = externalHooks; this.internalHooks = internalHooks; + this.mailer = mailer; this.userRepository = repositories.User; } @@ -126,8 +131,7 @@ export class PasswordResetController { url.searchParams.append('token', resetPasswordToken); try { - const mailer = UserManagementMailer.getInstance(); - await mailer.passwordReset({ + await this.mailer.passwordReset({ email, firstName, lastName, diff --git a/packages/cli/src/controllers/tags.controller.ts b/packages/cli/src/controllers/tags.controller.ts new file mode 100644 index 0000000000..3c73235c85 --- /dev/null +++ b/packages/cli/src/controllers/tags.controller.ts @@ -0,0 +1,102 @@ +import { Request, Response, NextFunction } from 'express'; +import type { Repository } from 'typeorm'; +import type { Config } from '@/config'; +import { Delete, Get, Middleware, Patch, Post, RestController } from '@/decorators'; +import type { IDatabaseCollections, IExternalHooksClass, ITagWithCountDb } from '@/Interfaces'; +import { TagEntity } from '@db/entities/TagEntity'; +import { getTagsWithCountDb } from '@/TagHelpers'; +import { validateEntity } from '@/GenericHelpers'; +import { BadRequestError, UnauthorizedError } from '@/ResponseHelper'; +import { TagsRequest } from '@/requests'; + +@RestController('/tags') +export class TagsController { + private config: Config; + + private externalHooks: IExternalHooksClass; + + private tagsRepository: Repository; + + constructor({ + config, + externalHooks, + repositories, + }: { + config: Config; + externalHooks: IExternalHooksClass; + repositories: Pick; + }) { + this.config = config; + this.externalHooks = externalHooks; + this.tagsRepository = repositories.Tag; + } + + // TODO: move this into a new decorator `@IfEnabled('workflowTagsDisabled')` + @Middleware() + workflowsEnabledMiddleware(req: Request, res: Response, next: NextFunction) { + if (this.config.getEnv('workflowTagsDisabled')) + throw new BadRequestError('Workflow tags are disabled'); + next(); + } + + // Retrieves all tags, with or without usage count + @Get('/') + async getAll(req: TagsRequest.GetAll): Promise { + const { withUsageCount } = req.query; + if (withUsageCount === 'true') { + const tablePrefix = this.config.getEnv('database.tablePrefix'); + return getTagsWithCountDb(tablePrefix); + } + + return this.tagsRepository.find({ select: ['id', 'name', 'createdAt', 'updatedAt'] }); + } + + // Creates a tag + @Post('/') + async createTag(req: TagsRequest.Create): Promise { + const newTag = new TagEntity(); + newTag.name = req.body.name.trim(); + + await this.externalHooks.run('tag.beforeCreate', [newTag]); + await validateEntity(newTag); + + const tag = await this.tagsRepository.save(newTag); + await this.externalHooks.run('tag.afterCreate', [tag]); + return tag; + } + + // Updates a tag + @Patch('/:id(\\d+)') + async updateTag(req: TagsRequest.Update): Promise { + const { name } = req.body; + const { id } = req.params; + + const newTag = new TagEntity(); + newTag.id = id; + newTag.name = name.trim(); + + await this.externalHooks.run('tag.beforeUpdate', [newTag]); + await validateEntity(newTag); + + const tag = await this.tagsRepository.save(newTag); + await this.externalHooks.run('tag.afterUpdate', [tag]); + return tag; + } + + @Delete('/:id(\\d+)') + async deleteTag(req: TagsRequest.Delete) { + const isInstanceOwnerSetUp = this.config.getEnv('userManagement.isInstanceOwnerSetUp'); + if (isInstanceOwnerSetUp && req.user.globalRole.name !== 'owner') { + throw new UnauthorizedError( + 'You are not allowed to perform this action', + 'Only owners can remove tags', + ); + } + const { id } = req.params; + await this.externalHooks.run('tag.beforeDelete', [id]); + + await this.tagsRepository.delete({ id }); + await this.externalHooks.run('tag.afterDelete', [id]); + return true; + } +} diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index ddb8a5c6cc..bb094f9ef1 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -13,7 +13,6 @@ import { getInstanceBaseUrl, hashPassword, isEmailSetUp, - isUserManagementEnabled, sanitizeUser, validatePassword, withFeatureFlags, @@ -35,6 +34,8 @@ import type { import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { AuthIdentity } from '@db/entities/AuthIdentity'; import type { PostHogClient } from '@/posthog'; +import { userManagementEnabledMiddleware } from '../middlewares/userManagementEnabled'; +import { isSamlLicensedAndEnabled } from '../sso/saml/samlHelpers'; @RestController('/users') export class UsersController { @@ -98,14 +99,15 @@ export class UsersController { /** * Send email invite(s) to one or multiple users and create user shell(s). */ - @Post('/') + @Post('/', { middlewares: [userManagementEnabledMiddleware] }) async sendEmailInvites(req: UserRequest.Invite) { - // TODO: this should be checked in the middleware rather than here - if (!isUserManagementEnabled()) { + if (isSamlLicensedAndEnabled()) { this.logger.debug( - 'Request to send email invite(s) to user(s) failed because user management is disabled', + 'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites', + ); + throw new BadRequestError( + 'SAML is enabled, so users are managed by the Identity Provider and cannot be added through invites', ); - throw new BadRequestError('User management is disabled'); } if (!this.config.getEnv('userManagement.isInstanceOwnerSetUp')) { diff --git a/packages/cli/src/credentials/oauth2Credential.api.ts b/packages/cli/src/credentials/oauth2Credential.api.ts index 06a48b110b..a8fd84cef7 100644 --- a/packages/cli/src/credentials/oauth2Credential.api.ts +++ b/packages/cli/src/credentials/oauth2Credential.api.ts @@ -11,7 +11,6 @@ import type { WorkflowExecuteMode, INodeCredentialsDetails, ICredentialsEncrypted, - IDataObject, } from 'n8n-workflow'; import { LoggerProxy } from 'n8n-workflow'; import { resolve as pathResolve } from 'path'; @@ -112,7 +111,7 @@ oauth2CredentialController.get( ); const token = new Csrf(); - // Generate a CSRF prevention token and send it as a OAuth2 state stringma/ERR + // Generate a CSRF prevention token and send it as an OAuth2 state string const csrfSecret = token.secretSync(); const state = { token: token.create(csrfSecret), @@ -174,6 +173,9 @@ oauth2CredentialController.get( }), ); +const renderCallbackError = (res: express.Response, errorMessage: string) => + res.render('oauth-error-callback', { error: { message: errorMessage } }); + /** * GET /oauth2-credential/callback * @@ -188,12 +190,12 @@ oauth2CredentialController.get( const { code, state: stateEncoded } = req.query; if (!code || !stateEncoded) { - const errorResponse = new ResponseHelper.ServiceUnavailableError( + return renderCallbackError( + res, `Insufficient parameters for OAuth2 callback. Received following query parameters: ${JSON.stringify( req.query, )}`, ); - return ResponseHelper.sendErrorResponse(res, errorResponse); } let state; @@ -203,31 +205,21 @@ oauth2CredentialController.get( token: string; }; } catch (error) { - const errorResponse = new ResponseHelper.ServiceUnavailableError( - 'Invalid state format returned', - ); - return ResponseHelper.sendErrorResponse(res, errorResponse); + return renderCallbackError(res, 'Invalid state format returned'); } const credential = await getCredentialWithoutUser(state.cid); if (!credential) { - LoggerProxy.error('OAuth2 callback failed because of insufficient permissions', { + const errorMessage = 'OAuth2 callback failed because of insufficient permissions'; + LoggerProxy.error(errorMessage, { userId: req.user?.id, credentialId: state.cid, }); - const errorResponse = new ResponseHelper.NotFoundError( - RESPONSE_ERROR_MESSAGES.NO_CREDENTIAL, - ); - return ResponseHelper.sendErrorResponse(res, errorResponse); + return renderCallbackError(res, errorMessage); } - let encryptionKey: string; - try { - encryptionKey = await UserSettings.getEncryptionKey(); - } catch (error) { - throw new ResponseHelper.InternalServerError((error as IDataObject).message as string); - } + const encryptionKey = await UserSettings.getEncryptionKey(); const mode: WorkflowExecuteMode = 'internal'; const timezone = config.getEnv('generic.timezone'); @@ -251,14 +243,12 @@ oauth2CredentialController.get( decryptedDataOriginal.csrfSecret === undefined || !token.verify(decryptedDataOriginal.csrfSecret as string, state.token) ) { - LoggerProxy.debug('OAuth2 callback state is invalid', { + const errorMessage = 'The OAuth2 callback state is invalid!'; + LoggerProxy.debug(errorMessage, { userId: req.user?.id, credentialId: state.cid, }); - const errorResponse = new ResponseHelper.NotFoundError( - 'The OAuth2 callback state is invalid!', - ); - return ResponseHelper.sendErrorResponse(res, errorResponse); + return renderCallbackError(res, errorMessage); } let options = {}; @@ -298,12 +288,12 @@ oauth2CredentialController.get( } if (oauthToken === undefined) { - LoggerProxy.error('OAuth2 callback failed: unable to get access tokens', { + const errorMessage = 'Unable to get OAuth2 access tokens!'; + LoggerProxy.error(errorMessage, { userId: req.user?.id, credentialId: state.cid, }); - const errorResponse = new ResponseHelper.NotFoundError('Unable to get access tokens!'); - return ResponseHelper.sendErrorResponse(res, errorResponse); + return renderCallbackError(res, errorMessage); } if (decryptedDataOriginal.oauthTokenData) { @@ -336,9 +326,7 @@ oauth2CredentialController.get( return res.sendFile(pathResolve(TEMPLATES_DIR, 'oauth-callback.html')); } catch (error) { - // Error response - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - return ResponseHelper.sendErrorResponse(res, error); + return renderCallbackError(res, (error as Error).message); } }, ); diff --git a/packages/cli/src/databases/entities/ExecutionEntity.ts b/packages/cli/src/databases/entities/ExecutionEntity.ts index 2172f27003..a29ef9da0e 100644 --- a/packages/cli/src/databases/entities/ExecutionEntity.ts +++ b/packages/cli/src/databases/entities/ExecutionEntity.ts @@ -1,9 +1,10 @@ import { ExecutionStatus, WorkflowExecuteMode } from 'n8n-workflow'; -import { Column, Entity, Generated, Index, PrimaryColumn } from 'typeorm'; +import { Column, Entity, Generated, Index, OneToMany, PrimaryColumn } from 'typeorm'; import { datetimeColumnType, jsonColumnType } from './AbstractEntity'; import { IWorkflowDb } from '@/Interfaces'; import type { IExecutionFlattedDb } from '@/Interfaces'; import { idStringifier } from '../utils/transformers'; +import type { ExecutionMetadata } from './ExecutionMetadata'; @Entity() @Index(['workflowId', 'id']) @@ -49,4 +50,7 @@ export class ExecutionEntity implements IExecutionFlattedDb { @Column({ type: datetimeColumnType, nullable: true }) waitTill: Date; + + @OneToMany('ExecutionMetadata', 'execution') + metadata: ExecutionMetadata[]; } diff --git a/packages/cli/src/databases/entities/ExecutionMetadata.ts b/packages/cli/src/databases/entities/ExecutionMetadata.ts new file mode 100644 index 0000000000..99ea8e01ce --- /dev/null +++ b/packages/cli/src/databases/entities/ExecutionMetadata.ts @@ -0,0 +1,22 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'; +import { ExecutionEntity } from './ExecutionEntity'; + +@Entity() +export class ExecutionMetadata { + @PrimaryGeneratedColumn() + id: number; + + @ManyToOne('ExecutionEntity', 'metadata', { + onDelete: 'CASCADE', + }) + execution: ExecutionEntity; + + @RelationId((executionMetadata: ExecutionMetadata) => executionMetadata.execution) + executionId: number; + + @Column('text') + key: string; + + @Column('text') + value: string; +} diff --git a/packages/cli/src/databases/entities/index.ts b/packages/cli/src/databases/entities/index.ts index 17ff382044..55ba7b8651 100644 --- a/packages/cli/src/databases/entities/index.ts +++ b/packages/cli/src/databases/entities/index.ts @@ -15,6 +15,7 @@ import { User } from './User'; import { WebhookEntity } from './WebhookEntity'; import { WorkflowEntity } from './WorkflowEntity'; import { WorkflowStatistics } from './WorkflowStatistics'; +import { ExecutionMetadata } from './ExecutionMetadata'; export const entities = { AuthIdentity, @@ -33,4 +34,5 @@ export const entities = { WebhookEntity, WorkflowEntity, WorkflowStatistics, + ExecutionMetadata, }; diff --git a/packages/cli/src/databases/migrations/mysqldb/1679416281779-CreateExecutionMetadataTable.ts b/packages/cli/src/databases/migrations/mysqldb/1679416281779-CreateExecutionMetadataTable.ts new file mode 100644 index 0000000000..89a53d340e --- /dev/null +++ b/packages/cli/src/databases/migrations/mysqldb/1679416281779-CreateExecutionMetadataTable.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; + +export class CreateExecutionMetadataTable1679416281779 implements MigrationInterface { + name = 'CreateExecutionMetadataTable1679416281779'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}execution_metadata ( + id int(11) auto_increment NOT NULL PRIMARY KEY, + executionId int(11) NOT NULL, + \`key\` TEXT NOT NULL, + value TEXT NOT NULL, + CONSTRAINT \`${tablePrefix}execution_metadata_FK\` FOREIGN KEY (\`executionId\`) REFERENCES \`${tablePrefix}execution_entity\` (\`id\`) ON DELETE CASCADE, + INDEX \`IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb\` (\`executionId\` ASC) + ) + ENGINE=InnoDB`, + ); + + // Remove indices that are no longer needed since the addition of the status column + await queryRunner.query( + `DROP INDEX \`IDX_${tablePrefix}06da892aaf92a48e7d3e400003\` ON \`${tablePrefix}execution_entity\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_${tablePrefix}78d62b89dc1433192b86dce18a\` ON \`${tablePrefix}execution_entity\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_${tablePrefix}1688846335d274033e15c846a4\` ON \`${tablePrefix}execution_entity\``, + ); + await queryRunner.query( + `DROP INDEX \`IDX_${tablePrefix}cefb067df2402f6aed0638a6c1\` ON \`${tablePrefix}execution_entity\``, + ); + + // Add index to the new status column + await queryRunner.query( + `CREATE INDEX \`IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584\` ON \`${tablePrefix}execution_entity\` (\`status\`, \`workflowId\`)`, + ); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`); + await queryRunner.query( + `CREATE INDEX \`IDX_${tablePrefix}06da892aaf92a48e7d3e400003\` ON \`${tablePrefix}execution_entity\` (\`workflowId\`, \`waitTill\`, \`id\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_${tablePrefix}78d62b89dc1433192b86dce18a\` ON \`${tablePrefix}execution_entity\` (\`workflowId\`, \`finished\`, \`id\`)`, + ); + await queryRunner.query( + `CREATE INDEX \`IDX_${tablePrefix}1688846335d274033e15c846a4\` ON \`${tablePrefix}execution_entity\` (\`finished\`, \`id\`)`, + ); + await queryRunner.query( + 'CREATE INDEX `IDX_' + + tablePrefix + + 'cefb067df2402f6aed0638a6c1` ON `' + + tablePrefix + + 'execution_entity` (`stoppedAt`)', + ); + await queryRunner.query( + `DROP INDEX \`IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584\` ON \`${tablePrefix}execution_entity\``, + ); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index b67441e08f..bb021d495b 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -34,6 +34,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236788851 } from './1677236788851-UpdateRunningExecutionStatus'; +import { CreateExecutionMetadataTable1679416281779 } from './1679416281779-CreateExecutionMetadataTable'; export const mysqlMigrations = [ InitialMigration1588157391238, @@ -72,4 +73,5 @@ export const mysqlMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236788851, + CreateExecutionMetadataTable1679416281779, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/1679416281778-CreateExecutionMetadataTable.ts b/packages/cli/src/databases/migrations/postgresdb/1679416281778-CreateExecutionMetadataTable.ts new file mode 100644 index 0000000000..384d3f2c03 --- /dev/null +++ b/packages/cli/src/databases/migrations/postgresdb/1679416281778-CreateExecutionMetadataTable.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; + +export class CreateExecutionMetadataTable1679416281778 implements MigrationInterface { + name = 'CreateExecutionMetadataTable1679416281778'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `CREATE TABLE ${tablePrefix}execution_metadata ( + "id" serial4 NOT NULL PRIMARY KEY, + "executionId" int4 NOT NULL, + "key" text NOT NULL, + "value" text NOT NULL, + CONSTRAINT ${tablePrefix}execution_metadata_fk FOREIGN KEY ("executionId") REFERENCES ${tablePrefix}execution_entity(id) ON DELETE CASCADE + )`, + ); + + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb" ON "${tablePrefix}execution_metadata" ("executionId");`, + ); + + // Remove indices that are no longer needed since the addition of the status column + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}33228da131bb1112247cf52a42"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}72ffaaab9f04c2c1f1ea86e662"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}58154df94c686818c99fb754ce"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_${tablePrefix}4f474ac92be81610439aaad61e"`); + + // Create new index for status + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584" ON "${tablePrefix}execution_entity" ("status", "workflowId");`, + ); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + + // Re-add removed indices + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}33228da131bb1112247cf52a42" ON ${tablePrefix}execution_entity ("stoppedAt") `, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}72ffaaab9f04c2c1f1ea86e662" ON ${tablePrefix}execution_entity ("finished", "id") `, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}58154df94c686818c99fb754ce" ON ${tablePrefix}execution_entity ("workflowId", "waitTill", "id") `, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}4f474ac92be81610439aaad61e" ON ${tablePrefix}execution_entity ("workflowId", "finished", "id") `, + ); + + await queryRunner.query( + `DROP INDEX IF EXISTS "IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584"`, + ); + + await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`); + } +} diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index 9aa75b16ed..0c0c33ca38 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -32,6 +32,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677236854063 } from './1677236854063-UpdateRunningExecutionStatus'; +import { CreateExecutionMetadataTable1679416281778 } from './1679416281778-CreateExecutionMetadataTable'; export const postgresMigrations = [ InitialMigration1587669153312, @@ -68,4 +69,5 @@ export const postgresMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677236854063, + CreateExecutionMetadataTable1679416281778, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/1679416281777-CreateExecutionMetadataTable.ts b/packages/cli/src/databases/migrations/sqlite/1679416281777-CreateExecutionMetadataTable.ts new file mode 100644 index 0000000000..39c39ff522 --- /dev/null +++ b/packages/cli/src/databases/migrations/sqlite/1679416281777-CreateExecutionMetadataTable.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; +import { getTablePrefix, logMigrationEnd, logMigrationStart } from '@db/utils/migrationHelpers'; + +export class CreateExecutionMetadataTable1679416281777 implements MigrationInterface { + name = 'CreateExecutionMetadataTable1679416281777'; + + public async up(queryRunner: QueryRunner): Promise { + logMigrationStart(this.name); + const tablePrefix = getTablePrefix(); + + await queryRunner.query( + `CREATE TABLE "${tablePrefix}execution_metadata" ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + executionId INTEGER NOT NULL, + "key" TEXT NOT NULL, + value TEXT NOT NULL, + CONSTRAINT ${tablePrefix}execution_metadata_entity_FK FOREIGN KEY (executionId) REFERENCES ${tablePrefix}execution_entity(id) ON DELETE CASCADE + )`, + ); + + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS "IDX_${tablePrefix}6d44376da6c1058b5e81ed8a154e1fee106046eb" ON "${tablePrefix}execution_metadata" ("executionId");`, + ); + + // Re add some lost indices from migration DeleteExecutionsWithWorkflows.ts + // that were part of AddExecutionEntityIndexes.ts + // not all were needed since we added the `status` column to execution_entity + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9' ON '${tablePrefix}execution_entity' ('waitTill', 'id') `, + ); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4' ON '${tablePrefix}execution_entity' ('workflowId', 'id') `, + ); + + // Also add index to the new status column + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS 'IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584' ON '${tablePrefix}execution_entity' ('status', 'workflowId') `, + ); + + // Remove no longer needed index to waitTill since it's already covered by the index b94b45ce2c73ce46c54f20b5f9 above + await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2'`); + // Remove index for stoppedAt since it's not used anymore + await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}cefb067df2402f6aed0638a6c1'`); + + logMigrationEnd(this.name); + } + + public async down(queryRunner: QueryRunner): Promise { + const tablePrefix = getTablePrefix(); + + await queryRunner.query(`DROP TABLE "${tablePrefix}execution_metadata"`); + await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}b94b45ce2c73ce46c54f20b5f9'`); + await queryRunner.query(`DROP INDEX IF EXISTS 'IDX_${tablePrefix}81fc04c8a17de15835713505e4'`); + await queryRunner.query( + `DROP INDEX IF EXISTS 'IDX_${tablePrefix}8b6f3f9ae234f137d707b98f3bf43584'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}ca4a71b47f28ac6ea88293a8e2" ON "${tablePrefix}execution_entity" ("waitTill")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_${tablePrefix}cefb067df2402f6aed0638a6c1" ON "${tablePrefix}execution_entity" ("stoppedAt")`, + ); + } +} diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index 42782c2430..2d1d3e6709 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -31,6 +31,7 @@ import { PurgeInvalidWorkflowConnections1675940580449 } from './1675940580449-Pu import { AddStatusToExecutions1674138566000 } from './1674138566000-AddStatusToExecutions'; import { MigrateExecutionStatus1676996103000 } from './1676996103000-MigrateExecutionStatus'; import { UpdateRunningExecutionStatus1677237073720 } from './1677237073720-UpdateRunningExecutionStatus'; +import { CreateExecutionMetadataTable1679416281777 } from './1679416281777-CreateExecutionMetadataTable'; const sqliteMigrations = [ InitialMigration1588102412422, @@ -66,6 +67,7 @@ const sqliteMigrations = [ AddStatusToExecutions1674138566000, MigrateExecutionStatus1676996103000, UpdateRunningExecutionStatus1677237073720, + CreateExecutionMetadataTable1679416281777, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/decorators/Middleware.ts b/packages/cli/src/decorators/Middleware.ts new file mode 100644 index 0000000000..6d5e957f6e --- /dev/null +++ b/packages/cli/src/decorators/Middleware.ts @@ -0,0 +1,11 @@ +import { CONTROLLER_MIDDLEWARES } from './constants'; +import type { MiddlewareMetadata } from './types'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const Middleware = (): MethodDecorator => (target, handlerName) => { + const controllerClass = target.constructor; + const middlewares = (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? + []) as MiddlewareMetadata[]; + middlewares.push({ handlerName: String(handlerName) }); + Reflect.defineMetadata(CONTROLLER_MIDDLEWARES, middlewares, controllerClass); +}; diff --git a/packages/cli/src/decorators/Route.ts b/packages/cli/src/decorators/Route.ts index 773bb2193a..e51369a64c 100644 --- a/packages/cli/src/decorators/Route.ts +++ b/packages/cli/src/decorators/Route.ts @@ -1,19 +1,30 @@ +import type { RequestHandler } from 'express'; import { CONTROLLER_ROUTES } from './constants'; import type { Method, RouteMetadata } from './types'; +interface RouteOptions { + middlewares?: RequestHandler[]; +} + /* eslint-disable @typescript-eslint/naming-convention */ const RouteFactory = (method: Method) => - (path: `/${string}`): MethodDecorator => + (path: `/${string}`, options: RouteOptions = {}): MethodDecorator => (target, handlerName) => { const controllerClass = target.constructor; const routes = (Reflect.getMetadata(CONTROLLER_ROUTES, controllerClass) ?? []) as RouteMetadata[]; - routes.push({ method, path, handlerName: String(handlerName) }); + routes.push({ + method, + path, + middlewares: options.middlewares ?? [], + handlerName: String(handlerName), + }); Reflect.defineMetadata(CONTROLLER_ROUTES, routes, controllerClass); }; export const Get = RouteFactory('get'); export const Post = RouteFactory('post'); +export const Put = RouteFactory('put'); export const Patch = RouteFactory('patch'); export const Delete = RouteFactory('delete'); diff --git a/packages/cli/src/decorators/constants.ts b/packages/cli/src/decorators/constants.ts index 9c77ab696e..6bff0e4a6c 100644 --- a/packages/cli/src/decorators/constants.ts +++ b/packages/cli/src/decorators/constants.ts @@ -1,2 +1,3 @@ export const CONTROLLER_ROUTES = 'CONTROLLER_ROUTES'; export const CONTROLLER_BASE_PATH = 'CONTROLLER_BASE_PATH'; +export const CONTROLLER_MIDDLEWARES = 'CONTROLLER_MIDDLEWARES'; diff --git a/packages/cli/src/decorators/index.ts b/packages/cli/src/decorators/index.ts index bda31ce887..71b82b5b69 100644 --- a/packages/cli/src/decorators/index.ts +++ b/packages/cli/src/decorators/index.ts @@ -1,3 +1,4 @@ export { RestController } from './RestController'; -export { Get, Post, Patch, Delete } from './Route'; +export { Get, Post, Put, Patch, Delete } from './Route'; +export { Middleware } from './Middleware'; export { registerController } from './registerController'; diff --git a/packages/cli/src/decorators/registerController.ts b/packages/cli/src/decorators/registerController.ts index 9f1ab5bba7..e20293ae21 100644 --- a/packages/cli/src/decorators/registerController.ts +++ b/packages/cli/src/decorators/registerController.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/naming-convention */ import { Router } from 'express'; import type { Config } from '@/config'; -import { CONTROLLER_BASE_PATH, CONTROLLER_ROUTES } from './constants'; +import { CONTROLLER_BASE_PATH, CONTROLLER_MIDDLEWARES, CONTROLLER_ROUTES } from './constants'; import { send } from '@/ResponseHelper'; // TODO: move `ResponseHelper.send` to this file -import type { Application, Request, Response } from 'express'; -import type { Controller, RouteMetadata } from './types'; +import type { Application, Request, Response, RequestHandler } from 'express'; +import type { Controller, MiddlewareMetadata, RouteMetadata } from './types'; export const registerController = (app: Application, config: Config, controller: object) => { const controllerClass = controller.constructor; @@ -20,9 +20,18 @@ export const registerController = (app: Application, config: Config, controller: const restBasePath = config.getEnv('endpoints.rest'); const prefix = `/${[restBasePath, controllerBasePath].join('/')}`.replace(/\/+/g, '/'); - routes.forEach(({ method, path, handlerName }) => { + const controllerMiddlewares = ( + (Reflect.getMetadata(CONTROLLER_MIDDLEWARES, controllerClass) ?? []) as MiddlewareMetadata[] + ).map( + ({ handlerName }) => + (controller as Controller)[handlerName].bind(controller) as RequestHandler, + ); + + routes.forEach(({ method, path, middlewares: routeMiddlewares, handlerName }) => { router[method]( path, + ...controllerMiddlewares, + ...routeMiddlewares, send(async (req: Request, res: Response) => (controller as Controller)[handlerName](req, res), ), diff --git a/packages/cli/src/decorators/types.ts b/packages/cli/src/decorators/types.ts index 829451f4d7..250abf0d27 100644 --- a/packages/cli/src/decorators/types.ts +++ b/packages/cli/src/decorators/types.ts @@ -1,12 +1,19 @@ -import type { Request, Response } from 'express'; +import type { Request, Response, RequestHandler } from 'express'; -export type Method = 'get' | 'post' | 'patch' | 'delete'; +export type Method = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export interface MiddlewareMetadata { + handlerName: string; +} export interface RouteMetadata { method: Method; path: string; handlerName: string; + middlewares: RequestHandler[]; } -type RequestHandler = (req?: Request, res?: Response) => Promise; -export type Controller = Record; +export type Controller = Record< + RouteMetadata['handlerName'], + (req?: Request, res?: Response) => Promise +>; diff --git a/packages/cli/src/eventbus/EventMessageClasses/index.ts b/packages/cli/src/eventbus/EventMessageClasses/index.ts index 508b5031cf..4b4fe47f8d 100644 --- a/packages/cli/src/eventbus/EventMessageClasses/index.ts +++ b/packages/cli/src/eventbus/EventMessageClasses/index.ts @@ -49,3 +49,11 @@ export type EventMessageTypes = | EventMessageWorkflow | EventMessageAudit | EventMessageNode; + +export interface FailedEventSummary { + lastNodeExecuted: string; + executionId: string; + name: string; + event: string; + timestamp: string; +} diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts index 439e4773e6..5d63f57b26 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBus.ts @@ -1,7 +1,11 @@ import { LoggerProxy } from 'n8n-workflow'; import type { MessageEventBusDestinationOptions } from 'n8n-workflow'; import type { DeleteResult } from 'typeorm'; -import type { EventMessageTypes } from '../EventMessageClasses/'; +import type { + EventMessageTypes, + EventNamesTypes, + FailedEventSummary, +} from '../EventMessageClasses/'; import type { MessageEventBusDestination } from '../MessageEventBusDestination/MessageEventBusDestination.ee'; import { MessageEventBusLogWriter } from '../MessageEventBusWriter/MessageEventBusLogWriter'; import EventEmitter from 'events'; @@ -249,6 +253,48 @@ export class MessageEventBus extends EventEmitter { ); } + async getEventsFailed(amount = 5): Promise { + const result: FailedEventSummary[] = []; + try { + const queryResult = await this.logWriter?.getMessagesAll(); + const uniques = uniqby(queryResult, 'id'); + const filteredExecutionIds = uniques + .filter((e) => + (['n8n.workflow.crashed', 'n8n.workflow.failed'] as EventNamesTypes[]).includes( + e.eventName, + ), + ) + .map((e) => ({ + executionId: e.payload.executionId as string, + name: e.payload.workflowName, + timestamp: e.ts, + event: e.eventName, + })) + .filter((e) => e) + .sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1)) + .slice(-amount); + + for (const execution of filteredExecutionIds) { + const data = await recoverExecutionDataFromEventLogMessages( + execution.executionId, + queryResult, + false, + ); + if (data) { + const lastNodeExecuted = data.resultData.lastNodeExecuted; + result.push({ + lastNodeExecuted: lastNodeExecuted ?? '', + executionId: execution.executionId, + name: execution.name as string, + event: execution.event, + timestamp: execution.timestamp.toISO(), + }); + } + } + } catch {} + return result; + } + async getEventsAll(): Promise { const queryResult = await this.logWriter?.getMessagesAll(); const filtered = uniqby(queryResult, 'id'); diff --git a/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts b/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts index 5ba935b1e3..29eab2872a 100644 --- a/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts +++ b/packages/cli/src/eventbus/MessageEventBus/MessageEventBusHelper.ts @@ -1,7 +1,7 @@ -import config from '@/config'; -import { getLicense } from '@/License'; +import { License } from '@/License'; +import { Container } from 'typedi'; export function isLogStreamingEnabled(): boolean { - const license = getLicense(); - return config.getEnv('enterprise.features.logStreaming') || license.isLogStreamingEnabled(); + const license = Container.get(License); + return license.isLogStreamingEnabled(); } diff --git a/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts b/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts index 88399233f2..af95df2e88 100644 --- a/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts +++ b/packages/cli/src/eventbus/MessageEventBus/recoverEvents.ts @@ -10,6 +10,7 @@ import { workflowExecutionCompleted } from '../../events/WorkflowStatistics'; import { eventBus } from './MessageEventBus'; import { Container } from 'typedi'; import { InternalHooks } from '@/InternalHooks'; +import { getWorkflowHooksMain } from '@/WorkflowExecuteAdditionalData'; export async function recoverExecutionDataFromEventLogMessages( executionId: string, @@ -122,9 +123,6 @@ export async function recoverExecutionDataFromEventLogMessages( } } - if (!executionData.resultData.error && workflowError) { - executionData.resultData.error = workflowError; - } if (!lastNodeRunTimestamp) { const workflowEndedMessage = messages.find((message) => ( @@ -138,6 +136,11 @@ export async function recoverExecutionDataFromEventLogMessages( if (workflowEndedMessage) { lastNodeRunTimestamp = workflowEndedMessage.ts; } else { + if (!workflowError) { + workflowError = new WorkflowOperationError( + 'Workflow did not finish, possible out-of-memory issue', + ); + } const workflowStartedMessage = messages.find( (message) => message.eventName === 'n8n.workflow.started', ); @@ -146,6 +149,11 @@ export async function recoverExecutionDataFromEventLogMessages( } } } + + if (!executionData.resultData.error && workflowError) { + executionData.resultData.error = workflowError; + } + if (applyToDb) { await Db.collections.Execution.update(executionId, { data: stringify(executionData), @@ -174,6 +182,20 @@ export async function recoverExecutionDataFromEventLogMessages( stoppedAt: lastNodeRunTimestamp?.toJSDate(), status: 'crashed', }; + const workflowHooks = getWorkflowHooksMain( + { + userId: '', + workflowData: executionEntry.workflowData, + executionMode: executionEntry.mode, + executionData, + runData: executionData.resultData.runData, + retryOf: executionEntry.retryOf, + }, + executionId, + ); + + // execute workflowExecuteAfter hook to trigger error workflow + await workflowHooks.executeHookFunctions('workflowExecuteAfter', [iRunData]); // calling workflowExecutionCompleted directly because the eventEmitter is not up yet at this point await workflowExecutionCompleted(executionEntry.workflowData, iRunData); diff --git a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts index c8dc5f74ba..87293f6149 100644 --- a/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts +++ b/packages/cli/src/eventbus/MessageEventBusWriter/MessageEventBusLogWriter.ts @@ -158,9 +158,8 @@ export class MessageEventBusLogWriter { sentMessages: [], unfinishedExecutions: {}, }; - const logCount = logHistory - ? Math.min(config.get('eventBus.logWriter.keepLogCount') as number, logHistory) - : (config.get('eventBus.logWriter.keepLogCount') as number); + const configLogCount = config.get('eventBus.logWriter.keepLogCount'); + const logCount = logHistory ? Math.min(configLogCount, logHistory) : configLogCount; for (let i = logCount; i >= 0; i--) { const logFileName = this.getLogFileName(i); if (logFileName) { @@ -255,9 +254,8 @@ export class MessageEventBusLogWriter { logHistory?: number, ): Promise { const result: EventMessageTypes[] = []; - const logCount = logHistory - ? Math.min(config.get('eventBus.logWriter.keepLogCount') as number, logHistory) - : (config.get('eventBus.logWriter.keepLogCount') as number); + const configLogCount = config.get('eventBus.logWriter.keepLogCount'); + const logCount = logHistory ? Math.min(configLogCount, logHistory) : configLogCount; for (let i = 0; i < logCount; i++) { const logFileName = this.getLogFileName(i); if (logFileName) { diff --git a/packages/cli/src/eventbus/eventBusRoutes.ts b/packages/cli/src/eventbus/eventBus.controller.ts similarity index 67% rename from packages/cli/src/eventbus/eventBusRoutes.ts rename to packages/cli/src/eventbus/eventBus.controller.ts index 26d76e6c39..bc18472d4c 100644 --- a/packages/cli/src/eventbus/eventBusRoutes.ts +++ b/packages/cli/src/eventbus/eventBus.controller.ts @@ -17,22 +17,26 @@ import { MessageEventBusDestinationSyslog, } from './MessageEventBusDestination/MessageEventBusDestinationSyslog.ee'; import { MessageEventBusDestinationWebhook } from './MessageEventBusDestination/MessageEventBusDestinationWebhook.ee'; +import type { EventMessageTypes, FailedEventSummary } from './EventMessageClasses'; import { eventNamesAll } from './EventMessageClasses'; import type { EventMessageAuditOptions } from './EventMessageClasses/EventMessageAudit'; import { EventMessageAudit } from './EventMessageClasses/EventMessageAudit'; -import { BadRequestError } from '../ResponseHelper'; +import { BadRequestError } from '@/ResponseHelper'; import type { MessageEventBusDestinationWebhookOptions, MessageEventBusDestinationOptions, + IRunExecutionData, } from 'n8n-workflow'; import { MessageEventBusDestinationTypeNames, EventMessageTypeNames } from 'n8n-workflow'; -import type { User } from '../databases/entities/User'; +import type { User } from '@db/entities/User'; import * as ResponseHelper from '@/ResponseHelper'; import type { EventMessageNodeOptions } from './EventMessageClasses/EventMessageNode'; import { EventMessageNode } from './EventMessageClasses/EventMessageNode'; import { recoverExecutionDataFromEventLogMessages } from './MessageEventBus/recoverEvents'; - -export const eventBusRouter = express.Router(); +import { RestController, Get, Post, Delete } from '@/decorators'; +import type { MessageEventBusDestination } from './MessageEventBusDestination/MessageEventBusDestination.ee'; +import { isOwnerMiddleware } from '../middlewares/isOwner'; +import type { DeleteResult } from 'typeorm'; // ---------------------------------------- // TypeGuards @@ -50,7 +54,6 @@ const isWithQueryString = (candidate: unknown): candidate is { query: string } = return o.query !== undefined; }; -// TODO: add credentials const isMessageEventBusDestinationWebhookOptions = ( candidate: unknown, ): candidate is MessageEventBusDestinationWebhookOptions => { @@ -68,11 +71,18 @@ const isMessageEventBusDestinationOptions = ( }; // ---------------------------------------- -// Events +// Controller // ---------------------------------------- -eventBusRouter.get( - '/event', - ResponseHelper.send(async (req: express.Request): Promise => { + +@RestController('/eventbus') +export class EventBusController { + // ---------------------------------------- + // Events + // ---------------------------------------- + @Get('/event', { middlewares: [isOwnerMiddleware] }) + async getEvents( + req: express.Request, + ): Promise> { if (isWithQueryString(req.query)) { switch (req.query.query as EventMessageReturnMode) { case 'sent': @@ -85,14 +95,19 @@ eventBusRouter.get( default: return eventBus.getEventsAll(); } + } else { + return eventBus.getEventsAll(); } - return eventBus.getEventsAll(); - }), -); + } -eventBusRouter.get( - '/execution/:id', - ResponseHelper.send(async (req: express.Request): Promise => { + @Get('/failed') + async getFailedEvents(req: express.Request): Promise { + const amount = parseInt(req.query?.amount as string) ?? 5; + return eventBus.getEventsFailed(amount); + } + + @Get('/execution/:id') + async getEventForExecutionId(req: express.Request): Promise { if (req.params?.id) { let logHistory; if (req.query?.logHistory) { @@ -100,40 +115,27 @@ eventBusRouter.get( } return eventBus.getEventsByExecutionId(req.params.id, logHistory); } - }), -); + return; + } -eventBusRouter.get( - '/execution-recover/:id', - ResponseHelper.send(async (req: express.Request): Promise => { + @Get('/execution-recover/:id') + async getRecoveryForExecutionId(req: express.Request): Promise { + const { id } = req.params; if (req.params?.id) { - let logHistory; - let applyToDb = true; - if (req.query?.logHistory) { - logHistory = parseInt(req.query.logHistory as string, 10); - } - if (req.query?.applyToDb) { - applyToDb = !!req.query.applyToDb; - } - const messages = await eventBus.getEventsByExecutionId(req.params.id, logHistory); + const logHistory = parseInt(req.query.logHistory as string, 10) || undefined; + const applyToDb = req.query.applyToDb !== undefined ? !!req.query.applyToDb : true; + const messages = await eventBus.getEventsByExecutionId(id, logHistory); if (messages.length > 0) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const recoverResult = await recoverExecutionDataFromEventLogMessages( - req.params.id, - messages, - applyToDb, - ); - return recoverResult; + return recoverExecutionDataFromEventLogMessages(id, messages, applyToDb); } } - }), -); + return; + } -eventBusRouter.post( - '/event', - ResponseHelper.send(async (req: express.Request): Promise => { + @Post('/event', { middlewares: [isOwnerMiddleware] }) + async postEvent(req: express.Request): Promise { + let msg: EventMessageTypes | undefined; if (isEventMessageOptions(req.body)) { - let msg; switch (req.body.__type) { case EventMessageTypeNames.workflow: msg = new EventMessageWorkflow(req.body as EventMessageWorkflowOptions); @@ -154,35 +156,30 @@ eventBusRouter.post( 'Body is not a serialized EventMessage or eventName does not match format {namespace}.{domain}.{event}', ); } - }), -); + return msg; + } -// ---------------------------------------- -// Destinations -// ---------------------------------------- + // ---------------------------------------- + // Destinations + // ---------------------------------------- -eventBusRouter.get( - '/destination', - ResponseHelper.send(async (req: express.Request): Promise => { - let result = []; + @Get('/destination') + async getDestination(req: express.Request): Promise { if (isWithIdString(req.query)) { - result = await eventBus.findDestination(req.query.id); + return eventBus.findDestination(req.query.id); } else { - result = await eventBus.findDestination(); + return eventBus.findDestination(); } - return result; - }), -); + } -eventBusRouter.post( - '/destination', - ResponseHelper.send(async (req: express.Request): Promise => { + @Post('/destination', { middlewares: [isOwnerMiddleware] }) + async postDestination(req: express.Request): Promise { if (!req.user || (req.user as User).globalRole.name !== 'owner') { throw new ResponseHelper.UnauthorizedError('Invalid request'); } + let result: MessageEventBusDestination | undefined; if (isMessageEventBusDestinationOptions(req.body)) { - let result; switch (req.body.__type) { case MessageEventBusDestinationTypeNames.sentry: if (isMessageEventBusDestinationSentryOptions(req.body)) { @@ -214,51 +211,41 @@ eventBusRouter.post( if (result) { await result.saveToDb(); return { - ...result, + ...result.serialize(), eventBusInstance: undefined, }; } throw new BadRequestError('There was an error adding the destination'); } throw new BadRequestError('Body is not configuring MessageEventBusDestinationOptions'); - }), -); + } -eventBusRouter.get( - '/testmessage', - ResponseHelper.send(async (req: express.Request): Promise => { - let result = false; + @Get('/testmessage') + async sendTestMessage(req: express.Request): Promise { if (isWithIdString(req.query)) { - result = await eventBus.testDestination(req.query.id); + return eventBus.testDestination(req.query.id); } - return result; - }), -); + return false; + } -eventBusRouter.delete( - '/destination', - ResponseHelper.send(async (req: express.Request): Promise => { + @Delete('/destination', { middlewares: [isOwnerMiddleware] }) + async deleteDestination(req: express.Request): Promise { if (!req.user || (req.user as User).globalRole.name !== 'owner') { throw new ResponseHelper.UnauthorizedError('Invalid request'); } if (isWithIdString(req.query)) { - const result = await eventBus.removeDestination(req.query.id); - if (result) { - return result; - } + return eventBus.removeDestination(req.query.id); } else { throw new BadRequestError('Query is missing id'); } - }), -); + } -// ---------------------------------------- -// Utilities -// ---------------------------------------- + // ---------------------------------------- + // Utilities + // ---------------------------------------- -eventBusRouter.get( - '/eventnames', - ResponseHelper.send(async (): Promise => { + @Get('/eventnames') + async getEventNames(): Promise { return eventNamesAll; - }), -); + } +} diff --git a/packages/cli/src/executions/executionHelpers.ts b/packages/cli/src/executions/executionHelpers.ts index b6fe81e1f1..148bd9b8b9 100644 --- a/packages/cli/src/executions/executionHelpers.ts +++ b/packages/cli/src/executions/executionHelpers.ts @@ -1,7 +1,7 @@ +import { Container } from 'typedi'; import type { IExecutionFlattedDb } from '@/Interfaces'; import type { ExecutionStatus } from 'n8n-workflow'; -import { getLicense } from '@/License'; -import config from '@/config'; +import { License } from '@/License'; export function getStatusUsingPreviousExecutionStatusMethod( execution: IExecutionFlattedDb, @@ -20,9 +20,6 @@ export function getStatusUsingPreviousExecutionStatusMethod( } export function isAdvancedExecutionFiltersEnabled(): boolean { - const license = getLicense(); - return ( - config.getEnv('enterprise.features.advancedExecutionFilters') || - license.isAdvancedExecutionFiltersEnabled() - ); + const license = Container.get(License); + return license.isAdvancedExecutionFiltersEnabled(); } diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index a4f9d05dcd..6165d13b2a 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -14,7 +14,7 @@ import type { } from 'n8n-workflow'; import { deepCopy, LoggerProxy, jsonParse, Workflow } from 'n8n-workflow'; import type { FindOperator, FindOptionsWhere } from 'typeorm'; -import { In, IsNull, LessThanOrEqual, Not, Raw } from 'typeorm'; +import { In, IsNull, LessThanOrEqual, MoreThanOrEqual, Not, Raw } from 'typeorm'; import { ActiveExecutions } from '@/ActiveExecutions'; import config from '@/config'; import type { User } from '@db/entities/User'; @@ -26,7 +26,7 @@ import type { IWorkflowExecutionDataProcess, } from '@/Interfaces'; import { NodeTypes } from '@/NodeTypes'; -import * as Queue from '@/Queue'; +import { Queue } from '@/Queue'; import type { ExecutionRequest } from '@/requests'; import * as ResponseHelper from '@/ResponseHelper'; import { getSharedWorkflowIds } from '@/WorkflowHelpers'; @@ -35,10 +35,15 @@ import * as Db from '@/Db'; import * as GenericHelpers from '@/GenericHelpers'; import { parse } from 'flatted'; import { Container } from 'typedi'; -import { getStatusUsingPreviousExecutionStatusMethod } from './executionHelpers'; +import { + getStatusUsingPreviousExecutionStatusMethod, + isAdvancedExecutionFiltersEnabled, +} from './executionHelpers'; +import { ExecutionMetadata } from '@/databases/entities/ExecutionMetadata'; +import { DateUtils } from 'typeorm/util/DateUtils'; interface IGetExecutionsQueryFilter { - id?: FindOperator; + id?: FindOperator | string; finished?: boolean; mode?: string; retryOf?: string; @@ -47,12 +52,16 @@ interface IGetExecutionsQueryFilter { workflowId?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any waitTill?: FindOperator | boolean; + metadata?: Array<{ key: string; value: string }>; + startedAfter?: string; + startedBefore?: string; } const schemaGetExecutionsQueryFilter = { $id: '/IGetExecutionsQueryFilter', type: 'object', properties: { + id: { type: 'string' }, finished: { type: 'boolean' }, mode: { type: 'string' }, retryOf: { type: 'string' }, @@ -63,6 +72,21 @@ const schemaGetExecutionsQueryFilter = { }, waitTill: { type: 'boolean' }, workflowId: { anyOf: [{ type: 'integer' }, { type: 'string' }] }, + metadata: { type: 'array', items: { $ref: '#/$defs/metadata' } }, + startedAfter: { type: 'date-time' }, + startedBefore: { type: 'date-time' }, + }, + $defs: { + metadata: { + type: 'object', + required: ['key', 'value'], + properties: { + key: { + type: 'string', + }, + value: { type: 'string' }, + }, + }, }, }; @@ -84,17 +108,38 @@ export class ExecutionsService { static async getExecutionsCount( countFilter: IDataObject, user: User, + metadata?: Array<{ key: string; value: string }>, ): Promise<{ count: number; estimated: boolean }> { const dbType = config.getEnv('database.type'); const filteredFields = Object.keys(countFilter).filter((field) => field !== 'id'); // For databases other than Postgres, do a regular count // when filtering based on `workflowId` or `finished` fields. - if (dbType !== 'postgresdb' || filteredFields.length > 0 || user.globalRole.name !== 'owner') { + if ( + dbType !== 'postgresdb' || + metadata?.length || + filteredFields.length > 0 || + user.globalRole.name !== 'owner' + ) { const sharedWorkflowIds = await this.getWorkflowIdsForUser(user); - const countParams = { where: { workflowId: In(sharedWorkflowIds), ...countFilter } }; - const count = await Db.collections.Execution.count(countParams); + let query = Db.collections.Execution.createQueryBuilder('execution') + .select() + .orderBy('execution.id', 'DESC') + .where({ workflowId: In(sharedWorkflowIds) }); + + if (metadata?.length) { + query = query.leftJoinAndSelect(ExecutionMetadata, 'md', 'md.executionId = execution.id'); + for (const md of metadata) { + query = query.andWhere('md.key = :key AND md.value = :value', md); + } + } + + if (filteredFields.length > 0) { + query = query.andWhere(countFilter); + } + + const count = await query.getCount(); return { count, estimated: false }; } @@ -138,6 +183,18 @@ export class ExecutionsService { } else { delete filter.waitTill; } + + if (Array.isArray(filter.metadata)) { + delete filter.metadata; + } + + if ('startedAfter' in filter) { + delete filter.startedAfter; + } + + if ('startedBefore' in filter) { + delete filter.startedBefore; + } } } @@ -197,7 +254,7 @@ export class ExecutionsService { const executingWorkflowIds: string[] = []; if (config.getEnv('executions.mode') === 'queue') { - const queue = await Queue.getInstance(); + const queue = Container.get(Queue); const currentJobs = await queue.getJobs(['active', 'waiting']); executingWorkflowIds.push(...currentJobs.map(({ data }) => data.executionId)); } @@ -227,17 +284,17 @@ export class ExecutionsService { } = {}; if (req.query.lastId) { - rangeQuery.push('id < :lastId'); + rangeQuery.push('execution.id < :lastId'); rangeQueryParams.lastId = req.query.lastId; } if (req.query.firstId) { - rangeQuery.push('id > :firstId'); + rangeQuery.push('execution.id > :firstId'); rangeQueryParams.firstId = req.query.firstId; } if (executingWorkflowIds.length > 0) { - rangeQuery.push('id NOT IN (:...executingWorkflowIds)'); + rangeQuery.push('execution.id NOT IN (:...executingWorkflowIds)'); rangeQueryParams.executingWorkflowIds = executingWorkflowIds; } @@ -261,11 +318,36 @@ export class ExecutionsService { 'execution.workflowData', 'execution.status', ]) - .orderBy('id', 'DESC') + .orderBy('execution.id', 'DESC') .take(limit) .where(findWhere); const countFilter = deepCopy(filter ?? {}); + const metadata = isAdvancedExecutionFiltersEnabled() ? filter?.metadata : undefined; + + if (metadata?.length) { + query = query.leftJoin(ExecutionMetadata, 'md', 'md.executionId = execution.id'); + for (const md of metadata) { + query = query.andWhere('md.key = :key AND md.value = :value', md); + } + } + + if (filter?.startedAfter) { + query = query.andWhere({ + startedAt: MoreThanOrEqual( + DateUtils.mixedDateToUtcDatetimeString(new Date(filter.startedAfter)), + ), + }); + } + + if (filter?.startedBefore) { + query = query.andWhere({ + startedAt: LessThanOrEqual( + DateUtils.mixedDateToUtcDatetimeString(new Date(filter.startedBefore)), + ), + }); + } + // deepcopy breaks the In operator so we need to reapply it if (filter?.status) { Object.assign(filter, { status: In(filter.status) }); @@ -285,6 +367,7 @@ export class ExecutionsService { const { count, estimated } = await this.getExecutionsCount( countFilter as IDataObject, req.user, + metadata, ); const formattedExecutions: IExecutionsSummary[] = executions.map((execution) => { @@ -541,6 +624,9 @@ export class ExecutionsService { // delete executions by date, if user may access the underlying workflows where.startedAt = LessThanOrEqual(deleteBefore); Object.assign(where, requestFilters); + if (where.status) { + where.status = In(requestFiltersRaw!.status as string[]); + } } else if (ids) { // delete executions by IDs, if user may access the underlying workflows where.id = In(ids); @@ -568,6 +654,10 @@ export class ExecutionsService { idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)), ); - await Db.collections.Execution.delete(idsToDelete); + do { + // Delete in batches to avoid "SQLITE_ERROR: Expression tree is too large (maximum depth 1000)" error + const batch = idsToDelete.splice(0, 500); + await Db.collections.Execution.delete(batch); + } while (idsToDelete.length > 0); } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 81b43dd317..cb0ff5c3b5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,33 +1 @@ -/* eslint-disable import/first */ -export * from './CredentialsHelper'; -export * from './CredentialTypes'; -export * from './CredentialsOverwrites'; -export * from './Interfaces'; -export * from './NodeTypes'; -export * from './WaitingWebhooks'; -export * from './WorkflowCredentials'; -export * from './WorkflowRunner'; - -import { ActiveExecutions } from './ActiveExecutions'; -import * as Db from './Db'; -import * as GenericHelpers from './GenericHelpers'; -import * as ResponseHelper from './ResponseHelper'; -import * as Server from './Server'; -import * as TestWebhooks from './TestWebhooks'; -import * as WebhookHelpers from './WebhookHelpers'; -import * as WebhookServer from './WebhookServer'; -import * as WorkflowExecuteAdditionalData from './WorkflowExecuteAdditionalData'; -import * as WorkflowHelpers from './WorkflowHelpers'; - -export { - ActiveExecutions, - Db, - GenericHelpers, - ResponseHelper, - Server, - TestWebhooks, - WebhookHelpers, - WebhookServer, - WorkflowExecuteAdditionalData, - WorkflowHelpers, -}; +export {}; diff --git a/packages/cli/src/license/License.service.ts b/packages/cli/src/license/License.service.ts index 5f7f1ff41c..adba06ca9c 100644 --- a/packages/cli/src/license/License.service.ts +++ b/packages/cli/src/license/License.service.ts @@ -1,4 +1,5 @@ -import { getLicense } from '@/License'; +import { Container } from 'typedi'; +import { License } from '@/License'; import type { ILicenseReadResponse } from '@/Interfaces'; import * as Db from '@/Db'; @@ -11,7 +12,7 @@ export class LicenseService { // Helper for getting the basic license data that we want to return static async getLicenseData(): Promise { const triggerCount = await LicenseService.getActiveTriggerCount(); - const license = getLicense(); + const license = Container.get(License); const mainPlan = license.getMainPlan(); return { diff --git a/packages/cli/src/license/license.controller.ts b/packages/cli/src/license/license.controller.ts index 71a28336f7..75d8252411 100644 --- a/packages/cli/src/license/license.controller.ts +++ b/packages/cli/src/license/license.controller.ts @@ -7,7 +7,7 @@ import { getLogger } from '@/Logger'; import * as ResponseHelper from '@/ResponseHelper'; import type { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; import { LicenseService } from './License.service'; -import { getLicense } from '@/License'; +import { License } from '@/License'; import type { AuthenticatedRequest, LicenseRequest } from '@/requests'; import { isInstanceOwner } from '@/PublicApi/v1/handlers/users/users.service'; import { Container } from 'typedi'; @@ -69,7 +69,7 @@ licenseController.post( '/activate', ResponseHelper.send(async (req: LicenseRequest.Activate): Promise => { // Call the license manager activate function and tell it to throw an error - const license = getLicense(); + const license = Container.get(License); try { await license.activate(req.body.activationKey); } catch (e) { @@ -111,7 +111,7 @@ licenseController.post( '/renew', ResponseHelper.send(async (): Promise => { // Call the license manager activate function and tell it to throw an error - const license = getLicense(); + const license = Container.get(License); try { await license.renew(); } catch (e) { diff --git a/packages/cli/src/middlewares/auth.ts b/packages/cli/src/middlewares/auth.ts index 34c76ea592..1299f47408 100644 --- a/packages/cli/src/middlewares/auth.ts +++ b/packages/cli/src/middlewares/auth.ts @@ -18,7 +18,7 @@ import { } from '@/UserManagement/UserManagementHelper'; import type { Repository } from 'typeorm'; import type { User } from '@db/entities/User'; -import { SamlUrls } from '../sso/saml/constants'; +import { SamlUrls } from '@/sso/saml/constants'; const jwtFromRequest = (req: Request) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access diff --git a/packages/cli/src/middlewares/isOwner.ts b/packages/cli/src/middlewares/isOwner.ts new file mode 100644 index 0000000000..d3e3c70a0f --- /dev/null +++ b/packages/cli/src/middlewares/isOwner.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from 'express'; +import { LoggerProxy } from 'n8n-workflow'; +import type { AuthenticatedRequest } from '@/requests'; + +export const isOwnerMiddleware: RequestHandler = (req: AuthenticatedRequest, res, next) => { + if (req.user.globalRole.name === 'owner') { + next(); + } else { + LoggerProxy.debug('Request failed because user is not owner'); + res.status(401).send('Unauthorized'); + } +}; diff --git a/packages/cli/src/middlewares/userManagementEnabled.ts b/packages/cli/src/middlewares/userManagementEnabled.ts new file mode 100644 index 0000000000..c1f3c58c6f --- /dev/null +++ b/packages/cli/src/middlewares/userManagementEnabled.ts @@ -0,0 +1,12 @@ +import type { RequestHandler } from 'express'; +import { LoggerProxy } from 'n8n-workflow'; +import { isUserManagementEnabled } from '../UserManagement/UserManagementHelper'; + +export const userManagementEnabledMiddleware: RequestHandler = (req, res, next) => { + if (isUserManagementEnabled()) { + next(); + } else { + LoggerProxy.debug('Request failed because user management is disabled'); + res.status(400).json({ status: 'error', message: 'User management is disabled' }); + } +}; diff --git a/packages/cli/src/push/abstract.push.ts b/packages/cli/src/push/abstract.push.ts index 633cb126a8..a9022bdc5c 100644 --- a/packages/cli/src/push/abstract.push.ts +++ b/packages/cli/src/push/abstract.push.ts @@ -1,4 +1,4 @@ -import { LoggerProxy as Logger } from 'n8n-workflow'; +import { jsonStringify, LoggerProxy as Logger } from 'n8n-workflow'; import type { IPushDataType } from '@/Interfaces'; import { eventBus } from '../eventbus'; @@ -38,7 +38,7 @@ export abstract class AbstractPush { Logger.debug(`Send data of type "${type}" to editor-UI`, { dataType: type, sessionId }); - const sendData = JSON.stringify({ type, data }); + const sendData = jsonStringify({ type, data }, { replaceCircularRefs: true }); if (sessionId === undefined) { // Send to all connected clients diff --git a/packages/cli/src/requests.ts b/packages/cli/src/requests.ts index f8c5af17b4..a90ec2b021 100644 --- a/packages/cli/src/requests.ts +++ b/packages/cli/src/requests.ts @@ -15,7 +15,7 @@ import { NoXss } from '@db/utils/customValidators'; import type { PublicUser, IExecutionDeleteFilter, IWorkflowDb } from '@/Interfaces'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; -import type * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; +import type { UserManagementMailer } from '@/UserManagement/email'; export class UserUpdatePayload implements Pick { @IsEmail() @@ -46,7 +46,7 @@ export type AuthenticatedRequest< RequestQuery = {}, > = Omit, 'user'> & { user: User; - mailer?: UserManagementMailer.UserManagementMailer; + mailer?: UserManagementMailer; globalMemberRole?: Role; }; @@ -330,6 +330,9 @@ export type NodeListSearchRequest = AuthenticatedRequest< // ---------------------------------- export declare namespace TagsRequest { + type GetAll = AuthenticatedRequest<{}, {}, {}, { withUsageCount: string }>; + type Create = AuthenticatedRequest<{}, {}, { name: string }>; + type Update = AuthenticatedRequest<{ id: string }, {}, { name: string }>; type Delete = AuthenticatedRequest<{ id: string }>; } diff --git a/packages/cli/src/sso/saml/constants.ts b/packages/cli/src/sso/saml/constants.ts index 6f92690f5f..3729f3ce51 100644 --- a/packages/cli/src/sso/saml/constants.ts +++ b/packages/cli/src/sso/saml/constants.ts @@ -28,8 +28,6 @@ export class SamlUrls { export const SAML_PREFERENCES_DB_KEY = 'features.saml'; -export const SAML_ENTERPRISE_FEATURE_ENABLED = 'enterprise.features.saml'; - export const SAML_LOGIN_LABEL = 'sso.saml.loginLabel'; export const SAML_LOGIN_ENABLED = 'sso.saml.loginEnabled'; diff --git a/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts index 257d694e48..6e4600b895 100644 --- a/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts +++ b/packages/cli/src/sso/saml/middleware/samlEnabledMiddleware.ts @@ -1,5 +1,5 @@ import type { RequestHandler } from 'express'; -import type { AuthenticatedRequest } from '../../../requests'; +import type { AuthenticatedRequest } from '@/requests'; import { isSamlLicensed, isSamlLicensedAndEnabled } from '../samlHelpers'; export const samlLicensedOwnerMiddleware: RequestHandler = ( @@ -21,3 +21,11 @@ export const samlLicensedAndEnabledMiddleware: RequestHandler = (req, res, next) res.status(401).json({ status: 'error', message: 'Unauthorized' }); } }; + +export const samlLicensedMiddleware: RequestHandler = (req, res, next) => { + if (isSamlLicensed()) { + next(); + } else { + res.status(401).json({ status: 'error', message: 'Unauthorized' }); + } +}; diff --git a/packages/cli/src/sso/saml/routes/saml.controller.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts new file mode 100644 index 0000000000..28b9d3c522 --- /dev/null +++ b/packages/cli/src/sso/saml/routes/saml.controller.ee.ts @@ -0,0 +1,150 @@ +import express from 'express'; +import { Get, Post, RestController } from '@/decorators'; +import { SamlUrls } from '../constants'; +import { + samlLicensedAndEnabledMiddleware, + samlLicensedMiddleware, + samlLicensedOwnerMiddleware, +} from '../middleware/samlEnabledMiddleware'; +import { SamlService } from '../saml.service.ee'; +import { SamlConfiguration } from '../types/requests'; +import { AuthError, BadRequestError } from '@/ResponseHelper'; +import { getInitSSOFormView } from '../views/initSsoPost'; +import { issueCookie } from '@/auth/jwt'; +import { validate } from 'class-validator'; +import type { PostBindingContext } from 'samlify/types/src/entity'; +import { isSamlLicensedAndEnabled } from '../samlHelpers'; +import type { SamlLoginBinding } from '../types'; +import { AuthenticatedRequest } from '@/requests'; +import { getServiceProviderEntityId, getServiceProviderReturnUrl } from '../serviceProvider.ee'; + +@RestController('/sso/saml') +export class SamlController { + constructor(private samlService: SamlService) {} + + @Get(SamlUrls.metadata) + async getServiceProviderMetadata(req: express.Request, res: express.Response) { + return res + .header('Content-Type', 'text/xml') + .send(this.samlService.getServiceProviderInstance().getMetadata()); + } + + /** + * GET /sso/saml/config + * Return SAML config + */ + @Get(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) + async configGet(req: AuthenticatedRequest, res: express.Response) { + const prefs = this.samlService.samlPreferences; + return res.send({ + ...prefs, + entityID: getServiceProviderEntityId(), + returnUrl: getServiceProviderReturnUrl(), + }); + } + + /** + * POST /sso/saml/config + * Set SAML config + */ + @Post(SamlUrls.config, { middlewares: [samlLicensedOwnerMiddleware] }) + async configPost(req: SamlConfiguration.Update, res: express.Response) { + const validationResult = await validate(req.body); + if (validationResult.length === 0) { + const result = await this.samlService.setSamlPreferences(req.body); + return res.send(result); + } else { + throw new BadRequestError( + 'Body is not a valid SamlPreferences object: ' + + validationResult.map((e) => e.toString()).join(','), + ); + } + } + + /** + * POST /sso/saml/config/toggle + * Set SAML config + */ + @Post(SamlUrls.configToggleEnabled, { middlewares: [samlLicensedOwnerMiddleware] }) + 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 }); + return res.sendStatus(200); + } + + /** + * GET /sso/saml/acs + * Assertion Consumer Service endpoint + */ + @Get(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) + async acsGet(req: express.Request, res: express.Response) { + return this.acsHandler(req, res, 'redirect'); + } + + /** + * POST /sso/saml/acs + * Assertion Consumer Service endpoint + */ + @Post(SamlUrls.acs, { middlewares: [samlLicensedMiddleware] }) + async acsPost(req: express.Request, res: express.Response) { + return this.acsHandler(req, res, 'post'); + } + + /** + * Handles the ACS endpoint for both GET and POST requests + * Available if SAML is licensed, even if not enabled to run connection tests + * For test connections, returns status 202 if SAML is not enabled + */ + private async acsHandler(req: express.Request, res: express.Response, binding: SamlLoginBinding) { + const loginResult = await this.samlService.handleSamlLogin(req, binding); + if (loginResult) { + if (loginResult.authenticatedUser) { + // Only sign in user if SAML is enabled, otherwise treat as test connection + if (isSamlLicensedAndEnabled()) { + await issueCookie(res, loginResult.authenticatedUser); + if (loginResult.onboardingRequired) { + return res.redirect(SamlUrls.samlOnboarding); + } else { + return res.redirect(SamlUrls.defaultRedirect); + } + } else { + return res.status(202).send(loginResult.attributes); + } + } + } + throw new AuthError('SAML Authentication failed'); + } + + /** + * GET /sso/saml/initsso + * Access URL for implementing SP-init SSO + * This endpoint is available if SAML is licensed and enabled + */ + @Get(SamlUrls.initSSO, { middlewares: [samlLicensedAndEnabledMiddleware] }) + async initSsoGet(req: express.Request, res: express.Response) { + return this.handleInitSSO(res); + } + + /** + * GET /sso/saml/config/test + * Test SAML config + * This endpoint is available if SAML is licensed and the requestor is an instance owner + */ + @Get(SamlUrls.configTest, { middlewares: [samlLicensedOwnerMiddleware] }) + async configTestGet(req: AuthenticatedRequest, res: express.Response) { + return this.handleInitSSO(res); + } + + private async handleInitSSO(res: express.Response) { + const result = this.samlService.getLoginRequestUrl(); + if (result?.binding === 'redirect') { + return res.send(result.context.context); + } else if (result?.binding === 'post') { + return res.send(getInitSSOFormView(result.context as PostBindingContext)); + } else { + throw new AuthError('SAML redirect failed, please check your SAML configuration.'); + } + } +} diff --git a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts deleted file mode 100644 index 8f142d7484..0000000000 --- a/packages/cli/src/sso/saml/routes/saml.controller.protected.ee.ts +++ /dev/null @@ -1,147 +0,0 @@ -import express from 'express'; -import { - samlLicensedAndEnabledMiddleware, - samlLicensedOwnerMiddleware, -} from '../middleware/samlEnabledMiddleware'; -import { SamlService } from '../saml.service.ee'; -import { SamlUrls } from '../constants'; -import type { SamlConfiguration } from '../types/requests'; -import { AuthError, BadRequestError } from '@/ResponseHelper'; -import { issueCookie } from '../../../auth/jwt'; -import { validate } from 'class-validator'; -import type { PostBindingContext } from 'samlify/types/src/entity'; -import { getInitSSOFormView } from '../views/initSsoPost'; -import { getInitSSOPostView } from '../views/initSsoRedirect'; - -export const samlControllerProtected = express.Router(); - -/** - * GET /sso/saml/config - * Return SAML config - */ -samlControllerProtected.get( - SamlUrls.config, - samlLicensedOwnerMiddleware, - (req: SamlConfiguration.Read, res: express.Response) => { - const prefs = SamlService.getInstance().samlPreferences; - return res.send(prefs); - }, -); - -/** - * POST /sso/saml/config - * Set SAML config - */ -samlControllerProtected.post( - SamlUrls.config, - samlLicensedOwnerMiddleware, - async (req: SamlConfiguration.Update, res: express.Response) => { - const validationResult = await validate(req.body); - if (validationResult.length === 0) { - const result = await SamlService.getInstance().setSamlPreferences(req.body); - return res.send(result); - } else { - throw new BadRequestError( - 'Body is not a valid SamlPreferences object: ' + - validationResult.map((e) => e.toString()).join(','), - ); - } - }, -); - -/** - * POST /sso/saml/config/toggle - * Set SAML config - */ -samlControllerProtected.post( - SamlUrls.configToggleEnabled, - samlLicensedOwnerMiddleware, - async (req: SamlConfiguration.Toggle, res: express.Response) => { - if (req.body.loginEnabled === undefined) { - throw new BadRequestError('Body should contain a boolean "loginEnabled" property'); - } - await SamlService.getInstance().setSamlPreferences({ loginEnabled: req.body.loginEnabled }); - res.sendStatus(200); - }, -); - -/** - * GET /sso/saml/acs - * Assertion Consumer Service endpoint - */ -samlControllerProtected.get( - SamlUrls.acs, - samlLicensedAndEnabledMiddleware, - async (req: express.Request, res: express.Response) => { - const loginResult = await SamlService.getInstance().handleSamlLogin(req, 'redirect'); - if (loginResult) { - if (loginResult.authenticatedUser) { - await issueCookie(res, loginResult.authenticatedUser); - if (loginResult.onboardingRequired) { - return res.redirect(SamlUrls.samlOnboarding); - } else { - return res.redirect(SamlUrls.defaultRedirect); - } - } - } - throw new AuthError('SAML Authentication failed'); - }, -); - -/** - * POST /sso/saml/acs - * Assertion Consumer Service endpoint - */ -samlControllerProtected.post( - SamlUrls.acs, - samlLicensedAndEnabledMiddleware, - async (req: express.Request, res: express.Response) => { - const loginResult = await SamlService.getInstance().handleSamlLogin(req, 'post'); - if (loginResult) { - if (loginResult.authenticatedUser) { - await issueCookie(res, loginResult.authenticatedUser); - if (loginResult.onboardingRequired) { - return res.redirect(SamlUrls.samlOnboarding); - } else { - return res.redirect(SamlUrls.defaultRedirect); - } - } - } - throw new AuthError('SAML Authentication failed'); - }, -); - -/** - * GET /sso/saml/initsso - * Access URL for implementing SP-init SSO - */ -samlControllerProtected.get( - SamlUrls.initSSO, - samlLicensedAndEnabledMiddleware, - async (req: express.Request, res: express.Response) => { - const result = SamlService.getInstance().getLoginRequestUrl(); - if (result?.binding === 'redirect') { - // forced client side redirect through the use of a javascript redirect - return res.send(getInitSSOPostView(result.context)); - // TODO:SAML: If we want the frontend to handle the redirect, we will send the redirect URL instead: - // return res.status(301).send(result.context.context); - } else if (result?.binding === 'post') { - return res.send(getInitSSOFormView(result.context as PostBindingContext)); - } else { - throw new AuthError('SAML redirect failed, please check your SAML configuration.'); - } - }, -); - -/** - * GET /sso/saml/config/test - * Test SAML config - */ -samlControllerProtected.get( - SamlUrls.configTest, - samlLicensedOwnerMiddleware, - async (req: express.Request, res: express.Response) => { - const testResult = await SamlService.getInstance().testSamlConnection(); - return res.send(testResult); - }, -); diff --git a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts b/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts deleted file mode 100644 index 999b7f2df2..0000000000 --- a/packages/cli/src/sso/saml/routes/saml.controller.public.ee.ts +++ /dev/null @@ -1,19 +0,0 @@ -import express from 'express'; -import { SamlUrls } from '../constants'; -import { SamlService } from '../saml.service.ee'; - -/** - * SSO Endpoints that are public - */ - -export const samlControllerPublic = express.Router(); - -/** - * GET /sso/saml/metadata - * Return Service Provider metadata - */ -samlControllerPublic.get(SamlUrls.metadata, async (req: express.Request, res: express.Response) => { - return res - .header('Content-Type', 'text/xml') - .send(SamlService.getInstance().getServiceProviderInstance().getMetadata()); -}); diff --git a/packages/cli/src/sso/saml/saml.service.ee.ts b/packages/cli/src/sso/saml/saml.service.ee.ts index 523b904f0d..1bf8e36b9f 100644 --- a/packages/cli/src/sso/saml/saml.service.ee.ts +++ b/packages/cli/src/sso/saml/saml.service.ee.ts @@ -1,4 +1,5 @@ import type express from 'express'; +import { Service } from 'typedi'; import * as Db from '@/Db'; import type { User } from '@/databases/entities/User'; import { jsonParse, LoggerProxy } from 'n8n-workflow'; @@ -26,9 +27,8 @@ import type { SamlLoginBinding } from './types'; import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity'; import { validateMetadata, validateResponse } from './samlValidator'; +@Service() export class SamlService { - private static instance: SamlService; - private identityProviderInstance: IdentityProviderInstance | undefined; private _samlPreferences: SamlPreferences = { @@ -48,6 +48,13 @@ export class SamlService { loginLabel: 'SAML', wantAssertionsSigned: true, wantMessageSigned: true, + signatureConfig: { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }, }; public get samlPreferences(): SamlPreferences { @@ -58,13 +65,6 @@ export class SamlService { }; } - static getInstance(): SamlService { - if (!SamlService.instance) { - SamlService.instance = new SamlService(); - } - return SamlService.instance; - } - async init(): Promise { await this.loadFromDbAndApplySamlPreferences(); setSchemaValidator({ @@ -189,6 +189,8 @@ export class SamlService { this._samlPreferences.mapping = prefs.mapping ?? this._samlPreferences.mapping; this._samlPreferences.ignoreSSL = prefs.ignoreSSL ?? this._samlPreferences.ignoreSSL; this._samlPreferences.acsBinding = prefs.acsBinding ?? this._samlPreferences.acsBinding; + this._samlPreferences.signatureConfig = + prefs.signatureConfig ?? this._samlPreferences.signatureConfig; this._samlPreferences.authnRequestsSigned = prefs.authnRequestsSigned ?? this._samlPreferences.authnRequestsSigned; this._samlPreferences.wantAssertionsSigned = @@ -208,7 +210,7 @@ export class SamlService { } this._samlPreferences.metadata = prefs.metadata; } - setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); + await setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled()); setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel()); this.getIdentityProviderInstance(true); const result = await this.saveSamlPreferencesToDb(); diff --git a/packages/cli/src/sso/saml/samlHelpers.ts b/packages/cli/src/sso/saml/samlHelpers.ts index fb9e0a3b7f..5975c02653 100644 --- a/packages/cli/src/sso/saml/samlHelpers.ts +++ b/packages/cli/src/sso/saml/samlHelpers.ts @@ -1,16 +1,22 @@ +import { Container } from 'typedi'; import config from '@/config'; import * as Db from '@/Db'; -import { AuthIdentity } from '../../databases/entities/AuthIdentity'; -import { User } from '../../databases/entities/User'; -import { getLicense } from '../../License'; -import { AuthError } from '../../ResponseHelper'; -import { hashPassword, isUserManagementEnabled } from '../../UserManagement/UserManagementHelper'; +import { AuthIdentity } from '@db/entities/AuthIdentity'; +import { User } from '@db/entities/User'; +import { License } from '@/License'; +import { AuthError } from '@/ResponseHelper'; +import { hashPassword, isUserManagementEnabled } from '@/UserManagement/UserManagementHelper'; import type { SamlPreferences } from './types/samlPreferences'; import type { SamlUserAttributes } from './types/samlUserAttributes'; import type { FlowResult } from 'samlify/types/src/flow'; import type { SamlAttributeMapping } from './types/samlAttributeMapping'; -import { SAML_ENTERPRISE_FEATURE_ENABLED, SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; -import { isSamlCurrentAuthenticationMethod } from '../ssoHelpers'; +import { SAML_LOGIN_ENABLED, SAML_LOGIN_LABEL } from './constants'; +import { + isEmailCurrentAuthenticationMethod, + isSamlCurrentAuthenticationMethod, + setCurrentAuthenticationMethod, +} from '../ssoHelpers'; +import { LoggerProxy } from 'n8n-workflow'; /** * Check whether the SAML feature is licensed and enabled in the instance */ @@ -22,8 +28,22 @@ export function getSamlLoginLabel(): string { return config.getEnv(SAML_LOGIN_LABEL); } -export function setSamlLoginEnabled(enabled: boolean): void { - config.set(SAML_LOGIN_ENABLED, enabled); +// can only toggle between email and saml, not directly to e.g. ldap +export async function setSamlLoginEnabled(enabled: boolean): Promise { + if (config.get(SAML_LOGIN_ENABLED) === enabled) { + return; + } + if (enabled && isEmailCurrentAuthenticationMethod()) { + config.set(SAML_LOGIN_ENABLED, true); + await setCurrentAuthenticationMethod('saml'); + } else if (!enabled && isSamlCurrentAuthenticationMethod()) { + config.set(SAML_LOGIN_ENABLED, false); + await setCurrentAuthenticationMethod('email'); + } else { + LoggerProxy.warn( + 'Cannot switch SAML login enabled state when an authentication method other than email is active', + ); + } } export function setSamlLoginLabel(label: string): void { @@ -31,11 +51,8 @@ export function setSamlLoginLabel(label: string): void { } export function isSamlLicensed(): boolean { - const license = getLicense(); - return ( - isUserManagementEnabled() && - (license.isSamlEnabled() || config.getEnv(SAML_ENTERPRISE_FEATURE_ENABLED)) - ); + const license = Container.get(License); + return isUserManagementEnabled() && license.isSamlEnabled(); } export function isSamlLicensedAndEnabled(): boolean { diff --git a/packages/cli/src/sso/saml/serviceProvider.ee.ts b/packages/cli/src/sso/saml/serviceProvider.ee.ts index 796d64a343..5d99283012 100644 --- a/packages/cli/src/sso/saml/serviceProvider.ee.ts +++ b/packages/cli/src/sso/saml/serviceProvider.ee.ts @@ -7,25 +7,34 @@ import type { SamlPreferences } from './types/samlPreferences'; let serviceProviderInstance: ServiceProviderInstance | undefined; +export function getServiceProviderEntityId(): string { + return getInstanceBaseUrl() + SamlUrls.restMetadata; +} + +export function getServiceProviderReturnUrl(): string { + return getInstanceBaseUrl() + SamlUrls.restAcs; +} + // TODO:SAML: make these configurable for the end user export function getServiceProviderInstance(prefs: SamlPreferences): ServiceProviderInstance { if (serviceProviderInstance === undefined) { serviceProviderInstance = ServiceProvider({ - entityID: getInstanceBaseUrl() + SamlUrls.restMetadata, + entityID: getServiceProviderEntityId(), authnRequestsSigned: prefs.authnRequestsSigned, wantAssertionsSigned: prefs.wantAssertionsSigned, wantMessageSigned: prefs.wantMessageSigned, + signatureConfig: prefs.signatureConfig, nameIDFormat: ['urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'], assertionConsumerService: [ { isDefault: prefs.acsBinding === 'post', Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - Location: getInstanceBaseUrl() + SamlUrls.restAcs, + Location: getServiceProviderReturnUrl(), }, { isDefault: prefs.acsBinding === 'redirect', Binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-REDIRECT', - Location: getInstanceBaseUrl() + SamlUrls.restAcs, + Location: getServiceProviderReturnUrl(), }, ], }); diff --git a/packages/cli/src/sso/saml/types/requests.ts b/packages/cli/src/sso/saml/types/requests.ts index a095df7870..4d58afb2dd 100644 --- a/packages/cli/src/sso/saml/types/requests.ts +++ b/packages/cli/src/sso/saml/types/requests.ts @@ -1,8 +1,7 @@ -import type { AuthenticatedRequest } from '../../../requests'; +import type { AuthenticatedRequest } from '@/requests'; import type { SamlPreferences } from './samlPreferences'; export declare namespace SamlConfiguration { - type Read = AuthenticatedRequest<{}, {}, {}, {}>; type Update = AuthenticatedRequest<{}, {}, SamlPreferences, {}>; type Toggle = AuthenticatedRequest<{}, {}, { loginEnabled: boolean }, {}>; } diff --git a/packages/cli/src/sso/saml/types/samlPreferences.ts b/packages/cli/src/sso/saml/types/samlPreferences.ts index d013d0ed4e..c5c72bcd0f 100644 --- a/packages/cli/src/sso/saml/types/samlPreferences.ts +++ b/packages/cli/src/sso/saml/types/samlPreferences.ts @@ -1,4 +1,5 @@ import { IsBoolean, IsObject, IsOptional, IsString } from 'class-validator'; +import { SignatureConfig } from 'samlify/types/src/types'; import { SamlLoginBinding } from '.'; import { SamlAttributeMapping } from './samlAttributeMapping'; @@ -46,4 +47,14 @@ export class SamlPreferences { @IsString() @IsOptional() acsBinding?: SamlLoginBinding = 'post'; + + @IsObject() + @IsOptional() + signatureConfig?: SignatureConfig = { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }; } diff --git a/packages/cli/src/sso/saml/views/initSsoRedirect.ts b/packages/cli/src/sso/saml/views/initSsoRedirect.ts deleted file mode 100644 index 56db9ce083..0000000000 --- a/packages/cli/src/sso/saml/views/initSsoRedirect.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { BindingContext } from 'samlify/types/src/entity'; - -export function getInitSSOPostView(context: BindingContext): string { - return ` - - `; -} diff --git a/packages/cli/src/sso/ssoHelpers.ts b/packages/cli/src/sso/ssoHelpers.ts index f296feddcb..70c375b6c4 100644 --- a/packages/cli/src/sso/ssoHelpers.ts +++ b/packages/cli/src/sso/ssoHelpers.ts @@ -1,8 +1,38 @@ import config from '@/config'; +import * as Db from '@/Db'; import type { AuthProviderType } from '@/databases/entities/AuthIdentity'; +/** + * Only one authentication method can be active at a time. This function sets the current authentication method + * and saves it to the database. + * SSO methods should only switch to email and then to another method. Email can switch to any method. + * @param authenticationMethod + */ +export async function setCurrentAuthenticationMethod( + authenticationMethod: AuthProviderType, +): Promise { + config.set('userManagement.authenticationMethod', authenticationMethod); + await Db.collections.Settings.save({ + key: 'userManagement.authenticationMethod', + value: authenticationMethod, + loadOnStartup: true, + }); +} + +export function getCurrentAuthenticationMethod(): AuthProviderType { + return config.getEnv('userManagement.authenticationMethod'); +} + export function isSamlCurrentAuthenticationMethod(): boolean { - return config.getEnv('userManagement.authenticationMethod') === 'saml'; + return getCurrentAuthenticationMethod() === 'saml'; +} + +export function isLdapCurrentAuthenticationMethod(): boolean { + return getCurrentAuthenticationMethod() === 'ldap'; +} + +export function isEmailCurrentAuthenticationMethod(): boolean { + return getCurrentAuthenticationMethod() === 'email'; } export function isSsoJustInTimeProvisioningEnabled(): boolean { @@ -12,7 +42,3 @@ export function isSsoJustInTimeProvisioningEnabled(): boolean { export function doRedirectUsersFromLoginToSsoFlow(): boolean { return config.getEnv('sso.redirectLoginToSso'); } - -export function setCurrentAuthenticationMethod(authenticationMethod: AuthProviderType): void { - config.set('userManagement.authenticationMethod', authenticationMethod); -} diff --git a/packages/cli/src/telemetry/index.ts b/packages/cli/src/telemetry/index.ts index 516c76f653..cdc7b96f8b 100644 --- a/packages/cli/src/telemetry/index.ts +++ b/packages/cli/src/telemetry/index.ts @@ -7,7 +7,7 @@ import { LoggerProxy } from 'n8n-workflow'; import config from '@/config'; import type { IExecutionTrackProperties } from '@/Interfaces'; import { getLogger } from '@/Logger'; -import { getLicense } from '@/License'; +import { License } from '@/License'; import { LicenseService } from '@/license/License.service'; import { N8N_VERSION } from '@/constants'; import { Service } from 'typedi'; @@ -39,7 +39,7 @@ export class Telemetry { private executionCountsBuffer: IExecutionsBuffer = {}; - constructor(private postHog: PostHogClient) {} + constructor(private postHog: PostHogClient, private license: License) {} setInstanceId(instanceId: string) { this.instanceId = instanceId; @@ -97,8 +97,8 @@ export class Telemetry { // License info const pulsePacket = { - plan_name_current: getLicense().getPlanName(), - quota: getLicense().getTriggerLimit(), + plan_name_current: this.license.getPlanName(), + quota: this.license.getTriggerLimit(), usage: await LicenseService.getActiveTriggerCount(), }; allPromises.push(this.track('pulse', pulsePacket)); diff --git a/packages/cli/src/utils.ts b/packages/cli/src/utils.ts index 7ec8afb253..c99ef97f50 100644 --- a/packages/cli/src/utils.ts +++ b/packages/cli/src/utils.ts @@ -3,6 +3,13 @@ import { CliWorkflowOperationError, SubworkflowOperationError } from 'n8n-workfl import type { INode } from 'n8n-workflow'; import { START_NODES } from './constants'; +/** + * Returns if the given id is a valid workflow id + */ +export function isWorkflowIdValid(id: string | null | undefined): boolean { + return !(typeof id === 'string' && isNaN(parseInt(id, 10))); +} + function findWorkflowStart(executionMode: 'integrated' | 'cli') { return function (nodes: INode[]) { const executeWorkflowTriggerNode = nodes.find( diff --git a/packages/cli/src/workflows/workflows.services.ts b/packages/cli/src/workflows/workflows.services.ts index 58a0a56a7b..8563038e4c 100644 --- a/packages/cli/src/workflows/workflows.services.ts +++ b/packages/cli/src/workflows/workflows.services.ts @@ -245,32 +245,27 @@ export class WorkflowsService { await Container.get(ActiveWorkflowRunner).remove(workflowId); } - if (workflow.settings) { - if (workflow.settings.timezone === 'DEFAULT') { - // Do not save the default timezone - delete workflow.settings.timezone; - } - if (workflow.settings.saveDataErrorExecution === 'DEFAULT') { - // Do not save when default got set - delete workflow.settings.saveDataErrorExecution; - } - if (workflow.settings.saveDataSuccessExecution === 'DEFAULT') { - // Do not save when default got set - delete workflow.settings.saveDataSuccessExecution; - } - if (workflow.settings.saveManualExecutions === 'DEFAULT') { - // Do not save when default got set - delete workflow.settings.saveManualExecutions; - } - if ( - parseInt(workflow.settings.executionTimeout as string, 10) === - config.get('executions.timeout') - ) { - // Do not save when default got set - delete workflow.settings.executionTimeout; + const workflowSettings = workflow.settings ?? {}; + + const keysAllowingDefault = [ + 'timezone', + 'saveDataErrorExecution', + 'saveDataSuccessExecution', + 'saveManualExecutions', + 'saveExecutionProgress', + ] as const; + for (const key of keysAllowingDefault) { + // Do not save the default value + if (workflowSettings[key] === 'DEFAULT') { + delete workflowSettings[key]; } } + if (workflowSettings.executionTimeout === config.get('executions.timeout')) { + // Do not save when default got set + delete workflowSettings.executionTimeout; + } + if (workflow.name) { workflow.updatedAt = new Date(); // required due to atomic update await validateEntity(workflow); diff --git a/packages/cli/templates/oauth-error-callback.handlebars b/packages/cli/templates/oauth-error-callback.handlebars new file mode 100644 index 0000000000..38af48fd82 --- /dev/null +++ b/packages/cli/templates/oauth-error-callback.handlebars @@ -0,0 +1,19 @@ + + + n8n - OAuth Callback + + + + {{#if error}} +

Error:

+
{{error.message}}
+ {{/if}} + Failed to connect. The window can be closed now. + + + diff --git a/packages/cli/test/integration/auth.api.test.ts b/packages/cli/test/integration/auth.api.test.ts index cad139d0ce..e81cf06aa3 100644 --- a/packages/cli/test/integration/auth.api.test.ts +++ b/packages/cli/test/integration/auth.api.test.ts @@ -1,339 +1,338 @@ -import express from 'express'; +import type { Application } from 'express'; +import type { SuperAgentTest } from 'supertest'; import validator from 'validator'; import config from '@/config'; import * as Db from '@/Db'; import { AUTH_COOKIE_NAME } from '@/constants'; import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; import { LOGGED_OUT_RESPONSE_BODY } from './shared/constants'; import { randomValidPassword } from './shared/random'; import * as testDb from './shared/testDb'; import type { AuthAgent } from './shared/types'; import * as utils from './shared/utils'; -let app: express.Application; +let app: Application; let globalOwnerRole: Role; let globalMemberRole: Role; +let owner: User; let authAgent: AuthAgent; +let authlessAgent: SuperAgentTest; +let authOwnerAgent: SuperAgentTest; +const ownerPassword = randomValidPassword(); beforeAll(async () => { app = await utils.initTestServer({ endpointGroups: ['auth'] }); + authAgent = utils.createAuthAgent(app); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); - - authAgent = utils.createAuthAgent(app); }); beforeEach(async () => { await testDb.truncate(['User']); - + authlessAgent = utils.createAgent(app); config.set('ldap.disabled', true); - - config.set('userManagement.isInstanceOwnerSetUp', true); - - await Db.collections.Settings.update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(true) }, - ); + await utils.setInstanceOwnerSetUp(true); }); afterAll(async () => { await testDb.terminate(); }); -test('POST /login should log user in', async () => { - const ownerPassword = randomValidPassword(); - const owner = await testDb.createUser({ - password: ownerPassword, - globalRole: globalOwnerRole, +describe('POST /login', () => { + beforeEach(async () => { + owner = await testDb.createUser({ + password: ownerPassword, + globalRole: globalOwnerRole, + }); }); - const authlessAgent = utils.createAgent(app); + test('should log user in', async () => { + const response = await authlessAgent.post('/login').send({ + email: owner.email, + password: ownerPassword, + }); - const response = await authlessAgent.post('/login').send({ - email: owner.email, - password: ownerPassword, + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + apiKey, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(owner.email); + expect(firstName).toBe(owner.firstName); + expect(lastName).toBe(owner.lastName); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + }); +}); + +describe('GET /login', () => { + test('should return 401 Unauthorized if no cookie', async () => { + const response = await authlessAgent.get('/login'); + + expect(response.statusCode).toBe(401); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); }); - expect(response.statusCode).toBe(200); + test('should return cookie if UM is disabled and no cookie is already set', async () => { + await testDb.createUserShell(globalOwnerRole); + await utils.setInstanceOwnerSetUp(false); - const { - id, - email, - firstName, - lastName, - password, - personalizationAnswers, - globalRole, - resetPasswordToken, - apiKey, - } = response.body.data; + const response = await authlessAgent.get('/login'); - expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(owner.email); - expect(firstName).toBe(owner.firstName); - expect(lastName).toBe(owner.lastName); - expect(password).toBeUndefined(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); - expect(apiKey).toBeUndefined(); + expect(response.statusCode).toBe(200); - const authToken = utils.getAuthToken(response); - expect(authToken).toBeDefined(); + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + }); + + test('should return 401 Unauthorized if invalid cookie', async () => { + authlessAgent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`); + + const response = await authlessAgent.get('/login'); + + expect(response.statusCode).toBe(401); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); + }); + + test('should return logged-in owner shell', async () => { + const ownerShell = await testDb.createUserShell(globalOwnerRole); + + const response = await authAgent(ownerShell).get('/login'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + apiKey, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); + }); + + test('should return logged-in member shell', async () => { + const memberShell = await testDb.createUserShell(globalMemberRole); + + const response = await authAgent(memberShell).get('/login'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + apiKey, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBeDefined(); + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('member'); + expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); + }); + + test('should return logged-in owner', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + const response = await authAgent(owner).get('/login'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + apiKey, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(owner.email); + expect(firstName).toBe(owner.firstName); + expect(lastName).toBe(owner.lastName); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); + }); + + test('should return logged-in member', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const response = await authAgent(member).get('/login'); + + expect(response.statusCode).toBe(200); + + const { + id, + email, + firstName, + lastName, + password, + personalizationAnswers, + globalRole, + resetPasswordToken, + apiKey, + } = response.body.data; + + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(member.email); + expect(firstName).toBe(member.firstName); + expect(lastName).toBe(member.lastName); + expect(password).toBeUndefined(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole).toBeDefined(); + expect(globalRole.name).toBe('member'); + expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); + }); }); -test('GET /login should return 401 Unauthorized if no cookie', async () => { - const authlessAgent = utils.createAgent(app); +describe('GET /resolve-signup-token', () => { + beforeEach(async () => { + owner = await testDb.createUser({ + password: ownerPassword, + globalRole: globalOwnerRole, + }); + authOwnerAgent = authAgent(owner); + }); - const response = await authlessAgent.get('/login'); + test('should validate invite token', async () => { + const memberShell = await testDb.createUserShell(globalMemberRole); - expect(response.statusCode).toBe(401); + const response = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId: memberShell.id }); - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); -}); - -test('GET /login should return cookie if UM is disabled and no cookie is already set', async () => { - const authlessAgent = utils.createAgent(app); - await testDb.createUserShell(globalOwnerRole); - - config.set('userManagement.isInstanceOwnerSetUp', false); - - await Db.collections.Settings.update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(false) }, - ); - - const response = await authlessAgent.get('/login'); - - expect(response.statusCode).toBe(200); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeDefined(); -}); - -test('GET /login should return 401 Unauthorized if invalid cookie', async () => { - const invalidAuthAgent = utils.createAgent(app); - invalidAuthAgent.jar.setCookie(`${AUTH_COOKIE_NAME}=invalid`); - - const response = await invalidAuthAgent.get('/login'); - - expect(response.statusCode).toBe(401); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); -}); - -test('GET /login should return logged-in owner shell', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).get('/login'); - - expect(response.statusCode).toBe(200); - - const { - id, - email, - firstName, - lastName, - password, - personalizationAnswers, - globalRole, - resetPasswordToken, - apiKey, - } = response.body.data; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBeDefined(); - expect(firstName).toBeNull(); - expect(lastName).toBeNull(); - expect(password).toBeUndefined(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); - expect(apiKey).toBeUndefined(); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); -}); - -test('GET /login should return logged-in member shell', async () => { - const memberShell = await testDb.createUserShell(globalMemberRole); - - const response = await authAgent(memberShell).get('/login'); - - expect(response.statusCode).toBe(200); - - const { - id, - email, - firstName, - lastName, - password, - personalizationAnswers, - globalRole, - resetPasswordToken, - apiKey, - } = response.body.data; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBeDefined(); - expect(firstName).toBeNull(); - expect(lastName).toBeNull(); - expect(password).toBeUndefined(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('member'); - expect(globalRole.scope).toBe('global'); - expect(apiKey).toBeUndefined(); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); -}); - -test('GET /login should return logged-in owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const response = await authAgent(owner).get('/login'); - - expect(response.statusCode).toBe(200); - - const { - id, - email, - firstName, - lastName, - password, - personalizationAnswers, - globalRole, - resetPasswordToken, - apiKey, - } = response.body.data; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(owner.email); - expect(firstName).toBe(owner.firstName); - expect(lastName).toBe(owner.lastName); - expect(password).toBeUndefined(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); - expect(apiKey).toBeUndefined(); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); -}); - -test('GET /login should return logged-in member', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const response = await authAgent(member).get('/login'); - - expect(response.statusCode).toBe(200); - - const { - id, - email, - firstName, - lastName, - password, - personalizationAnswers, - globalRole, - resetPasswordToken, - apiKey, - } = response.body.data; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(member.email); - expect(firstName).toBe(member.firstName); - expect(lastName).toBe(member.lastName); - expect(password).toBeUndefined(); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole).toBeDefined(); - expect(globalRole.name).toBe('member'); - expect(globalRole.scope).toBe('global'); - expect(apiKey).toBeUndefined(); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); -}); - -test('GET /resolve-signup-token should validate invite token', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const memberShell = await testDb.createUserShell(globalMemberRole); - - const response = await authAgent(owner) - .get('/resolve-signup-token') - .query({ inviterId: owner.id }) - .query({ inviteeId: memberShell.id }); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ - data: { - inviter: { - firstName: owner.firstName, - lastName: owner.lastName, + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + data: { + inviter: { + firstName: owner.firstName, + lastName: owner.lastName, + }, }, - }, + }); + }); + + test('should fail with invalid inputs', async () => { + const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); + + const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id }); + + const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); + + const third = await authOwnerAgent.get('/resolve-signup-token').query({ + inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d', + inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d', + }); + + // user is already set up, so call should error + const fourth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId }); + + // cause inconsistent DB state + await Db.collections.User.update(owner.id, { email: '' }); + const fifth = await authOwnerAgent + .get('/resolve-signup-token') + .query({ inviterId: owner.id }) + .query({ inviteeId }); + + for (const response of [first, second, third, fourth, fifth]) { + expect(response.statusCode).toBe(400); + } }); }); -test('GET /resolve-signup-token should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(owner); +describe('POST /logout', () => { + test('should log user out', async () => { + const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const { id: inviteeId } = await testDb.createUser({ globalRole: globalMemberRole }); + const response = await authAgent(owner).post('/logout'); - const first = await authOwnerAgent.get('/resolve-signup-token').query({ inviterId: owner.id }); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY); - const second = await authOwnerAgent.get('/resolve-signup-token').query({ inviteeId }); - - const third = await authOwnerAgent.get('/resolve-signup-token').query({ - inviterId: '5531199e-b7ae-425b-a326-a95ef8cca59d', - inviteeId: 'cb133beb-7729-4c34-8cd1-a06be8834d9d', + const authToken = utils.getAuthToken(response); + expect(authToken).toBeUndefined(); }); - - // user is already set up, so call should error - const fourth = await authOwnerAgent - .get('/resolve-signup-token') - .query({ inviterId: owner.id }) - .query({ inviteeId }); - - // cause inconsistent DB state - await Db.collections.User.update(owner.id, { email: '' }); - const fifth = await authOwnerAgent - .get('/resolve-signup-token') - .query({ inviterId: owner.id }) - .query({ inviteeId }); - - for (const response of [first, second, third, fourth, fifth]) { - expect(response.statusCode).toBe(400); - } -}); - -test('POST /logout should log user out', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const response = await authAgent(owner).post('/logout'); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual(LOGGED_OUT_RESPONSE_BODY); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeUndefined(); }); diff --git a/packages/cli/test/integration/auth.mw.test.ts b/packages/cli/test/integration/auth.mw.test.ts index 94291ddfc8..1c4c5b72a6 100644 --- a/packages/cli/test/integration/auth.mw.test.ts +++ b/packages/cli/test/integration/auth.mw.test.ts @@ -1,26 +1,21 @@ -import express from 'express'; - -import request from 'supertest'; -import type { Role } from '@db/entities/Role'; +import type { SuperAgentTest } from 'supertest'; import { - REST_PATH_SEGMENT, ROUTES_REQUIRING_AUTHENTICATION, ROUTES_REQUIRING_AUTHORIZATION, } from './shared/constants'; import * as testDb from './shared/testDb'; -import type { AuthAgent } from './shared/types'; import * as utils from './shared/utils'; -let app: express.Application; -let globalMemberRole: Role; -let authAgent: AuthAgent; +let authlessAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] }); + const app = await utils.initTestServer({ endpointGroups: ['me', 'auth', 'owner', 'users'] }); + const globalMemberRole = await testDb.getGlobalMemberRole(); + const member = await testDb.createUser({ globalRole: globalMemberRole }); - globalMemberRole = await testDb.getGlobalMemberRole(); - - authAgent = utils.createAuthAgent(app); + authlessAgent = utils.createAgent(app); + authMemberAgent = utils.createAuthAgent(app)(member); }); afterAll(async () => { @@ -31,9 +26,8 @@ ROUTES_REQUIRING_AUTHENTICATION.concat(ROUTES_REQUIRING_AUTHORIZATION).forEach(( const [method, endpoint] = getMethodAndEndpoint(route); test(`${route} should return 401 Unauthorized if no cookie`, async () => { - const response = await request(app)[method](endpoint).use(utils.prefix(REST_PATH_SEGMENT)); - - expect(response.statusCode).toBe(401); + const { statusCode } = await authlessAgent[method](endpoint); + expect(statusCode).toBe(401); }); }); @@ -41,10 +35,8 @@ ROUTES_REQUIRING_AUTHORIZATION.forEach(async (route) => { const [method, endpoint] = getMethodAndEndpoint(route); test(`${route} should return 403 Forbidden for member`, async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const response = await authAgent(member)[method](endpoint); - - expect(response.statusCode).toBe(403); + const { statusCode } = await authMemberAgent[method](endpoint); + expect(statusCode).toBe(403); }); }); diff --git a/packages/cli/test/integration/commands/reset.cmd.test.ts b/packages/cli/test/integration/commands/reset.cmd.test.ts index 8fc239d910..49cc20a733 100644 --- a/packages/cli/test/integration/commands/reset.cmd.test.ts +++ b/packages/cli/test/integration/commands/reset.cmd.test.ts @@ -26,7 +26,7 @@ afterAll(async () => { await testDb.terminate(); }); -test('user-management:reset should reset DB to default user state', async () => { +test.skip('user-management:reset should reset DB to default user state', async () => { await testDb.createUser({ globalRole: globalOwnerRole }); await Reset.run(); diff --git a/packages/cli/test/integration/credentials.ee.test.ts b/packages/cli/test/integration/credentials.ee.test.ts index 81313739e4..139975eb5b 100644 --- a/packages/cli/test/integration/credentials.ee.test.ts +++ b/packages/cli/test/integration/credentials.ee.test.ts @@ -1,43 +1,48 @@ -import express from 'express'; -import { UserSettings } from 'n8n-core'; +import type { SuperAgentTest } from 'supertest'; import { In } from 'typeorm'; +import { UserSettings } from 'n8n-core'; +import type { IUser } from 'n8n-workflow'; import * as Db from '@/Db'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import type { CredentialWithSharings } from '@/credentials/credentials.types'; import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; import { randomCredentialPayload } from './shared/random'; import * as testDb from './shared/testDb'; import type { AuthAgent, SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils'; -import type { IUser } from 'n8n-workflow'; -let app: express.Application; -let globalOwnerRole: Role; let globalMemberRole: Role; -let credentialOwnerRole: Role; -let saveCredential: SaveCredentialFunction; +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; let authAgent: AuthAgent; +let saveCredential: SaveCredentialFunction; let sharingSpy: jest.SpyInstance; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['credentials'] }); + const app = await utils.initTestServer({ endpointGroups: ['credentials'] }); utils.initConfigFile(); - globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); - credentialOwnerRole = await testDb.getCredentialOwnerRole(); + const credentialOwnerRole = await testDb.getCredentialOwnerRole(); + + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + member = await testDb.createUser({ globalRole: globalMemberRole }); + + authAgent = utils.createAuthAgent(app); + authOwnerAgent = authAgent(owner); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); - authAgent = utils.createAuthAgent(app); - sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); }); beforeEach(async () => { - await testDb.truncate(['User', 'SharedCredentials', 'Credentials']); + await testDb.truncate(['SharedCredentials', 'Credentials']); }); afterAll(async () => { @@ -47,490 +52,452 @@ afterAll(async () => { // ---------------------------------------- // dynamic router switching // ---------------------------------------- +describe('router should switch based on flag', () => { + let savedCredentialId: string; -test('router should switch based on flag', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + beforeEach(async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + savedCredentialId = savedCredential.id; + }); - // free router - sharingSpy.mockReturnValueOnce(false); + test('when sharing is disabled', async () => { + sharingSpy.mockReturnValueOnce(false); - const freeShareResponse = authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent + .put(`/credentials/${savedCredentialId}/share`) + .send({ shareWithIds: [member.id] }) + .expect(404); - const freeGetResponse = authAgent(owner).get(`/credentials/${savedCredential.id}`).send(); + await authOwnerAgent.get(`/credentials/${savedCredentialId}`).send().expect(200); + }); - const [{ statusCode: freeShareStatus }, { statusCode: freeGetStatus }] = await Promise.all([ - freeShareResponse, - freeGetResponse, - ]); + test('when sharing is enabled', async () => { + await authOwnerAgent + .put(`/credentials/${savedCredentialId}/share`) + .send({ shareWithIds: [member.id] }) + .expect(200); - expect(freeShareStatus).toBe(404); - expect(freeGetStatus).toBe(200); - - // EE router - - const eeShareResponse = authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [member.id] }); - - const eeGetResponse = authAgent(owner).get(`/credentials/${savedCredential.id}`).send(); - - const [{ statusCode: eeShareStatus }, { statusCode: eeGetStatus }] = await Promise.all([ - eeShareResponse, - eeGetResponse, - ]); - - expect(eeShareStatus).toBe(200); - expect(eeGetStatus).toBe(200); + await authOwnerAgent.get(`/credentials/${savedCredentialId}`).send().expect(200); + }); }); // ---------------------------------------- // GET /credentials - fetch all credentials // ---------------------------------------- - -test('GET /credentials should return all creds for owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const [member1, member2, member3] = await testDb.createManyUsers(3, { - globalRole: globalMemberRole, - }); - - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - await saveCredential(randomCredentialPayload(), { user: member1 }); - - const sharedWith = [member1, member2, member3]; - await testDb.shareCredentialWithUsers(savedCredential, sharedWith); - - const response = await authAgent(owner).get('/credentials'); - - expect(response.statusCode).toBe(200); - expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred - - const [ownerCredential, memberCredential] = response.body.data as CredentialWithSharings[]; - - validateMainCredentialData(ownerCredential); - expect(ownerCredential.data).toBeUndefined(); - - validateMainCredentialData(memberCredential); - expect(memberCredential.data).toBeUndefined(); - - expect(ownerCredential.ownedBy).toMatchObject({ - id: owner.id, - email: owner.email, - firstName: owner.firstName, - lastName: owner.lastName, - }); - - expect(Array.isArray(ownerCredential.sharedWith)).toBe(true); - expect(ownerCredential.sharedWith).toHaveLength(3); - - // Fix order issue (MySQL might return items in any order) - const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWith!].sort( - (a: IUser, b: IUser) => (a.email < b.email ? -1 : 1), - ); - const orderedSharedWith = [...sharedWith].sort((a, b) => (a.email < b.email ? -1 : 1)); - - ownerCredentialsSharedWithOrdered.forEach((sharee: IUser, idx: number) => { - expect(sharee).toMatchObject({ - id: orderedSharedWith[idx].id, - email: orderedSharedWith[idx].email, - firstName: orderedSharedWith[idx].firstName, - lastName: orderedSharedWith[idx].lastName, +describe('GET /credentials', () => { + test('should return all creds for owner', async () => { + const [member1, member2, member3] = await testDb.createManyUsers(3, { + globalRole: globalMemberRole, }); + + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + await saveCredential(randomCredentialPayload(), { user: member1 }); + + const sharedWith = [member1, member2, member3]; + await testDb.shareCredentialWithUsers(savedCredential, sharedWith); + + const response = await authOwnerAgent.get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(2); // owner retrieved owner cred and member cred + + const [ownerCredential, memberCredential] = response.body.data as CredentialWithSharings[]; + + validateMainCredentialData(ownerCredential); + expect(ownerCredential.data).toBeUndefined(); + + validateMainCredentialData(memberCredential); + expect(memberCredential.data).toBeUndefined(); + + expect(ownerCredential.ownedBy).toMatchObject({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); + + expect(Array.isArray(ownerCredential.sharedWith)).toBe(true); + expect(ownerCredential.sharedWith).toHaveLength(3); + + // Fix order issue (MySQL might return items in any order) + const ownerCredentialsSharedWithOrdered = [...ownerCredential.sharedWith!].sort( + (a: IUser, b: IUser) => (a.email < b.email ? -1 : 1), + ); + const orderedSharedWith = [...sharedWith].sort((a, b) => (a.email < b.email ? -1 : 1)); + + ownerCredentialsSharedWithOrdered.forEach((sharee: IUser, idx: number) => { + expect(sharee).toMatchObject({ + id: orderedSharedWith[idx].id, + email: orderedSharedWith[idx].email, + firstName: orderedSharedWith[idx].firstName, + lastName: orderedSharedWith[idx].lastName, + }); + }); + + expect(memberCredential.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + + expect(Array.isArray(memberCredential.sharedWith)).toBe(true); + expect(memberCredential.sharedWith).toHaveLength(0); }); - expect(memberCredential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); + test('should return only relevant creds for member', async () => { + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); - expect(Array.isArray(memberCredential.sharedWith)).toBe(true); - expect(memberCredential.sharedWith).toHaveLength(0); -}); + await saveCredential(randomCredentialPayload(), { user: member2 }); + const savedMemberCredential = await saveCredential(randomCredentialPayload(), { + user: member1, + }); -test('GET /credentials should return only relevant creds for member', async () => { - const [member1, member2] = await testDb.createManyUsers(2, { - globalRole: globalMemberRole, - }); + await testDb.shareCredentialWithUsers(savedMemberCredential, [member2]); - await saveCredential(randomCredentialPayload(), { user: member2 }); - const savedMemberCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + const response = await authAgent(member1).get('/credentials'); - await testDb.shareCredentialWithUsers(savedMemberCredential, [member2]); + expect(response.statusCode).toBe(200); + expect(response.body.data).toHaveLength(1); // member retrieved only member cred - const response = await authAgent(member1).get('/credentials'); + const [member1Credential] = response.body.data; - expect(response.statusCode).toBe(200); - expect(response.body.data).toHaveLength(1); // member retrieved only member cred + validateMainCredentialData(member1Credential); + expect(member1Credential.data).toBeUndefined(); - const [member1Credential] = response.body.data; + expect(member1Credential.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); - validateMainCredentialData(member1Credential); - expect(member1Credential.data).toBeUndefined(); + expect(Array.isArray(member1Credential.sharedWith)).toBe(true); + expect(member1Credential.sharedWith).toHaveLength(1); - expect(member1Credential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); + const [sharee] = member1Credential.sharedWith; - expect(Array.isArray(member1Credential.sharedWith)).toBe(true); - expect(member1Credential.sharedWith).toHaveLength(1); - - const [sharee] = member1Credential.sharedWith; - - expect(sharee).toMatchObject({ - id: member2.id, - email: member2.email, - firstName: member2.firstName, - lastName: member2.lastName, + expect(sharee).toMatchObject({ + id: member2.id, + email: member2.email, + firstName: member2.firstName, + lastName: member2.lastName, + }); }); }); // ---------------------------------------- // GET /credentials/:id - fetch a certain credential // ---------------------------------------- +describe('GET /credentials/:id', () => { + test('should retrieve owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); -test('GET /credentials/:id should retrieve owned cred for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = authAgent(ownerShell); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); + const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); - const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + expect(firstResponse.statusCode).toBe(200); - expect(firstResponse.statusCode).toBe(200); + const { data: firstCredential } = firstResponse.body; + validateMainCredentialData(firstCredential); + expect(firstCredential.data).toBeUndefined(); + expect(firstCredential.ownedBy).toMatchObject({ + id: owner.id, + email: owner.email, + firstName: owner.firstName, + lastName: owner.lastName, + }); + expect(firstCredential.sharedWith).toHaveLength(0); - const { data: firstCredential } = firstResponse.body; - validateMainCredentialData(firstCredential); - expect(firstCredential.data).toBeUndefined(); - expect(firstCredential.ownedBy).toMatchObject({ - id: ownerShell.id, - email: ownerShell.email, - firstName: ownerShell.firstName, - lastName: ownerShell.lastName, - }); - expect(firstCredential.sharedWith).toHaveLength(0); + const secondResponse = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); - const secondResponse = await authOwnerAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + expect(secondResponse.statusCode).toBe(200); - expect(secondResponse.statusCode).toBe(200); - - const { data: secondCredential } = secondResponse.body; - validateMainCredentialData(secondCredential); - expect(secondCredential.data).toBeDefined(); -}); - -test('GET /credentials/:id should retrieve non-owned cred for owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(owner); - const [member1, member2] = await testDb.createManyUsers(2, { - globalRole: globalMemberRole, + const { data: secondCredential } = secondResponse.body; + validateMainCredentialData(secondCredential); + expect(secondCredential.data).toBeDefined(); }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); - await testDb.shareCredentialWithUsers(savedCredential, [member2]); + test('should retrieve non-owned cred for owner', async () => { + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); - const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + await testDb.shareCredentialWithUsers(savedCredential, [member2]); - expect(response1.statusCode).toBe(200); + const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); - validateMainCredentialData(response1.body.data); - expect(response1.body.data.data).toBeUndefined(); - expect(response1.body.data.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); - expect(response1.body.data.sharedWith).toHaveLength(1); - expect(response1.body.data.sharedWith[0]).toMatchObject({ - id: member2.id, - email: member2.email, - firstName: member2.firstName, - lastName: member2.lastName, + expect(response1.statusCode).toBe(200); + + validateMainCredentialData(response1.body.data); + expect(response1.body.data.data).toBeUndefined(); + expect(response1.body.data.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + expect(response1.body.data.sharedWith).toHaveLength(1); + expect(response1.body.data.sharedWith[0]).toMatchObject({ + id: member2.id, + email: member2.email, + firstName: member2.firstName, + lastName: member2.lastName, + }); + + const response2 = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response2.statusCode).toBe(200); + + validateMainCredentialData(response2.body.data); + expect(response2.body.data.data).toBeUndefined(); + expect(response2.body.data.sharedWith).toHaveLength(1); }); - const response2 = await authOwnerAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + test('should retrieve owned cred for member', async () => { + const [member1, member2, member3] = await testDb.createManyUsers(3, { + globalRole: globalMemberRole, + }); + const authMemberAgent = authAgent(member1); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + await testDb.shareCredentialWithUsers(savedCredential, [member2, member3]); - expect(response2.statusCode).toBe(200); + const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); - validateMainCredentialData(response2.body.data); - expect(response2.body.data.data).toBeUndefined(); - expect(response2.body.data.sharedWith).toHaveLength(1); -}); + expect(firstResponse.statusCode).toBe(200); -test('GET /credentials/:id should retrieve owned cred for member', async () => { - const [member1, member2, member3] = await testDb.createManyUsers(3, { - globalRole: globalMemberRole, - }); - const authMemberAgent = authAgent(member1); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); - await testDb.shareCredentialWithUsers(savedCredential, [member2, member3]); + const { data: firstCredential } = firstResponse.body; + validateMainCredentialData(firstCredential); + expect(firstCredential.data).toBeUndefined(); + expect(firstCredential.ownedBy).toMatchObject({ + id: member1.id, + email: member1.email, + firstName: member1.firstName, + lastName: member1.lastName, + }); + expect(firstCredential.sharedWith).toHaveLength(2); + firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { + expect([member2.id, member3.id]).toContain(sharee.id); + }); - const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + const secondResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); - expect(firstResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); - const { data: firstCredential } = firstResponse.body; - validateMainCredentialData(firstCredential); - expect(firstCredential.data).toBeUndefined(); - expect(firstCredential.ownedBy).toMatchObject({ - id: member1.id, - email: member1.email, - firstName: member1.firstName, - lastName: member1.lastName, - }); - expect(firstCredential.sharedWith).toHaveLength(2); - firstCredential.sharedWith.forEach((sharee: IUser, idx: number) => { - expect([member2.id, member3.id]).toContain(sharee.id); + const { data: secondCredential } = secondResponse.body; + validateMainCredentialData(secondCredential); + expect(secondCredential.data).toBeDefined(); + expect(firstCredential.sharedWith).toHaveLength(2); }); - const secondResponse = await authMemberAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + test('should not retrieve non-owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - expect(secondResponse.statusCode).toBe(200); + const response = await authAgent(member).get(`/credentials/${savedCredential.id}`); - const { data: secondCredential } = secondResponse.body; - validateMainCredentialData(secondCredential); - expect(secondCredential.data).toBeDefined(); - expect(firstCredential.sharedWith).toHaveLength(2); -}); - -test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - - const response = await authAgent(member).get(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(403); - expect(response.body.data).toBeUndefined(); // owner's cred not returned -}); - -test('GET /credentials/:id should fail with missing encryption key', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const response = await authAgent(ownerShell) - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); -}); - -test('GET /credentials/:id should return 404 if cred not found', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).get('/credentials/789'); - expect(response.statusCode).toBe(404); - - const responseAbc = await authAgent(ownerShell).get('/credentials/abc'); - expect(responseAbc.statusCode).toBe(404); - - // because EE router has precedence, check if forwards this route - const responseNew = await authAgent(ownerShell).get('/credentials/new'); - expect(responseNew.statusCode).toBe(200); -}); - -// ---------------------------------------- -// indempotent share/unshare -// ---------------------------------------- - -test('PUT /credentials/:id/share should share the credential with the provided userIds and unshare it for missing ones', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const [member1, member2, member3, member4, member5] = await testDb.createManyUsers(5, { - globalRole: globalMemberRole, - }); - const shareWithIds = [member1.id, member2.id, member3.id]; - - await testDb.shareCredentialWithUsers(savedCredential, [member4, member5]); - - const response = await authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds }); - - expect(response.statusCode).toBe(200); - expect(response.body.data).toBeUndefined(); - - const sharedCredentials = await Db.collections.SharedCredentials.find({ - relations: ['role'], - where: { credentialsId: savedCredential.id }, + expect(response.statusCode).toBe(403); + expect(response.body.data).toBeUndefined(); // owner's cred not returned }); - // check that sharings have been removed/added correctly - expect(sharedCredentials.length).toBe(shareWithIds.length + 1); // +1 for the owner + test('should fail with missing encryption key', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - sharedCredentials.forEach((sharedCredential) => { - if (sharedCredential.userId === owner.id) { - expect(sharedCredential.role.name).toBe('owner'); - expect(sharedCredential.role.scope).toBe('credential'); - return; - } - expect(shareWithIds).toContain(sharedCredential.userId); - expect(sharedCredential.role.name).toBe('user'); - expect(sharedCredential.role.scope).toBe('credential'); + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); + + const response = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); + }); + + test('should return 404 if cred not found', async () => { + const response = await authOwnerAgent.get('/credentials/789'); + expect(response.statusCode).toBe(404); + + const responseAbc = await authOwnerAgent.get('/credentials/abc'); + expect(responseAbc.statusCode).toBe(404); + + // because EE router has precedence, check if forwards this route + const responseNew = await authOwnerAgent.get('/credentials/new'); + expect(responseNew.statusCode).toBe(200); }); }); // ---------------------------------------- -// share +// idempotent share/unshare // ---------------------------------------- +describe('PUT /credentials/:id/share', () => { + test('should share the credential with the provided userIds and unshare it for missing ones', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); -test('PUT /credentials/:id/share should share the credential with the provided userIds', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const [member1, member2, member3] = await testDb.createManyUsers(3, { - globalRole: globalMemberRole, - }); - const memberIds = [member1.id, member2.id, member3.id]; - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + const [member1, member2, member3, member4, member5] = await testDb.createManyUsers(5, { + globalRole: globalMemberRole, + }); + const shareWithIds = [member1.id, member2.id, member3.id]; - const response = await authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: memberIds }); + await testDb.shareCredentialWithUsers(savedCredential, [member4, member5]); - expect(response.statusCode).toBe(200); - expect(response.body.data).toBeUndefined(); - - // check that sharings got correctly set in DB - const sharedCredentials = await Db.collections.SharedCredentials.find({ - relations: ['role'], - where: { credentialsId: savedCredential.id, userId: In([...memberIds]) }, - }); - - expect(sharedCredentials.length).toBe(memberIds.length); - - sharedCredentials.forEach((sharedCredential) => { - expect(sharedCredential.role.name).toBe('user'); - expect(sharedCredential.role.scope).toBe('credential'); - }); - - // check that owner still exists - const ownerSharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['role'], - where: { credentialsId: savedCredential.id, userId: owner.id }, - }); - - expect(ownerSharedCredential.role.name).toBe('owner'); - expect(ownerSharedCredential.role.scope).toBe('credential'); -}); - -test('PUT /credentials/:id/share should respond 403 for non-existing credentials', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const response = await authAgent(owner) - .put(`/credentials/1234567/share`) - .send({ shareWithIds: [member.id] }); - - expect(response.statusCode).toBe(403); -}); - -test('PUT /credentials/:id/share should respond 403 for non-owned credentials', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - - const response = await authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [member.id] }); - - expect(response.statusCode).toBe(403); -}); - -test('PUT /credentials/:id/share should ignore pending sharee', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const memberShell = await testDb.createUserShell(globalMemberRole); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const response = await authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [memberShell.id] }); - - expect(response.statusCode).toBe(200); - - const sharedCredentials = await Db.collections.SharedCredentials.find({ - where: { credentialsId: savedCredential.id }, - }); - - expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); -}); - -test('PUT /credentials/:id/share should ignore non-existing sharee', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const response = await authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: ['bce38a11-5e45-4d1c-a9ee-36e4a20ab0fc'] }); - - expect(response.statusCode).toBe(200); - - const sharedCredentials = await Db.collections.SharedCredentials.find({ - where: { credentialsId: savedCredential.id }, - }); - - expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); -}); - -test('PUT /credentials/:id/share should respond 400 if invalid payload is provided', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - - const responses = await Promise.all([ - authAgent(owner).put(`/credentials/${savedCredential.id}/share`).send(), - authAgent(owner) + const response = await authOwnerAgent .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [1] }), - ]); + .send({ shareWithIds }); - responses.forEach((response) => expect(response.statusCode).toBe(400)); -}); + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); -// ---------------------------------------- -// unshare -// ---------------------------------------- + const sharedCredentials = await Db.collections.SharedCredentials.find({ + relations: ['role'], + where: { credentialsId: savedCredential.id }, + }); -test('PUT /credentials/:id/share should unshare the credential', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + // check that sharings have been removed/added correctly + expect(sharedCredentials.length).toBe(shareWithIds.length + 1); // +1 for the owner - const [member1, member2] = await testDb.createManyUsers(2, { - globalRole: globalMemberRole, + sharedCredentials.forEach((sharedCredential) => { + if (sharedCredential.userId === owner.id) { + expect(sharedCredential.role.name).toBe('owner'); + expect(sharedCredential.role.scope).toBe('credential'); + return; + } + expect(shareWithIds).toContain(sharedCredential.userId); + expect(sharedCredential.role.name).toBe('user'); + expect(sharedCredential.role.scope).toBe('credential'); + }); }); - await testDb.shareCredentialWithUsers(savedCredential, [member1, member2]); + test('should share the credential with the provided userIds', async () => { + const [member1, member2, member3] = await testDb.createManyUsers(3, { + globalRole: globalMemberRole, + }); + const memberIds = [member1.id, member2.id, member3.id]; + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - const response = await authAgent(owner) - .put(`/credentials/${savedCredential.id}/share`) - .send({ shareWithIds: [] }); + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: memberIds }); - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200); + expect(response.body.data).toBeUndefined(); - const sharedCredentials = await Db.collections.SharedCredentials.find({ - where: { credentialsId: savedCredential.id }, + // check that sharings got correctly set in DB + const sharedCredentials = await Db.collections.SharedCredentials.find({ + relations: ['role'], + where: { credentialsId: savedCredential.id, userId: In([...memberIds]) }, + }); + + expect(sharedCredentials.length).toBe(memberIds.length); + + sharedCredentials.forEach((sharedCredential) => { + expect(sharedCredential.role.name).toBe('user'); + expect(sharedCredential.role.scope).toBe('credential'); + }); + + // check that owner still exists + const ownerSharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['role'], + where: { credentialsId: savedCredential.id, userId: owner.id }, + }); + + expect(ownerSharedCredential.role.name).toBe('owner'); + expect(ownerSharedCredential.role.scope).toBe('credential'); }); - expect(sharedCredentials).toHaveLength(1); - expect(sharedCredentials[0].userId).toBe(owner.id); + test('should respond 403 for non-existing credentials', async () => { + const response = await authOwnerAgent + .put(`/credentials/1234567/share`) + .send({ shareWithIds: [member.id] }); + + expect(response.statusCode).toBe(403); + }); + + test('should respond 403 for non-owned credentials', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [member.id] }); + + expect(response.statusCode).toBe(403); + }); + + test('should ignore pending sharee', async () => { + const memberShell = await testDb.createUserShell(globalMemberRole); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [memberShell.id] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + where: { credentialsId: savedCredential.id }, + }); + + expect(sharedCredentials).toHaveLength(1); + expect(sharedCredentials[0].userId).toBe(owner.id); + }); + + test('should ignore non-existing sharee', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: ['bce38a11-5e45-4d1c-a9ee-36e4a20ab0fc'] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + where: { credentialsId: savedCredential.id }, + }); + + expect(sharedCredentials).toHaveLength(1); + expect(sharedCredentials[0].userId).toBe(owner.id); + }); + + test('should respond 400 if invalid payload is provided', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const responses = await Promise.all([ + authOwnerAgent.put(`/credentials/${savedCredential.id}/share`).send(), + authOwnerAgent.put(`/credentials/${savedCredential.id}/share`).send({ shareWithIds: [1] }), + ]); + + responses.forEach((response) => expect(response.statusCode).toBe(400)); + }); + test('should unshare the credential', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); + + await testDb.shareCredentialWithUsers(savedCredential, [member1, member2]); + + const response = await authOwnerAgent + .put(`/credentials/${savedCredential.id}/share`) + .send({ shareWithIds: [] }); + + expect(response.statusCode).toBe(200); + + const sharedCredentials = await Db.collections.SharedCredentials.find({ + where: { credentialsId: savedCredential.id }, + }); + + expect(sharedCredentials).toHaveLength(1); + expect(sharedCredentials[0].userId).toBe(owner.id); + }); }); function validateMainCredentialData(credential: CredentialWithSharings) { diff --git a/packages/cli/test/integration/credentials.test.ts b/packages/cli/test/integration/credentials.test.ts index 1de7561721..3a2f5f8415 100644 --- a/packages/cli/test/integration/credentials.test.ts +++ b/packages/cli/test/integration/credentials.test.ts @@ -1,26 +1,31 @@ -import express from 'express'; +import type { Application } from 'express'; +import type { SuperAgentTest } from 'supertest'; import { UserSettings } from 'n8n-core'; import * as Db from '@/Db'; +import config from '@/config'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; +import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; import { randomCredentialPayload, randomName, randomString } from './shared/random'; import * as testDb from './shared/testDb'; import type { SaveCredentialFunction } from './shared/types'; import * as utils from './shared/utils'; - -import config from '@/config'; -import type { CredentialsEntity } from '@db/entities/CredentialsEntity'; import type { AuthAgent } from './shared/types'; // mock that credentialsSharing is not enabled const mockIsCredentialsSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled'); mockIsCredentialsSharingEnabled.mockReturnValue(false); -let app: express.Application; +let app: Application; let globalOwnerRole: Role; let globalMemberRole: Role; +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; let authAgent: AuthAgent; @@ -33,13 +38,18 @@ beforeAll(async () => { globalMemberRole = await testDb.getGlobalMemberRole(); const credentialOwnerRole = await testDb.getCredentialOwnerRole(); + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + member = await testDb.createUser({ globalRole: globalMemberRole }); + saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); authAgent = utils.createAuthAgent(app); + authOwnerAgent = authAgent(owner); + authMemberAgent = authAgent(member); }); beforeEach(async () => { - await testDb.truncate(['User', 'SharedCredentials', 'Credentials']); + await testDb.truncate(['SharedCredentials', 'Credentials']); }); afterAll(async () => { @@ -49,526 +59,490 @@ afterAll(async () => { // ---------------------------------------- // GET /credentials - fetch all credentials // ---------------------------------------- +describe('GET /credentials', () => { + test('should return all creds for owner', async () => { + const [{ id: savedOwnerCredentialId }, { id: savedMemberCredentialId }] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: owner }), + saveCredential(randomCredentialPayload(), { user: member }), + ]); -test('GET /credentials should return all creds for owner', async () => { - const [owner, member] = await Promise.all([ - testDb.createUser({ globalRole: globalOwnerRole }), - testDb.createUser({ globalRole: globalMemberRole }), - ]); + const response = await authOwnerAgent.get('/credentials'); - const [{ id: savedOwnerCredentialId }, { id: savedMemberCredentialId }] = await Promise.all([ - saveCredential(randomCredentialPayload(), { user: owner }), - saveCredential(randomCredentialPayload(), { user: member }), - ]); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); // owner retrieved owner cred and member cred - const response = await authAgent(owner).get('/credentials'); + const savedCredentialsIds = [savedOwnerCredentialId, savedMemberCredentialId]; + response.body.data.forEach((credential: CredentialsEntity) => { + validateMainCredentialData(credential); + expect(credential.data).toBeUndefined(); + expect(savedCredentialsIds).toContain(credential.id); + }); + }); - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(2); // owner retrieved owner cred and member cred + test('should return only own creds for member', async () => { + const [member1, member2] = await testDb.createManyUsers(2, { + globalRole: globalMemberRole, + }); - const savedCredentialsIds = [savedOwnerCredentialId, savedMemberCredentialId]; - response.body.data.forEach((credential: CredentialsEntity) => { - validateMainCredentialData(credential); - expect(credential.data).toBeUndefined(); - expect(savedCredentialsIds).toContain(credential.id); + const [savedCredential1] = await Promise.all([ + saveCredential(randomCredentialPayload(), { user: member1 }), + saveCredential(randomCredentialPayload(), { user: member2 }), + ]); + + const response = await authAgent(member1).get('/credentials'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); // member retrieved only own cred + + const [member1Credential] = response.body.data; + + validateMainCredentialData(member1Credential); + expect(member1Credential.data).toBeUndefined(); + expect(member1Credential.id).toBe(savedCredential1.id); }); }); -test('GET /credentials should return only own creds for member', async () => { - const [member1, member2] = await testDb.createManyUsers(2, { - globalRole: globalMemberRole, +describe('POST /credentials', () => { + test('should create cred', async () => { + const payload = randomCredentialPayload(); + + const response = await authOwnerAgent.post('/credentials').send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(payload.name); + expect(type).toBe(payload.type); + if (!payload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } + expect(nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); + expect(encryptedData).not.toBe(payload.data); + + const credential = await Db.collections.Credentials.findOneByOrFail({ id }); + + expect(credential.name).toBe(payload.name); + expect(credential.type).toBe(payload.type); + expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess![0].nodeType); + expect(credential.data).not.toBe(payload.data); + + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['user', 'credentials'], + where: { credentialsId: credential.id }, + }); + + expect(sharedCredential.user.id).toBe(owner.id); + expect(sharedCredential.credentials.name).toBe(payload.name); }); - const [savedCredential1] = await Promise.all([ - saveCredential(randomCredentialPayload(), { user: member1 }), - saveCredential(randomCredentialPayload(), { user: member2 }), - ]); - - const response = await authAgent(member1).get('/credentials'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(1); // member retrieved only own cred - - const [member1Credential] = response.body.data; - - validateMainCredentialData(member1Credential); - expect(member1Credential.data).toBeUndefined(); - expect(member1Credential.id).toBe(savedCredential1.id); -}); - -test('POST /credentials should create cred', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const payload = randomCredentialPayload(); - - const response = await authAgent(ownerShell).post('/credentials').send(payload); - - expect(response.statusCode).toBe(200); - - const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; - - expect(name).toBe(payload.name); - expect(type).toBe(payload.type); - if (!payload.nodesAccess) { - fail('Payload did not contain a nodesAccess array'); - } - expect(nodesAccess[0].nodeType).toBe(payload.nodesAccess[0].nodeType); - expect(encryptedData).not.toBe(payload.data); - - const credential = await Db.collections.Credentials.findOneByOrFail({ id }); - - expect(credential.name).toBe(payload.name); - expect(credential.type).toBe(payload.type); - expect(credential.nodesAccess[0].nodeType).toBe(payload.nodesAccess![0].nodeType); - expect(credential.data).not.toBe(payload.data); - - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['user', 'credentials'], - where: { credentialsId: credential.id }, + test('should fail with invalid inputs', async () => { + await Promise.all( + INVALID_PAYLOADS.map(async (invalidPayload) => { + const response = await authOwnerAgent.post('/credentials').send(invalidPayload); + expect(response.statusCode).toBe(400); + }), + ); }); - expect(sharedCredential.user.id).toBe(ownerShell.id); - expect(sharedCredential.credentials.name).toBe(payload.name); -}); + test('should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); -test('POST /credentials should fail with invalid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = authAgent(ownerShell); + const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); - await Promise.all( - INVALID_PAYLOADS.map(async (invalidPayload) => { - const response = await authOwnerAgent.post('/credentials').send(invalidPayload); - expect(response.statusCode).toBe(400); - }), - ); -}); + expect(response.statusCode).toBe(500); -test('POST /credentials should fail with missing encryption key', async () => { - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).post('/credentials').send(randomCredentialPayload()); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); -}); - -test('POST /credentials should ignore ID in payload', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = authAgent(ownerShell); - - const firstResponse = await authOwnerAgent - .post('/credentials') - .send({ id: '8', ...randomCredentialPayload() }); - - expect(firstResponse.body.data.id).not.toBe('8'); - - const secondResponse = await authOwnerAgent - .post('/credentials') - .send({ id: 8, ...randomCredentialPayload() }); - - expect(secondResponse.body.data.id).not.toBe(8); -}); - -test('DELETE /credentials/:id should delete owned cred for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - - const response = await authAgent(ownerShell).delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ data: true }); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeNull(); // deleted -}); - -test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - - const response = await authAgent(ownerShell).delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ data: true }); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeNull(); // deleted -}); - -test('DELETE /credentials/:id should delete owned cred for member', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - - const response = await authAgent(member).delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({ data: true }); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeNull(); // deleted -}); - -test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - - const response = await authAgent(member).delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(404); - - const shellCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(shellCredential).toBeDefined(); // not deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeDefined(); // not deleted -}); - -test('DELETE /credentials/:id should fail if cred not found', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).delete('/credentials/123'); - - expect(response.statusCode).toBe(404); -}); - -test('PATCH /credentials/:id should update owned cred for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - const patchPayload = randomCredentialPayload(); - - const response = await authAgent(ownerShell) - .patch(`/credentials/${savedCredential.id}`) - .send(patchPayload); - - expect(response.statusCode).toBe(200); - - const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; - - expect(name).toBe(patchPayload.name); - expect(type).toBe(patchPayload.type); - if (!patchPayload.nodesAccess) { - fail('Payload did not contain a nodesAccess array'); - } - expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); - - expect(encryptedData).not.toBe(patchPayload.data); - - const credential = await Db.collections.Credentials.findOneByOrFail({ id }); - - expect(credential.name).toBe(patchPayload.name); - expect(credential.type).toBe(patchPayload.type); - expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); - expect(credential.data).not.toBe(patchPayload.data); - - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['credentials'], - where: { credentialsId: credential.id }, + mock.mockRestore(); }); - expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated + test('should ignore ID in payload', async () => { + const firstResponse = await authOwnerAgent + .post('/credentials') + .send({ id: '8', ...randomCredentialPayload() }); + + expect(firstResponse.body.data.id).not.toBe('8'); + + const secondResponse = await authOwnerAgent + .post('/credentials') + .send({ id: 8, ...randomCredentialPayload() }); + + expect(secondResponse.body.data.id).not.toBe(8); + }); }); -test('PATCH /credentials/:id should update non-owned cred for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const patchPayload = randomCredentialPayload(); +describe('DELETE /credentials/:id', () => { + test('should delete owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - const response = await authAgent(ownerShell) - .patch(`/credentials/${savedCredential.id}`) - .send(patchPayload); + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); - expect(response.statusCode).toBe(200); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); - const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); - expect(name).toBe(patchPayload.name); - expect(type).toBe(patchPayload.type); + expect(deletedCredential).toBeNull(); // deleted - if (!patchPayload.nodesAccess) { - fail('Payload did not contain a nodesAccess array'); - } - expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - expect(encryptedData).not.toBe(patchPayload.data); - - const credential = await Db.collections.Credentials.findOneByOrFail({ id }); - - expect(credential.name).toBe(patchPayload.name); - expect(credential.type).toBe(patchPayload.type); - expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); - expect(credential.data).not.toBe(patchPayload.data); - - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['credentials'], - where: { credentialsId: credential.id }, + expect(deletedSharedCredential).toBeNull(); // deleted }); - expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated -}); + test('should delete non-owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); -test('PATCH /credentials/:id should update owned cred for member', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const patchPayload = randomCredentialPayload(); + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); - const response = await authAgent(member) - .patch(`/credentials/${savedCredential.id}`) - .send(patchPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); - expect(response.statusCode).toBe(200); + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); - const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + expect(deletedCredential).toBeNull(); // deleted - expect(name).toBe(patchPayload.name); - expect(type).toBe(patchPayload.type); + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - if (!patchPayload.nodesAccess) { - fail('Payload did not contain a nodesAccess array'); - } - expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); - - expect(encryptedData).not.toBe(patchPayload.data); - - const credential = await Db.collections.Credentials.findOneByOrFail({ id }); - - expect(credential.name).toBe(patchPayload.name); - expect(credential.type).toBe(patchPayload.type); - expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); - expect(credential.data).not.toBe(patchPayload.data); - - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['credentials'], - where: { credentialsId: credential.id }, + expect(deletedSharedCredential).toBeNull(); // deleted }); - expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated -}); + test('should delete owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); -test('PATCH /credentials/:id should not update non-owned cred for member', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); - const patchPayload = randomCredentialPayload(); + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - const response = await authAgent(member) - .patch(`/credentials/${savedCredential.id}`) - .send(patchPayload); + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ data: true }); - expect(response.statusCode).toBe(404); + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); - const shellCredential = await Db.collections.Credentials.findOneByOrFail({ - id: savedCredential.id, + expect(deletedCredential).toBeNull(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); + + expect(deletedSharedCredential).toBeNull(); // deleted }); - expect(shellCredential.name).not.toBe(patchPayload.name); // not updated + test('should not delete non-owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); + + expect(shellCredential).toBeDefined(); // not deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); + + expect(deletedSharedCredential).toBeDefined(); // not deleted + }); + + test('should fail if cred not found', async () => { + const response = await authOwnerAgent.delete('/credentials/123'); + + expect(response.statusCode).toBe(404); + }); }); -test('PATCH /credentials/:id should fail with invalid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = authAgent(ownerShell); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); +describe('PATCH /credentials/:id', () => { + test('should update owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + const patchPayload = randomCredentialPayload(); - await Promise.all( - INVALID_PAYLOADS.map(async (invalidPayload) => { - const response = await authOwnerAgent - .patch(`/credentials/${savedCredential.id}`) - .send(invalidPayload); + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); - if (response.statusCode === 500) { - console.log(response.statusCode, response.body); + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + if (!patchPayload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials.findOneByOrFail({ id }); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['credentials'], + where: { credentialsId: credential.id }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated + }); + + test('should update non-owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const patchPayload = randomCredentialPayload(); + + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + + if (!patchPayload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials.findOneByOrFail({ id }); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['credentials'], + where: { credentialsId: credential.id }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated + }); + + test('should update owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const patchPayload = randomCredentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(200); + + const { id, name, type, nodesAccess, data: encryptedData } = response.body.data; + + expect(name).toBe(patchPayload.name); + expect(type).toBe(patchPayload.type); + + if (!patchPayload.nodesAccess) { + fail('Payload did not contain a nodesAccess array'); + } + expect(nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess[0].nodeType); + + expect(encryptedData).not.toBe(patchPayload.data); + + const credential = await Db.collections.Credentials.findOneByOrFail({ id }); + + expect(credential.name).toBe(patchPayload.name); + expect(credential.type).toBe(patchPayload.type); + expect(credential.nodesAccess[0].nodeType).toBe(patchPayload.nodesAccess![0].nodeType); + expect(credential.data).not.toBe(patchPayload.data); + + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['credentials'], + where: { credentialsId: credential.id }, + }); + + expect(sharedCredential.credentials.name).toBe(patchPayload.name); // updated + }); + + test('should not update non-owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + const patchPayload = randomCredentialPayload(); + + const response = await authMemberAgent + .patch(`/credentials/${savedCredential.id}`) + .send(patchPayload); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials.findOneByOrFail({ + id: savedCredential.id, + }); + + expect(shellCredential.name).not.toBe(patchPayload.name); // not updated + }); + + test('should fail with invalid inputs', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); + + await Promise.all( + INVALID_PAYLOADS.map(async (invalidPayload) => { + const response = await authOwnerAgent + .patch(`/credentials/${savedCredential.id}`) + .send(invalidPayload); + + if (response.statusCode === 500) { + console.log(response.statusCode, response.body); + } + expect(response.statusCode).toBe(400); + }), + ); + }); + + test('should fail if cred not found', async () => { + const response = await authOwnerAgent.patch('/credentials/123').send(randomCredentialPayload()); + + expect(response.statusCode).toBe(404); + }); + + test('should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); + + const response = await authOwnerAgent.post('/credentials').send(randomCredentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); + }); +}); + +describe('GET /credentials/new', () => { + test('should return default name for new credential or its increment', async () => { + const name = config.getEnv('credentials.defaultName'); + let tempName = name; + + for (let i = 0; i < 4; i++) { + const response = await authOwnerAgent.get(`/credentials/new?name=${name}`); + + expect(response.statusCode).toBe(200); + if (i === 0) { + expect(response.body.data.name).toBe(name); + } else { + tempName = name + ' ' + (i + 1); + expect(response.body.data.name).toBe(tempName); } - expect(response.statusCode).toBe(400); - }), - ); -}); - -test('PATCH /credentials/:id should fail if cred not found', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell) - .patch('/credentials/123') - .send(randomCredentialPayload()); - - expect(response.statusCode).toBe(404); -}); - -test('PATCH /credentials/:id should fail with missing encryption key', async () => { - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).post('/credentials').send(randomCredentialPayload()); - - expect(response.statusCode).toBe(500); - - mock.mockRestore(); -}); - -test('GET /credentials/new should return default name for new credential or its increment', async () => { - const ownerShell = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(ownerShell); - const name = config.getEnv('credentials.defaultName'); - let tempName = name; - - for (let i = 0; i < 4; i++) { - const response = await authOwnerAgent.get(`/credentials/new?name=${name}`); - - expect(response.statusCode).toBe(200); - if (i === 0) { - expect(response.body.data.name).toBe(name); - } else { - tempName = name + ' ' + (i + 1); - expect(response.body.data.name).toBe(tempName); + await saveCredential({ ...randomCredentialPayload(), name: tempName }, { user: owner }); } - await saveCredential({ ...randomCredentialPayload(), name: tempName }, { user: ownerShell }); - } -}); + }); -test('GET /credentials/new should return name from query for new credential or its increment', async () => { - const ownerShell = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(ownerShell); - const name = 'special credential name'; - let tempName = name; + test('should return name from query for new credential or its increment', async () => { + const name = 'special credential name'; + let tempName = name; - for (let i = 0; i < 4; i++) { - const response = await authOwnerAgent.get(`/credentials/new?name=${name}`); + for (let i = 0; i < 4; i++) { + const response = await authOwnerAgent.get(`/credentials/new?name=${name}`); - expect(response.statusCode).toBe(200); - if (i === 0) { - expect(response.body.data.name).toBe(name); - } else { - tempName = name + ' ' + (i + 1); - expect(response.body.data.name).toBe(tempName); + expect(response.statusCode).toBe(200); + if (i === 0) { + expect(response.body.data.name).toBe(name); + } else { + tempName = name + ' ' + (i + 1); + expect(response.body.data.name).toBe(tempName); + } + await saveCredential({ ...randomCredentialPayload(), name: tempName }, { user: owner }); } - await saveCredential({ ...randomCredentialPayload(), name: tempName }, { user: ownerShell }); - } + }); }); -test('GET /credentials/:id should retrieve owned cred for owner', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = authAgent(ownerShell); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); +describe('GET /credentials/:id', () => { + test('should retrieve owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); - const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + const firstResponse = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); - expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.statusCode).toBe(200); - validateMainCredentialData(firstResponse.body.data); - expect(firstResponse.body.data.data).toBeUndefined(); + validateMainCredentialData(firstResponse.body.data); + expect(firstResponse.body.data.data).toBeUndefined(); - const secondResponse = await authOwnerAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + const secondResponse = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); - validateMainCredentialData(secondResponse.body.data); - expect(secondResponse.body.data.data).toBeDefined(); -}); + validateMainCredentialData(secondResponse.body.data); + expect(secondResponse.body.data.data).toBeDefined(); + }); -test('GET /credentials/:id should retrieve owned cred for member', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = authAgent(member); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + test('should retrieve owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); + const firstResponse = await authMemberAgent.get(`/credentials/${savedCredential.id}`); - expect(firstResponse.statusCode).toBe(200); + expect(firstResponse.statusCode).toBe(200); - validateMainCredentialData(firstResponse.body.data); - expect(firstResponse.body.data.data).toBeUndefined(); + validateMainCredentialData(firstResponse.body.data); + expect(firstResponse.body.data.data).toBeUndefined(); - const secondResponse = await authMemberAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + const secondResponse = await authMemberAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); - expect(secondResponse.statusCode).toBe(200); + expect(secondResponse.statusCode).toBe(200); - validateMainCredentialData(secondResponse.body.data); - expect(secondResponse.body.data.data).toBeDefined(); -}); + validateMainCredentialData(secondResponse.body.data); + expect(secondResponse.body.data.data).toBeDefined(); + }); -test('GET /credentials/:id should retrieve non-owned cred for owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(owner); - const member = await testDb.createUser({ globalRole: globalMemberRole }); + test('should retrieve non-owned cred for owner', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); - const response1 = await authOwnerAgent.get(`/credentials/${savedCredential.id}`); + expect(response1.statusCode).toBe(200); - expect(response1.statusCode).toBe(200); + validateMainCredentialData(response1.body.data); + expect(response1.body.data.data).toBeUndefined(); - validateMainCredentialData(response1.body.data); - expect(response1.body.data.data).toBeUndefined(); + const response2 = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); - const response2 = await authOwnerAgent - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + expect(response2.statusCode).toBe(200); - expect(response2.statusCode).toBe(200); + validateMainCredentialData(response2.body.data); + expect(response2.body.data.data).toBeDefined(); + }); - validateMainCredentialData(response2.body.data); - expect(response2.body.data.data).toBeDefined(); -}); + test('should not retrieve non-owned cred for member', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); -test('GET /credentials/:id should not retrieve non-owned cred for member', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); + const response = await authMemberAgent.get(`/credentials/${savedCredential.id}`); - const response = await authAgent(member).get(`/credentials/${savedCredential.id}`); + expect(response.statusCode).toBe(404); + expect(response.body.data).toBeUndefined(); // owner's cred not returned + }); - expect(response.statusCode).toBe(404); - expect(response.body.data).toBeUndefined(); // owner's cred not returned -}); + test('should fail with missing encryption key', async () => { + const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); -test('GET /credentials/:id should fail with missing encryption key', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: ownerShell }); + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); + const response = await authOwnerAgent + .get(`/credentials/${savedCredential.id}`) + .query({ includeData: true }); - const response = await authAgent(ownerShell) - .get(`/credentials/${savedCredential.id}`) - .query({ includeData: true }); + expect(response.statusCode).toBe(500); - expect(response.statusCode).toBe(500); + mock.mockRestore(); + }); - mock.mockRestore(); -}); + test('should return 404 if cred not found', async () => { + const response = await authOwnerAgent.get('/credentials/789'); + expect(response.statusCode).toBe(404); -test('GET /credentials/:id should return 404 if cred not found', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const response = await authAgent(ownerShell).get('/credentials/789'); - expect(response.statusCode).toBe(404); - - const responseAbc = await authAgent(ownerShell).get('/credentials/abc'); - expect(responseAbc.statusCode).toBe(404); + const responseAbc = await authOwnerAgent.get('/credentials/abc'); + expect(responseAbc.statusCode).toBe(404); + }); }); function validateMainCredentialData(credential: CredentialsEntity) { diff --git a/packages/cli/test/integration/eventbus.test.ts b/packages/cli/test/integration/eventbus.test.ts index d30b542f14..f2cee9561b 100644 --- a/packages/cli/test/integration/eventbus.test.ts +++ b/packages/cli/test/integration/eventbus.test.ts @@ -2,6 +2,8 @@ import express from 'express'; import config from '@/config'; import axios from 'axios'; import syslog from 'syslog-client'; +import { v4 as uuid } from 'uuid'; +import type { SuperAgentTest } from 'supertest'; import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; import { Role } from '@db/entities/Role'; @@ -15,14 +17,14 @@ import { MessageEventBusDestinationWebhookOptions, } from 'n8n-workflow'; import { eventBus } from '@/eventbus'; -import { SuperAgentTest } from 'supertest'; import { EventMessageGeneric } from '@/eventbus/EventMessageClasses/EventMessageGeneric'; import { MessageEventBusDestinationSyslog } from '@/eventbus/MessageEventBusDestination/MessageEventBusDestinationSyslog.ee'; import { MessageEventBusDestinationWebhook } from '@/eventbus/MessageEventBusDestination/MessageEventBusDestinationWebhook.ee'; import { MessageEventBusDestinationSentry } from '@/eventbus/MessageEventBusDestination/MessageEventBusDestinationSentry.ee'; import { EventMessageAudit } from '@/eventbus/EventMessageClasses/EventMessageAudit'; -import { v4 as uuid } from 'uuid'; -import { EventNamesTypes } from '../../src/eventbus/EventMessageClasses'; +import { EventNamesTypes } from '@/eventbus/EventMessageClasses'; +import Container from 'typedi'; +import { License } from '../../src/License'; jest.unmock('@/eventbus/MessageEventBus/MessageEventBus'); jest.mock('axios'); @@ -54,6 +56,7 @@ const testWebhookDestination: MessageEventBusDestinationWebhookOptions = { enabled: false, subscribedEvents: ['n8n.test.message', 'n8n.audit.user.updated'], }; + const testSentryDestination: MessageEventBusDestinationSentryOptions = { ...defaultMessageEventBusDestinationSentryOptions, id: '450ca04b-87dd-4837-a052-ab3a347a00e9', @@ -76,6 +79,7 @@ async function confirmIdSent(id: string) { } beforeAll(async () => { + Container.get(License).isLogStreamingEnabled = () => true; app = await utils.initTestServer({ endpointGroups: ['eventBus'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); @@ -99,19 +103,16 @@ beforeAll(async () => { utils.initConfigFile(); config.set('eventBus.logWriter.logBaseName', 'n8n-test-logwriter'); - config.set('eventBus.logWriter.keepLogCount', '1'); - config.set('enterprise.features.logStreaming', true); + config.set('eventBus.logWriter.keepLogCount', 1); + config.set('userManagement.disabled', false); + config.set('userManagement.isInstanceOwnerSetUp', true); await eventBus.initialize(); }); -beforeEach(async () => { - config.set('userManagement.disabled', false); - config.set('userManagement.isInstanceOwnerSetUp', true); -}); - afterAll(async () => { jest.mock('@/eventbus/MessageEventBus/MessageEventBus'); + Container.reset(); await testDb.terminate(); await eventBus.close(); }); @@ -180,7 +181,6 @@ test.skip('should send message to syslog', async () => { eventName: 'n8n.test.message' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const syslogDestination = eventBus.destinations[ testSyslogDestination.id! @@ -221,7 +221,6 @@ test.skip('should confirm send message if there are no subscribers', async () => eventName: 'n8n.test.unsub' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const syslogDestination = eventBus.destinations[ testSyslogDestination.id! @@ -257,7 +256,6 @@ test('should anonymize audit message to syslog ', async () => { }, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const syslogDestination = eventBus.destinations[ testSyslogDestination.id! @@ -319,7 +317,6 @@ test('should send message to webhook ', async () => { eventName: 'n8n.test.message' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const webhookDestination = eventBus.destinations[ testWebhookDestination.id! @@ -354,7 +351,6 @@ test('should send message to sentry ', async () => { eventName: 'n8n.test.message' as EventNamesTypes, id: uuid(), }); - config.set('enterprise.features.logStreaming', true); const sentryDestination = eventBus.destinations[ testSentryDestination.id! diff --git a/packages/cli/test/integration/ldap/ldap.api.test.ts b/packages/cli/test/integration/ldap/ldap.api.test.ts index 36b8924469..61a44b59ea 100644 --- a/packages/cli/test/integration/ldap/ldap.api.test.ts +++ b/packages/cli/test/integration/ldap/ldap.api.test.ts @@ -1,11 +1,12 @@ import express from 'express'; import type { Entry as LdapUser } from 'ldapts'; +import { Not } from 'typeorm'; import { jsonParse } from 'n8n-workflow'; import config from '@/config'; import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; -import { LDAP_DEFAULT_CONFIGURATION, LDAP_ENABLED, LDAP_FEATURE_NAME } from '@/Ldap/constants'; +import { LDAP_DEFAULT_CONFIGURATION, LDAP_FEATURE_NAME } from '@/Ldap/constants'; import { LdapManager } from '@/Ldap/LdapManager.ee'; import { LdapService } from '@/Ldap/LdapService.ee'; import { encryptPassword, saveLdapSynchronization } from '@/Ldap/helpers'; @@ -15,13 +16,15 @@ import { randomEmail, randomName, uniqueId } from './../shared/random'; import * as testDb from './../shared/testDb'; import type { AuthAgent } from '../shared/types'; import * as utils from '../shared/utils'; +import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; +import Container from 'typedi'; +import { License } from '../../../src/License'; jest.mock('@/telemetry'); jest.mock('@/UserManagement/email/NodeMailer'); let app: express.Application; let globalMemberRole: Role; -let globalOwnerRole: Role; let owner: User; let authAgent: AuthAgent; @@ -40,22 +43,24 @@ const defaultLdapConfig = { }; beforeAll(async () => { + Container.get(License).isLdapEnabled = () => true; app = await utils.initTestServer({ endpointGroups: ['auth', 'ldap'] }); - const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles(); + const [globalOwnerRole, fetchedGlobalMemberRole] = await testDb.getAllRoles(); - globalOwnerRole = fetchedGlobalOwnerRole; globalMemberRole = fetchedGlobalMemberRole; + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + authAgent = utils.createAuthAgent(app); - config.set(LDAP_ENABLED, true); defaultLdapConfig.bindingAdminPassword = await encryptPassword( defaultLdapConfig.bindingAdminPassword, ); utils.initConfigFile(); - await utils.initLdapManager(); + + await setCurrentAuthenticationMethod('email'); }); beforeEach(async () => { @@ -66,20 +71,19 @@ beforeEach(async () => { 'Credentials', 'SharedWorkflow', 'Workflow', - 'User', ]); - owner = await testDb.createUser({ globalRole: globalOwnerRole }); + await Db.collections.User.delete({ id: Not(owner.id) }); jest.mock('@/telemetry'); config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); config.set('userManagement.emails.mode', ''); - config.set('enterprise.features.ldap', true); }); afterAll(async () => { + Container.reset(); await testDb.terminate(); }); @@ -176,6 +180,7 @@ describe('PUT /ldap/config', () => { const emailUser = await Db.collections.User.findOneByOrFail({ id: member.id }); const localLdapIdentities = await testDb.getLdapIdentities(); + expect(getCurrentAuthenticationMethod()).toBe('email'); expect(emailUser.email).toBe(member.email); expect(emailUser.lastName).toBe(member.lastName); expect(emailUser.firstName).toBe(member.firstName); @@ -192,6 +197,7 @@ test('GET /ldap/config route should retrieve current configuration', async () => let response = await authAgent(owner).put('/ldap/config').send(validPayload); expect(response.statusCode).toBe(200); + expect(getCurrentAuthenticationMethod()).toBe('ldap'); response = await authAgent(owner).get('/ldap/config'); diff --git a/packages/cli/test/integration/license.api.test.ts b/packages/cli/test/integration/license.api.test.ts index b8908915e1..44d84e1150 100644 --- a/packages/cli/test/integration/license.api.test.ts +++ b/packages/cli/test/integration/license.api.test.ts @@ -1,41 +1,36 @@ -import express from 'express'; - +import type { SuperAgentTest } from 'supertest'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; -import * as testDb from './shared/testDb'; -import type { AuthAgent } from './shared/types'; -import * as utils from './shared/utils'; +import type { User } from '@db/entities/User'; import { ILicensePostResponse, ILicenseReadResponse } from '@/Interfaces'; import { License } from '@/License'; +import * as testDb from './shared/testDb'; +import * as utils from './shared/utils'; const MOCK_SERVER_URL = 'https://server.com/v1'; const MOCK_RENEW_OFFSET = 259200; -const MOCK_INSTANCE_ID = 'instance-id'; -let app: express.Application; -let globalOwnerRole: Role; -let globalMemberRole: Role; -let authAgent: AuthAgent; -let license: License; +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['license'] }); + const app = await utils.initTestServer({ endpointGroups: ['license'] }); - globalOwnerRole = await testDb.getGlobalOwnerRole(); - globalMemberRole = await testDb.getGlobalMemberRole(); + const globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalMemberRole = await testDb.getGlobalMemberRole(); + owner = await testDb.createUserShell(globalOwnerRole); + member = await testDb.createUserShell(globalMemberRole); - authAgent = utils.createAuthAgent(app); + const authAgent = utils.createAuthAgent(app); + authOwnerAgent = authAgent(owner); + authMemberAgent = authAgent(member); config.set('license.serverUrl', MOCK_SERVER_URL); config.set('license.autoRenewEnabled', true); config.set('license.autoRenewOffset', MOCK_RENEW_OFFSET); }); -beforeEach(async () => { - license = new License(); - await license.init(MOCK_INSTANCE_ID); -}); - afterEach(async () => { await testDb.truncate(['Settings']); }); @@ -44,98 +39,66 @@ afterAll(async () => { await testDb.terminate(); }); -test('GET /license should return license information to the instance owner', async () => { - const userShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(userShell).get('/license'); - - expect(response.statusCode).toBe(200); - - // No license defined so we just expect the result to be the defaults - expect(response.body).toStrictEqual(DEFAULT_LICENSE_RESPONSE); -}); - -test('GET /license should return license information to a regular user', async () => { - const userShell = await testDb.createUserShell(globalMemberRole); - - const response = await authAgent(userShell).get('/license'); - - expect(response.statusCode).toBe(200); - - // No license defined so we just expect the result to be the defaults - expect(response.body).toStrictEqual(DEFAULT_LICENSE_RESPONSE); -}); - -test('POST /license/activate should work for instance owner', async () => { - const userShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(userShell) - .post('/license/activate') - .send({ activationKey: 'abcde' }); - - expect(response.statusCode).toBe(200); - - // No license defined so we just expect the result to be the defaults - expect(response.body).toMatchObject(DEFAULT_POST_RESPONSE); -}); - -test('POST /license/activate does not work for regular users', async () => { - const userShell = await testDb.createUserShell(globalMemberRole); - - const response = await authAgent(userShell) - .post('/license/activate') - .send({ activationKey: 'abcde' }); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(NON_OWNER_ACTIVATE_RENEW_MESSAGE); -}); - -test('POST /license/activate errors out properly', async () => { - License.prototype.activate = jest.fn().mockImplementation(() => { - throw new Error(INVALID_ACIVATION_KEY_MESSAGE); +describe('GET /license', () => { + test('should return license information to the instance owner', async () => { + // No license defined so we just expect the result to be the defaults + await authOwnerAgent.get('/license').expect(200, DEFAULT_LICENSE_RESPONSE); }); - const userShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(userShell) - .post('/license/activate') - .send({ activationKey: 'abcde' }); - - expect(response.statusCode).toBe(400); - expect(response.body.message).toBe(INVALID_ACIVATION_KEY_MESSAGE); + test('should return license information to a regular user', async () => { + // No license defined so we just expect the result to be the defaults + await authMemberAgent.get('/license').expect(200, DEFAULT_LICENSE_RESPONSE); + }); }); -test('POST /license/renew should work for instance owner', async () => { - const userShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(userShell).post('/license/renew'); - - expect(response.statusCode).toBe(200); - - // No license defined so we just expect the result to be the defaults - expect(response.body).toMatchObject(DEFAULT_POST_RESPONSE); -}); - -test('POST /license/renew does not work for regular users', async () => { - const userShell = await testDb.createUserShell(globalMemberRole); - - const response = await authAgent(userShell).post('/license/renew'); - - expect(response.statusCode).toBe(403); - expect(response.body.message).toBe(NON_OWNER_ACTIVATE_RENEW_MESSAGE); -}); - -test('POST /license/renew errors out properly', async () => { - License.prototype.renew = jest.fn().mockImplementation(() => { - throw new Error(RENEW_ERROR_MESSAGE); +describe('POST /license/activate', () => { + test('should work for instance owner', async () => { + await authOwnerAgent + .post('/license/activate') + .send({ activationKey: 'abcde' }) + .expect(200, DEFAULT_POST_RESPONSE); }); - const userShell = await testDb.createUserShell(globalOwnerRole); + test('does not work for regular users', async () => { + await authMemberAgent + .post('/license/activate') + .send({ activationKey: 'abcde' }) + .expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); + }); - const response = await authAgent(userShell).post('/license/renew'); + test('errors out properly', async () => { + License.prototype.activate = jest.fn().mockImplementation(() => { + throw new Error(INVALID_ACIVATION_KEY_MESSAGE); + }); - expect(response.statusCode).toBe(400); - expect(response.body.message).toBe(RENEW_ERROR_MESSAGE); + await authOwnerAgent + .post('/license/activate') + .send({ activationKey: 'abcde' }) + .expect(400, { code: 400, message: INVALID_ACIVATION_KEY_MESSAGE }); + }); +}); + +describe('POST /license/renew', () => { + test('should work for instance owner', async () => { + // No license defined so we just expect the result to be the defaults + await authOwnerAgent.post('/license/renew').expect(200, DEFAULT_POST_RESPONSE); + }); + + test('does not work for regular users', async () => { + await authMemberAgent + .post('/license/renew') + .expect(403, { code: 403, message: NON_OWNER_ACTIVATE_RENEW_MESSAGE }); + }); + + test('errors out properly', async () => { + License.prototype.renew = jest.fn().mockImplementation(() => { + throw new Error(RENEW_ERROR_MESSAGE); + }); + + await authOwnerAgent + .post('/license/renew') + .expect(400, { code: 400, message: RENEW_ERROR_MESSAGE }); + }); }); const DEFAULT_LICENSE_RESPONSE: { data: ILicenseReadResponse } = { diff --git a/packages/cli/test/integration/me.api.test.ts b/packages/cli/test/integration/me.api.test.ts index 9a2d2cd7c5..7c5004a36e 100644 --- a/packages/cli/test/integration/me.api.test.ts +++ b/packages/cli/test/integration/me.api.test.ts @@ -1,10 +1,10 @@ -import express from 'express'; +import type { Application } from 'express'; +import type { SuperAgentTest } from 'supertest'; import { IsNull } from 'typeorm'; import validator from 'validator'; - -import config from '@/config'; import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { randomApiKey, @@ -17,7 +17,7 @@ import * as testDb from './shared/testDb'; import type { AuthAgent } from './shared/types'; import * as utils from './shared/utils'; -let app: express.Application; +let app: Application; let globalOwnerRole: Role; let globalMemberRole: Role; let authAgent: AuthAgent; @@ -40,10 +40,16 @@ afterAll(async () => { }); describe('Owner shell', () => { - test('PATCH /me should succeed with valid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); + let ownerShell: User; + let authOwnerShellAgent: SuperAgentTest; + beforeEach(async () => { + ownerShell = await testDb.createUserShell(globalOwnerRole); + await testDb.addApiKey(ownerShell); + authOwnerShellAgent = authAgent(ownerShell); + }); + + test('PATCH /me should succeed with valid inputs', async () => { for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authOwnerShellAgent.patch('/me').send(validPayload); @@ -83,9 +89,6 @@ describe('Owner shell', () => { }); test('PATCH /me should fail with invalid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); - for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { const response = await authOwnerShellAgent.patch('/me').send(invalidPayload); expect(response.statusCode).toBe(400); @@ -98,9 +101,6 @@ describe('Owner shell', () => { }); test('PATCH /me/password should fail for shell', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); - const validPasswordPayload = { currentPassword: randomValidPassword(), newPassword: randomValidPassword(), @@ -130,9 +130,6 @@ describe('Owner shell', () => { }); test('POST /me/survey should succeed with valid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); - const validPayloads = [SURVEY, {}]; for (const validPayload of validPayloads) { @@ -150,9 +147,7 @@ describe('Owner shell', () => { }); test('POST /me/api-key should create an api key', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).post('/me/api-key'); + const response = await authOwnerShellAgent.post('/me/api-key'); expect(response.statusCode).toBe(200); expect(response.body.data.apiKey).toBeDefined(); @@ -166,20 +161,14 @@ describe('Owner shell', () => { }); test('GET /me/api-key should fetch the api key', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const response = await authAgent(ownerShell).get('/me/api-key'); + const response = await authOwnerShellAgent.get('/me/api-key'); expect(response.statusCode).toBe(200); expect(response.body.data.apiKey).toEqual(ownerShell.apiKey); }); test('DELETE /me/api-key should fetch the api key', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const response = await authAgent(ownerShell).delete('/me/api-key'); + const response = await authOwnerShellAgent.delete('/me/api-key'); expect(response.statusCode).toBe(200); @@ -192,19 +181,22 @@ describe('Owner shell', () => { }); describe('Member', () => { - beforeEach(async () => { - config.set('userManagement.isInstanceOwnerSetUp', true); + const memberPassword = randomValidPassword(); + let member: User; + let authMemberAgent: SuperAgentTest; - await Db.collections.Settings.update( - { key: 'userManagement.isInstanceOwnerSetUp' }, - { value: JSON.stringify(true) }, - ); + beforeEach(async () => { + member = await testDb.createUser({ + password: memberPassword, + globalRole: globalMemberRole, + apiKey: randomApiKey(), + }); + authMemberAgent = authAgent(member); + + await utils.setInstanceOwnerSetUp(true); }); test('PATCH /me should succeed with valid inputs', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = authAgent(member); - for (const validPayload of VALID_PATCH_ME_PAYLOADS) { const response = await authMemberAgent.patch('/me').send(validPayload); @@ -244,9 +236,6 @@ describe('Member', () => { }); test('PATCH /me should fail with invalid inputs', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = authAgent(member); - for (const invalidPayload of INVALID_PATCH_ME_PAYLOADS) { const response = await authMemberAgent.patch('/me').send(invalidPayload); expect(response.statusCode).toBe(400); @@ -259,18 +248,12 @@ describe('Member', () => { }); test('PATCH /me/password should succeed with valid inputs', async () => { - const memberPassword = randomValidPassword(); - const member = await testDb.createUser({ - password: memberPassword, - globalRole: globalMemberRole, - }); - const validPayload = { currentPassword: memberPassword, newPassword: randomValidPassword(), }; - const response = await authAgent(member).patch('/me/password').send(validPayload); + const response = await authMemberAgent.patch('/me/password').send(validPayload); expect(response.statusCode).toBe(200); expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); @@ -280,9 +263,6 @@ describe('Member', () => { }); test('PATCH /me/password should fail with invalid inputs', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = authAgent(member); - for (const payload of INVALID_PASSWORD_PAYLOADS) { const response = await authMemberAgent.patch('/me/password').send(payload); expect([400, 500].includes(response.statusCode)).toBe(true); @@ -299,9 +279,6 @@ describe('Member', () => { }); test('POST /me/survey should succeed with valid inputs', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const authMemberAgent = authAgent(member); - const validPayloads = [SURVEY, {}]; for (const validPayload of validPayloads) { @@ -318,11 +295,6 @@ describe('Member', () => { }); test('POST /me/api-key should create an api key', async () => { - const member = await testDb.createUser({ - globalRole: globalMemberRole, - apiKey: randomApiKey(), - }); - const response = await authAgent(member).post('/me/api-key'); expect(response.statusCode).toBe(200); @@ -335,11 +307,6 @@ describe('Member', () => { }); test('GET /me/api-key should fetch the api key', async () => { - const member = await testDb.createUser({ - globalRole: globalMemberRole, - apiKey: randomApiKey(), - }); - const response = await authAgent(member).get('/me/api-key'); expect(response.statusCode).toBe(200); @@ -347,11 +314,6 @@ describe('Member', () => { }); test('DELETE /me/api-key should fetch the api key', async () => { - const member = await testDb.createUser({ - globalRole: globalMemberRole, - apiKey: randomApiKey(), - }); - const response = await authAgent(member).delete('/me/api-key'); expect(response.statusCode).toBe(200); @@ -364,7 +326,7 @@ describe('Member', () => { describe('Owner', () => { beforeEach(async () => { - config.set('userManagement.isInstanceOwnerSetUp', true); + await utils.setInstanceOwnerSetUp(true); }); test('PATCH /me should succeed with valid inputs', async () => { diff --git a/packages/cli/test/integration/nodes.api.test.ts b/packages/cli/test/integration/nodes.api.test.ts index 9e20ca1b74..fb6166bd9f 100644 --- a/packages/cli/test/integration/nodes.api.test.ts +++ b/packages/cli/test/integration/nodes.api.test.ts @@ -1,10 +1,6 @@ import path from 'path'; - -import express from 'express'; import { mocked } from 'jest-mock'; - -import * as utils from './shared/utils'; -import * as testDb from './shared/testDb'; +import type { SuperAgentTest } from 'supertest'; import { executeCommand, checkNpmPackageStatus, @@ -15,13 +11,13 @@ import { import { findInstalledPackage, isPackageInstalled } from '@/CommunityNodes/packageModel'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { InstalledPackages } from '@db/entities/InstalledPackages'; - -import type { Role } from '@db/entities/Role'; -import type { AuthAgent } from './shared/types'; +import type { User } from '@db/entities/User'; import type { InstalledNodes } from '@db/entities/InstalledNodes'; -import { COMMUNITY_PACKAGE_VERSION } from './shared/constants'; import { NodeTypes } from '@/NodeTypes'; import { Push } from '@/push'; +import { COMMUNITY_PACKAGE_VERSION } from './shared/constants'; +import * as utils from './shared/utils'; +import * as testDb from './shared/testDb'; const mockLoadNodesAndCredentials = utils.mockInstance(LoadNodesAndCredentials); utils.mockInstance(NodeTypes); @@ -48,22 +44,21 @@ jest.mock('@/CommunityNodes/packageModel', () => { const mockedEmptyPackage = mocked(utils.emptyPackage); -let app: express.Application; -let globalOwnerRole: Role; -let authAgent: AuthAgent; +let ownerShell: User; +let authOwnerShellAgent: SuperAgentTest; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['nodes'] }); + const app = await utils.initTestServer({ endpointGroups: ['nodes'] }); - globalOwnerRole = await testDb.getGlobalOwnerRole(); - - authAgent = utils.createAuthAgent(app); + const globalOwnerRole = await testDb.getGlobalOwnerRole(); + ownerShell = await testDb.createUserShell(globalOwnerRole); + authOwnerShellAgent = utils.createAuthAgent(app)(ownerShell); utils.initConfigFile(); }); beforeEach(async () => { - await testDb.truncate(['InstalledNodes', 'InstalledPackages', 'User']); + await testDb.truncate(['InstalledNodes', 'InstalledPackages']); mocked(executeCommand).mockReset(); mocked(findInstalledPackage).mockReset(); @@ -73,255 +68,216 @@ afterAll(async () => { await testDb.terminate(); }); -/** - * GET /nodes - */ +describe('GET /nodes', () => { + test('should respond 200 if no nodes are installed', async () => { + const { + statusCode, + body: { data }, + } = await authOwnerShellAgent.get('/nodes'); -test('GET /nodes should respond 200 if no nodes are installed', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + expect(statusCode).toBe(200); + expect(data).toHaveLength(0); + }); - const { - statusCode, - body: { data }, - } = await authAgent(ownerShell).get('/nodes'); + test('should return list of one installed package and node', async () => { + const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); + await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); - expect(statusCode).toBe(200); - expect(data).toHaveLength(0); -}); + const { + statusCode, + body: { data }, + } = await authOwnerShellAgent.get('/nodes'); -test('GET /nodes should return list of one installed package and node', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + expect(statusCode).toBe(200); + expect(data).toHaveLength(1); + expect(data[0].installedNodes).toHaveLength(1); + }); - const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); + test('should return list of multiple installed packages and nodes', async () => { + const first = await testDb.saveInstalledPackage(utils.installedPackagePayload()); + await testDb.saveInstalledNode(utils.installedNodePayload(first.packageName)); - const { - statusCode, - body: { data }, - } = await authAgent(ownerShell).get('/nodes'); + const second = await testDb.saveInstalledPackage(utils.installedPackagePayload()); + await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); + await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); - expect(statusCode).toBe(200); - expect(data).toHaveLength(1); - expect(data[0].installedNodes).toHaveLength(1); -}); + const { + statusCode, + body: { data }, + } = await authOwnerShellAgent.get('/nodes'); -test('GET /nodes should return list of multiple installed packages and nodes', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + expect(statusCode).toBe(200); + expect(data).toHaveLength(2); - const first = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(first.packageName)); + const allNodes = data.reduce( + (acc: InstalledNodes[], cur: InstalledPackages) => acc.concat(cur.installedNodes), + [], + ); - const second = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); - await testDb.saveInstalledNode(utils.installedNodePayload(second.packageName)); + expect(allNodes).toHaveLength(3); + }); - const { - statusCode, - body: { data }, - } = await authAgent(ownerShell).get('/nodes'); + test('should not check for updates if no packages installed', async () => { + await authOwnerShellAgent.get('/nodes'); - expect(statusCode).toBe(200); - expect(data).toHaveLength(2); + expect(mocked(executeCommand)).toHaveBeenCalledTimes(0); + }); - const allNodes = data.reduce( - (acc: InstalledNodes[], cur: InstalledPackages) => acc.concat(cur.installedNodes), - [], - ); + test('should check for updates if packages installed', async () => { + const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); + await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); - expect(allNodes).toHaveLength(3); -}); + await authOwnerShellAgent.get('/nodes'); -test('GET /nodes should not check for updates if no packages installed', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + expect(mocked(executeCommand)).toHaveBeenCalledWith('npm outdated --json', { + doNotHandleError: true, + }); + }); - await authAgent(ownerShell).get('/nodes'); + test('should report package updates if available', async () => { + const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); + await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); - expect(mocked(executeCommand)).toHaveBeenCalledTimes(0); -}); + mocked(executeCommand).mockImplementationOnce(() => { + throw { + code: 1, + stdout: JSON.stringify({ + [packageName]: { + current: COMMUNITY_PACKAGE_VERSION.CURRENT, + wanted: COMMUNITY_PACKAGE_VERSION.CURRENT, + latest: COMMUNITY_PACKAGE_VERSION.UPDATED, + location: path.join('node_modules', packageName), + }, + }), + }; + }); -test('GET /nodes should check for updates if packages installed', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + mocked(isNpmError).mockReturnValueOnce(true); - const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); + const { + body: { data }, + } = await authOwnerShellAgent.get('/nodes'); - await authAgent(ownerShell).get('/nodes'); - - expect(mocked(executeCommand)).toHaveBeenCalledWith('npm outdated --json', { - doNotHandleError: true, + expect(data[0].installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT); + expect(data[0].updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED); }); }); -test('GET /nodes should report package updates if available', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); +describe('POST /nodes', () => { + test('should reject if package name is missing', async () => { + const { statusCode } = await authOwnerShellAgent.post('/nodes'); - const { packageName } = await testDb.saveInstalledPackage(utils.installedPackagePayload()); - await testDb.saveInstalledNode(utils.installedNodePayload(packageName)); - - mocked(executeCommand).mockImplementationOnce(() => { - throw { - code: 1, - stdout: JSON.stringify({ - [packageName]: { - current: COMMUNITY_PACKAGE_VERSION.CURRENT, - wanted: COMMUNITY_PACKAGE_VERSION.CURRENT, - latest: COMMUNITY_PACKAGE_VERSION.UPDATED, - location: path.join('node_modules', packageName), - }, - }), - }; + expect(statusCode).toBe(400); }); - mocked(isNpmError).mockReturnValueOnce(true); + test('should reject if package is duplicate', async () => { + mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); + mocked(isPackageInstalled).mockResolvedValueOnce(true); + mocked(hasPackageLoaded).mockReturnValueOnce(true); - const { - body: { data }, - } = await authAgent(ownerShell).get('/nodes'); + const { + statusCode, + body: { message }, + } = await authOwnerShellAgent.post('/nodes').send({ + name: utils.installedPackagePayload().packageName, + }); - expect(data[0].installedVersion).toBe(COMMUNITY_PACKAGE_VERSION.CURRENT); - expect(data[0].updateAvailable).toBe(COMMUNITY_PACKAGE_VERSION.UPDATED); -}); - -/** - * POST /nodes - */ - -test('POST /nodes should reject if package name is missing', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const { statusCode } = await authAgent(ownerShell).post('/nodes'); - - expect(statusCode).toBe(400); -}); - -test('POST /nodes should reject if package is duplicate', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); - mocked(isPackageInstalled).mockResolvedValueOnce(true); - mocked(hasPackageLoaded).mockReturnValueOnce(true); - - const { - statusCode, - body: { message }, - } = await authAgent(ownerShell).post('/nodes').send({ - name: utils.installedPackagePayload().packageName, + expect(statusCode).toBe(400); + expect(message).toContain('already installed'); }); - expect(statusCode).toBe(400); - expect(message).toContain('already installed'); -}); + test('should allow installing packages that could not be loaded', async () => { + mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); + mocked(hasPackageLoaded).mockReturnValueOnce(false); + mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'OK' }); -test('POST /nodes should allow installing packages that could not be loaded', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + mockLoadNodesAndCredentials.installNpmModule.mockImplementationOnce(mockedEmptyPackage); - mocked(findInstalledPackage).mockResolvedValueOnce(new InstalledPackages()); - mocked(hasPackageLoaded).mockReturnValueOnce(false); - mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'OK' }); + const { statusCode } = await authOwnerShellAgent.post('/nodes').send({ + name: utils.installedPackagePayload().packageName, + }); - mockLoadNodesAndCredentials.loadNpmModule.mockImplementationOnce(mockedEmptyPackage); - - const { statusCode } = await authAgent(ownerShell).post('/nodes').send({ - name: utils.installedPackagePayload().packageName, + expect(statusCode).toBe(200); + expect(mocked(removePackageFromMissingList)).toHaveBeenCalled(); }); - expect(statusCode).toBe(200); - expect(mocked(removePackageFromMissingList)).toHaveBeenCalled(); + test('should not install a banned package', async () => { + mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'Banned' }); + + const { + statusCode, + body: { message }, + } = await authOwnerShellAgent.post('/nodes').send({ + name: utils.installedPackagePayload().packageName, + }); + + expect(statusCode).toBe(400); + expect(message).toContain('banned'); + }); }); -test('POST /nodes should not install a banned package', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - mocked(checkNpmPackageStatus).mockResolvedValueOnce({ status: 'Banned' }); +describe('DELETE /nodes', () => { + test('should not delete if package name is empty', async () => { + const response = await authOwnerShellAgent.delete('/nodes'); - const { - statusCode, - body: { message }, - } = await authAgent(ownerShell).post('/nodes').send({ - name: utils.installedPackagePayload().packageName, + expect(response.statusCode).toBe(400); }); - expect(statusCode).toBe(400); - expect(message).toContain('banned'); -}); + test('should reject if package is not installed', async () => { + const { + statusCode, + body: { message }, + } = await authOwnerShellAgent.delete('/nodes').query({ + name: utils.installedPackagePayload().packageName, + }); -/** - * DELETE /nodes - */ - -test('DELETE /nodes should not delete if package name is empty', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).delete('/nodes'); - - expect(response.statusCode).toBe(400); -}); - -test('DELETE /nodes should reject if package is not installed', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const { - statusCode, - body: { message }, - } = await authAgent(ownerShell).delete('/nodes').query({ - name: utils.installedPackagePayload().packageName, + expect(statusCode).toBe(400); + expect(message).toContain('not installed'); }); - expect(statusCode).toBe(400); - expect(message).toContain('not installed'); + test('should uninstall package', async () => { + const removeSpy = mockLoadNodesAndCredentials.removeNpmModule.mockImplementationOnce(jest.fn()); + + mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); + + const { statusCode } = await authOwnerShellAgent.delete('/nodes').query({ + name: utils.installedPackagePayload().packageName, + }); + + expect(statusCode).toBe(200); + expect(removeSpy).toHaveBeenCalledTimes(1); + }); }); -test('DELETE /nodes should uninstall package', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); +describe('PATCH /nodes', () => { + test('should reject if package name is empty', async () => { + const response = await authOwnerShellAgent.patch('/nodes'); - const removeSpy = mockLoadNodesAndCredentials.removeNpmModule.mockImplementationOnce(jest.fn()); - - mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); - - const { statusCode } = await authAgent(ownerShell).delete('/nodes').query({ - name: utils.installedPackagePayload().packageName, + expect(response.statusCode).toBe(400); }); - expect(statusCode).toBe(200); - expect(removeSpy).toHaveBeenCalledTimes(1); -}); + test('reject if package is not installed', async () => { + const { + statusCode, + body: { message }, + } = await authOwnerShellAgent.patch('/nodes').send({ + name: utils.installedPackagePayload().packageName, + }); -/** - * PATCH /nodes - */ - -test('PATCH /nodes should reject if package name is empty', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).patch('/nodes'); - - expect(response.statusCode).toBe(400); -}); - -test('PATCH /nodes reject if package is not installed', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const { - statusCode, - body: { message }, - } = await authAgent(ownerShell).patch('/nodes').send({ - name: utils.installedPackagePayload().packageName, + expect(statusCode).toBe(400); + expect(message).toContain('not installed'); }); - expect(statusCode).toBe(400); - expect(message).toContain('not installed'); -}); + test('should update a package', async () => { + const updateSpy = + mockLoadNodesAndCredentials.updateNpmModule.mockImplementationOnce(mockedEmptyPackage); -test('PATCH /nodes should update a package', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); + mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); - const updateSpy = - mockLoadNodesAndCredentials.updateNpmModule.mockImplementationOnce(mockedEmptyPackage); + await authOwnerShellAgent.patch('/nodes').send({ + name: utils.installedPackagePayload().packageName, + }); - mocked(findInstalledPackage).mockImplementationOnce(mockedEmptyPackage); - - await authAgent(ownerShell).patch('/nodes').send({ - name: utils.installedPackagePayload().packageName, + expect(updateSpy).toHaveBeenCalledTimes(1); }); - - expect(updateSpy).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli/test/integration/owner.api.test.ts b/packages/cli/test/integration/owner.api.test.ts index 0f0e79d5a6..8b59df68d7 100644 --- a/packages/cli/test/integration/owner.api.test.ts +++ b/packages/cli/test/integration/owner.api.test.ts @@ -1,9 +1,11 @@ -import express from 'express'; +import type { Application } from 'express'; import validator from 'validator'; +import type { SuperAgentTest } from 'supertest'; import config from '@/config'; import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; import { randomEmail, randomInvalidPassword, @@ -11,23 +13,22 @@ import { randomValidPassword, } from './shared/random'; import * as testDb from './shared/testDb'; -import type { AuthAgent } from './shared/types'; import * as utils from './shared/utils'; -let app: express.Application; +let app: Application; let globalOwnerRole: Role; -let authAgent: AuthAgent; +let ownerShell: User; +let authOwnerShellAgent: SuperAgentTest; beforeAll(async () => { app = await utils.initTestServer({ endpointGroups: ['owner'] }); - globalOwnerRole = await testDb.getGlobalOwnerRole(); - - authAgent = utils.createAuthAgent(app); }); beforeEach(async () => { config.set('userManagement.isInstanceOwnerSetUp', false); + ownerShell = await testDb.createUserShell(globalOwnerRole); + authOwnerShellAgent = utils.createAuthAgent(app)(ownerShell); }); afterEach(async () => { @@ -38,152 +39,149 @@ afterAll(async () => { await testDb.terminate(); }); -test('POST /owner/setup should create owner and enable isInstanceOwnerSetUp', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); +describe('POST /owner/setup', () => { + test('should create owner and enable isInstanceOwnerSetUp', async () => { + const newOwnerData = { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }; - const newOwnerData = { - email: randomEmail(), - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), - }; + const response = await authOwnerShellAgent.post('/owner/setup').send(newOwnerData); - const response = await authAgent(ownerShell).post('/owner/setup').send(newOwnerData); + expect(response.statusCode).toBe(200); - expect(response.statusCode).toBe(200); + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + globalRole, + password, + resetPasswordToken, + isPending, + apiKey, + } = response.body.data; - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - globalRole, - password, - resetPasswordToken, - isPending, - apiKey, - } = response.body.data; + expect(validator.isUUID(id)).toBe(true); + expect(email).toBe(newOwnerData.email); + expect(firstName).toBe(newOwnerData.firstName); + expect(lastName).toBe(newOwnerData.lastName); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeUndefined(); + expect(isPending).toBe(false); + expect(resetPasswordToken).toBeUndefined(); + expect(globalRole.name).toBe('owner'); + expect(globalRole.scope).toBe('global'); + expect(apiKey).toBeUndefined(); - expect(validator.isUUID(id)).toBe(true); - expect(email).toBe(newOwnerData.email); - expect(firstName).toBe(newOwnerData.firstName); - expect(lastName).toBe(newOwnerData.lastName); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(isPending).toBe(false); - expect(resetPasswordToken).toBeUndefined(); - expect(globalRole.name).toBe('owner'); - expect(globalRole.scope).toBe('global'); - expect(apiKey).toBeUndefined(); + const storedOwner = await Db.collections.User.findOneByOrFail({ id }); + expect(storedOwner.password).not.toBe(newOwnerData.password); + expect(storedOwner.email).toBe(newOwnerData.email); + expect(storedOwner.firstName).toBe(newOwnerData.firstName); + expect(storedOwner.lastName).toBe(newOwnerData.lastName); - const storedOwner = await Db.collections.User.findOneByOrFail({ id }); - expect(storedOwner.password).not.toBe(newOwnerData.password); - expect(storedOwner.email).toBe(newOwnerData.email); - expect(storedOwner.firstName).toBe(newOwnerData.firstName); - expect(storedOwner.lastName).toBe(newOwnerData.lastName); + const isInstanceOwnerSetUpConfig = config.getEnv('userManagement.isInstanceOwnerSetUp'); + expect(isInstanceOwnerSetUpConfig).toBe(true); - const isInstanceOwnerSetUpConfig = config.getEnv('userManagement.isInstanceOwnerSetUp'); - expect(isInstanceOwnerSetUpConfig).toBe(true); - - const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp(); - expect(isInstanceOwnerSetUpSetting).toBe(true); -}); - -test('POST /owner/setup should create owner with lowercased email', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const newOwnerData = { - email: randomEmail().toUpperCase(), - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), - }; - - const response = await authAgent(ownerShell).post('/owner/setup').send(newOwnerData); - - expect(response.statusCode).toBe(200); - - const { id, email } = response.body.data; - - expect(id).toBe(ownerShell.id); - expect(email).toBe(newOwnerData.email.toLowerCase()); - - const storedOwner = await Db.collections.User.findOneByOrFail({ id }); - expect(storedOwner.email).toBe(newOwnerData.email.toLowerCase()); -}); - -test('POST /owner/setup should fail with invalid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = authAgent(ownerShell); - - await Promise.all( - INVALID_POST_OWNER_PAYLOADS.map(async (invalidPayload) => { - const response = await authOwnerAgent.post('/owner/setup').send(invalidPayload); - expect(response.statusCode).toBe(400); - }), - ); -}); - -test('POST /owner/skip-setup should persist skipping setup to the DB', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - - const response = await authAgent(ownerShell).post('/owner/skip-setup').send(); - - expect(response.statusCode).toBe(200); - - const skipConfig = config.getEnv('userManagement.skipInstanceOwnerSetup'); - expect(skipConfig).toBe(true); - - const { value } = await Db.collections.Settings.findOneByOrFail({ - key: 'userManagement.skipInstanceOwnerSetup', + const isInstanceOwnerSetUpSetting = await utils.isInstanceOwnerSetUp(); + expect(isInstanceOwnerSetUpSetting).toBe(true); + }); + + test('should create owner with lowercased email', async () => { + const newOwnerData = { + email: randomEmail().toUpperCase(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }; + + const response = await authOwnerShellAgent.post('/owner/setup').send(newOwnerData); + + expect(response.statusCode).toBe(200); + + const { id, email } = response.body.data; + + expect(id).toBe(ownerShell.id); + expect(email).toBe(newOwnerData.email.toLowerCase()); + + const storedOwner = await Db.collections.User.findOneByOrFail({ id }); + expect(storedOwner.email).toBe(newOwnerData.email.toLowerCase()); + }); + + const INVALID_POST_OWNER_PAYLOADS = [ + { + email: '', + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: '', + lastName: randomName(), + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: '', + password: randomValidPassword(), + }, + { + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, + { + firstName: randomName(), + lastName: randomName(), + }, + { + firstName: randomName(), + }, + { + lastName: randomName(), + }, + { + email: randomEmail(), + firstName: 'John { + const authOwnerAgent = authOwnerShellAgent; + + await Promise.all( + INVALID_POST_OWNER_PAYLOADS.map(async (invalidPayload) => { + const response = await authOwnerAgent.post('/owner/setup').send(invalidPayload); + expect(response.statusCode).toBe(400); + }), + ); }); - expect(value).toBe('true'); }); -const INVALID_POST_OWNER_PAYLOADS = [ - { - email: '', - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), - }, - { - email: randomEmail(), - firstName: '', - lastName: randomName(), - password: randomValidPassword(), - }, - { - email: randomEmail(), - firstName: randomName(), - lastName: '', - password: randomValidPassword(), - }, - { - email: randomEmail(), - firstName: randomName(), - lastName: randomName(), - password: randomInvalidPassword(), - }, - { - firstName: randomName(), - lastName: randomName(), - }, - { - firstName: randomName(), - }, - { - lastName: randomName(), - }, - { - email: randomEmail(), - firstName: 'John { + test('should persist skipping setup to the DB', async () => { + const response = await authOwnerShellAgent.post('/owner/skip-setup').send(); + + expect(response.statusCode).toBe(200); + + const skipConfig = config.getEnv('userManagement.skipInstanceOwnerSetup'); + expect(skipConfig).toBe(true); + + const { value } = await Db.collections.Settings.findOneByOrFail({ + key: 'userManagement.skipInstanceOwnerSetup', + }); + expect(value).toBe('true'); + }); +}); diff --git a/packages/cli/test/integration/passwordReset.api.test.ts b/packages/cli/test/integration/passwordReset.api.test.ts index 13e458ad11..38c9042ddf 100644 --- a/packages/cli/test/integration/passwordReset.api.test.ts +++ b/packages/cli/test/integration/passwordReset.api.test.ts @@ -1,10 +1,12 @@ -import express from 'express'; +import type { SuperAgentTest } from 'supertest'; import { v4 as uuid } from 'uuid'; +import { compare } from 'bcryptjs'; -import * as utils from './shared/utils'; import * as Db from '@/Db'; import config from '@/config'; -import { compare } from 'bcryptjs'; +import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; +import * as utils from './shared/utils'; import { randomEmail, randomInvalidPassword, @@ -12,276 +14,237 @@ import { randomValidPassword, } from './shared/random'; import * as testDb from './shared/testDb'; -import type { Role } from '@db/entities/Role'; jest.mock('@/UserManagement/email/NodeMailer'); -let app: express.Application; let globalOwnerRole: Role; let globalMemberRole: Role; +let owner: User; +let authlessAgent: SuperAgentTest; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['passwordReset'] }); + const app = await utils.initTestServer({ endpointGroups: ['passwordReset'] }); globalOwnerRole = await testDb.getGlobalOwnerRole(); globalMemberRole = await testDb.getGlobalMemberRole(); + + authlessAgent = utils.createAgent(app); }); beforeEach(async () => { await testDb.truncate(['User']); - - jest.mock('@/config'); + owner = await testDb.createUser({ globalRole: globalOwnerRole }); config.set('userManagement.isInstanceOwnerSetUp', true); - config.set('userManagement.emails.mode', ''); }); afterAll(async () => { await testDb.terminate(); }); -test('POST /forgot-password should send password reset email', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +describe('POST /forgot-password', () => { + test('should send password reset email', async () => { + const member = await testDb.createUser({ + email: 'test@test.com', + globalRole: globalMemberRole, + }); - const authlessAgent = utils.createAgent(app); - const member = await testDb.createUser({ - email: 'test@test.com', - globalRole: globalMemberRole, + config.set('userManagement.emails.mode', 'smtp'); + + await Promise.all( + [{ email: owner.email }, { email: member.email.toUpperCase() }].map(async (payload) => { + const response = await authlessAgent.post('/forgot-password').send(payload); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({}); + + const user = await Db.collections.User.findOneByOrFail({ email: payload.email }); + expect(user.resetPasswordToken).toBeDefined(); + expect(user.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000)); + }), + ); }); - config.set('userManagement.emails.mode', 'smtp'); + test('should fail if emailing is not set up', async () => { + config.set('userManagement.emails.mode', ''); - await Promise.all( - [{ email: owner.email }, { email: member.email.toUpperCase() }].map(async (payload) => { - const response = await authlessAgent.post('/forgot-password').send(payload); + await authlessAgent.post('/forgot-password').send({ email: owner.email }).expect(500); - expect(response.statusCode).toBe(200); - expect(response.body).toEqual({}); + const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email }); + expect(storedOwner.resetPasswordToken).toBeNull(); + }); - const user = await Db.collections.User.findOneByOrFail({ email: payload.email }); - expect(user.resetPasswordToken).toBeDefined(); - expect(user.resetPasswordTokenExpiration).toBeGreaterThan(Math.ceil(Date.now() / 1000)); - }), - ); + test('should fail with invalid inputs', async () => { + config.set('userManagement.emails.mode', 'smtp'); + + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + await Promise.all( + invalidPayloads.map(async (invalidPayload) => { + const response = await authlessAgent.post('/forgot-password').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email }); + expect(storedOwner.resetPasswordToken).toBeNull(); + }), + ); + }); + + test('should fail if user is not found', async () => { + config.set('userManagement.emails.mode', 'smtp'); + + const response = await authlessAgent.post('/forgot-password').send({ email: randomEmail() }); + + expect(response.statusCode).toBe(200); // expect 200 to remain vague + }); }); -test('POST /forgot-password should fail if emailing is not set up', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +describe('GET /resolve-password-token', () => { + beforeEach(() => { + config.set('userManagement.emails.mode', 'smtp'); + }); - const authlessAgent = utils.createAgent(app); + test('should succeed with valid inputs', async () => { + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; - const response = await authlessAgent.post('/forgot-password').send({ email: owner.email }); + await Db.collections.User.update(owner.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); - expect(response.statusCode).toBe(500); + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: owner.id, token: resetPasswordToken }); - const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email }); - expect(storedOwner.resetPasswordToken).toBeNull(); -}); + expect(response.statusCode).toBe(200); + }); -test('POST /forgot-password should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + test('should fail with invalid inputs', async () => { + const first = await authlessAgent.get('/resolve-password-token').query({ token: uuid() }); - const authlessAgent = utils.createAgent(app); + const second = await authlessAgent.get('/resolve-password-token').query({ userId: owner.id }); - config.set('userManagement.emails.mode', 'smtp'); - - const invalidPayloads = [ - randomEmail(), - [randomEmail()], - {}, - [{ name: randomName() }], - [{ email: randomName() }], - ]; - - await Promise.all( - invalidPayloads.map(async (invalidPayload) => { - const response = await authlessAgent.post('/forgot-password').send(invalidPayload); + for (const response of [first, second]) { expect(response.statusCode).toBe(400); - - const storedOwner = await Db.collections.User.findOneByOrFail({ email: owner.email }); - expect(storedOwner.resetPasswordToken).toBeNull(); - }), - ); -}); - -test('POST /forgot-password should fail if user is not found', async () => { - const authlessAgent = utils.createAgent(app); - - config.set('userManagement.emails.mode', 'smtp'); - - const response = await authlessAgent.post('/forgot-password').send({ email: randomEmail() }); - - expect(response.statusCode).toBe(200); // expect 200 to remain vague -}); - -test('GET /resolve-password-token should succeed with valid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - const resetPasswordToken = uuid(); - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; - - await Db.collections.User.update(owner.id, { - resetPasswordToken, - resetPasswordTokenExpiration, + } }); - const response = await authlessAgent - .get('/resolve-password-token') - .query({ userId: owner.id, token: resetPasswordToken }); + test('should fail if user is not found', async () => { + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: owner.id, token: uuid() }); - expect(response.statusCode).toBe(200); -}); - -test('GET /resolve-password-token should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - config.set('userManagement.emails.mode', 'smtp'); - - const first = await authlessAgent.get('/resolve-password-token').query({ token: uuid() }); - - const second = await authlessAgent.get('/resolve-password-token').query({ userId: owner.id }); - - for (const response of [first, second]) { - expect(response.statusCode).toBe(400); - } -}); - -test('GET /resolve-password-token should fail if user is not found', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - config.set('userManagement.emails.mode', 'smtp'); - - const response = await authlessAgent - .get('/resolve-password-token') - .query({ userId: owner.id, token: uuid() }); - - expect(response.statusCode).toBe(404); -}); - -test('GET /resolve-password-token should fail if token is expired', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - const resetPasswordToken = uuid(); - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; - - await Db.collections.User.update(owner.id, { - resetPasswordToken, - resetPasswordTokenExpiration, + expect(response.statusCode).toBe(404); }); - config.set('userManagement.emails.mode', 'smtp'); + test('should fail if token is expired', async () => { + const resetPasswordToken = uuid(); + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; - const response = await authlessAgent - .get('/resolve-password-token') - .query({ userId: owner.id, token: resetPasswordToken }); + await Db.collections.User.update(owner.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); - expect(response.statusCode).toBe(404); + const response = await authlessAgent + .get('/resolve-password-token') + .query({ userId: owner.id, token: resetPasswordToken }); + + expect(response.statusCode).toBe(404); + }); }); -test('POST /change-password should succeed with valid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - +describe('POST /change-password', () => { const resetPasswordToken = uuid(); - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; - - await Db.collections.User.update(owner.id, { - resetPasswordToken, - resetPasswordTokenExpiration, - }); - const passwordToStore = randomValidPassword(); - const response = await authlessAgent.post('/change-password').send({ - token: resetPasswordToken, - userId: owner.id, - password: passwordToStore, - }); + test('should succeed with valid inputs', async () => { + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; - expect(response.statusCode).toBe(200); + await Db.collections.User.update(owner.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); - const authToken = utils.getAuthToken(response); - expect(authToken).toBeDefined(); - - const { password: storedPassword } = await Db.collections.User.findOneByOrFail({ id: owner.id }); - - const comparisonResult = await compare(passwordToStore, storedPassword); - expect(comparisonResult).toBe(true); - expect(storedPassword).not.toBe(passwordToStore); -}); - -test('POST /change-password should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - const resetPasswordToken = uuid(); - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; - - await Db.collections.User.update(owner.id, { - resetPasswordToken, - resetPasswordTokenExpiration, - }); - - const invalidPayloads = [ - { token: uuid() }, - { id: owner.id }, - { password: randomValidPassword() }, - { token: uuid(), id: owner.id }, - { token: uuid(), password: randomValidPassword() }, - { id: owner.id, password: randomValidPassword() }, - { - id: owner.id, - password: randomInvalidPassword(), + const response = await authlessAgent.post('/change-password').send({ token: resetPasswordToken, - }, - { + userId: owner.id, + password: passwordToStore, + }); + + expect(response.statusCode).toBe(200); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const { password: storedPassword } = await Db.collections.User.findOneByOrFail({ id: owner.id, - password: randomValidPassword(), - token: uuid(), - }, - ]; + }); - await Promise.all( - invalidPayloads.map(async (invalidPayload) => { - const response = await authlessAgent.post('/change-password').query(invalidPayload); - expect(response.statusCode).toBe(400); - - const { password: storedPassword } = await Db.collections.User.findOneByOrFail({}); - expect(owner.password).toBe(storedPassword); - }), - ); -}); - -test('POST /change-password should fail when token has expired', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - const resetPasswordToken = uuid(); - const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; - - await Db.collections.User.update(owner.id, { - resetPasswordToken, - resetPasswordTokenExpiration, + const comparisonResult = await compare(passwordToStore, storedPassword); + expect(comparisonResult).toBe(true); + expect(storedPassword).not.toBe(passwordToStore); }); - const passwordToStore = randomValidPassword(); + test('should fail with invalid inputs', async () => { + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) + 100; - const response = await authlessAgent.post('/change-password').send({ - token: resetPasswordToken, - userId: owner.id, - password: passwordToStore, + await Db.collections.User.update(owner.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const invalidPayloads = [ + { token: uuid() }, + { id: owner.id }, + { password: randomValidPassword() }, + { token: uuid(), id: owner.id }, + { token: uuid(), password: randomValidPassword() }, + { id: owner.id, password: randomValidPassword() }, + { + id: owner.id, + password: randomInvalidPassword(), + token: resetPasswordToken, + }, + { + id: owner.id, + password: randomValidPassword(), + token: uuid(), + }, + ]; + + await Promise.all( + invalidPayloads.map(async (invalidPayload) => { + const response = await authlessAgent.post('/change-password').query(invalidPayload); + expect(response.statusCode).toBe(400); + + const { password: storedPassword } = await Db.collections.User.findOneByOrFail({}); + expect(owner.password).toBe(storedPassword); + }), + ); }); - expect(response.statusCode).toBe(404); + test('should fail when token has expired', async () => { + const resetPasswordTokenExpiration = Math.floor(Date.now() / 1000) - 1; + + await Db.collections.User.update(owner.id, { + resetPasswordToken, + resetPasswordTokenExpiration, + }); + + const response = await authlessAgent.post('/change-password').send({ + token: resetPasswordToken, + userId: owner.id, + password: passwordToStore, + }); + + expect(response.statusCode).toBe(404); + }); }); diff --git a/packages/cli/test/integration/publicApi/credentials.test.ts b/packages/cli/test/integration/publicApi/credentials.test.ts index 10b486cbaf..a9754ceb31 100644 --- a/packages/cli/test/integration/publicApi/credentials.test.ts +++ b/packages/cli/test/integration/publicApi/credentials.test.ts @@ -1,24 +1,25 @@ -import express from 'express'; - +import type { SuperAgentTest } from 'supertest'; import { UserSettings } from 'n8n-core'; - import * as Db from '@/Db'; import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; import { RESPONSE_ERROR_MESSAGES } from '@/constants'; import { randomApiKey, randomName, randomString } from '../shared/random'; import * as utils from '../shared/utils'; import type { CredentialPayload, SaveCredentialFunction } from '../shared/types'; import * as testDb from '../shared/testDb'; -let app: express.Application; -let globalOwnerRole: Role; let globalMemberRole: Role; let credentialOwnerRole: Role; +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; beforeAll(async () => { - app = await utils.initTestServer({ + const app = await utils.initTestServer({ endpointGroups: ['publicApi'], applyAuth: false, enablePublicAPI: true, @@ -26,334 +27,265 @@ beforeAll(async () => { utils.initConfigFile(); - const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, _, fetchedCredentialOwnerRole] = + const [globalOwnerRole, fetchedGlobalMemberRole, _, fetchedCredentialOwnerRole] = await testDb.getAllRoles(); - globalOwnerRole = fetchedGlobalOwnerRole; globalMemberRole = fetchedGlobalMemberRole; credentialOwnerRole = fetchedCredentialOwnerRole; + owner = await testDb.addApiKey(await testDb.createUserShell(globalOwnerRole)); + member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + + authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: owner, + }); + authMemberAgent = utils.createAgent(app, { + apiPath: 'public', + version: 1, + auth: true, + user: member, + }); + saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); utils.initCredentialsTypes(); }); beforeEach(async () => { - await testDb.truncate(['User', 'SharedCredentials', 'Credentials']); + await testDb.truncate(['SharedCredentials', 'Credentials']); }); afterAll(async () => { await testDb.terminate(); }); -test('POST /credentials should create credentials', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); +describe('POST /credentials', () => { + test('should create credentials', async () => { + const payload = { + name: 'test credential', + type: 'githubApi', + data: { + accessToken: 'abcdefghijklmnopqrstuvwxyz', + user: 'test', + server: 'testServer', + }, + }; - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, - }); - const payload = { - name: 'test credential', - type: 'githubApi', - data: { - accessToken: 'abcdefghijklmnopqrstuvwxyz', - user: 'test', - server: 'testServer', - }, - }; + const response = await authOwnerAgent.post('/credentials').send(payload); - const response = await authOwnerAgent.post('/credentials').send(payload); + expect(response.statusCode).toBe(200); - expect(response.statusCode).toBe(200); + const { id, name, type } = response.body; - const { id, name, type } = response.body; + expect(name).toBe(payload.name); + expect(type).toBe(payload.type); - expect(name).toBe(payload.name); - expect(type).toBe(payload.type); + const credential = await Db.collections.Credentials.findOneByOrFail({ id }); - const credential = await Db.collections.Credentials.findOneByOrFail({ id }); + expect(credential.name).toBe(payload.name); + expect(credential.type).toBe(payload.type); + expect(credential.data).not.toBe(payload.data); - expect(credential.name).toBe(payload.name); - expect(credential.type).toBe(payload.type); - expect(credential.data).not.toBe(payload.data); + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['user', 'credentials', 'role'], + where: { credentialsId: credential.id, userId: owner.id }, + }); - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['user', 'credentials', 'role'], - where: { credentialsId: credential.id, userId: ownerShell.id }, + expect(sharedCredential.role).toEqual(credentialOwnerRole); + expect(sharedCredential.credentials.name).toBe(payload.name); }); - expect(sharedCredential.role).toEqual(credentialOwnerRole); - expect(sharedCredential.credentials.name).toBe(payload.name); + test('should fail with invalid inputs', async () => { + await Promise.all( + INVALID_PAYLOADS.map(async (invalidPayload) => { + const response = await authOwnerAgent.post('/credentials').send(invalidPayload); + expect(response.statusCode === 400 || response.statusCode === 415).toBe(true); + }), + ); + }); + + test('should fail with missing encryption key', async () => { + const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); + mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); + + const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + + expect(response.statusCode).toBe(500); + + mock.mockRestore(); + }); }); -test('POST /credentials should fail with invalid inputs', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); +describe('DELETE /credentials/:id', () => { + test('should delete owned cred for owner', async () => { + const savedCredential = await saveCredential(dbCredential(), { user: owner }); - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const { name, type } = response.body; + + expect(name).toBe(savedCredential.name); + expect(type).toBe(savedCredential.type); + + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); + + expect(deletedCredential).toBeNull(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); + + expect(deletedSharedCredential).toBeNull(); // deleted }); - await Promise.all( - INVALID_PAYLOADS.map(async (invalidPayload) => { - const response = await authOwnerAgent.post('/credentials').send(invalidPayload); - expect(response.statusCode === 400 || response.statusCode === 415).toBe(true); - }), - ); + test('should delete non-owned cred for owner', async () => { + const savedCredential = await saveCredential(dbCredential(), { user: member }); + + const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); + + expect(deletedCredential).toBeNull(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); + + expect(deletedSharedCredential).toBeNull(); // deleted + }); + + test('should delete owned cred for member', async () => { + const savedCredential = await saveCredential(dbCredential(), { user: member }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const { name, type } = response.body; + + expect(name).toBe(savedCredential.name); + expect(type).toBe(savedCredential.type); + + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); + + expect(deletedCredential).toBeNull(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); + + expect(deletedSharedCredential).toBeNull(); // deleted + }); + + test('should delete owned cred for member but leave others untouched', async () => { + const anotherMember = await testDb.createUser({ + globalRole: globalMemberRole, + apiKey: randomApiKey(), + }); + + const savedCredential = await saveCredential(dbCredential(), { user: member }); + const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member }); + const notToBeChangedCredential2 = await saveCredential(dbCredential(), { + user: anotherMember, + }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(200); + + const { name, type } = response.body; + + expect(name).toBe(savedCredential.name); + expect(type).toBe(savedCredential.type); + + const deletedCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); + + expect(deletedCredential).toBeNull(); // deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOne({ + where: { + credentialsId: savedCredential.id, + }, + }); + + expect(deletedSharedCredential).toBeNull(); // deleted + + await Promise.all( + [notToBeChangedCredential, notToBeChangedCredential2].map(async (credential) => { + const untouchedCredential = await Db.collections.Credentials.findOneBy({ + id: credential.id, + }); + + expect(untouchedCredential).toEqual(credential); // not deleted + + const untouchedSharedCredential = await Db.collections.SharedCredentials.findOne({ + where: { + credentialsId: credential.id, + }, + }); + + expect(untouchedSharedCredential).toBeDefined(); // not deleted + }), + ); + }); + + test('should not delete non-owned cred for member', async () => { + const savedCredential = await saveCredential(dbCredential(), { user: owner }); + + const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); + + expect(response.statusCode).toBe(404); + + const shellCredential = await Db.collections.Credentials.findOneBy({ + id: savedCredential.id, + }); + + expect(shellCredential).toBeDefined(); // not deleted + + const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); + + expect(deletedSharedCredential).toBeDefined(); // not deleted + }); + + test('should fail if cred not found', async () => { + const response = await authOwnerAgent.delete('/credentials/123'); + + expect(response.statusCode).toBe(404); + }); }); -test('POST /credentials should fail with missing encryption key', async () => { - const mock = jest.spyOn(UserSettings, 'getEncryptionKey'); - mock.mockRejectedValue(new Error(RESPONSE_ERROR_MESSAGES.NO_ENCRYPTION_KEY)); +describe('GET /credentials/schema/:credentialType', () => { + test('should fail due to not found type', async () => { + const response = await authOwnerAgent.get('/credentials/schema/testing'); - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, + expect(response.statusCode).toBe(404); }); - const response = await authOwnerAgent.post('/credentials').send(credentialPayload()); + test('should retrieve credential type', async () => { + const response = await authOwnerAgent.get('/credentials/schema/githubApi'); - expect(response.statusCode).toBe(500); + const { additionalProperties, type, properties, required } = response.body; - mock.mockRestore(); -}); - -test('DELETE /credentials/:id should delete owned cred for owner', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, + expect(additionalProperties).toBe(false); + expect(type).toBe('object'); + expect(properties.server).toBeDefined(); + expect(properties.server.type).toBe('string'); + expect(properties.user.type).toBeDefined(); + expect(properties.user.type).toBe('string'); + expect(properties.accessToken.type).toBeDefined(); + expect(properties.accessToken.type).toBe('string'); + expect(required).toEqual(expect.arrayContaining(['server', 'user', 'accessToken'])); + expect(response.statusCode).toBe(200); }); - - const savedCredential = await saveCredential(dbCredential(), { user: ownerShell }); - - const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - - const { name, type } = response.body; - - expect(name).toBe(savedCredential.name); - expect(type).toBe(savedCredential.type); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeNull(); // deleted -}); - -test('DELETE /credentials/:id should delete non-owned cred for owner', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, - }); - - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const savedCredential = await saveCredential(dbCredential(), { user: member }); - - const response = await authOwnerAgent.delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeNull(); // deleted -}); - -test('DELETE /credentials/:id should delete owned cred for member', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authMemberAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: member, - }); - - const savedCredential = await saveCredential(dbCredential(), { user: member }); - - const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - - const { name, type } = response.body; - - expect(name).toBe(savedCredential.name); - expect(type).toBe(savedCredential.type); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeNull(); // deleted -}); - -test('DELETE /credentials/:id should delete owned cred for member but leave others untouched', async () => { - const member1 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - const member2 = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const savedCredential = await saveCredential(dbCredential(), { user: member1 }); - const notToBeChangedCredential = await saveCredential(dbCredential(), { user: member1 }); - const notToBeChangedCredential2 = await saveCredential(dbCredential(), { user: member2 }); - - const authMemberAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: member1, - }); - - const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(200); - - const { name, type } = response.body; - - expect(name).toBe(savedCredential.name); - expect(type).toBe(savedCredential.type); - - const deletedCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(deletedCredential).toBeNull(); // deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOne({ - where: { - credentialsId: savedCredential.id, - }, - }); - - expect(deletedSharedCredential).toBeNull(); // deleted - - await Promise.all( - [notToBeChangedCredential, notToBeChangedCredential2].map(async (credential) => { - const untouchedCredential = await Db.collections.Credentials.findOneBy({ id: credential.id }); - - expect(untouchedCredential).toEqual(credential); // not deleted - - const untouchedSharedCredential = await Db.collections.SharedCredentials.findOne({ - where: { - credentialsId: credential.id, - }, - }); - - expect(untouchedSharedCredential).toBeDefined(); // not deleted - }), - ); -}); - -test('DELETE /credentials/:id should not delete non-owned cred for member', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authMemberAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: member, - }); - const savedCredential = await saveCredential(dbCredential(), { user: ownerShell }); - - const response = await authMemberAgent.delete(`/credentials/${savedCredential.id}`); - - expect(response.statusCode).toBe(404); - - const shellCredential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - - expect(shellCredential).toBeDefined(); // not deleted - - const deletedSharedCredential = await Db.collections.SharedCredentials.findOneBy({}); - - expect(deletedSharedCredential).toBeDefined(); // not deleted -}); - -test('DELETE /credentials/:id should fail if cred not found', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, - }); - - const response = await authOwnerAgent.delete('/credentials/123'); - - expect(response.statusCode).toBe(404); -}); - -test('GET /credentials/schema/:credentialType should fail due to not found type', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, - }); - - const response = await authOwnerAgent.get('/credentials/schema/testing'); - - expect(response.statusCode).toBe(404); -}); - -test('GET /credentials/schema/:credentialType should retrieve credential type', async () => { - let ownerShell = await testDb.createUserShell(globalOwnerRole); - ownerShell = await testDb.addApiKey(ownerShell); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - version: 1, - auth: true, - user: ownerShell, - }); - - const response = await authOwnerAgent.get('/credentials/schema/githubApi'); - - const { additionalProperties, type, properties, required } = response.body; - - expect(additionalProperties).toBe(false); - expect(type).toBe('object'); - expect(properties.server).toBeDefined(); - expect(properties.server.type).toBe('string'); - expect(properties.user.type).toBeDefined(); - expect(properties.user.type).toBe('string'); - expect(properties.accessToken.type).toBeDefined(); - expect(properties.accessToken.type).toBe('string'); - expect(required).toEqual(expect.arrayContaining(['server', 'user', 'accessToken'])); - expect(response.statusCode).toBe(200); }); const credentialPayload = (): CredentialPayload => ({ diff --git a/packages/cli/test/integration/publicApi/executions.test.ts b/packages/cli/test/integration/publicApi/executions.test.ts index c377c870af..4c70977dce 100644 --- a/packages/cli/test/integration/publicApi/executions.test.ts +++ b/packages/cli/test/integration/publicApi/executions.test.ts @@ -1,15 +1,16 @@ -import express from 'express'; - +import type { Application } from 'express'; +import type { SuperAgentTest } from 'supertest'; import config from '@/config'; -import { Role } from '@db/entities/Role'; -import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import type { User } from '@db/entities/User'; +import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils'; import * as testDb from '../shared/testDb'; -let app: express.Application; -let globalOwnerRole: Role; +let app: Application; +let owner: User; +let authOwnerAgent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; beforeAll(async () => { @@ -19,7 +20,8 @@ beforeAll(async () => { enablePublicAPI: true, }); - globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalOwnerRole = await testDb.getGlobalOwnerRole(); + owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); await utils.initBinaryManager(); await utils.initNodeTypes(); @@ -31,13 +33,19 @@ beforeEach(async () => { await testDb.truncate([ 'SharedCredentials', 'SharedWorkflow', - 'User', 'Workflow', 'Credentials', 'Execution', 'Settings', ]); + authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); }); @@ -50,270 +58,27 @@ afterAll(async () => { await testDb.terminate(); }); -test('GET /executions/:id should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +const testWithAPIKey = + (method: 'get' | 'post' | 'put' | 'delete', url: string, apiKey: string | null) => async () => { + authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey }); + const response = await authOwnerAgent[method](url); + expect(response.statusCode).toBe(401); + }; - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); +describe('GET /executions/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/executions/1', null)); - const response = await authOwnerAgent.get('/executions/1'); + test('should fail due to invalid API Key', testWithAPIKey('get', '/executions/1', 'abcXYZ')); - expect(response.statusCode).toBe(401); -}); + test('should get an execution', async () => { + const workflow = await testDb.createWorkflow({}, owner); -test('GET /executions/:id should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - owner.apiKey = 'abcXYZ'; + const execution = await testDb.createSuccessfulExecution(workflow); - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); + const response = await authOwnerAgent.get(`/executions/${execution.id}`); - const response = await authOwnerAgent.get('/executions/1'); + expect(response.statusCode).toBe(200); - expect(response.statusCode).toBe(401); -}); - -test('GET /executions/:id should get an execution', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflow({}, owner); - - const execution = await testDb.createSuccessfulExecution(workflow); - - const response = await authOwnerAgent.get(`/executions/${execution.id}`); - - expect(response.statusCode).toBe(200); - - const { - id, - finished, - mode, - retryOf, - retrySuccessId, - startedAt, - stoppedAt, - workflowId, - waitTill, - } = response.body; - - expect(id).toBeDefined(); - expect(finished).toBe(true); - expect(mode).toEqual(execution.mode); - expect(retrySuccessId).toBeNull(); - expect(retryOf).toBeNull(); - expect(startedAt).not.toBeNull(); - expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(execution.workflowId); - expect(waitTill).toBeNull(); -}); - -test('DELETE /executions/:id should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.delete('/executions/1'); - - expect(response.statusCode).toBe(401); -}); - -test('DELETE /executions/:id should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.delete('/executions/1'); - - expect(response.statusCode).toBe(401); -}); - -test('DELETE /executions/:id should delete an execution', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflow({}, owner); - - const execution = await testDb.createSuccessfulExecution(workflow); - - const response = await authOwnerAgent.delete(`/executions/${execution.id}`); - - expect(response.statusCode).toBe(200); - - const { - id, - finished, - mode, - retryOf, - retrySuccessId, - startedAt, - stoppedAt, - workflowId, - waitTill, - } = response.body; - - expect(id).toBeDefined(); - expect(finished).toBe(true); - expect(mode).toEqual(execution.mode); - expect(retrySuccessId).toBeNull(); - expect(retryOf).toBeNull(); - expect(startedAt).not.toBeNull(); - expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(execution.workflowId); - expect(waitTill).toBeNull(); -}); - -test('GET /executions should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.get('/executions'); - - expect(response.statusCode).toBe(401); -}); - -test('GET /executions should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.get('/executions'); - - expect(response.statusCode).toBe(401); -}); - -test('GET /executions should retrieve all successful executions', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflow({}, owner); - - const successfullExecution = await testDb.createSuccessfulExecution(workflow); - - await testDb.createErrorExecution(workflow); - - const response = await authOwnerAgent.get(`/executions`).query({ - status: 'success', - }); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(1); - expect(response.body.nextCursor).toBe(null); - - const { - id, - finished, - mode, - retryOf, - retrySuccessId, - startedAt, - stoppedAt, - workflowId, - waitTill, - } = response.body.data[0]; - - expect(id).toBeDefined(); - expect(finished).toBe(true); - expect(mode).toEqual(successfullExecution.mode); - expect(retrySuccessId).toBeNull(); - expect(retryOf).toBeNull(); - expect(startedAt).not.toBeNull(); - expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(successfullExecution.workflowId); - expect(waitTill).toBeNull(); -}); - -// failing on Postgres and MySQL - ref: https://github.com/n8n-io/n8n/pull/3834 -test.skip('GET /executions should paginate two executions', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflow({}, owner); - - const firstSuccessfulExecution = await testDb.createSuccessfulExecution(workflow); - - const secondSuccessfulExecution = await testDb.createSuccessfulExecution(workflow); - - await testDb.createErrorExecution(workflow); - - const firstExecutionResponse = await authOwnerAgent.get(`/executions`).query({ - status: 'success', - limit: 1, - }); - - expect(firstExecutionResponse.statusCode).toBe(200); - expect(firstExecutionResponse.body.data.length).toBe(1); - expect(firstExecutionResponse.body.nextCursor).toBeDefined(); - - const secondExecutionResponse = await authOwnerAgent.get(`/executions`).query({ - status: 'success', - limit: 1, - cursor: firstExecutionResponse.body.nextCursor, - }); - - expect(secondExecutionResponse.statusCode).toBe(200); - expect(secondExecutionResponse.body.data.length).toBe(1); - expect(secondExecutionResponse.body.nextCursor).toBeNull(); - - const successfulExecutions = [firstSuccessfulExecution, secondSuccessfulExecution]; - const executions = [...firstExecutionResponse.body.data, ...secondExecutionResponse.body.data]; - - for (let i = 0; i < executions.length; i++) { const { id, finished, @@ -324,146 +89,33 @@ test.skip('GET /executions should paginate two executions', async () => { stoppedAt, workflowId, waitTill, - } = executions[i]; + } = response.body; expect(id).toBeDefined(); expect(finished).toBe(true); - expect(mode).toEqual(successfulExecutions[i].mode); + expect(mode).toEqual(execution.mode); expect(retrySuccessId).toBeNull(); expect(retryOf).toBeNull(); expect(startedAt).not.toBeNull(); expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(successfulExecutions[i].workflowId); + expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); - } + }); }); -test('GET /executions should retrieve all error executions', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); +describe('DELETE /executions/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('delete', '/executions/1', null)); - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); + test('should fail due to invalid API Key', testWithAPIKey('delete', '/executions/1', 'abcXYZ')); - const workflow = await testDb.createWorkflow({}, owner); + test('should delete an execution', async () => { + const workflow = await testDb.createWorkflow({}, owner); + const execution = await testDb.createSuccessfulExecution(workflow); - await testDb.createSuccessfulExecution(workflow); + const response = await authOwnerAgent.delete(`/executions/${execution.id}`); - const errorExecution = await testDb.createErrorExecution(workflow); + expect(response.statusCode).toBe(200); - const response = await authOwnerAgent.get(`/executions`).query({ - status: 'error', - }); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(1); - expect(response.body.nextCursor).toBe(null); - - const { - id, - finished, - mode, - retryOf, - retrySuccessId, - startedAt, - stoppedAt, - workflowId, - waitTill, - } = response.body.data[0]; - - expect(id).toBeDefined(); - expect(finished).toBe(false); - expect(mode).toEqual(errorExecution.mode); - expect(retrySuccessId).toBeNull(); - expect(retryOf).toBeNull(); - expect(startedAt).not.toBeNull(); - expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(errorExecution.workflowId); - expect(waitTill).toBeNull(); -}); - -test('GET /executions should return all waiting executions', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflow({}, owner); - - await testDb.createSuccessfulExecution(workflow); - - await testDb.createErrorExecution(workflow); - - const waitingExecution = await testDb.createWaitingExecution(workflow); - - const response = await authOwnerAgent.get(`/executions`).query({ - status: 'waiting', - }); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(1); - expect(response.body.nextCursor).toBe(null); - - const { - id, - finished, - mode, - retryOf, - retrySuccessId, - startedAt, - stoppedAt, - workflowId, - waitTill, - } = response.body.data[0]; - - expect(id).toBeDefined(); - expect(finished).toBe(false); - expect(mode).toEqual(waitingExecution.mode); - expect(retrySuccessId).toBeNull(); - expect(retryOf).toBeNull(); - expect(startedAt).not.toBeNull(); - expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(waitingExecution.workflowId); - expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000); -}); - -test('GET /executions should retrieve all executions of specific workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const [workflow, workflow2] = await testDb.createManyWorkflows(2, {}, owner); - - const savedExecutions = await testDb.createManyExecutions( - 2, - workflow, - // @ts-ignore - testDb.createSuccessfulExecution, - ); - // @ts-ignore - await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfulExecution); - - const response = await authOwnerAgent.get(`/executions`).query({ - workflowId: workflow.id, - }); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(2); - expect(response.body.nextCursor).toBe(null); - - for (const execution of response.body.data) { const { id, finished, @@ -474,16 +126,240 @@ test('GET /executions should retrieve all executions of specific workflow', asyn stoppedAt, workflowId, waitTill, - } = execution; + } = response.body; - expect(savedExecutions.some((exec) => exec.id === id)).toBe(true); + expect(id).toBeDefined(); expect(finished).toBe(true); - expect(mode).toBeDefined(); + expect(mode).toEqual(execution.mode); expect(retrySuccessId).toBeNull(); expect(retryOf).toBeNull(); expect(startedAt).not.toBeNull(); expect(stoppedAt).not.toBeNull(); - expect(workflowId).toBe(workflow.id); + expect(workflowId).toBe(execution.workflowId); expect(waitTill).toBeNull(); - } + }); +}); + +describe('GET /executions', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/executions', null)); + + test('should fail due to invalid API Key', testWithAPIKey('get', '/executions', 'abcXYZ')); + + test('should retrieve all successful executions', async () => { + const workflow = await testDb.createWorkflow({}, owner); + + const successfulExecution = await testDb.createSuccessfulExecution(workflow); + + await testDb.createErrorExecution(workflow); + + const response = await authOwnerAgent.get(`/executions`).query({ + status: 'success', + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).toBe(null); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(finished).toBe(true); + expect(mode).toEqual(successfulExecution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(successfulExecution.workflowId); + expect(waitTill).toBeNull(); + }); + + // failing on Postgres and MySQL - ref: https://github.com/n8n-io/n8n/pull/3834 + test.skip('should paginate two executions', async () => { + const workflow = await testDb.createWorkflow({}, owner); + + const firstSuccessfulExecution = await testDb.createSuccessfulExecution(workflow); + + const secondSuccessfulExecution = await testDb.createSuccessfulExecution(workflow); + + await testDb.createErrorExecution(workflow); + + const firstExecutionResponse = await authOwnerAgent.get(`/executions`).query({ + status: 'success', + limit: 1, + }); + + expect(firstExecutionResponse.statusCode).toBe(200); + expect(firstExecutionResponse.body.data.length).toBe(1); + expect(firstExecutionResponse.body.nextCursor).toBeDefined(); + + const secondExecutionResponse = await authOwnerAgent.get(`/executions`).query({ + status: 'success', + limit: 1, + cursor: firstExecutionResponse.body.nextCursor, + }); + + expect(secondExecutionResponse.statusCode).toBe(200); + expect(secondExecutionResponse.body.data.length).toBe(1); + expect(secondExecutionResponse.body.nextCursor).toBeNull(); + + const successfulExecutions = [firstSuccessfulExecution, secondSuccessfulExecution]; + const executions = [...firstExecutionResponse.body.data, ...secondExecutionResponse.body.data]; + + for (let i = 0; i < executions.length; i++) { + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = executions[i]; + + expect(id).toBeDefined(); + expect(finished).toBe(true); + expect(mode).toEqual(successfulExecutions[i].mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(successfulExecutions[i].workflowId); + expect(waitTill).toBeNull(); + } + }); + + test('should retrieve all error executions', async () => { + const workflow = await testDb.createWorkflow({}, owner); + + await testDb.createSuccessfulExecution(workflow); + + const errorExecution = await testDb.createErrorExecution(workflow); + + const response = await authOwnerAgent.get(`/executions`).query({ + status: 'error', + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).toBe(null); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(finished).toBe(false); + expect(mode).toEqual(errorExecution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(errorExecution.workflowId); + expect(waitTill).toBeNull(); + }); + + test('should return all waiting executions', async () => { + const workflow = await testDb.createWorkflow({}, owner); + + await testDb.createSuccessfulExecution(workflow); + + await testDb.createErrorExecution(workflow); + + const waitingExecution = await testDb.createWaitingExecution(workflow); + + const response = await authOwnerAgent.get(`/executions`).query({ + status: 'waiting', + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).toBe(null); + + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = response.body.data[0]; + + expect(id).toBeDefined(); + expect(finished).toBe(false); + expect(mode).toEqual(waitingExecution.mode); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(waitingExecution.workflowId); + expect(new Date(waitTill).getTime()).toBeGreaterThan(Date.now() - 1000); + }); + + test('should retrieve all executions of specific workflow', async () => { + const [workflow, workflow2] = await testDb.createManyWorkflows(2, {}, owner); + + const savedExecutions = await testDb.createManyExecutions( + 2, + workflow, + // @ts-ignore + testDb.createSuccessfulExecution, + ); + // @ts-ignore + await testDb.createManyExecutions(2, workflow2, testDb.createSuccessfulExecution); + + const response = await authOwnerAgent.get(`/executions`).query({ + workflowId: workflow.id, + }); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + expect(response.body.nextCursor).toBe(null); + + for (const execution of response.body.data) { + const { + id, + finished, + mode, + retryOf, + retrySuccessId, + startedAt, + stoppedAt, + workflowId, + waitTill, + } = execution; + + expect(savedExecutions.some((exec) => exec.id === id)).toBe(true); + expect(finished).toBe(true); + expect(mode).toBeDefined(); + expect(retrySuccessId).toBeNull(); + expect(retryOf).toBeNull(); + expect(startedAt).not.toBeNull(); + expect(stoppedAt).not.toBeNull(); + expect(workflowId).toBe(workflow.id); + expect(waitTill).toBeNull(); + } + }); }); diff --git a/packages/cli/test/integration/publicApi/workflows.test.ts b/packages/cli/test/integration/publicApi/workflows.test.ts index 8c418e0d44..02c76b616c 100644 --- a/packages/cli/test/integration/publicApi/workflows.test.ts +++ b/packages/cli/test/integration/publicApi/workflows.test.ts @@ -1,19 +1,22 @@ -import express from 'express'; - +import type { Application } from 'express'; +import type { SuperAgentTest } from 'supertest'; import * as Db from '@/Db'; import config from '@/config'; -import { Role } from '@db/entities/Role'; -import { TagEntity } from '@db/entities/TagEntity'; -import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import type { Role } from '@db/entities/Role'; +import type { TagEntity } from '@db/entities/TagEntity'; +import type { User } from '@db/entities/User'; +import type { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; import { randomApiKey } from '../shared/random'; import * as utils from '../shared/utils'; import * as testDb from '../shared/testDb'; -let app: express.Application; -let globalOwnerRole: Role; -let globalMemberRole: Role; +let app: Application; let workflowOwnerRole: Role; +let owner: User; +let member: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; let workflowRunner: ActiveWorkflowRunner; beforeAll(async () => { @@ -23,27 +26,41 @@ beforeAll(async () => { enablePublicAPI: true, }); - const [fetchedGlobalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole] = - await testDb.getAllRoles(); + const [globalOwnerRole, globalMemberRole, fetchedWorkflowOwnerRole] = await testDb.getAllRoles(); - globalOwnerRole = fetchedGlobalOwnerRole; - globalMemberRole = fetchedGlobalMemberRole; workflowOwnerRole = fetchedWorkflowOwnerRole; + owner = await testDb.createUser({ + globalRole: globalOwnerRole, + apiKey: randomApiKey(), + }); + + member = await testDb.createUser({ + globalRole: globalMemberRole, + apiKey: randomApiKey(), + }); + utils.initConfigFile(); await utils.initNodeTypes(); workflowRunner = await utils.initActiveWorkflowRunner(); }); beforeEach(async () => { - await testDb.truncate([ - 'SharedCredentials', - 'SharedWorkflow', - 'Tag', - 'User', - 'Workflow', - 'Credentials', - ]); + await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Tag', 'Workflow', 'Credentials']); + + authOwnerAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: owner, + version: 1, + }); + + authMemberAgent = utils.createAgent(app, { + apiPath: 'public', + auth: true, + user: member, + version: 1, + }); config.set('userManagement.disabled', false); config.set('userManagement.isInstanceOwnerSetUp', true); @@ -57,61 +74,253 @@ afterAll(async () => { await testDb.terminate(); }); -test('GET /workflows should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +const testWithAPIKey = + (method: 'get' | 'post' | 'put' | 'delete', url: string, apiKey: string | null) => async () => { + authOwnerAgent.set({ 'X-N8N-API-KEY': apiKey }); + const response = await authOwnerAgent[method](url); + expect(response.statusCode).toBe(401); + }; - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, +describe('GET /workflows', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/workflows', null)); + + test('should fail due to invalid API Key', testWithAPIKey('get', '/workflows', 'abcXYZ')); + + test('should return all owned workflows', async () => { + await Promise.all([ + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + ]); + + const response = await authMemberAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(3); + expect(response.body.nextCursor).toBeNull(); + + for (const workflow of response.body.data) { + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags, + } = workflow; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(tags).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } }); - const response = await authOwnerAgent.get('/workflows'); + test('should return all owned workflows with pagination', async () => { + await Promise.all([ + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, member), + ]); - expect(response.statusCode).toBe(401); + const response = await authMemberAgent.get('/workflows?limit=1'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.nextCursor).not.toBeNull(); + + const response2 = await authMemberAgent.get( + `/workflows?limit=1&cursor=${response.body.nextCursor}`, + ); + + expect(response2.statusCode).toBe(200); + expect(response2.body.data.length).toBe(1); + expect(response2.body.nextCursor).not.toBeNull(); + expect(response2.body.nextCursor).not.toBe(response.body.nextCursor); + + const responses = [...response.body.data, ...response2.body.data]; + + for (const workflow of responses) { + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags, + } = workflow; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(tags).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } + + // check that we really received a different result + expect(Number(response.body.data[0].id)).toBeLessThan(Number(response2.body.data[0].id)); + }); + + test('should return all owned workflows filtered by tag', async () => { + const tag = await testDb.createTag({}); + + const [workflow] = await Promise.all([ + testDb.createWorkflow({ tags: [tag] }, member), + testDb.createWorkflow({}, member), + ]); + + const response = await authMemberAgent.get(`/workflows?tags=${tag.name}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(1); + + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags: wfTags, + } = response.body.data[0]; + + expect(id).toBe(workflow.id); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + expect(wfTags.length).toBe(1); + expect(wfTags[0].id).toBe(tag.id); + }); + + test('should return all owned workflows filtered by tags', async () => { + const tags = await Promise.all([await testDb.createTag({}), await testDb.createTag({})]); + const tagNames = tags.map((tag) => tag.name).join(','); + + const [workflow1, workflow2] = await Promise.all([ + testDb.createWorkflow({ tags }, member), + testDb.createWorkflow({ tags }, member), + testDb.createWorkflow({}, member), + testDb.createWorkflow({ tags: [tags[0]] }, member), + testDb.createWorkflow({ tags: [tags[1]] }, member), + ]); + + const response = await authMemberAgent.get(`/workflows?tags=${tagNames}`); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); + + for (const workflow of response.body.data) { + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + workflow; + + expect(id).toBeDefined(); + expect([workflow1.id, workflow2.id].includes(id)).toBe(true); + + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + + expect(workflow.tags.length).toBe(2); + workflow.tags.forEach((tag: TagEntity) => { + expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true); + }); + } + }); + + test('should return all workflows for owner', async () => { + await Promise.all([ + testDb.createWorkflow({}, owner), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, owner), + testDb.createWorkflow({}, member), + testDb.createWorkflow({}, owner), + ]); + + const response = await authOwnerAgent.get('/workflows'); + + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(5); + expect(response.body.nextCursor).toBeNull(); + + for (const workflow of response.body.data) { + const { + id, + connections, + active, + staticData, + nodes, + settings, + name, + createdAt, + updatedAt, + tags, + } = workflow; + + expect(id).toBeDefined(); + expect(name).toBeDefined(); + expect(connections).toBeDefined(); + expect(active).toBe(false); + expect(staticData).toBeDefined(); + expect(nodes).toBeDefined(); + expect(tags).toBeDefined(); + expect(settings).toBeDefined(); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); + } + }); }); -test('GET /workflows should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); +describe('GET /workflows/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('get', '/workflows/2', null)); - owner.apiKey = 'abcXYZ'; + test('should fail due to invalid API Key', testWithAPIKey('get', '/workflows/2', 'abcXYZ')); - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, + test('should fail due to non-existing workflow', async () => { + const response = await authOwnerAgent.get(`/workflows/2`); + expect(response.statusCode).toBe(404); }); - const response = await authOwnerAgent.get('/workflows'); + test('should retrieve workflow', async () => { + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); - expect(response.statusCode).toBe(401); -}); + const response = await authMemberAgent.get(`/workflows/${workflow.id}`); -test('GET /workflows should return all owned workflows', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); + expect(response.statusCode).toBe(200); - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - await Promise.all([ - testDb.createWorkflow({}, member), - testDb.createWorkflow({}, member), - testDb.createWorkflow({}, member), - ]); - - const response = await authAgent.get('/workflows'); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(3); - expect(response.body.nextCursor).toBeNull(); - - for (const workflow of response.body.data) { const { id, connections, @@ -123,1151 +332,577 @@ test('GET /workflows should return all owned workflows', async () => { createdAt, updatedAt, tags, - } = workflow; + } = response.body; - expect(id).toBeDefined(); - expect(name).toBeDefined(); - expect(connections).toBeDefined(); + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); expect(active).toBe(false); - expect(staticData).toBeDefined(); - expect(nodes).toBeDefined(); - expect(tags).toBeDefined(); - expect(settings).toBeDefined(); - expect(createdAt).toBeDefined(); - expect(updatedAt).toBeDefined(); - } -}); - -test('GET /workflows should return all owned workflows with pagination', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(tags).toEqual([]); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); }); - await Promise.all([ - testDb.createWorkflow({}, member), - testDb.createWorkflow({}, member), - testDb.createWorkflow({}, member), - ]); + test('should retrieve non-owned workflow for owner', async () => { + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); - const response = await authAgent.get('/workflows?limit=1'); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(1); - expect(response.body.nextCursor).not.toBeNull(); + expect(response.statusCode).toBe(200); - const response2 = await authAgent.get(`/workflows?limit=1&cursor=${response.body.nextCursor}`); - - expect(response2.statusCode).toBe(200); - expect(response2.body.data.length).toBe(1); - expect(response2.body.nextCursor).not.toBeNull(); - expect(response2.body.nextCursor).not.toBe(response.body.nextCursor); - - const responses = [...response.body.data, ...response2.body.data]; - - for (const workflow of responses) { - const { - id, - connections, - active, - staticData, - nodes, - settings, - name, - createdAt, - updatedAt, - tags, - } = workflow; - - expect(id).toBeDefined(); - expect(name).toBeDefined(); - expect(connections).toBeDefined(); - expect(active).toBe(false); - expect(staticData).toBeDefined(); - expect(nodes).toBeDefined(); - expect(tags).toBeDefined(); - expect(settings).toBeDefined(); - expect(createdAt).toBeDefined(); - expect(updatedAt).toBeDefined(); - } - - // check that we really received a different result - expect(Number(response.body.data[0].id)).toBeLessThan(Number(response2.body.data[0].id)); -}); - -test('GET /workflows should return all owned workflows filtered by tag', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - const tag = await testDb.createTag({}); - - const [workflow] = await Promise.all([ - testDb.createWorkflow({ tags: [tag] }, member), - testDb.createWorkflow({}, member), - ]); - - const response = await authAgent.get(`/workflows?tags=${tag.name}`); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(1); - - const { - id, - connections, - active, - staticData, - nodes, - settings, - name, - createdAt, - updatedAt, - tags: wfTags, - } = response.body.data[0]; - - expect(id).toBe(workflow.id); - expect(name).toBeDefined(); - expect(connections).toBeDefined(); - expect(active).toBe(false); - expect(staticData).toBeDefined(); - expect(nodes).toBeDefined(); - expect(settings).toBeDefined(); - expect(createdAt).toBeDefined(); - expect(updatedAt).toBeDefined(); - - expect(wfTags.length).toBe(1); - expect(wfTags[0].id).toBe(tag.id); -}); - -test('GET /workflows should return all owned workflows filtered by tags', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - const tags = await Promise.all([await testDb.createTag({}), await testDb.createTag({})]); - const tagNames = tags.map((tag) => tag.name).join(','); - - const [workflow1, workflow2] = await Promise.all([ - testDb.createWorkflow({ tags }, member), - testDb.createWorkflow({ tags }, member), - testDb.createWorkflow({}, member), - testDb.createWorkflow({ tags: [tags[0]] }, member), - testDb.createWorkflow({ tags: [tags[1]] }, member), - ]); - - const response = await authAgent.get(`/workflows?tags=${tagNames}`); - - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(2); - - for (const workflow of response.body.data) { const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflow; + response.body; - expect(id).toBeDefined(); - expect([workflow1.id, workflow2.id].includes(id)).toBe(true); - - expect(name).toBeDefined(); - expect(connections).toBeDefined(); + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); expect(active).toBe(false); - expect(staticData).toBeDefined(); - expect(nodes).toBeDefined(); - expect(settings).toBeDefined(); - expect(createdAt).toBeDefined(); - expect(updatedAt).toBeDefined(); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + }); +}); - expect(workflow.tags.length).toBe(2); - workflow.tags.forEach((tag: TagEntity) => { - expect(tags.some((savedTag) => savedTag.id === tag.id)).toBe(true); +describe('DELETE /workflows/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('delete', '/workflows/2', null)); + + test('should fail due to invalid API Key', testWithAPIKey('delete', '/workflows/2', 'abcXYZ')); + + test('should fail due to non-existing workflow', async () => { + const response = await authOwnerAgent.delete(`/workflows/2`); + expect(response.statusCode).toBe(404); + }); + + test('should delete the workflow', async () => { + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); + + const response = await authMemberAgent.delete(`/workflows/${workflow.id}`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // make sure the workflow actually deleted from the db + const sharedWorkflow = await Db.collections.SharedWorkflow.findOneBy({ + workflowId: workflow.id, }); - } -}); -test('GET /workflows should return all workflows for owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, + expect(sharedWorkflow).toBeNull(); }); - await Promise.all([ - testDb.createWorkflow({}, owner), - testDb.createWorkflow({}, member), - testDb.createWorkflow({}, owner), - testDb.createWorkflow({}, member), - testDb.createWorkflow({}, owner), - ]); + test('should delete non-owned workflow when owner', async () => { + // create and assign workflow to owner + const workflow = await testDb.createWorkflow({}, member); - const response = await authOwnerAgent.get('/workflows'); + const response = await authMemberAgent.delete(`/workflows/${workflow.id}`); - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(5); - expect(response.body.nextCursor).toBeNull(); + expect(response.statusCode).toBe(200); - for (const workflow of response.body.data) { - const { - id, - connections, - active, - staticData, - nodes, - settings, - name, - createdAt, - updatedAt, - tags, - } = workflow; + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; - expect(id).toBeDefined(); - expect(name).toBeDefined(); - expect(connections).toBeDefined(); + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); expect(active).toBe(false); - expect(staticData).toBeDefined(); - expect(nodes).toBeDefined(); - expect(tags).toBeDefined(); - expect(settings).toBeDefined(); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // make sure the workflow actually deleted from the db + const sharedWorkflow = await Db.collections.SharedWorkflow.findOneBy({ + workflowId: workflow.id, + }); + + expect(sharedWorkflow).toBeNull(); + }); +}); + +describe('POST /workflows/:id/activate', () => { + test('should fail due to missing API Key', testWithAPIKey('post', '/workflows/2/activate', null)); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('post', '/workflows/2/activate', 'abcXYZ'), + ); + + test('should fail due to non-existing workflow', async () => { + const response = await authOwnerAgent.post(`/workflows/2/activate`); + expect(response.statusCode).toBe(404); + }); + + test('should fail due to trying to activate a workflow without a trigger', async () => { + const workflow = await testDb.createWorkflow({}, owner); + const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`); + expect(response.statusCode).toBe(400); + }); + + test('should set workflow as active', async () => { + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(true); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // check whether the workflow is on the database + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.active).toBe(true); + + // check whether the workflow is on the active workflow runner + expect(await workflowRunner.isActive(workflow.id)).toBe(true); + }); + + test('should set non-owned workflow as active when owner', async () => { + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + const response = await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + expect(response.statusCode).toBe(200); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + response.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(true); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toEqual(workflow.createdAt.toISOString()); + expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); + + // check whether the workflow is on the database + const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: owner.id, + workflowId: workflow.id, + }, + }); + + expect(sharedOwnerWorkflow).toBeNull(); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.active).toBe(true); + + // check whether the workflow is on the active workflow runner + expect(await workflowRunner.isActive(workflow.id)).toBe(true); + }); +}); + +describe('POST /workflows/:id/deactivate', () => { + test( + 'should fail due to missing API Key', + testWithAPIKey('post', '/workflows/2/deactivate', null), + ); + + test( + 'should fail due to invalid API Key', + testWithAPIKey('post', '/workflows/2/deactivate', 'abcXYZ'), + ); + + test('should fail due to non-existing workflow', async () => { + const response = await authOwnerAgent.post(`/workflows/2/deactivate`); + expect(response.statusCode).toBe(404); + }); + + test('should deactivate workflow', async () => { + const workflow = await testDb.createWorkflowWithTrigger({}, member); + + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); + + const workflowDeactivationResponse = await authMemberAgent.post( + `/workflows/${workflow.id}/deactivate`, + ); + + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + workflowDeactivationResponse.body; + + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); expect(createdAt).toBeDefined(); expect(updatedAt).toBeDefined(); - } -}); -test('GET /workflows/:id should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - owner.apiKey = null; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.get(`/workflows/2`); - - expect(response.statusCode).toBe(401); -}); - -test('GET /workflows/:id should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.get(`/workflows/2`); - - expect(response.statusCode).toBe(401); -}); - -test('GET /workflows/:id should fail due to non-existing workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.get(`/workflows/2`); - - expect(response.statusCode).toBe(404); -}); - -test('GET /workflows/:id should retrieve workflow', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - // create and assign workflow to owner - const workflow = await testDb.createWorkflow({}, member); - - const response = await authAgent.get(`/workflows/${workflow.id}`); - - expect(response.statusCode).toBe(200); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt, tags } = - response.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(false); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(tags).toEqual([]); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toEqual(workflow.createdAt.toISOString()); - expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); -}); - -test('GET /workflows/:id should retrieve non-owned workflow for owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - // create and assign workflow to owner - const workflow = await testDb.createWorkflow({}, member); - - const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); - - expect(response.statusCode).toBe(200); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(false); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toEqual(workflow.createdAt.toISOString()); - expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); -}); - -test('DELETE /workflows/:id should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.delete(`/workflows/2`); - - expect(response.statusCode).toBe(401); -}); - -test('DELETE /workflows/:id should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.delete(`/workflows/2`); - - expect(response.statusCode).toBe(401); -}); - -test('DELETE /workflows/:id should fail due to non-existing workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.delete(`/workflows/2`); - - expect(response.statusCode).toBe(404); -}); - -test('DELETE /workflows/:id should delete the workflow', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - // create and assign workflow to owner - const workflow = await testDb.createWorkflow({}, member); - - const response = await authAgent.delete(`/workflows/${workflow.id}`); - - expect(response.statusCode).toBe(200); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(false); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toEqual(workflow.createdAt.toISOString()); - expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); - - // make sure the workflow actually deleted from the db - const sharedWorkflow = await Db.collections.SharedWorkflow.findOneBy({ - workflowId: workflow.id, - }); - - expect(sharedWorkflow).toBeNull(); -}); - -test('DELETE /workflows/:id should delete non-owned workflow when owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - // create and assign workflow to owner - const workflow = await testDb.createWorkflow({}, member); - - const response = await authAgent.delete(`/workflows/${workflow.id}`); - - expect(response.statusCode).toBe(200); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(false); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toEqual(workflow.createdAt.toISOString()); - expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); - - // make sure the workflow actually deleted from the db - const sharedWorkflow = await Db.collections.SharedWorkflow.findOneBy({ - workflowId: workflow.id, - }); - - expect(sharedWorkflow).toBeNull(); -}); - -test('POST /workflows/:id/activate should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post(`/workflows/2/activate`); - - expect(response.statusCode).toBe(401); -}); - -test('POST /workflows/:id/activate should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post(`/workflows/2/activate`); - - expect(response.statusCode).toBe(401); -}); - -test('POST /workflows/:id/activate should fail due to non-existing workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post(`/workflows/2/activate`); - - expect(response.statusCode).toBe(404); -}); - -test('POST /workflows/:id/activate should fail due to trying to activate a workflow without a trigger', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflow({}, owner); - - const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`); - - expect(response.statusCode).toBe(400); -}); - -test('POST /workflows/:id/activate should set workflow as active', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - const workflow = await testDb.createWorkflowWithTrigger({}, member); - - const response = await authAgent.post(`/workflows/${workflow.id}/activate`); - - expect(response.statusCode).toBe(200); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(true); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toEqual(workflow.createdAt.toISOString()); - expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); - - // check whether the workflow is on the database - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: workflow.id, - }, - relations: ['workflow'], - }); - - expect(sharedWorkflow?.workflow.active).toBe(true); - - // check whether the workflow is on the active workflow runner - expect(await workflowRunner.isActive(workflow.id)).toBe(true); -}); - -test('POST /workflows/:id/activate should set non-owned workflow as active when owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflowWithTrigger({}, member); - - const response = await authAgent.post(`/workflows/${workflow.id}/activate`); - - expect(response.statusCode).toBe(200); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - response.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(true); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toEqual(workflow.createdAt.toISOString()); - expect(updatedAt).toEqual(workflow.updatedAt.toISOString()); - - // check whether the workflow is on the database - const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: owner.id, - workflowId: workflow.id, - }, - }); - - expect(sharedOwnerWorkflow).toBeNull(); - - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: workflow.id, - }, - relations: ['workflow'], - }); - - expect(sharedWorkflow?.workflow.active).toBe(true); - - // check whether the workflow is on the active workflow runner - expect(await workflowRunner.isActive(workflow.id)).toBe(true); -}); - -test('POST /workflows/:id/deactivate should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post(`/workflows/2/deactivate`); - - expect(response.statusCode).toBe(401); -}); - -test('POST /workflows/:id/deactivate should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post(`/workflows/2/deactivate`); - - expect(response.statusCode).toBe(401); -}); - -test('POST /workflows/:id/deactivate should fail due to non-existing workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post(`/workflows/2/deactivate`); - - expect(response.statusCode).toBe(404); -}); - -test('POST /workflows/:id/deactivate should deactivate workflow', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - const workflow = await testDb.createWorkflowWithTrigger({}, member); - - await authAgent.post(`/workflows/${workflow.id}/activate`); - - const workflowDeactivationResponse = await authAgent.post(`/workflows/${workflow.id}/deactivate`); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflowDeactivationResponse.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(false); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toBeDefined(); - expect(updatedAt).toBeDefined(); - - // get the workflow after it was deactivated - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: workflow.id, - }, - relations: ['workflow'], - }); - - // check whether the workflow is deactivated in the database - expect(sharedWorkflow?.workflow.active).toBe(false); - - expect(await workflowRunner.isActive(workflow.id)).toBe(false); -}); - -test('POST /workflows/:id/deactivate should deactivate non-owned workflow when owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const workflow = await testDb.createWorkflowWithTrigger({}, member); - - await authAgent.post(`/workflows/${workflow.id}/activate`); - - const workflowDeactivationResponse = await authAgent.post(`/workflows/${workflow.id}/deactivate`); - - const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = - workflowDeactivationResponse.body; - - expect(id).toEqual(workflow.id); - expect(name).toEqual(workflow.name); - expect(connections).toEqual(workflow.connections); - expect(active).toBe(false); - expect(staticData).toEqual(workflow.staticData); - expect(nodes).toEqual(workflow.nodes); - expect(settings).toEqual(workflow.settings); - expect(createdAt).toBeDefined(); - expect(updatedAt).toBeDefined(); - - // check whether the workflow is deactivated in the database - const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: owner.id, - workflowId: workflow.id, - }, - }); - - expect(sharedOwnerWorkflow).toBeNull(); - - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: workflow.id, - }, - relations: ['workflow'], - }); - - expect(sharedWorkflow?.workflow.active).toBe(false); - - expect(await workflowRunner.isActive(workflow.id)).toBe(false); -}); - -test('POST /workflows should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post('/workflows'); - - expect(response.statusCode).toBe(401); -}); - -test('POST /workflows should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post('/workflows'); - - expect(response.statusCode).toBe(401); -}); - -test('POST /workflows should fail due to invalid body', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.post('/workflows').send({}); - - expect(response.statusCode).toBe(400); -}); - -test('POST /workflows should create workflow', async () => { - const member = await testDb.createUser({ globalRole: globalMemberRole, apiKey: randomApiKey() }); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, - }); - - const payload = { - name: 'testing', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], + // get the workflow after it was deactivated + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: workflow.id, }, - ], - connections: {}, - staticData: null, - settings: { - saveExecutionProgress: true, - saveManualExecutions: true, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; + relations: ['workflow'], + }); - const response = await authAgent.post('/workflows').send(payload); + // check whether the workflow is deactivated in the database + expect(sharedWorkflow?.workflow.active).toBe(false); - expect(response.statusCode).toBe(200); - - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; - - expect(id).toBeDefined(); - expect(name).toBe(payload.name); - expect(connections).toEqual(payload.connections); - expect(settings).toEqual(payload.settings); - expect(staticData).toEqual(payload.staticData); - expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); - expect(createdAt).toBeDefined(); - expect(updatedAt).toEqual(createdAt); - - // check if created workflow in DB - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: response.body.id, - }, - relations: ['workflow', 'role'], + expect(await workflowRunner.isActive(workflow.id)).toBe(false); }); - expect(sharedWorkflow?.workflow.name).toBe(name); - expect(sharedWorkflow?.workflow.createdAt.toISOString()).toBe(createdAt); - expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); -}); + test('should deactivate non-owned workflow when owner', async () => { + const workflow = await testDb.createWorkflowWithTrigger({}, member); -test('PUT /workflows/:id should fail due to missing API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); + await authMemberAgent.post(`/workflows/${workflow.id}/activate`); - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); + const workflowDeactivationResponse = await authMemberAgent.post( + `/workflows/${workflow.id}/deactivate`, + ); - const response = await authOwnerAgent.put(`/workflows/1`); + const { id, connections, active, staticData, nodes, settings, name, createdAt, updatedAt } = + workflowDeactivationResponse.body; - expect(response.statusCode).toBe(401); -}); + expect(id).toEqual(workflow.id); + expect(name).toEqual(workflow.name); + expect(connections).toEqual(workflow.connections); + expect(active).toBe(false); + expect(staticData).toEqual(workflow.staticData); + expect(nodes).toEqual(workflow.nodes); + expect(settings).toEqual(workflow.settings); + expect(createdAt).toBeDefined(); + expect(updatedAt).toBeDefined(); -test('PUT /workflows/:id should fail due to invalid API Key', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - owner.apiKey = 'abcXYZ'; - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.put(`/workflows/1`).send({}); - - expect(response.statusCode).toBe(401); -}); - -test('PUT /workflows/:id should fail due to non-existing workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const response = await authOwnerAgent.put(`/workflows/1`).send({ - name: 'testing', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], + // check whether the workflow is deactivated in the database + const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: owner.id, + workflowId: workflow.id, }, - ], - connections: {}, - staticData: null, - settings: { - saveExecutionProgress: true, - saveManualExecutions: true, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }); + }); - expect(response.statusCode).toBe(404); + expect(sharedOwnerWorkflow).toBeNull(); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: workflow.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.active).toBe(false); + + expect(await workflowRunner.isActive(workflow.id)).toBe(false); + }); }); -test('PUT /workflows/:id should fail due to invalid body', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); +describe('POST /workflows', () => { + test('should fail due to missing API Key', testWithAPIKey('post', '/workflows', null)); - const authOwnerAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, + test('should fail due to invalid API Key', testWithAPIKey('post', '/workflows', 'abcXYZ')); + + test('should fail due to invalid body', async () => { + const response = await authOwnerAgent.post('/workflows').send({}); + expect(response.statusCode).toBe(400); }); - const response = await authOwnerAgent.put(`/workflows/1`).send({ - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], + test('should create workflow', async () => { + const payload = { + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', }, - ], - connections: {}, - staticData: null, - settings: { - saveExecutionProgress: true, - saveManualExecutions: true, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }); + }; - expect(response.statusCode).toBe(400); + const response = await authMemberAgent.post('/workflows').send(payload); + + expect(response.statusCode).toBe(200); + + const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = + response.body; + + expect(id).toBeDefined(); + expect(name).toBe(payload.name); + expect(connections).toEqual(payload.connections); + expect(settings).toEqual(payload.settings); + expect(staticData).toEqual(payload.staticData); + expect(nodes).toEqual(payload.nodes); + expect(active).toBe(false); + expect(createdAt).toBeDefined(); + expect(updatedAt).toEqual(createdAt); + + // check if created workflow in DB + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: response.body.id, + }, + relations: ['workflow', 'role'], + }); + + expect(sharedWorkflow?.workflow.name).toBe(name); + expect(sharedWorkflow?.workflow.createdAt.toISOString()).toBe(createdAt); + expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); + }); }); -test('PUT /workflows/:id should update workflow', async () => { - const member = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); +describe('PUT /workflows/:id', () => { + test('should fail due to missing API Key', testWithAPIKey('put', '/workflows/1', null)); - const workflow = await testDb.createWorkflow({}, member); + test('should fail due to invalid API Key', testWithAPIKey('put', '/workflows/1', 'abcXYZ')); - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: member, - version: 1, + test('should fail due to non-existing workflow', async () => { + const response = await authOwnerAgent.put(`/workflows/1`).send({ + name: 'testing', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }); + + expect(response.statusCode).toBe(404); }); - const payload = { - name: 'name updated', - nodes: [ - { - id: 'uuid-1234', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], + test('should fail due to invalid body', async () => { + const response = await authOwnerAgent.put(`/workflows/1`).send({ + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + ], + connections: {}, + staticData: null, + settings: { + saveExecutionProgress: true, + saveManualExecutions: true, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', }, - { - id: 'uuid-1234', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], - }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; + }); - const response = await authAgent.put(`/workflows/${workflow.id}`).send(payload); - - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; - - expect(response.statusCode).toBe(200); - - expect(id).toBe(workflow.id); - expect(name).toBe(payload.name); - expect(connections).toEqual(payload.connections); - expect(settings).toEqual(payload.settings); - expect(staticData).toMatchObject(JSON.parse(payload.staticData)); - expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); - expect(createdAt).toBe(workflow.createdAt.toISOString()); - expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); - - // check updated workflow in DB - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: response.body.id, - }, - relations: ['workflow'], + expect(response.statusCode).toBe(400); }); - expect(sharedWorkflow?.workflow.name).toBe(payload.name); - expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( - workflow.updatedAt.getTime(), - ); -}); - -test('PUT /workflows/:id should update non-owned workflow if owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole, apiKey: randomApiKey() }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const workflow = await testDb.createWorkflow({}, member); - - const authAgent = utils.createAgent(app, { - apiPath: 'public', - auth: true, - user: owner, - version: 1, - }); - - const payload = { - name: 'name owner updated', - nodes: [ - { - id: 'uuid-1', - parameters: {}, - name: 'Start', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [240, 300], - }, - { - id: 'uuid-2', - parameters: {}, - name: 'Cron', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [400, 300], - }, - ], - connections: {}, - staticData: '{"id":1}', - settings: { - saveExecutionProgress: false, - saveManualExecutions: false, - saveDataErrorExecution: 'all', - saveDataSuccessExecution: 'all', - executionTimeout: 3600, - timezone: 'America/New_York', - }, - }; - - const response = await authAgent.put(`/workflows/${workflow.id}`).send(payload); - - const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = - response.body; - - expect(response.statusCode).toBe(200); - - expect(id).toBe(workflow.id); - expect(name).toBe(payload.name); - expect(connections).toEqual(payload.connections); - expect(settings).toEqual(payload.settings); - expect(staticData).toMatchObject(JSON.parse(payload.staticData)); - expect(nodes).toEqual(payload.nodes); - expect(active).toBe(false); - expect(createdAt).toBe(workflow.createdAt.toISOString()); - expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); - - // check updated workflow in DB - const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: owner.id, - workflowId: response.body.id, - }, - }); - - expect(sharedOwnerWorkflow).toBeNull(); - - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - where: { - userId: member.id, - workflowId: response.body.id, - }, - relations: ['workflow', 'role'], - }); - - expect(sharedWorkflow?.workflow.name).toBe(payload.name); - expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( - workflow.updatedAt.getTime(), - ); - expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); + test('should update workflow', async () => { + const workflow = await testDb.createWorkflow({}, member); + const payload = { + name: 'name updated', + nodes: [ + { + id: 'uuid-1234', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-1234', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = + response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect(name).toBe(payload.name); + expect(connections).toEqual(payload.connections); + expect(settings).toEqual(payload.settings); + expect(staticData).toMatchObject(JSON.parse(payload.staticData)); + expect(nodes).toEqual(payload.nodes); + expect(active).toBe(false); + expect(createdAt).toBe(workflow.createdAt.toISOString()); + expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); + + // check updated workflow in DB + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: response.body.id, + }, + relations: ['workflow'], + }); + + expect(sharedWorkflow?.workflow.name).toBe(payload.name); + expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( + workflow.updatedAt.getTime(), + ); + }); + + test('should update non-owned workflow if owner', async () => { + const workflow = await testDb.createWorkflow({}, member); + + const payload = { + name: 'name owner updated', + nodes: [ + { + id: 'uuid-1', + parameters: {}, + name: 'Start', + type: 'n8n-nodes-base.start', + typeVersion: 1, + position: [240, 300], + }, + { + id: 'uuid-2', + parameters: {}, + name: 'Cron', + type: 'n8n-nodes-base.cron', + typeVersion: 1, + position: [400, 300], + }, + ], + connections: {}, + staticData: '{"id":1}', + settings: { + saveExecutionProgress: false, + saveManualExecutions: false, + saveDataErrorExecution: 'all', + saveDataSuccessExecution: 'all', + executionTimeout: 3600, + timezone: 'America/New_York', + }, + }; + + const response = await authMemberAgent.put(`/workflows/${workflow.id}`).send(payload); + + const { id, name, nodes, connections, staticData, active, settings, createdAt, updatedAt } = + response.body; + + expect(response.statusCode).toBe(200); + + expect(id).toBe(workflow.id); + expect(name).toBe(payload.name); + expect(connections).toEqual(payload.connections); + expect(settings).toEqual(payload.settings); + expect(staticData).toMatchObject(JSON.parse(payload.staticData)); + expect(nodes).toEqual(payload.nodes); + expect(active).toBe(false); + expect(createdAt).toBe(workflow.createdAt.toISOString()); + expect(updatedAt).not.toBe(workflow.updatedAt.toISOString()); + + // check updated workflow in DB + const sharedOwnerWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: owner.id, + workflowId: response.body.id, + }, + }); + + expect(sharedOwnerWorkflow).toBeNull(); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + where: { + userId: member.id, + workflowId: response.body.id, + }, + relations: ['workflow', 'role'], + }); + + expect(sharedWorkflow?.workflow.name).toBe(payload.name); + expect(sharedWorkflow?.workflow.updatedAt.getTime()).toBeGreaterThan( + workflow.updatedAt.getTime(), + ); + expect(sharedWorkflow?.role).toEqual(workflowOwnerRole); + }); }); diff --git a/packages/cli/test/integration/saml/saml.api.test.ts b/packages/cli/test/integration/saml/saml.api.test.ts index 02176989f1..72410c56be 100644 --- a/packages/cli/test/integration/saml/saml.api.test.ts +++ b/packages/cli/test/integration/saml/saml.api.test.ts @@ -1,78 +1,136 @@ -import express from 'express'; - +import type { SuperAgentTest } from 'supertest'; import config from '@/config'; -import type { Role } from '@db/entities/Role'; +import type { User } from '@db/entities/User'; +import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers'; +import { getCurrentAuthenticationMethod, setCurrentAuthenticationMethod } from '@/sso/ssoHelpers'; import { randomEmail, randomName, randomValidPassword } from '../shared/random'; import * as testDb from '../shared/testDb'; -import type { AuthAgent } from '../shared/types'; import * as utils from '../shared/utils'; -import { setSamlLoginEnabled } from '../../../src/sso/saml/samlHelpers'; -import { setCurrentAuthenticationMethod } from '../../../src/sso/ssoHelpers'; +import { sampleConfig } from './sampleMetadata'; +import Container from 'typedi'; +import { License } from '../../../src/License'; -let app: express.Application; -let globalOwnerRole: Role; -let globalMemberRole: Role; -let authAgent: AuthAgent; +let owner: User; +let authOwnerAgent: SuperAgentTest; -function enableSaml(enable: boolean) { - setSamlLoginEnabled(enable); - setCurrentAuthenticationMethod(enable ? 'saml' : 'email'); - config.set('enterprise.features.saml', enable); +async function enableSaml(enable: boolean) { + await setSamlLoginEnabled(enable); } beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['me'] }); - - globalOwnerRole = await testDb.getGlobalOwnerRole(); - globalMemberRole = await testDb.getGlobalMemberRole(); - - authAgent = utils.createAuthAgent(app); -}); - -beforeEach(async () => { - await testDb.truncate(['User']); + Container.get(License).isSamlEnabled = () => true; + const app = await utils.initTestServer({ endpointGroups: ['me', 'saml'] }); + owner = await testDb.createOwner(); + authOwnerAgent = utils.createAuthAgent(app)(owner); }); afterAll(async () => { + Container.reset(); await testDb.terminate(); }); -describe('Owner shell', () => { - test('PATCH /me should succeed with valid inputs', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); - const response = await authOwnerShellAgent.patch('/me').send({ - email: randomEmail(), - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), +describe('Instance owner', () => { + describe('PATCH /me', () => { + test('should succeed with valid inputs', async () => { + await enableSaml(false); + await authOwnerAgent + .patch('/me') + .send({ + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }) + .expect(200); + }); + + test('should throw BadRequestError if email is changed when SAML is enabled', async () => { + await enableSaml(true); + await authOwnerAgent + .patch('/me') + .send({ + email: randomEmail(), + firstName: randomName(), + lastName: randomName(), + }) + .expect(400, { code: 400, message: 'SAML user may not change their email' }); }); - expect(response.statusCode).toBe(200); }); - test('PATCH /me should throw BadRequestError if email is changed when SAML is enabled', async () => { - enableSaml(true); - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); - const response = await authOwnerShellAgent.patch('/me').send({ - email: randomEmail(), - firstName: randomName(), - lastName: randomName(), + describe('PATCH /password', () => { + test('should throw BadRequestError if password is changed when SAML is enabled', async () => { + await enableSaml(true); + await authOwnerAgent + .patch('/me/password') + .send({ + password: randomValidPassword(), + }) + .expect(400, { + code: 400, + message: 'With SAML enabled, users need to use their SAML provider to change passwords', + }); }); - expect(response.statusCode).toBe(400); - expect(response.body.message).toContain('SAML'); - enableSaml(false); }); - test('PATCH /password should throw BadRequestError if password is changed when SAML is enabled', async () => { - enableSaml(true); - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerShellAgent = authAgent(ownerShell); - const response = await authOwnerShellAgent.patch('/me/password').send({ - password: randomValidPassword(), + describe('POST /sso/saml/config', () => { + test('should post saml config', async () => { + await authOwnerAgent + .post('/sso/saml/config') + .send({ + ...sampleConfig, + loginEnabled: true, + }) + .expect(200); + expect(getCurrentAuthenticationMethod()).toBe('saml'); + }); + }); + + describe('POST /sso/saml/config/toggle', () => { + test('should toggle saml as default authentication method', async () => { + await enableSaml(true); + expect(getCurrentAuthenticationMethod()).toBe('saml'); + + await authOwnerAgent + .post('/sso/saml/config/toggle') + .send({ + loginEnabled: false, + }) + .expect(200); + expect(getCurrentAuthenticationMethod()).toBe('email'); + + await authOwnerAgent + .post('/sso/saml/config/toggle') + .send({ + loginEnabled: true, + }) + .expect(200); + expect(getCurrentAuthenticationMethod()).toBe('saml'); + }); + }); + + describe('POST /sso/saml/config/toggle', () => { + test('should fail enable saml if default authentication is not email', async () => { + await enableSaml(true); + + await authOwnerAgent + .post('/sso/saml/config/toggle') + .send({ + loginEnabled: false, + }) + .expect(200); + expect(getCurrentAuthenticationMethod()).toBe('email'); + + await setCurrentAuthenticationMethod('ldap'); + expect(getCurrentAuthenticationMethod()).toBe('ldap'); + + await authOwnerAgent + .post('/sso/saml/config/toggle') + .send({ + loginEnabled: true, + }) + .expect(200); + + expect(getCurrentAuthenticationMethod()).toBe('ldap'); }); - expect(response.statusCode).toBe(400); - expect(response.body.message).toContain('SAML'); - enableSaml(false); }); }); diff --git a/packages/cli/test/integration/saml/sampleMetadata.ts b/packages/cli/test/integration/saml/sampleMetadata.ts new file mode 100644 index 0000000000..fd7968c2fb --- /dev/null +++ b/packages/cli/test/integration/saml/sampleMetadata.ts @@ -0,0 +1,30 @@ +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 = { + mapping: { + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/firstname', + lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/lastname', + userPrincipalName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + }, + metadata: sampleMetadata, + metadataUrl: '', + ignoreSSL: true, + loginBinding: 'redirect', + acsBinding: 'post', + authnRequestsSigned: false, + loginEnabled: false, + loginLabel: 'SAML Login', + wantAssertionsSigned: true, + wantMessageSigned: true, + signatureConfig: { + prefix: 'ds', + location: { + reference: '/samlp:Response/saml:Issuer', + action: 'after', + }, + }, + entityID: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/metadata', + returnUrl: 'https://n8n-tunnel.localhost.dev/rest/sso/saml/acs', +}; diff --git a/packages/cli/test/integration/shared/augmentation.d.ts b/packages/cli/test/integration/shared/augmentation.d.ts index ecd3ec8e13..4dfa538e9a 100644 --- a/packages/cli/test/integration/shared/augmentation.d.ts +++ b/packages/cli/test/integration/shared/augmentation.d.ts @@ -1,5 +1,4 @@ import superagent = require('superagent'); -import type { ObjectLiteral } from 'typeorm'; /** * Make `SuperTest` string-indexable. diff --git a/packages/cli/test/integration/shared/testDb.ts b/packages/cli/test/integration/shared/testDb.ts index 4b26eb1625..c7fb165b3d 100644 --- a/packages/cli/test/integration/shared/testDb.ts +++ b/packages/cli/test/integration/shared/testDb.ts @@ -172,7 +172,7 @@ export async function createUser(attributes: Partial = {}): Promise password: await hashPassword(password ?? randomValidPassword()), firstName: firstName ?? randomName(), lastName: lastName ?? randomName(), - globalRole: globalRole ?? (await getGlobalMemberRole()), + globalRoleId: (globalRole ?? (await getGlobalMemberRole())).id, ...rest, }; diff --git a/packages/cli/test/integration/shared/types.d.ts b/packages/cli/test/integration/shared/types.d.ts index bd73989758..97bb13843d 100644 --- a/packages/cli/test/integration/shared/types.d.ts +++ b/packages/cli/test/integration/shared/types.d.ts @@ -22,6 +22,7 @@ type EndpointGroup = | 'publicApi' | 'nodes' | 'ldap' + | 'saml' | 'eventBus' | 'license'; diff --git a/packages/cli/test/integration/shared/utils.ts b/packages/cli/test/integration/shared/utils.ts index ba28f16732..351d6bf3b4 100644 --- a/packages/cli/test/integration/shared/utils.ts +++ b/packages/cli/test/integration/shared/utils.ts @@ -9,13 +9,11 @@ import set from 'lodash.set'; import { BinaryDataManager, UserSettings } from 'n8n-core'; import { ICredentialType, - ICredentialTypes, IDataObject, IExecuteFunctions, INode, INodeExecutionData, INodeParameters, - INodesAndCredentials, ITriggerFunctions, ITriggerResponse, LoggerProxy, @@ -31,11 +29,8 @@ import { DeepPartial } from 'ts-essentials'; import config from '@/config'; import * as Db from '@/Db'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; -import { CredentialTypes } from '@/CredentialTypes'; import { ExternalHooks } from '@/ExternalHooks'; -import { NodeTypes } from '@/NodeTypes'; import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; -import { nodesController } from '@/api/nodes.api'; import { workflowsController } from '@/workflows/workflows.controller'; import { AUTH_COOKIE_NAME, NODE_PACKAGE_PREFIX } from '@/constants'; import { credentialsController } from '@/credentials/credentials.controller'; @@ -44,7 +39,7 @@ import type { User } from '@db/entities/User'; import { getLogger } from '@/Logger'; import { loadPublicApiVersions } from '@/PublicApi/'; import { issueJWT } from '@/auth/jwt'; -import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; +import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer'; import { AUTHLESS_ENDPOINTS, COMMUNITY_NODE_VERSION, @@ -61,11 +56,12 @@ import type { PostgresSchemaSection, } from './types'; import { licenseController } from '@/license/license.controller'; -import { eventBusRouter } from '@/eventbus/eventBusRoutes'; import { registerController } from '@/decorators'; import { AuthController, + LdapController, MeController, + NodesController, OwnerController, PasswordResetController, UsersController, @@ -74,11 +70,17 @@ import { setupAuthMiddlewares } from '@/middlewares'; import * as testDb from '../shared/testDb'; import { v4 as uuid } from 'uuid'; -import { handleLdapInit } from '@/Ldap/helpers'; -import { ldapController } from '@/Ldap/routes/ldap.controller.ee'; import { InternalHooks } from '@/InternalHooks'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { PostHogClient } from '@/posthog'; +import { LdapManager } from '@/Ldap/LdapManager.ee'; +import { handleLdapInit } from '@/Ldap/helpers'; +import { Push } from '@/push'; +import { setSamlLoginEnabled } from '@/sso/saml/samlHelpers'; +import { SamlService } from '@/sso/saml/saml.service.ee'; +import { SamlController } from '@/sso/saml/routes/saml.controller.ee'; +import { EventBusController } from '@/eventbus/eventBus.controller'; +import { License } from '../../../src/License'; export const mockInstance = ( ctor: new (...args: any[]) => T, @@ -89,13 +91,6 @@ export const mockInstance = ( return instance; }; -const loadNodesAndCredentials: INodesAndCredentials = { - loaded: { nodes: {}, credentials: {} }, - known: { nodes: {}, credentials: {} }, - credentialTypes: {} as ICredentialTypes, -}; -Container.set(LoadNodesAndCredentials, loadNodesAndCredentials); - /** * Initialize a test server. */ @@ -155,10 +150,7 @@ export async function initTestServer({ const map: Record = { credentials: { controller: credentialsController, path: 'credentials' }, workflows: { controller: workflowsController, path: 'workflows' }, - nodes: { controller: nodesController, path: 'nodes' }, license: { controller: licenseController, path: 'license' }, - eventBus: { controller: eventBusRouter, path: 'eventbus' }, - ldap: { controller: ldapController, path: 'ldap' }, }; if (enablePublicAPI) { @@ -178,11 +170,14 @@ export async function initTestServer({ if (functionEndpoints.length) { const externalHooks = Container.get(ExternalHooks); const internalHooks = Container.get(InternalHooks); - const mailer = UserManagementMailer.getInstance(); + const mailer = Container.get(UserManagementMailer); const repositories = Db.collections; for (const group of functionEndpoints) { switch (group) { + case 'eventBus': + registerController(testServer.app, config, new EventBusController()); + break; case 'auth': registerController( testServer.app, @@ -190,6 +185,32 @@ export async function initTestServer({ new AuthController({ config, logger, internalHooks, repositories }), ); break; + case 'ldap': + Container.get(License).isLdapEnabled = () => true; + await handleLdapInit(); + const { service, sync } = LdapManager.getInstance(); + registerController( + testServer.app, + config, + new LdapController(service, sync, internalHooks), + ); + break; + case 'saml': + await setSamlLoginEnabled(true); + const samlService = Container.get(SamlService); + registerController(testServer.app, config, new SamlController(samlService)); + break; + case 'nodes': + registerController( + testServer.app, + config, + new NodesController( + config, + Container.get(LoadNodesAndCredentials), + Container.get(Push), + internalHooks, + ), + ); case 'me': registerController( testServer.app, @@ -206,6 +227,7 @@ export async function initTestServer({ logger, externalHooks, internalHooks, + mailer, repositories, }), ); @@ -246,15 +268,7 @@ const classifyEndpointGroups = (endpointGroups: EndpointGroup[]) => { const routerEndpoints: EndpointGroup[] = []; const functionEndpoints: EndpointGroup[] = []; - const ROUTER_GROUP = [ - 'credentials', - 'nodes', - 'workflows', - 'publicApi', - 'ldap', - 'eventBus', - 'license', - ]; + const ROUTER_GROUP = ['credentials', 'workflows', 'publicApi', 'license']; endpointGroups.forEach((group) => (ROUTER_GROUP.includes(group) ? routerEndpoints : functionEndpoints).push(group), @@ -320,13 +334,6 @@ export async function initCredentialsTypes(): Promise { }; } -/** - * Initialize LDAP manager. - */ -export async function initLdapManager(): Promise { - await handleLdapInit(); -} - /** * Initialize node types. */ @@ -734,6 +741,15 @@ export async function isInstanceOwnerSetUp() { return Boolean(value); } +export const setInstanceOwnerSetUp = async (value: boolean) => { + config.set('userManagement.isInstanceOwnerSetUp', value); + + await Db.collections.Settings.update( + { key: 'userManagement.isInstanceOwnerSetUp' }, + { value: JSON.stringify(value) }, + ); +}; + // ---------------------------------- // misc // ---------------------------------- diff --git a/packages/cli/test/integration/users.api.test.ts b/packages/cli/test/integration/users.api.test.ts index 28f36c4364..759d12b8cb 100644 --- a/packages/cli/test/integration/users.api.test.ts +++ b/packages/cli/test/integration/users.api.test.ts @@ -1,5 +1,6 @@ -import express from 'express'; import validator from 'validator'; +import { Not } from 'typeorm'; +import type { SuperAgentTest } from 'supertest'; import config from '@/config'; import * as Db from '@/Db'; @@ -8,6 +9,9 @@ import type { Role } from '@db/entities/Role'; import type { User } from '@db/entities/User'; import { WorkflowEntity } from '@db/entities/WorkflowEntity'; import { compareHash } from '@/UserManagement/UserManagementHelper'; +import { UserManagementMailer } from '@/UserManagement/email/UserManagementMailer'; +import { NodeMailer } from '@/UserManagement/email/NodeMailer'; + import { SUCCESS_RESPONSE_BODY } from './shared/constants'; import { randomCredentialPayload, @@ -17,41 +21,40 @@ import { randomValidPassword, } from './shared/random'; import * as testDb from './shared/testDb'; -import type { AuthAgent } from './shared/types'; import * as utils from './shared/utils'; -import * as UserManagementMailer from '@/UserManagement/email/UserManagementMailer'; -import { NodeMailer } from '@/UserManagement/email/NodeMailer'; - jest.mock('@/UserManagement/email/NodeMailer'); -let app: express.Application; let globalMemberRole: Role; -let globalOwnerRole: Role; let workflowOwnerRole: Role; let credentialOwnerRole: Role; -let authAgent: AuthAgent; +let owner: User; +let authlessAgent: SuperAgentTest; +let authOwnerAgent: SuperAgentTest; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['users'] }); + const app = await utils.initTestServer({ endpointGroups: ['users'] }); const [ - fetchedGlobalOwnerRole, + globalOwnerRole, fetchedGlobalMemberRole, fetchedWorkflowOwnerRole, fetchedCredentialOwnerRole, ] = await testDb.getAllRoles(); - globalOwnerRole = fetchedGlobalOwnerRole; globalMemberRole = fetchedGlobalMemberRole; workflowOwnerRole = fetchedWorkflowOwnerRole; credentialOwnerRole = fetchedCredentialOwnerRole; - authAgent = utils.createAuthAgent(app); + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + + authlessAgent = utils.createAgent(app); + authOwnerAgent = utils.createAuthAgent(app)(owner); }); beforeEach(async () => { - await testDb.truncate(['User', 'SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials']); + await testDb.truncate(['SharedCredentials', 'SharedWorkflow', 'Workflow', 'Credentials']); + await Db.collections.User.delete({ id: Not(owner.id) }); jest.mock('@/config'); @@ -65,18 +68,16 @@ afterAll(async () => { await testDb.terminate(); }); -test('GET /users should return all users', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +describe('GET /users', () => { + test('should return all users', async () => { + await testDb.createUser({ globalRole: globalMemberRole }); - await testDb.createUser({ globalRole: globalMemberRole }); + const response = await authOwnerAgent.get('/users'); - const response = await authAgent(owner).get('/users'); + expect(response.statusCode).toBe(200); + expect(response.body.data.length).toBe(2); - expect(response.statusCode).toBe(200); - expect(response.body.data.length).toBe(2); - - await Promise.all( - response.body.data.map(async (user: User) => { + response.body.data.map((user: User) => { const { id, email, @@ -100,442 +101,421 @@ test('GET /users should return all users', async () => { expect(isPending).toBe(false); expect(globalRole).toBeDefined(); expect(apiKey).not.toBeDefined(); - }), - ); + }); + }); }); -test('DELETE /users/:id should delete the user', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +describe('DELETE /users/:id', () => { + test('should delete the user', async () => { + const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); - const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); + const newWorkflow = new WorkflowEntity(); - const newWorkflow = new WorkflowEntity(); + Object.assign(newWorkflow, { + name: randomName(), + active: false, + connections: {}, + nodes: [], + }); - Object.assign(newWorkflow, { - name: randomName(), - active: false, - connections: {}, - nodes: [], + const savedWorkflow = await Db.collections.Workflow.save(newWorkflow); + + await Db.collections.SharedWorkflow.save({ + role: workflowOwnerRole, + user: userToDelete, + workflow: savedWorkflow, + }); + + const newCredential = new CredentialsEntity(); + + Object.assign(newCredential, { + name: randomName(), + data: '', + type: '', + nodesAccess: [], + }); + + const savedCredential = await Db.collections.Credentials.save(newCredential); + + await Db.collections.SharedCredentials.save({ + role: credentialOwnerRole, + user: userToDelete, + credentials: savedCredential, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); + + const user = await Db.collections.User.findOneBy({ id: userToDelete.id }); + expect(user).toBeNull(); // deleted + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ + relations: ['user'], + where: { userId: userToDelete.id, roleId: workflowOwnerRole.id }, + }); + expect(sharedWorkflow).toBeNull(); // deleted + + const sharedCredential = await Db.collections.SharedCredentials.findOne({ + relations: ['user'], + where: { userId: userToDelete.id, roleId: credentialOwnerRole.id }, + }); + expect(sharedCredential).toBeNull(); // deleted + + const workflow = await Db.collections.Workflow.findOneBy({ id: savedWorkflow.id }); + expect(workflow).toBeNull(); // deleted + + // TODO: Include active workflow and check whether webhook has been removed + + const credential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); + expect(credential).toBeNull(); // deleted }); - const savedWorkflow = await Db.collections.Workflow.save(newWorkflow); + test('should fail to delete self', async () => { + const response = await authOwnerAgent.delete(`/users/${owner.id}`); - await Db.collections.SharedWorkflow.save({ - role: workflowOwnerRole, - user: userToDelete, - workflow: savedWorkflow, + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User.findOneBy({ id: owner.id }); + expect(user).toBeDefined(); }); - const newCredential = new CredentialsEntity(); + test('should fail if user to delete is transferee', async () => { + const { id: idToDelete } = await testDb.createUser({ globalRole: globalMemberRole }); - Object.assign(newCredential, { - name: randomName(), - data: '', - type: '', - nodesAccess: [], + const response = await authOwnerAgent.delete(`/users/${idToDelete}`).query({ + transferId: idToDelete, + }); + + expect(response.statusCode).toBe(400); + + const user = await Db.collections.User.findOneBy({ id: idToDelete }); + expect(user).toBeDefined(); }); - const savedCredential = await Db.collections.Credentials.save(newCredential); + test('with transferId should perform transfer', async () => { + const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); - await Db.collections.SharedCredentials.save({ - role: credentialOwnerRole, - user: userToDelete, - credentials: savedCredential, + const savedWorkflow = await testDb.createWorkflow(undefined, userToDelete); + + const savedCredential = await testDb.saveCredential(randomCredentialPayload(), { + user: userToDelete, + role: credentialOwnerRole, + }); + + const response = await authOwnerAgent.delete(`/users/${userToDelete.id}`).query({ + transferId: owner.id, + }); + + expect(response.statusCode).toBe(200); + + const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({ + relations: ['workflow'], + where: { userId: owner.id }, + }); + + expect(sharedWorkflow.workflow).toBeDefined(); + expect(sharedWorkflow.workflow.id).toBe(savedWorkflow.id); + + const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ + relations: ['credentials'], + where: { userId: owner.id }, + }); + + expect(sharedCredential.credentials).toBeDefined(); + expect(sharedCredential.credentials.id).toBe(savedCredential.id); + + const deletedUser = await Db.collections.User.findOneBy({ id: userToDelete.id }); + + expect(deletedUser).toBeNull(); }); - - const response = await authAgent(owner).delete(`/users/${userToDelete.id}`); - - expect(response.statusCode).toBe(200); - expect(response.body).toEqual(SUCCESS_RESPONSE_BODY); - - const user = await Db.collections.User.findOneBy({ id: userToDelete.id }); - expect(user).toBeNull(); // deleted - - const sharedWorkflow = await Db.collections.SharedWorkflow.findOne({ - relations: ['user'], - where: { userId: userToDelete.id, roleId: workflowOwnerRole.id }, - }); - expect(sharedWorkflow).toBeNull(); // deleted - - const sharedCredential = await Db.collections.SharedCredentials.findOne({ - relations: ['user'], - where: { userId: userToDelete.id, roleId: credentialOwnerRole.id }, - }); - expect(sharedCredential).toBeNull(); // deleted - - const workflow = await Db.collections.Workflow.findOneBy({ id: savedWorkflow.id }); - expect(workflow).toBeNull(); // deleted - - // TODO: Include active workflow and check whether webhook has been removed - - const credential = await Db.collections.Credentials.findOneBy({ id: savedCredential.id }); - expect(credential).toBeNull(); // deleted }); -test('DELETE /users/:id should fail to delete self', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +describe('POST /users/:id', () => { + test('should fill out a user shell', async () => { + const memberShell = await testDb.createUserShell(globalMemberRole); - const response = await authAgent(owner).delete(`/users/${owner.id}`); - - expect(response.statusCode).toBe(400); - - const user = await Db.collections.User.findOneBy({ id: owner.id }); - expect(user).toBeDefined(); -}); - -test('DELETE /users/:id should fail if user to delete is transferee', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const { id: idToDelete } = await testDb.createUser({ globalRole: globalMemberRole }); - - const response = await authAgent(owner).delete(`/users/${idToDelete}`).query({ - transferId: idToDelete, - }); - - expect(response.statusCode).toBe(400); - - const user = await Db.collections.User.findOneBy({ id: idToDelete }); - expect(user).toBeDefined(); -}); - -test('DELETE /users/:id with transferId should perform transfer', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const userToDelete = await testDb.createUser({ globalRole: globalMemberRole }); - - const savedWorkflow = await testDb.createWorkflow(undefined, userToDelete); - - const savedCredential = await testDb.saveCredential(randomCredentialPayload(), { - user: userToDelete, - role: credentialOwnerRole, - }); - - const response = await authAgent(owner).delete(`/users/${userToDelete.id}`).query({ - transferId: owner.id, - }); - - expect(response.statusCode).toBe(200); - - const sharedWorkflow = await Db.collections.SharedWorkflow.findOneOrFail({ - relations: ['workflow'], - where: { userId: owner.id }, - }); - - expect(sharedWorkflow.workflow).toBeDefined(); - expect(sharedWorkflow.workflow.id).toBe(savedWorkflow.id); - - const sharedCredential = await Db.collections.SharedCredentials.findOneOrFail({ - relations: ['credentials'], - where: { userId: owner.id }, - }); - - expect(sharedCredential.credentials).toBeDefined(); - expect(sharedCredential.credentials.id).toBe(savedCredential.id); - - const deletedUser = await Db.collections.User.findOneBy({ id: userToDelete.id }); - - expect(deletedUser).toBeNull(); -}); - -test('POST /users/:id should fill out a user shell', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const memberShell = await testDb.createUserShell(globalMemberRole); - - const memberData = { - inviterId: owner.id, - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), - }; - - const authlessAgent = utils.createAgent(app); - - const response = await authlessAgent.post(`/users/${memberShell.id}`).send(memberData); - - const { - id, - email, - firstName, - lastName, - personalizationAnswers, - password, - resetPasswordToken, - globalRole, - isPending, - apiKey, - } = response.body.data; - - expect(validator.isUUID(id)).toBe(true); - expect(email).toBeDefined(); - expect(firstName).toBe(memberData.firstName); - expect(lastName).toBe(memberData.lastName); - expect(personalizationAnswers).toBeNull(); - expect(password).toBeUndefined(); - expect(resetPasswordToken).toBeUndefined(); - expect(isPending).toBe(false); - expect(globalRole).toBeDefined(); - expect(apiKey).not.toBeDefined(); - - const authToken = utils.getAuthToken(response); - expect(authToken).toBeDefined(); - - const member = await Db.collections.User.findOneByOrFail({ id: memberShell.id }); - expect(member.firstName).toBe(memberData.firstName); - expect(member.lastName).toBe(memberData.lastName); - expect(member.password).not.toBe(memberData.password); -}); - -test('POST /users/:id should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const authlessAgent = utils.createAgent(app); - - const memberShellEmail = randomEmail(); - - const memberShell = await Db.collections.User.save({ - email: memberShellEmail, - globalRole: globalMemberRole, - }); - - const invalidPayloads = [ - { - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), - }, - { - inviterId: owner.id, - firstName: randomName(), - password: randomValidPassword(), - }, - { - inviterId: owner.id, - firstName: randomName(), - password: randomValidPassword(), - }, - { + const memberData = { inviterId: owner.id, firstName: randomName(), lastName: randomName(), - }, - { - inviterId: owner.id, - firstName: randomName(), - lastName: randomName(), - password: randomInvalidPassword(), - }, - ]; + password: randomValidPassword(), + }; - await Promise.all( - invalidPayloads.map(async (invalidPayload) => { - const response = await authlessAgent.post(`/users/${memberShell.id}`).send(invalidPayload); - expect(response.statusCode).toBe(400); + const response = await authlessAgent.post(`/users/${memberShell.id}`).send(memberData); - const storedUser = await Db.collections.User.findOneOrFail({ - where: { email: memberShellEmail }, - }); + const { + id, + email, + firstName, + lastName, + personalizationAnswers, + password, + resetPasswordToken, + globalRole, + isPending, + apiKey, + } = response.body.data; - expect(storedUser.firstName).toBeNull(); - expect(storedUser.lastName).toBeNull(); - expect(storedUser.password).toBeNull(); - }), - ); -}); - -test('POST /users/:id should fail with already accepted invite', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - - const newMemberData = { - inviterId: owner.id, - firstName: randomName(), - lastName: randomName(), - password: randomValidPassword(), - }; - - const authlessAgent = utils.createAgent(app); - - const response = await authlessAgent.post(`/users/${member.id}`).send(newMemberData); - - expect(response.statusCode).toBe(400); - - const storedMember = await Db.collections.User.findOneOrFail({ - where: { email: member.email }, - }); - expect(storedMember.firstName).not.toBe(newMemberData.firstName); - expect(storedMember.lastName).not.toBe(newMemberData.lastName); - - const comparisonResult = await compareHash(member.password, storedMember.password); - expect(comparisonResult).toBe(false); - expect(storedMember.password).not.toBe(newMemberData.password); -}); - -test('POST /users should succeed if emailing is not set up', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const response = await authAgent(owner) - .post('/users') - .send([{ email: randomEmail() }]); - - expect(response.statusCode).toBe(200); - expect(response.body.data[0].user.inviteAcceptUrl).toBeDefined(); -}); - -test('POST /users should fail if user management is disabled', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - config.set('userManagement.disabled', true); - config.set('userManagement.isInstanceOwnerSetUp', false); - - const response = await authAgent(owner) - .post('/users') - .send([{ email: randomEmail() }]); - - expect(response.statusCode).toBe(400); -}); - -test('POST /users should email invites and create user shells but ignore existing', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const memberShell = await testDb.createUserShell(globalMemberRole); - - config.set('userManagement.emails.mode', 'smtp'); - - const testEmails = [randomEmail(), randomEmail().toUpperCase(), memberShell.email, member.email]; - - const payload = testEmails.map((e) => ({ email: e })); - - const response = await authAgent(owner).post('/users').send(payload); - - expect(response.statusCode).toBe(200); - - for (const { - user: { id, email: receivedEmail }, - error, - } of response.body.data) { expect(validator.isUUID(id)).toBe(true); - expect(id).not.toBe(member.id); - - const lowerCasedEmail = receivedEmail.toLowerCase(); - expect(receivedEmail).toBe(lowerCasedEmail); - expect(payload.some(({ email }) => email.toLowerCase() === lowerCasedEmail)).toBe(true); - - if (error) { - expect(error).toBe('Email could not be sent'); - } - - const storedUser = await Db.collections.User.findOneByOrFail({ id }); - const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = - storedUser; - - expect(firstName).toBeNull(); - expect(lastName).toBeNull(); + expect(email).toBeDefined(); + expect(firstName).toBe(memberData.firstName); + expect(lastName).toBe(memberData.lastName); expect(personalizationAnswers).toBeNull(); - expect(password).toBeNull(); - expect(resetPasswordToken).toBeNull(); - } + expect(password).toBeUndefined(); + expect(resetPasswordToken).toBeUndefined(); + expect(isPending).toBe(false); + expect(globalRole).toBeDefined(); + expect(apiKey).not.toBeDefined(); + + const authToken = utils.getAuthToken(response); + expect(authToken).toBeDefined(); + + const member = await Db.collections.User.findOneByOrFail({ id: memberShell.id }); + expect(member.firstName).toBe(memberData.firstName); + expect(member.lastName).toBe(memberData.lastName); + expect(member.password).not.toBe(memberData.password); + }); + + test('should fail with invalid inputs', async () => { + const memberShellEmail = randomEmail(); + + const memberShell = await Db.collections.User.save({ + email: memberShellEmail, + globalRole: globalMemberRole, + }); + + const invalidPayloads = [ + { + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: owner.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: owner.id, + firstName: randomName(), + password: randomValidPassword(), + }, + { + inviterId: owner.id, + firstName: randomName(), + lastName: randomName(), + }, + { + inviterId: owner.id, + firstName: randomName(), + lastName: randomName(), + password: randomInvalidPassword(), + }, + ]; + + await Promise.all( + invalidPayloads.map(async (invalidPayload) => { + const response = await authlessAgent.post(`/users/${memberShell.id}`).send(invalidPayload); + expect(response.statusCode).toBe(400); + + const storedUser = await Db.collections.User.findOneOrFail({ + where: { email: memberShellEmail }, + }); + + expect(storedUser.firstName).toBeNull(); + expect(storedUser.lastName).toBeNull(); + expect(storedUser.password).toBeNull(); + }), + ); + }); + + test('should fail with already accepted invite', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole }); + + const newMemberData = { + inviterId: owner.id, + firstName: randomName(), + lastName: randomName(), + password: randomValidPassword(), + }; + + const response = await authlessAgent.post(`/users/${member.id}`).send(newMemberData); + + expect(response.statusCode).toBe(400); + + const storedMember = await Db.collections.User.findOneOrFail({ + where: { email: member.email }, + }); + expect(storedMember.firstName).not.toBe(newMemberData.firstName); + expect(storedMember.lastName).not.toBe(newMemberData.lastName); + + const comparisonResult = await compareHash(member.password, storedMember.password); + expect(comparisonResult).toBe(false); + expect(storedMember.password).not.toBe(newMemberData.password); + }); }); -test('POST /users should fail with invalid inputs', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = authAgent(owner); +describe('POST /users', () => { + beforeEach(() => { + config.set('userManagement.emails.mode', 'smtp'); + }); - config.set('userManagement.emails.mode', 'smtp'); + test('should succeed if emailing is not set up', async () => { + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); - const invalidPayloads = [ - randomEmail(), - [randomEmail()], - {}, - [{ name: randomName() }], - [{ email: randomName() }], - ]; + expect(response.statusCode).toBe(200); + expect(response.body.data[0].user.inviteAcceptUrl).toBeDefined(); + }); - await Promise.all( - invalidPayloads.map(async (invalidPayload) => { - const response = await authOwnerAgent.post('/users').send(invalidPayload); - expect(response.statusCode).toBe(400); + test('should fail if user management is disabled', async () => { + config.set('userManagement.disabled', true); + config.set('userManagement.isInstanceOwnerSetUp', false); - const users = await Db.collections.User.find(); - expect(users.length).toBe(1); // DB unaffected - }), - ); + const response = await authOwnerAgent.post('/users').send([{ email: randomEmail() }]); + + expect(response.statusCode).toBe(400); + }); + + test('should email invites and create user shells but ignore existing', async () => { + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const memberShell = await testDb.createUserShell(globalMemberRole); + + const testEmails = [ + randomEmail(), + randomEmail().toUpperCase(), + memberShell.email, + member.email, + ]; + + const payload = testEmails.map((e) => ({ email: e })); + + const response = await authOwnerAgent.post('/users').send(payload); + + expect(response.statusCode).toBe(200); + + for (const { + user: { id, email: receivedEmail }, + error, + } of response.body.data) { + expect(validator.isUUID(id)).toBe(true); + expect(id).not.toBe(member.id); + + const lowerCasedEmail = receivedEmail.toLowerCase(); + expect(receivedEmail).toBe(lowerCasedEmail); + expect(payload.some(({ email }) => email.toLowerCase() === lowerCasedEmail)).toBe(true); + + if (error) { + expect(error).toBe('Email could not be sent'); + } + + const storedUser = await Db.collections.User.findOneByOrFail({ id }); + const { firstName, lastName, personalizationAnswers, password, resetPasswordToken } = + storedUser; + + expect(firstName).toBeNull(); + expect(lastName).toBeNull(); + expect(personalizationAnswers).toBeNull(); + expect(password).toBeNull(); + expect(resetPasswordToken).toBeNull(); + } + }); + + test('should fail with invalid inputs', async () => { + const invalidPayloads = [ + randomEmail(), + [randomEmail()], + {}, + [{ name: randomName() }], + [{ email: randomName() }], + ]; + + await Promise.all( + invalidPayloads.map(async (invalidPayload) => { + const response = await authOwnerAgent.post('/users').send(invalidPayload); + expect(response.statusCode).toBe(400); + + const users = await Db.collections.User.find(); + expect(users.length).toBe(1); // DB unaffected + }), + ); + }); + + test('should ignore an empty payload', async () => { + const response = await authOwnerAgent.post('/users').send([]); + + const { data } = response.body; + + expect(response.statusCode).toBe(200); + expect(Array.isArray(data)).toBe(true); + expect(data.length).toBe(0); + + const users = await Db.collections.User.find(); + expect(users.length).toBe(1); + }); }); -test('POST /users should ignore an empty payload', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); +describe('POST /users/:id/reinvite', () => { + beforeEach(() => { + config.set('userManagement.emails.mode', 'smtp'); + // those configs are needed to make sure the reinvite email is sent,because of this check isEmailSetUp() + config.set('userManagement.emails.smtp.host', 'host'); + config.set('userManagement.emails.smtp.auth.user', 'user'); + config.set('userManagement.emails.smtp.auth.pass', 'pass'); + }); - config.set('userManagement.emails.mode', 'smtp'); + test('should send reinvite, but fail if user already accepted invite', async () => { + const email = randomEmail(); + const payload = [{ email }]; + const response = await authOwnerAgent.post('/users').send(payload); - const response = await authAgent(owner).post('/users').send([]); + expect(response.statusCode).toBe(200); - const { data } = response.body; + const { data } = response.body; + const invitedUserId = data[0].user.id; + const reinviteResponse = await authOwnerAgent.post(`/users/${invitedUserId}/reinvite`); - expect(response.statusCode).toBe(200); - expect(Array.isArray(data)).toBe(true); - expect(data.length).toBe(0); + expect(reinviteResponse.statusCode).toBe(200); - const users = await Db.collections.User.find(); - expect(users.length).toBe(1); + const member = await testDb.createUser({ globalRole: globalMemberRole }); + const reinviteMemberResponse = await authOwnerAgent.post(`/users/${member.id}/reinvite`); + + expect(reinviteMemberResponse.statusCode).toBe(400); + }); }); -test('POST /users/:id/reinvite should send reinvite, but fail if user already accepted invite', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: owner }); +describe('UserManagementMailer expect NodeMailer.verifyConnection', () => { + let mockInit: jest.SpyInstance, []>; + let mockVerifyConnection: jest.SpyInstance, []>; - config.set('userManagement.emails.mode', 'smtp'); + beforeAll(() => { + mockVerifyConnection = jest + .spyOn(NodeMailer.prototype, 'verifyConnection') + .mockImplementation(async () => {}); + mockInit = jest.spyOn(NodeMailer.prototype, 'init').mockImplementation(async () => {}); + }); - // those configs are needed to make sure the reinvite email is sent,because of this check isEmailSetUp() - config.set('userManagement.emails.smtp.host', 'host'); - config.set('userManagement.emails.smtp.auth.user', 'user'); - config.set('userManagement.emails.smtp.auth.pass', 'pass'); + afterAll(() => { + mockVerifyConnection.mockRestore(); + mockInit.mockRestore(); + }); - const email = randomEmail(); - const payload = [{ email }]; - const response = await authOwnerAgent.post('/users').send(payload); + test('not be called when SMTP not set up', async () => { + const userManagementMailer = new UserManagementMailer(); + // NodeMailer.verifyConnection gets called only explicitly + expect(async () => await userManagementMailer.verifyConnection()).rejects.toThrow(); - expect(response.statusCode).toBe(200); + expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(0); + }); - const { data } = response.body; - const invitedUserId = data[0].user.id; - const reinviteResponse = await authOwnerAgent.post(`/users/${invitedUserId}/reinvite`); + test('to be called when SMTP set up', async () => { + // host needs to be set, otherwise smtp is skipped + config.set('userManagement.emails.smtp.host', 'host'); + config.set('userManagement.emails.mode', 'smtp'); - expect(reinviteResponse.statusCode).toBe(200); - - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const reinviteMemberResponse = await authOwnerAgent.post(`/users/${member.id}/reinvite`); - - expect(reinviteMemberResponse.statusCode).toBe(400); -}); - -test('UserManagementMailer expect NodeMailer.verifyConnection not be called when SMTP not set up', async () => { - const mockVerifyConnection = jest.spyOn(NodeMailer.prototype, 'verifyConnection'); - mockVerifyConnection.mockImplementation(async () => {}); - - const userManagementMailer = UserManagementMailer.getInstance(); - // NodeMailer.verifyConnection gets called only explicitly - expect(async () => await userManagementMailer.verifyConnection()).rejects.toThrow(); - - expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(0); - - mockVerifyConnection.mockRestore(); -}); - -test('UserManagementMailer expect NodeMailer.verifyConnection to be called when SMTP set up', async () => { - const mockVerifyConnection = jest.spyOn(NodeMailer.prototype, 'verifyConnection'); - mockVerifyConnection.mockImplementation(async () => {}); - const mockInit = jest.spyOn(NodeMailer.prototype, 'init'); - mockInit.mockImplementation(async () => {}); - - // host needs to be set, otherwise smtp is skipped - config.set('userManagement.emails.smtp.host', 'host'); - config.set('userManagement.emails.mode', 'smtp'); - - const userManagementMailer = new UserManagementMailer.UserManagementMailer(); - // NodeMailer.verifyConnection gets called only explicitly - expect(async () => await userManagementMailer.verifyConnection()).not.toThrow(); - - // expect(NodeMailer.prototype.verifyConnection).toHaveBeenCalledTimes(1); - mockVerifyConnection.mockRestore(); - mockInit.mockRestore(); + const userManagementMailer = new UserManagementMailer(); + // NodeMailer.verifyConnection gets called only explicitly + expect(async () => await userManagementMailer.verifyConnection()).not.toThrow(); + }); }); diff --git a/packages/cli/test/integration/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows.controller.ee.test.ts index d43e9f790e..11eb0f29d2 100644 --- a/packages/cli/test/integration/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows.controller.ee.test.ts @@ -1,89 +1,91 @@ -import express from 'express'; +import type { SuperAgentTest } from 'supertest'; import { v4 as uuid } from 'uuid'; -import { INode } from 'n8n-workflow'; +import type { INode } from 'n8n-workflow'; + +import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; +import type { User } from '@db/entities/User'; +import config from '@/config'; import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; import { createWorkflow } from './shared/testDb'; -import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; -import type { Role } from '@db/entities/Role'; -import config from '@/config'; -import type { AuthAgent, SaveCredentialFunction } from './shared/types'; +import type { SaveCredentialFunction } from './shared/types'; import { makeWorkflow } from './shared/utils'; import { randomCredentialPayload } from './shared/random'; -import { ActiveWorkflowRunner } from '@/ActiveWorkflowRunner'; +import Container from 'typedi'; +import { License } from '../../src/License'; -let app: express.Application; -let globalOwnerRole: Role; -let globalMemberRole: Role; -let credentialOwnerRole: Role; -let authAgent: AuthAgent; +let owner: User; +let member: User; +let anotherMember: User; +let authOwnerAgent: SuperAgentTest; +let authMemberAgent: SuperAgentTest; +let authAnotherMemberAgent: SuperAgentTest; let saveCredential: SaveCredentialFunction; -let isSharingEnabled: jest.SpyInstance; -let workflowRunner: ActiveWorkflowRunner; let sharingSpy: jest.SpyInstance; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['workflows'] }); + Container.get(License).isSharingEnabled = () => true; + const app = await utils.initTestServer({ endpointGroups: ['workflows'] }); - globalOwnerRole = await testDb.getGlobalOwnerRole(); - globalMemberRole = await testDb.getGlobalMemberRole(); - credentialOwnerRole = await testDb.getCredentialOwnerRole(); + const globalOwnerRole = await testDb.getGlobalOwnerRole(); + const globalMemberRole = await testDb.getGlobalMemberRole(); + const credentialOwnerRole = await testDb.getCredentialOwnerRole(); + + owner = await testDb.createUser({ globalRole: globalOwnerRole }); + member = await testDb.createUser({ globalRole: globalMemberRole }); + anotherMember = await testDb.createUser({ globalRole: globalMemberRole }); + + const authAgent = utils.createAuthAgent(app); + authOwnerAgent = authAgent(owner); + authMemberAgent = authAgent(member); + authAnotherMemberAgent = authAgent(anotherMember); saveCredential = testDb.affixRoleToSaveCredential(credentialOwnerRole); - - authAgent = utils.createAuthAgent(app); - - isSharingEnabled = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); + sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); await utils.initNodeTypes(); - workflowRunner = await utils.initActiveWorkflowRunner(); - - config.set('enterprise.features.sharing', true); - sharingSpy = jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(true); // @TODO: Remove on release }); beforeEach(async () => { - await testDb.truncate(['User', 'Workflow', 'SharedWorkflow']); + await testDb.truncate(['Workflow', 'SharedWorkflow']); }); afterAll(async () => { + Container.reset(); await testDb.terminate(); }); -test('Router should switch dynamically', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); +describe('router should switch based on flag', () => { + let savedWorkflowId: string; - const createWorkflowResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); - const { id } = createWorkflowResponse.body.data; + beforeEach(async () => { + const createWorkflowResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); + savedWorkflowId = createWorkflowResponse.body.data.id; + }); - // free router + test('when sharing is disabled', async () => { + sharingSpy.mockReturnValueOnce(false); - isSharingEnabled.mockReturnValueOnce(false); + await authOwnerAgent + .put(`/workflows/${savedWorkflowId}/share`) + .send({ shareWithIds: [member.id] }) + .expect(404); + }); - const freeShareResponse = await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); - - expect(freeShareResponse.status).toBe(404); - - // EE router - - const paidShareResponse = await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); - - expect(paidShareResponse.status).toBe(200); + test('when sharing is enabled', async () => { + await authOwnerAgent + .put(`/workflows/${savedWorkflowId}/share`) + .send({ shareWithIds: [member.id] }) + .expect(200); + }); }); describe('PUT /workflows/:id', () => { test('PUT /workflows/:id/share should save sharing with new users', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); - const response = await authAgent(owner) + const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) .send({ shareWithIds: [member.id] }); @@ -94,10 +96,9 @@ describe('PUT /workflows/:id', () => { }); test('PUT /workflows/:id/share should succeed when sharing with invalid user-id', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const workflow = await createWorkflow({}, owner); - const response = await authAgent(owner) + const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) .send({ shareWithIds: [uuid()] }); @@ -108,12 +109,9 @@ describe('PUT /workflows/:id', () => { }); test('PUT /workflows/:id/share should allow sharing with multiple users', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const anotherMember = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); - const response = await authAgent(owner) + const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) .send({ shareWithIds: [member.id, anotherMember.id] }); @@ -124,13 +122,8 @@ describe('PUT /workflows/:id', () => { }); test('PUT /workflows/:id/share should override sharing', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const anotherMember = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); - const authOwnerAgent = authAgent(owner); - const response = await authOwnerAgent .put(`/workflows/${workflow.id}/share`) .send({ shareWithIds: [member.id, anotherMember.id] }); @@ -152,8 +145,6 @@ describe('PUT /workflows/:id', () => { describe('GET /workflows', () => { test('should return workflows without nodes, sharing and credential usage details', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); const tag = await testDb.createTag({ name: 'test' }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); @@ -183,7 +174,7 @@ describe('GET /workflows', () => { await testDb.shareWorkflowWithUsers(workflow, [member]); - const response = await authAgent(owner).get('/workflows'); + const response = await authOwnerAgent.get('/workflows'); const [fetchedWorkflow] = response.body.data; @@ -192,42 +183,37 @@ describe('GET /workflows', () => { id: owner.id, }); - expect(fetchedWorkflow.sharedWith).not.toBeDefined() - expect(fetchedWorkflow.usedCredentials).not.toBeDefined() - expect(fetchedWorkflow.nodes).not.toBeDefined() + expect(fetchedWorkflow.sharedWith).not.toBeDefined(); + expect(fetchedWorkflow.usedCredentials).not.toBeDefined(); + expect(fetchedWorkflow.nodes).not.toBeDefined(); expect(fetchedWorkflow.tags).toEqual( expect.arrayContaining([ expect.objectContaining({ id: expect.any(String), - name: expect.any(String) - }) - ]) - ) + name: expect.any(String), + }), + ]), + ); }); }); describe('GET /workflows/:id', () => { test('GET should fail with invalid id due to route rule', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const response = await authAgent(owner).get('/workflows/potatoes'); + const response = await authOwnerAgent.get('/workflows/potatoes'); expect(response.statusCode).toBe(404); }); test('GET should return 404 for non existing workflow', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - - const response = await authAgent(owner).get('/workflows/9001'); + const response = await authOwnerAgent.get('/workflows/9001'); expect(response.statusCode).toBe(404); }); test('GET should return a workflow with owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const workflow = await createWorkflow({}, owner); - const response = await authAgent(owner).get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); expect(response.statusCode).toBe(200); expect(response.body.data.ownedBy).toMatchObject({ @@ -241,12 +227,10 @@ describe('GET /workflows/:id', () => { }); test('GET should return shared workflow with user data', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); await testDb.shareWorkflowWithUsers(workflow, [member]); - const response = await authAgent(owner).get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); expect(response.statusCode).toBe(200); expect(response.body.data.ownedBy).toMatchObject({ @@ -266,13 +250,10 @@ describe('GET /workflows/:id', () => { }); test('GET should return all sharees', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member1 = await testDb.createUser({ globalRole: globalMemberRole }); - const member2 = await testDb.createUser({ globalRole: globalMemberRole }); const workflow = await createWorkflow({}, owner); - await testDb.shareWorkflowWithUsers(workflow, [member1, member2]); + await testDb.shareWorkflowWithUsers(workflow, [member, anotherMember]); - const response = await authAgent(owner).get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); expect(response.statusCode).toBe(200); expect(response.body.data.ownedBy).toMatchObject({ @@ -286,7 +267,6 @@ describe('GET /workflows/:id', () => { }); test('GET should return workflow with credentials owned by user', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const workflowPayload = makeWorkflow({ @@ -295,7 +275,7 @@ describe('GET /workflows/:id', () => { }); const workflow = await createWorkflow(workflowPayload, owner); - const response = await authAgent(owner).get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); expect(response.statusCode).toBe(200); expect(response.body.data.usedCredentials).toMatchObject([ @@ -310,8 +290,6 @@ describe('GET /workflows/:id', () => { }); test('GET should return workflow with credentials saying owner does not have access when not shared', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const workflowPayload = makeWorkflow({ @@ -320,7 +298,7 @@ describe('GET /workflows/:id', () => { }); const workflow = await createWorkflow(workflowPayload, owner); - const response = await authAgent(owner).get(`/workflows/${workflow.id}`); + const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); expect(response.statusCode).toBe(200); expect(response.body.data.usedCredentials).toMatchObject([ @@ -335,18 +313,16 @@ describe('GET /workflows/:id', () => { }); test('GET should return workflow with credentials for all users with or without access', async () => { - const member1 = await testDb.createUser({ globalRole: globalMemberRole }); - const member2 = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const workflowPayload = makeWorkflow({ withPinData: false, withCredential: { id: savedCredential.id, name: savedCredential.name }, }); - const workflow = await createWorkflow(workflowPayload, member1); - await testDb.shareWorkflowWithUsers(workflow, [member2]); + const workflow = await createWorkflow(workflowPayload, member); + await testDb.shareWorkflowWithUsers(workflow, [anotherMember]); - const responseMember1 = await authAgent(member1).get(`/workflows/${workflow.id}`); + const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`); expect(responseMember1.statusCode).toBe(200); expect(responseMember1.body.data.usedCredentials).toMatchObject([ { @@ -357,7 +333,7 @@ describe('GET /workflows/:id', () => { ]); expect(responseMember1.body.data.sharedWith).toHaveLength(1); - const responseMember2 = await authAgent(member2).get(`/workflows/${workflow.id}`); + const responseMember2 = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`); expect(responseMember2.statusCode).toBe(200); expect(responseMember2.body.data.usedCredentials).toMatchObject([ { @@ -370,20 +346,18 @@ describe('GET /workflows/:id', () => { }); test('GET should return workflow with credentials for all users with access', async () => { - const member1 = await testDb.createUser({ globalRole: globalMemberRole }); - const member2 = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); // Both users have access to the credential (none is owner) - await testDb.shareCredentialWithUsers(savedCredential, [member2]); + await testDb.shareCredentialWithUsers(savedCredential, [anotherMember]); const workflowPayload = makeWorkflow({ withPinData: false, withCredential: { id: savedCredential.id, name: savedCredential.name }, }); - const workflow = await createWorkflow(workflowPayload, member1); - await testDb.shareWorkflowWithUsers(workflow, [member2]); + const workflow = await createWorkflow(workflowPayload, member); + await testDb.shareWorkflowWithUsers(workflow, [anotherMember]); - const responseMember1 = await authAgent(member1).get(`/workflows/${workflow.id}`); + const responseMember1 = await authMemberAgent.get(`/workflows/${workflow.id}`); expect(responseMember1.statusCode).toBe(200); expect(responseMember1.body.data.usedCredentials).toMatchObject([ { @@ -394,7 +368,7 @@ describe('GET /workflows/:id', () => { ]); expect(responseMember1.body.data.sharedWith).toHaveLength(1); - const responseMember2 = await authAgent(member2).get(`/workflows/${workflow.id}`); + const responseMember2 = await authAnotherMemberAgent.get(`/workflows/${workflow.id}`); expect(responseMember2.statusCode).toBe(200); expect(responseMember2.body.data.usedCredentials).toMatchObject([ { @@ -409,33 +383,26 @@ describe('GET /workflows/:id', () => { describe('POST /workflows', () => { it('Should create a workflow that uses no credential', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const workflow = makeWorkflow({ withPinData: false }); - const response = await authAgent(owner).post('/workflows').send(workflow); + const response = await authOwnerAgent.post('/workflows').send(workflow); expect(response.statusCode).toBe(200); }); it('Should save a new workflow with credentials', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const workflow = makeWorkflow({ withPinData: false, withCredential: { id: savedCredential.id, name: savedCredential.name }, }); - const response = await authAgent(owner).post('/workflows').send(workflow); + const response = await authOwnerAgent.post('/workflows').send(workflow); expect(response.statusCode).toBe(200); }); it('Should not allow saving a workflow using credential you have no access', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // Credential belongs to owner, member cannot use it. const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const workflow = makeWorkflow({ @@ -443,7 +410,7 @@ describe('POST /workflows', () => { withCredential: { id: savedCredential.id, name: savedCredential.name }, }); - const response = await authAgent(member).post('/workflows').send(workflow); + const response = await authMemberAgent.post('/workflows').send(workflow); expect(response.statusCode).toBe(400); expect(response.body.message).toBe( @@ -452,9 +419,6 @@ describe('POST /workflows', () => { }); it('Should allow owner to save a workflow using credential owned by others', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // Credential belongs to owner, member cannot use it. const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const workflow = makeWorkflow({ @@ -462,32 +426,27 @@ describe('POST /workflows', () => { withCredential: { id: savedCredential.id, name: savedCredential.name }, }); - const response = await authAgent(owner).post('/workflows').send(workflow); + const response = await authOwnerAgent.post('/workflows').send(workflow); expect(response.statusCode).toBe(200); }); it('Should allow saving a workflow using a credential owned by others and shared with you', async () => { - const member1 = await testDb.createUser({ globalRole: globalMemberRole }); - const member2 = await testDb.createUser({ globalRole: globalMemberRole }); - - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); - await testDb.shareCredentialWithUsers(savedCredential, [member2]); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); + await testDb.shareCredentialWithUsers(savedCredential, [anotherMember]); const workflow = makeWorkflow({ withPinData: false, withCredential: { id: savedCredential.id, name: savedCredential.name }, }); - const response = await authAgent(member2).post('/workflows').send(workflow); + const response = await authAnotherMemberAgent.post('/workflows').send(workflow); expect(response.statusCode).toBe(200); }); }); describe('PATCH /workflows/:id - validate credential permissions to user', () => { it('Should succeed when saving unchanged workflow nodes', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const workflow = { name: 'test', @@ -511,10 +470,10 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => ], }; - const createResponse = await authAgent(owner).post('/workflows').send(workflow); + const createResponse = await authOwnerAgent.post('/workflows').send(workflow); const { id, versionId } = createResponse.body.data; - const response = await authAgent(owner).patch(`/workflows/${id}`).send({ + const response = await authOwnerAgent.patch(`/workflows/${id}`).send({ name: 'new name', versionId, }); @@ -523,9 +482,6 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => }); it('Should allow owner to add node containing credential not shared with the owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const workflow = { name: 'test', @@ -549,38 +505,33 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => ], }; - const createResponse = await authAgent(owner).post('/workflows').send(workflow); + const createResponse = await authOwnerAgent.post('/workflows').send(workflow); const { id, versionId } = createResponse.body.data; - const response = await authAgent(owner) - .patch(`/workflows/${id}`) - .send({ - versionId, - nodes: [ - { - id: 'uuid-1234', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: { - default: { - id: savedCredential.id, - name: savedCredential.name, - }, + const response = await authOwnerAgent.patch(`/workflows/${id}`).send({ + versionId, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, }, }, - ], - }); + }, + ], + }); expect(response.statusCode).toBe(200); }); it('Should prevent member from adding node containing credential inaccessible to member', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - const savedCredential = await saveCredential(randomCredentialPayload(), { user: owner }); const workflow = { @@ -605,48 +556,43 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => ], }; - const createResponse = await authAgent(owner).post('/workflows').send(workflow); + const createResponse = await authOwnerAgent.post('/workflows').send(workflow); const { id, versionId } = createResponse.body.data; - const response = await authAgent(member) - .patch(`/workflows/${id}`) - .send({ - versionId, - nodes: [ - { - id: 'uuid-1234', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: {}, - }, - { - id: 'uuid-12345', - name: 'Start', - parameters: {}, - position: [-20, 260], - type: 'n8n-nodes-base.start', - typeVersion: 1, - credentials: { - default: { - id: savedCredential.id, - name: savedCredential.name, - }, + const response = await authMemberAgent.patch(`/workflows/${id}`).send({ + versionId, + nodes: [ + { + id: 'uuid-1234', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: {}, + }, + { + id: 'uuid-12345', + name: 'Start', + parameters: {}, + position: [-20, 260], + type: 'n8n-nodes-base.start', + typeVersion: 1, + credentials: { + default: { + id: savedCredential.id, + name: savedCredential.name, }, }, - ], - }); + }, + ], + }); expect(response.statusCode).toBe(400); }); it('Should succeed but prevent modifying node attributes other than position, name and disabled', async () => { - const member1 = await testDb.createUser({ globalRole: globalMemberRole }); - const member2 = await testDb.createUser({ globalRole: globalMemberRole }); - - const savedCredential = await saveCredential(randomCredentialPayload(), { user: member1 }); + const savedCredential = await saveCredential(randomCredentialPayload(), { user: member }); const originalNodes: INode[] = [ { @@ -714,14 +660,12 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => nodes: originalNodes, }; - const createResponse = await authAgent(member1).post('/workflows').send(workflow); + const createResponse = await authMemberAgent.post('/workflows').send(workflow); const { id, versionId } = createResponse.body.data; - await authAgent(member1) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member2.id] }); + await authMemberAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [anotherMember.id] }); - const response = await authAgent(member2).patch(`/workflows/${id}`).send({ + const response = await authAnotherMemberAgent.patch(`/workflows/${id}`).send({ versionId, nodes: changedNodes, }); @@ -733,29 +677,24 @@ describe('PATCH /workflows/:id - validate credential permissions to user', () => describe('PATCH /workflows/:id - validate interim updates', () => { it('should block owner updating workflow nodes on interim update by member', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // owner creates and shares workflow - const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); // member accesses and updates workflow name - const memberGetResponse = await authAgent(member).get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); const { versionId: memberVersionId } = memberGetResponse.body.data; - await authAgent(member) + await authMemberAgent .patch(`/workflows/${id}`) .send({ name: 'Update by member', versionId: memberVersionId }); // owner blocked from updating workflow nodes - const updateAttemptResponse = await authAgent(owner) + const updateAttemptResponse = await authOwnerAgent .patch(`/workflows/${id}`) .send({ nodes: [], versionId: ownerVersionId }); @@ -764,38 +703,33 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); it('should block member updating workflow nodes on interim update by owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // owner creates, updates and shares workflow - const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerFirstVersionId } = createResponse.body.data; - const updateResponse = await authAgent(owner) + const updateResponse = await authOwnerAgent .patch(`/workflows/${id}`) .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); // member accesses workflow - const memberGetResponse = await authAgent(member).get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); const { versionId: memberVersionId } = memberGetResponse.body.data; // owner re-updates workflow - await authAgent(owner) + await authOwnerAgent .patch(`/workflows/${id}`) .send({ name: 'Owner update again', versionId: ownerSecondVersionId }); // member blocked from updating workflow - const updateAttemptResponse = await authAgent(member) + const updateAttemptResponse = await authMemberAgent .patch(`/workflows/${id}`) .send({ nodes: [], versionId: memberVersionId }); @@ -804,28 +738,23 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); it('should block owner activation on interim activation by member', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // owner creates and shares workflow - const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); // member accesses and activates workflow - const memberGetResponse = await authAgent(member).get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); const { versionId: memberVersionId } = memberGetResponse.body.data; - await authAgent(member) + await authMemberAgent .patch(`/workflows/${id}`) .send({ active: true, versionId: memberVersionId }); // owner blocked from activating workflow - const activationAttemptResponse = await authAgent(owner) + const activationAttemptResponse = await authOwnerAgent .patch(`/workflows/${id}`) .send({ active: true, versionId: ownerVersionId }); @@ -834,37 +763,32 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); it('should block member activation on interim activation by owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // owner creates, updates and shares workflow - const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerFirstVersionId } = createResponse.body.data; - const updateResponse = await authAgent(owner) + const updateResponse = await authOwnerAgent .patch(`/workflows/${id}`) .send({ name: 'Update by owner', versionId: ownerFirstVersionId }); const { versionId: ownerSecondVersionId } = updateResponse.body.data; - await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); // member accesses workflow - const memberGetResponse = await authAgent(member).get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); const { versionId: memberVersionId } = memberGetResponse.body.data; // owner activates workflow - await authAgent(owner) + await authOwnerAgent .patch(`/workflows/${id}`) .send({ active: true, versionId: ownerSecondVersionId }); // member blocked from activating workflow - const updateAttemptResponse = await authAgent(member) + const updateAttemptResponse = await authMemberAgent .patch(`/workflows/${id}`) .send({ active: true, versionId: memberVersionId }); @@ -873,31 +797,26 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); it('should block member updating workflow settings on interim update by owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // owner creates and shares workflow - const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); // member accesses workflow - const memberGetResponse = await authAgent(member).get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); const { versionId: memberVersionId } = memberGetResponse.body.data; // owner updates workflow name - await authAgent(owner) + await authOwnerAgent .patch(`/workflows/${id}`) .send({ name: 'Another name', versionId: ownerVersionId }); // member blocked from updating workflow settings - const updateAttemptResponse = await authAgent(member) + const updateAttemptResponse = await authMemberAgent .patch(`/workflows/${id}`) .send({ settings: { saveManualExecutions: true }, versionId: memberVersionId }); @@ -906,31 +825,26 @@ describe('PATCH /workflows/:id - validate interim updates', () => { }); it('should block member updating workflow name on interim update by owner', async () => { - const owner = await testDb.createUser({ globalRole: globalOwnerRole }); - const member = await testDb.createUser({ globalRole: globalMemberRole }); - // owner creates and shares workflow - const createResponse = await authAgent(owner).post('/workflows').send(makeWorkflow()); + const createResponse = await authOwnerAgent.post('/workflows').send(makeWorkflow()); const { id, versionId: ownerVersionId } = createResponse.body.data; - await authAgent(owner) - .put(`/workflows/${id}/share`) - .send({ shareWithIds: [member.id] }); + await authOwnerAgent.put(`/workflows/${id}/share`).send({ shareWithIds: [member.id] }); // member accesses workflow - const memberGetResponse = await authAgent(member).get(`/workflows/${id}`); + const memberGetResponse = await authMemberAgent.get(`/workflows/${id}`); const { versionId: memberVersionId } = memberGetResponse.body.data; // owner updates workflow settings - await authAgent(owner) + await authOwnerAgent .patch(`/workflows/${id}`) .send({ settings: { saveManualExecutions: true }, versionId: ownerVersionId }); // member blocked from updating workflow name - const updateAttemptResponse = await authAgent(member) + const updateAttemptResponse = await authMemberAgent .patch(`/workflows/${id}`) .send({ settings: { saveManualExecutions: true }, versionId: memberVersionId }); diff --git a/packages/cli/test/integration/workflows.controller.test.ts b/packages/cli/test/integration/workflows.controller.test.ts index 2d56589531..dd0d236ceb 100644 --- a/packages/cli/test/integration/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows.controller.test.ts @@ -1,78 +1,74 @@ -import express from 'express'; +import { SuperAgentTest } from 'supertest'; +import type { IPinData } from 'n8n-workflow'; + +import type { User } from '@db/entities/User'; +import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; import * as utils from './shared/utils'; import * as testDb from './shared/testDb'; -import * as UserManagementHelpers from '@/UserManagement/UserManagementHelper'; - -import type { Role } from '@db/entities/Role'; -import type { IPinData } from 'n8n-workflow'; import { makeWorkflow, MOCK_PINDATA } from './shared/utils'; -let app: express.Application; -let globalOwnerRole: Role; - -// mock whether sharing is enabled or not -jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); +let ownerShell: User; +let authOwnerAgent: SuperAgentTest; beforeAll(async () => { - app = await utils.initTestServer({ endpointGroups: ['workflows'] }); + const app = await utils.initTestServer({ endpointGroups: ['workflows'] }); + const globalOwnerRole = await testDb.getGlobalOwnerRole(); + ownerShell = await testDb.createUserShell(globalOwnerRole); + authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - globalOwnerRole = await testDb.getGlobalOwnerRole(); + // mock whether sharing is enabled or not + jest.spyOn(UserManagementHelpers, 'isSharingEnabled').mockReturnValue(false); }); beforeEach(async () => { - await testDb.truncate(['User', 'Workflow', 'SharedWorkflow']); + await testDb.truncate(['Workflow', 'SharedWorkflow']); }); afterAll(async () => { await testDb.terminate(); }); -test('POST /workflows should store pin data for node in workflow', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); +describe('POST /workflows', () => { + test('should store pin data for node in workflow', async () => { + const workflow = makeWorkflow({ withPinData: true }); - const workflow = makeWorkflow({ withPinData: true }); + const response = await authOwnerAgent.post('/workflows').send(workflow); - const response = await authOwnerAgent.post('/workflows').send(workflow); + expect(response.statusCode).toBe(200); - expect(response.statusCode).toBe(200); + const { pinData } = response.body.data as { pinData: IPinData }; - const { pinData } = response.body.data as { pinData: IPinData }; + expect(pinData).toMatchObject(MOCK_PINDATA); + }); - expect(pinData).toMatchObject(MOCK_PINDATA); + test('should set pin data to null if no pin data', async () => { + const workflow = makeWorkflow({ withPinData: false }); + + const response = await authOwnerAgent.post('/workflows').send(workflow); + + expect(response.statusCode).toBe(200); + + const { pinData } = response.body.data as { pinData: IPinData }; + + expect(pinData).toBeNull(); + }); }); -test('POST /workflows should set pin data to null if no pin data', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); +describe('GET /workflows/:id', () => { + test('should return pin data', async () => { + const workflow = makeWorkflow({ withPinData: true }); - const workflow = makeWorkflow({ withPinData: false }); + const workflowCreationResponse = await authOwnerAgent.post('/workflows').send(workflow); - const response = await authOwnerAgent.post('/workflows').send(workflow); + const { id } = workflowCreationResponse.body.data as { id: string }; - expect(response.statusCode).toBe(200); + const workflowRetrievalResponse = await authOwnerAgent.get(`/workflows/${id}`); - const { pinData } = response.body.data as { pinData: IPinData }; + expect(workflowRetrievalResponse.statusCode).toBe(200); - expect(pinData).toBeNull(); -}); - -test('GET /workflows/:id should return pin data', async () => { - const ownerShell = await testDb.createUserShell(globalOwnerRole); - const authOwnerAgent = utils.createAgent(app, { auth: true, user: ownerShell }); - - const workflow = makeWorkflow({ withPinData: true }); - - const workflowCreationResponse = await authOwnerAgent.post('/workflows').send(workflow); - - const { id } = workflowCreationResponse.body.data as { id: string }; - - const workflowRetrievalResponse = await authOwnerAgent.get(`/workflows/${id}`); - - expect(workflowRetrievalResponse.statusCode).toBe(200); - - const { pinData } = workflowRetrievalResponse.body.data as { pinData: IPinData }; - - expect(pinData).toMatchObject(MOCK_PINDATA); + const { pinData } = workflowRetrievalResponse.body.data as { pinData: IPinData }; + + expect(pinData).toMatchObject(MOCK_PINDATA); + }); }); diff --git a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts index 395b85d377..e3290446e0 100644 --- a/packages/cli/test/unit/ActiveWorkflowRunner.test.ts +++ b/packages/cli/test/unit/ActiveWorkflowRunner.test.ts @@ -18,7 +18,7 @@ import { User } from '@/databases/entities/User'; import { getLogger } from '@/Logger'; import { randomEmail, randomName } from '../integration/shared/random'; import * as Helpers from './Helpers'; -import { WorkflowExecuteAdditionalData } from '@/index'; +import * as WorkflowExecuteAdditionalData from '@/WorkflowExecuteAdditionalData'; import { WorkflowRunner } from '@/WorkflowRunner'; import { mock } from 'jest-mock-extended'; @@ -27,6 +27,8 @@ import { Container } from 'typedi'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; import { mockInstance } from '../integration/shared/utils'; import { Push } from '@/push'; +import { ActiveExecutions } from '@/ActiveExecutions'; +import { NodeTypes } from '@/NodeTypes'; /** * TODO: @@ -157,12 +159,17 @@ describe('ActiveWorkflowRunner', () => { beforeEach(() => { externalHooks = mock(); - activeWorkflowRunner = new ActiveWorkflowRunner(externalHooks); + activeWorkflowRunner = new ActiveWorkflowRunner( + new ActiveExecutions(), + externalHooks, + Container.get(NodeTypes), + ); }); afterEach(async () => { await activeWorkflowRunner.removeAll(); databaseActiveWorkflowsCount = 0; + databaseActiveWorkflowsList = []; jest.clearAllMocks(); }); diff --git a/packages/cli/test/unit/CredentialsHelper.test.ts b/packages/cli/test/unit/CredentialsHelper.test.ts index efa43aeb13..8ede15e4fd 100644 --- a/packages/cli/test/unit/CredentialsHelper.test.ts +++ b/packages/cli/test/unit/CredentialsHelper.test.ts @@ -11,20 +11,59 @@ import { } from 'n8n-workflow'; import { CredentialsHelper } from '@/CredentialsHelper'; import { CredentialTypes } from '@/CredentialTypes'; -import * as Helpers from './Helpers'; import { Container } from 'typedi'; import { NodeTypes } from '@/NodeTypes'; import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -const TEST_ENCRYPTION_KEY = 'test'; -const mockNodesAndCredentials: INodesAndCredentials = { - loaded: { nodes: {}, credentials: {} }, - known: { nodes: {}, credentials: {} }, - credentialTypes: {} as ICredentialTypes, -}; -Container.set(LoadNodesAndCredentials, mockNodesAndCredentials); - describe('CredentialsHelper', () => { + const TEST_ENCRYPTION_KEY = 'test'; + + const mockNodesAndCredentials: INodesAndCredentials = { + loaded: { + nodes: { + 'test.set': { + sourcePath: '', + type: { + description: { + displayName: 'Set', + name: 'set', + group: ['input'], + version: 1, + description: 'Sets a value', + defaults: { + name: 'Set', + color: '#0000FF', + }, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + displayName: 'Value1', + name: 'value1', + type: 'string', + default: 'default-value1', + }, + { + displayName: 'Value2', + name: 'value2', + type: 'string', + default: 'default-value2', + }, + ], + }, + }, + }, + }, + credentials: {}, + }, + known: { nodes: {}, credentials: {} }, + credentialTypes: {} as ICredentialTypes, + }; + + Container.set(LoadNodesAndCredentials, mockNodesAndCredentials); + + const nodeTypes = Container.get(NodeTypes); + describe('authenticate', () => { const tests: Array<{ description: string; @@ -219,8 +258,6 @@ describe('CredentialsHelper', () => { qs: {}, }; - const nodeTypes = Helpers.NodeTypes() as unknown as NodeTypes; - const workflow = new Workflow({ nodes: [node], connections: {}, diff --git a/packages/cli/test/unit/Events.test.ts b/packages/cli/test/unit/Events.test.ts index 0c72e8f405..25c2897352 100644 --- a/packages/cli/test/unit/Events.test.ts +++ b/packages/cli/test/unit/Events.test.ts @@ -1,32 +1,37 @@ -import { LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow'; -import { QueryFailedError } from 'typeorm'; +import { IRun, LoggerProxy, WorkflowExecuteMode } from 'n8n-workflow'; +import { QueryFailedError, Repository } from 'typeorm'; +import { mock } from 'jest-mock-extended'; + import config from '@/config'; -import { Db } from '@/index'; +import * as Db from '@/Db'; +import { User } from '@db/entities/User'; +import { WorkflowStatistics } from '@db/entities/WorkflowStatistics'; import { nodeFetchedData, workflowExecutionCompleted } from '@/events/WorkflowStatistics'; -import { getLogger } from '@/Logger'; import * as UserManagementHelper from '@/UserManagement/UserManagementHelper'; +import { getLogger } from '@/Logger'; import { InternalHooks } from '@/InternalHooks'; + import { mockInstance } from '../integration/shared/utils'; -const FAKE_USER_ID = 'abcde-fghij'; - +type WorkflowStatisticsRepository = Repository; jest.mock('@/Db', () => { return { collections: { - WorkflowStatistics: { - insert: jest.fn((...args) => {}), - update: jest.fn((...args) => {}), - }, + WorkflowStatistics: mock(), }, }; }); -jest.spyOn(UserManagementHelper, 'getWorkflowOwner').mockImplementation(async (_workflowId) => { - return { id: FAKE_USER_ID }; -}); describe('Events', () => { + const fakeUser = Object.assign(new User(), { id: 'abcde-fghij' }); const internalHooks = mockInstance(InternalHooks); + jest.spyOn(UserManagementHelper, 'getWorkflowOwner').mockResolvedValue(fakeUser); + + const workflowStatisticsRepository = Db.collections.WorkflowStatistics as ReturnType< + typeof mock + >; + beforeAll(() => { config.set('diagnostics.enabled', true); config.set('deployment.type', 'n8n-testing'); @@ -57,8 +62,9 @@ describe('Events', () => { nodes: [], connections: {}, }; - const runData = { + const runData: IRun = { finished: true, + status: 'success', data: { resultData: { runData: {} } }, mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), @@ -66,7 +72,7 @@ describe('Events', () => { await workflowExecutionCompleted(workflow, runData); expect(internalHooks.onFirstProductionWorkflowSuccess).toBeCalledTimes(1); expect(internalHooks.onFirstProductionWorkflowSuccess).toHaveBeenNthCalledWith(1, { - user_id: FAKE_USER_ID, + user_id: fakeUser.id, workflow_id: workflow.id, }); }); @@ -82,8 +88,9 @@ describe('Events', () => { nodes: [], connections: {}, }; - const runData = { + const runData: IRun = { finished: false, + status: 'failed', data: { resultData: { runData: {} } }, mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), @@ -94,7 +101,7 @@ describe('Events', () => { test('should not send metrics for updated entries', async () => { // Call the function with a fail insert, ensure update is called *and* metrics aren't sent - Db.collections.WorkflowStatistics.insert.mockImplementationOnce(() => { + workflowStatisticsRepository.insert.mockImplementationOnce(() => { throw new QueryFailedError('invalid insert', [], ''); }); const workflow = { @@ -106,8 +113,9 @@ describe('Events', () => { nodes: [], connections: {}, }; - const runData = { + const runData: IRun = { finished: true, + status: 'success', data: { resultData: { runData: {} } }, mode: 'internal' as WorkflowExecuteMode, startedAt: new Date(), @@ -132,7 +140,7 @@ describe('Events', () => { await nodeFetchedData(workflowId, node); expect(internalHooks.onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(internalHooks.onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { - user_id: FAKE_USER_ID, + user_id: fakeUser.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, @@ -159,7 +167,7 @@ describe('Events', () => { await nodeFetchedData(workflowId, node); expect(internalHooks.onFirstWorkflowDataLoad).toBeCalledTimes(1); expect(internalHooks.onFirstWorkflowDataLoad).toHaveBeenNthCalledWith(1, { - user_id: FAKE_USER_ID, + user_id: fakeUser.id, workflow_id: workflowId, node_type: node.type, node_id: node.id, @@ -170,7 +178,7 @@ describe('Events', () => { test('should not send metrics for entries that already have the flag set', async () => { // Fetch data for workflow 2 which is set up to not be altered in the mocks - Db.collections.WorkflowStatistics.insert.mockImplementationOnce(() => { + workflowStatisticsRepository.insert.mockImplementationOnce(() => { throw new QueryFailedError('invalid insert', [], ''); }); const workflowId = '1'; diff --git a/packages/cli/test/unit/Helpers.ts b/packages/cli/test/unit/Helpers.ts index 1f2f9b49b7..2d9f43ebf5 100644 --- a/packages/cli/test/unit/Helpers.ts +++ b/packages/cli/test/unit/Helpers.ts @@ -1,108 +1,4 @@ -import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; -import { - INodeType, - INodeTypeData, - INodeTypes, - IVersionedNodeType, - NodeHelpers, -} from 'n8n-workflow'; - -// TODO: delete this -class NodeTypesClass implements INodeTypes { - nodeTypes: INodeTypeData = { - 'test.set': { - sourcePath: '', - type: { - description: { - displayName: 'Set', - name: 'set', - group: ['input'], - version: 1, - description: 'Sets a value', - defaults: { - name: 'Set', - color: '#0000FF', - }, - inputs: ['main'], - outputs: ['main'], - properties: [ - { - displayName: 'Value1', - name: 'value1', - type: 'string', - default: 'default-value1', - }, - { - displayName: 'Value2', - name: 'value2', - type: 'string', - default: 'default-value2', - }, - ], - }, - }, - }, - 'fake-scheduler': { - sourcePath: '', - type: { - description: { - displayName: 'Schedule', - name: 'set', - group: ['input'], - version: 1, - description: 'Schedules execuitons', - defaults: { - name: 'Set', - color: '#0000FF', - }, - inputs: ['main'], - outputs: ['main'], - properties: [ - { - displayName: 'Value1', - name: 'value1', - type: 'string', - default: 'default-value1', - }, - { - displayName: 'Value2', - name: 'value2', - type: 'string', - default: 'default-value2', - }, - ], - }, - trigger: () => { - return Promise.resolve(undefined); - }, - }, - }, - }; - - constructor(nodesAndCredentials?: LoadNodesAndCredentials) { - if (nodesAndCredentials?.loaded?.nodes) { - this.nodeTypes = nodesAndCredentials?.loaded?.nodes; - } - } - - getByName(nodeType: string): INodeType | IVersionedNodeType { - return this.nodeTypes[nodeType].type; - } - - getByNameAndVersion(nodeType: string, version?: number): INodeType { - return NodeHelpers.getVersionedNodeType(this.nodeTypes[nodeType].type, version); - } -} - -let nodeTypesInstance: NodeTypesClass | undefined; - -export function NodeTypes(nodesAndCredentials?: LoadNodesAndCredentials): NodeTypesClass { - if (nodeTypesInstance === undefined) { - nodeTypesInstance = new NodeTypesClass(nodesAndCredentials); - } - - return nodeTypesInstance; -} +import { INodeTypeData } from 'n8n-workflow'; /** * Ensure all pending promises settle. The promise's `resolve` is placed in diff --git a/packages/cli/test/unit/PermissionChecker.test.ts b/packages/cli/test/unit/PermissionChecker.test.ts index 7fbe355351..71b550c50f 100644 --- a/packages/cli/test/unit/PermissionChecker.test.ts +++ b/packages/cli/test/unit/PermissionChecker.test.ts @@ -1,46 +1,47 @@ import { v4 as uuid } from 'uuid'; -import { - ICredentialTypes, - INodeTypeData, - INodeTypes, - SubworkflowOperationError, - Workflow, -} from 'n8n-workflow'; +import { Container } from 'typedi'; +import { ICredentialTypes, INodeTypes, SubworkflowOperationError, Workflow } from 'n8n-workflow'; import config from '@/config'; import * as Db from '@/Db'; -import * as testDb from '../integration/shared/testDb'; -import { mockNodeTypesData, NodeTypes as MockNodeTypes } from './Helpers'; +import { Role } from '@db/entities/Role'; +import { User } from '@db/entities/User'; +import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import { LoadNodesAndCredentials } from '@/LoadNodesAndCredentials'; +import { NodeTypes } from '@/NodeTypes'; import { UserService } from '@/user/user.service'; import { PermissionChecker } from '@/UserManagement/PermissionChecker'; import * as UserManagementHelper from '@/UserManagement/UserManagementHelper'; import { WorkflowsService } from '@/workflows/workflows.services'; + import { randomCredentialPayload as randomCred, randomPositiveDigit, } from '../integration/shared/random'; - -import { Role } from '@db/entities/Role'; +import * as testDb from '../integration/shared/testDb'; +import { mockNodeTypesData } from './Helpers'; import type { SaveCredentialFunction } from '../integration/shared/types'; -import { User } from '@db/entities/User'; -import { SharedWorkflow } from '@db/entities/SharedWorkflow'; +import { mockInstance } from '../integration/shared/utils'; let mockNodeTypes: INodeTypes; let credentialOwnerRole: Role; let workflowOwnerRole: Role; let saveCredential: SaveCredentialFunction; +const MOCK_NODE_TYPES_DATA = mockNodeTypesData(['start', 'actionNetwork']); +mockInstance(LoadNodesAndCredentials, { + loaded: { + nodes: MOCK_NODE_TYPES_DATA, + credentials: {}, + }, + known: { nodes: {}, credentials: {} }, + credentialTypes: {} as ICredentialTypes, +}); + beforeAll(async () => { await testDb.init(); - mockNodeTypes = MockNodeTypes({ - loaded: { - nodes: MOCK_NODE_TYPES_DATA, - credentials: {}, - }, - known: { nodes: {}, credentials: {} }, - credentialTypes: {} as ICredentialTypes, - }); + mockNodeTypes = Container.get(NodeTypes); credentialOwnerRole = await testDb.getCredentialOwnerRole(); workflowOwnerRole = await testDb.getWorkflowOwnerRole(); @@ -241,7 +242,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { nodes: [], connections: {}, active: false, - nodeTypes: MockNodeTypes(), + nodeTypes: mockNodeTypes, id: '2', }); await expect( @@ -263,7 +264,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { nodes: [], connections: {}, active: false, - nodeTypes: MockNodeTypes(), + nodeTypes: mockNodeTypes, id: '2', }); await expect( @@ -301,7 +302,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { nodes: [], connections: {}, active: false, - nodeTypes: MockNodeTypes(), + nodeTypes: mockNodeTypes, id: '2', settings: { callerPolicy: 'workflowsFromAList', @@ -327,7 +328,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { nodes: [], connections: {}, active: false, - nodeTypes: MockNodeTypes(), + nodeTypes: mockNodeTypes, id: '2', }); await expect( @@ -350,7 +351,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { nodes: [], connections: {}, active: false, - nodeTypes: MockNodeTypes(), + nodeTypes: mockNodeTypes, id: '2', settings: { callerPolicy: 'workflowsFromAList', @@ -376,7 +377,7 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { nodes: [], connections: {}, active: false, - nodeTypes: MockNodeTypes(), + nodeTypes: mockNodeTypes, id: '2', settings: { callerPolicy: 'any', @@ -387,5 +388,3 @@ describe('PermissionChecker.checkSubworkflowExecutePolicy', () => { ).resolves.not.toThrow(); }); }); - -const MOCK_NODE_TYPES_DATA = mockNodeTypesData(['start', 'actionNetwork']); diff --git a/packages/cli/test/unit/Telemetry.test.ts b/packages/cli/test/unit/Telemetry.test.ts index 5c5b4019d0..4b58caf994 100644 --- a/packages/cli/test/unit/Telemetry.test.ts +++ b/packages/cli/test/unit/Telemetry.test.ts @@ -2,6 +2,7 @@ import { Telemetry } from '@/telemetry'; import config from '@/config'; import { flushPromises } from './Helpers'; import { PostHogClient } from '@/posthog'; +import { mock } from 'jest-mock-extended'; jest.unmock('@/telemetry'); jest.mock('@/license/License.service', () => { @@ -45,7 +46,7 @@ describe('Telemetry', () => { const postHog = new PostHogClient(); postHog.init(instanceId); - telemetry = new Telemetry(postHog); + telemetry = new Telemetry(postHog, mock()); telemetry.setInstanceId(instanceId); (telemetry as any).rudderStack = { flush: () => {}, diff --git a/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts b/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts new file mode 100644 index 0000000000..9d364bd788 --- /dev/null +++ b/packages/cli/test/unit/WorkflowExecuteAdditionalData.test.ts @@ -0,0 +1,41 @@ +import { saveExecutionMetadata } from '@/WorkflowExecuteAdditionalData'; +import * as Db from '@/Db'; +import { mocked } from 'jest-mock'; + +jest.mock('@/Db', () => { + return { + collections: { + ExecutionMetadata: { + save: jest.fn(async () => Promise.resolve([])), + }, + }, + }; +}); + +describe('WorkflowExecuteAdditionalData', () => { + test('Execution metadata is saved in a batch', async () => { + const toSave = { + test1: 'value1', + test2: 'value2', + }; + const executionId = '1234'; + + await saveExecutionMetadata(executionId, toSave); + + expect(mocked(Db.collections.ExecutionMetadata.save)).toHaveBeenCalledTimes(1); + expect(mocked(Db.collections.ExecutionMetadata.save).mock.calls[0]).toEqual([ + [ + { + execution: { id: executionId }, + key: 'test1', + value: 'value1', + }, + { + execution: { id: executionId }, + key: 'test2', + value: 'value2', + }, + ], + ]); + }); +}); diff --git a/packages/core/package.json b/packages/core/package.json index 5955686d35..86086ec6d8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "n8n-core", - "version": "0.157.0", + "version": "0.160.0", "description": "Core functionality of n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -39,6 +39,7 @@ "@types/crypto-js": "^4.0.1", "@types/express": "^4.17.6", "@types/lodash.get": "^4.4.6", + "@types/lodash.pick": "^4.4.7", "@types/mime-types": "^2.1.0", "@types/request-promise-native": "~1.0.15", "@types/uuid": "^8.3.2" @@ -54,6 +55,7 @@ "flatted": "^3.2.4", "form-data": "^4.0.0", "lodash.get": "^4.4.2", + "lodash.pick": "^4.4.0", "mime-types": "^2.1.27", "n8n-workflow": "workspace:*", "oauth-1.0a": "^2.2.6", diff --git a/packages/core/src/BinaryDataManager/index.ts b/packages/core/src/BinaryDataManager/index.ts index a019fdaddb..b3ddb8738d 100644 --- a/packages/core/src/BinaryDataManager/index.ts +++ b/packages/core/src/BinaryDataManager/index.ts @@ -2,7 +2,7 @@ import { readFile, stat } from 'fs/promises'; import type { BinaryMetadata, IBinaryData, INodeExecutionData } from 'n8n-workflow'; import prettyBytes from 'pretty-bytes'; import type { Readable } from 'stream'; -import { BINARY_ENCODING } from '../Constants'; +import { BINARY_ENCODING } from 'n8n-workflow'; import type { IBinaryDataConfig, IBinaryDataManager } from '../Interfaces'; import { BinaryDataFileSystem } from './FileSystem'; import { binaryToBuffer } from './utils'; diff --git a/packages/core/src/Constants.ts b/packages/core/src/Constants.ts index 6d2fe99321..72894e6eaa 100644 --- a/packages/core/src/Constants.ts +++ b/packages/core/src/Constants.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/naming-convention */ -export const BINARY_ENCODING = 'base64'; export const CUSTOM_EXTENSION_ENV = 'N8N_CUSTOM_EXTENSIONS'; export const DOWNLOADED_NODES_SUBDIRECTORY = 'nodes'; export const ENCRYPTION_KEY_ENV_OVERWRITE = 'N8N_ENCRYPTION_KEY'; @@ -10,7 +9,6 @@ export const USER_SETTINGS_SUBFOLDER = '.n8n'; export const PLACEHOLDER_EMPTY_EXECUTION_ID = '__UNKNOWN__'; export const PLACEHOLDER_EMPTY_WORKFLOW_ID = '__EMPTY__'; export const TUNNEL_SUBDOMAIN_ENV = 'N8N_TUNNEL_SUBDOMAIN'; -export const WAIT_TIME_UNLIMITED = '3000-01-01T00:00:00.000Z'; export const RESPONSE_ERROR_MESSAGES = { NO_ENCRYPTION_KEY: 'Encryption key is missing or was not set', diff --git a/packages/core/src/NodeExecuteFunctions.ts b/packages/core/src/NodeExecuteFunctions.ts index 7d6b4648b2..147cc61fb9 100644 --- a/packages/core/src/NodeExecuteFunctions.ts +++ b/packages/core/src/NodeExecuteFunctions.ts @@ -49,6 +49,7 @@ import type { IPairedItemData, ICredentialTestFunctions, BinaryHelperFunctions, + NodeHelperFunctions, RequestHelperFunctions, FunctionsBase, IExecuteFunctions, @@ -62,6 +63,7 @@ import type { FileSystemHelperFunctions, } from 'n8n-workflow'; import { + createDeferredPromise, NodeApiError, NodeHelpers, NodeOperationError, @@ -73,6 +75,7 @@ import { ExpressionError, } from 'n8n-workflow'; +import pick from 'lodash.pick'; import { Agent } from 'https'; import { stringify } from 'qs'; import type { Token } from 'oauth-1.0a'; @@ -99,7 +102,7 @@ import type { } from 'axios'; import axios from 'axios'; import url, { URL, URLSearchParams } from 'url'; -import type { Readable } from 'stream'; +import { Readable } from 'stream'; import { access as fsAccess } from 'fs/promises'; import { createReadStream } from 'fs'; @@ -108,6 +111,13 @@ import type { IResponseError, IWorkflowSettings } from './Interfaces'; import { extractValue } from './ExtractValue'; import { getClientCredentialsToken } from './OAuth2Helper'; import { PLACEHOLDER_EMPTY_EXECUTION_ID } from './Constants'; +import { binaryToBuffer } from './BinaryDataManager/utils'; +import { + getAllWorkflowExecutionMetadata, + getWorkflowExecutionMetadata, + setAllWorkflowExecutionMetadata, + setWorkflowExecutionMetadata, +} from './WorkflowExecutionMetadata'; axios.defaults.timeout = 300000; // Prevent axios from adding x-form-www-urlencoded headers by default @@ -666,47 +676,47 @@ async function proxyRequestToAxios( return body; } } catch (error) { - const { request, response, isAxiosError, toJSON, config, ...errorData } = error; - if (configObject.simple === false && response) { - if (configObject.resolveWithFullResponse) { - return { - body: response.data, - headers: response.headers, - statusCode: response.status, - statusMessage: response.statusText, - }; - } else { - return response.data; - } - } + const { config, response } = error; // Axios hydrates the original error with more data. We extract them. // https://github.com/axios/axios/blob/master/lib/core/enhanceError.js // Note: `code` is ignored as it's an expected part of the errorData. - if (response) { - Logger.debug('Request proxied to Axios failed', { status: response.status }); - let responseData = response.data; - if (Buffer.isBuffer(responseData)) { - responseData = responseData.toString('utf-8'); + if (error.isAxiosError) { + if (response) { + Logger.debug('Request proxied to Axios failed', { status: response.status }); + let responseData = response.data; + + if (Buffer.isBuffer(responseData) || responseData instanceof Readable) { + responseData = await binaryToBuffer(responseData).then((buffer) => + buffer.toString('utf-8'), + ); + } + + if (configObject.simple === false) { + if (configObject.resolveWithFullResponse) { + return { + body: responseData, + headers: response.headers, + statusCode: response.status, + statusMessage: response.statusText, + }; + } else { + return responseData; + } + } + + const message = `${response.status as number} - ${JSON.stringify(responseData)}`; + throw Object.assign(new Error(message, { cause: error }), { + status: response.status, + options: pick(config ?? {}, ['url', 'method', 'data', 'headers']), + }); + } else { + throw Object.assign(new Error(error.message, { cause: error }), { + options: pick(config ?? {}, ['url', 'method', 'data', 'headers']), + }); } - error.message = `${response.status as number} - ${JSON.stringify(responseData)}`; } - error.cause = errorData; - error.error = error.response?.data || errorData; - error.statusCode = error.response?.status; - error.options = config || {}; - - // Remove not needed data and so also remove circular references - error.request = undefined; - error.config = undefined; - error.options.adapter = undefined; - error.options.httpsAgent = undefined; - error.options.paramsSerializer = undefined; - error.options.transformRequest = undefined; - error.options.transformResponse = undefined; - error.options.validateStatus = undefined; - throw error; } } @@ -1612,6 +1622,7 @@ export async function requestWithAuthentication( export function getAdditionalKeys( additionalData: IWorkflowExecuteAdditionalData, mode: WorkflowExecuteMode, + runExecutionData: IRunExecutionData | null, ): IWorkflowDataProxyAdditionalKeys { const executionId = additionalData.executionId || PLACEHOLDER_EMPTY_EXECUTION_ID; const resumeUrl = `${additionalData.webhookWaitingBaseUrl}/${executionId}`; @@ -1620,6 +1631,22 @@ export function getAdditionalKeys( id: executionId, mode: mode === 'manual' ? 'test' : 'production', resumeUrl, + customData: runExecutionData + ? { + set(key: string, value: string): void { + setWorkflowExecutionMetadata(runExecutionData, key, value); + }, + setAll(obj: Record): void { + setAllWorkflowExecutionMetadata(runExecutionData, obj); + }, + get(key: string): string { + return getWorkflowExecutionMetadata(runExecutionData, key); + }, + getAll(): Record { + return getAllWorkflowExecutionMetadata(runExecutionData); + }, + } + : undefined, }, // deprecated @@ -1954,6 +1981,7 @@ const getCommonWorkflowFunctions = ( node: INode, additionalData: IWorkflowExecuteAdditionalData, ): Omit => ({ + logger: Logger, getNode: () => deepCopy(node), getWorkflow: () => ({ id: workflow.id, @@ -2050,17 +2078,26 @@ const getFileSystemHelperFunctions = (node: INode): FileSystemHelperFunctions => }, }); +const getNodeHelperFunctions = ({ + executionId, +}: IWorkflowExecuteAdditionalData): NodeHelperFunctions => ({ + copyBinaryFile: async (filePath, fileName, mimeType) => + copyBinaryFile(executionId!, filePath, fileName, mimeType), +}); + const getBinaryHelperFunctions = ({ executionId, }: IWorkflowExecuteAdditionalData): BinaryHelperFunctions => ({ getBinaryStream, getBinaryMetadata, + binaryToBuffer, prepareBinaryData: async (binaryData, filePath, mimeType) => prepareBinaryData(binaryData, executionId!, filePath, mimeType), setBinaryDataBuffer: async (data, binaryData) => setBinaryDataBuffer(data, binaryData, executionId!), - copyBinaryFile: async (filePath, fileName, mimeType) => - copyBinaryFile(executionId!, filePath, fileName, mimeType), + copyBinaryFile: async () => { + throw new Error('copyBinaryFile has been removed. Please upgrade this node'); + }, }); /** @@ -2108,13 +2145,14 @@ export function getExecutePollFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), undefined, fallbackValue, options, ); }, helpers: { + createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getBinaryHelperFunctions(additionalData), returnJsonArray, @@ -2166,13 +2204,14 @@ export function getExecuteTriggerFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), undefined, fallbackValue, options, ); }, helpers: { + createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getBinaryHelperFunctions(additionalData), returnJsonArray, @@ -2224,7 +2263,7 @@ export function getExecuteFunctions( connectionInputData, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), executeData, ); }, @@ -2290,7 +2329,7 @@ export function getExecuteFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), executeData, fallbackValue, options, @@ -2307,12 +2346,13 @@ export function getExecuteFunctions( {}, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), executeData, ); return dataProxy.getDataProxy(); }, prepareOutputData: NodeHelpers.prepareOutputData, + binaryToBuffer, async putExecutionToWait(waitTill: Date): Promise { runExecutionData.waitTill = waitTill; if (additionalData.setExecutionStatus) { @@ -2347,6 +2387,7 @@ export function getExecuteFunctions( await additionalData.hooks?.executeHookFunctions('sendResponse', [response]); }, helpers: { + createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getFileSystemHelperFunctions(node), ...getBinaryHelperFunctions(additionalData), @@ -2359,6 +2400,7 @@ export function getExecuteFunctions( normalizeItems, constructExecutionMetaData, }, + nodeHelpers: getNodeHelperFunctions(additionalData), }; })(workflow, runExecutionData, connectionInputData, inputData, node) as IExecuteFunctions; } @@ -2394,7 +2436,7 @@ export function getExecuteSingleFunctions( connectionInputData, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), executeData, ); }, @@ -2465,7 +2507,7 @@ export function getExecuteSingleFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), executeData, fallbackValue, options, @@ -2482,12 +2524,13 @@ export function getExecuteSingleFunctions( {}, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), executeData, ); return dataProxy.getDataProxy(); }, helpers: { + createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getBinaryHelperFunctions(additionalData), @@ -2574,7 +2617,7 @@ export function getLoadOptionsFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), undefined, fallbackValue, options, @@ -2623,7 +2666,7 @@ export function getExecuteHookFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, runExecutionData), undefined, fallbackValue, options, @@ -2637,7 +2680,7 @@ export function getExecuteHookFunctions( additionalData, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, null), isTest, ); }, @@ -2700,7 +2743,7 @@ export function getExecuteWebhookFunctions( itemIndex, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, null), undefined, fallbackValue, options, @@ -2738,15 +2781,17 @@ export function getExecuteWebhookFunctions( additionalData, mode, additionalData.timezone, - getAdditionalKeys(additionalData, mode), + getAdditionalKeys(additionalData, mode, null), ), getWebhookName: () => webhookData.webhookDescription.name, prepareOutputData: NodeHelpers.prepareOutputData, helpers: { + createDeferredPromise, ...getRequestHelperFunctions(workflow, node, additionalData), ...getBinaryHelperFunctions(additionalData), returnJsonArray, }, + nodeHelpers: getNodeHelperFunctions(additionalData), }; })(workflow, node); } diff --git a/packages/core/src/WorkflowExecutionMetadata.ts b/packages/core/src/WorkflowExecutionMetadata.ts new file mode 100644 index 0000000000..146d5418d2 --- /dev/null +++ b/packages/core/src/WorkflowExecutionMetadata.ts @@ -0,0 +1,44 @@ +import type { IRunExecutionData } from 'n8n-workflow'; + +export const KV_LIMIT = 10; + +export function setWorkflowExecutionMetadata( + executionData: IRunExecutionData, + key: string, + value: unknown, +) { + if (!executionData.resultData.metadata) { + executionData.resultData.metadata = {}; + } + // Currently limited to 10 metadata KVs + if ( + !(key in executionData.resultData.metadata) && + Object.keys(executionData.resultData.metadata).length >= KV_LIMIT + ) { + return; + } + executionData.resultData.metadata[String(key).slice(0, 50)] = String(value).slice(0, 255); +} + +export function setAllWorkflowExecutionMetadata( + executionData: IRunExecutionData, + obj: Record, +) { + Object.entries(obj).forEach(([key, value]) => + setWorkflowExecutionMetadata(executionData, key, value), + ); +} + +export function getAllWorkflowExecutionMetadata( + executionData: IRunExecutionData, +): Record { + // Make a copy so it can't be modified directly + return { ...executionData.resultData.metadata } ?? {}; +} + +export function getWorkflowExecutionMetadata( + executionData: IRunExecutionData, + key: string, +): string { + return getAllWorkflowExecutionMetadata(executionData)[String(key).slice(0, 50)]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 34deb18f36..61cca27d8e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,9 +15,3 @@ export * from './NodeExecuteFunctions'; export * from './WorkflowExecute'; export { eventEmitter, NodeExecuteFunctions, UserSettings }; export * from './errors'; - -declare module 'http' { - export interface IncomingMessage { - rawBody: Buffer; - } -} diff --git a/packages/core/test/WorkflowExecutionMetadata.test.ts b/packages/core/test/WorkflowExecutionMetadata.test.ts new file mode 100644 index 0000000000..1c1ee49bf2 --- /dev/null +++ b/packages/core/test/WorkflowExecutionMetadata.test.ts @@ -0,0 +1,165 @@ +import { + getAllWorkflowExecutionMetadata, + getWorkflowExecutionMetadata, + KV_LIMIT, + setAllWorkflowExecutionMetadata, + setWorkflowExecutionMetadata, +} from '@/WorkflowExecutionMetadata'; +import type { IRunExecutionData } from 'n8n-workflow'; + +describe('Execution Metadata functions', () => { + test('setWorkflowExecutionMetadata will set a value', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + setWorkflowExecutionMetadata(executionData, 'test1', 'value1'); + + expect(metadata).toEqual({ + test1: 'value1', + }); + }); + + test('setAllWorkflowExecutionMetadata will set multiple values', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + setAllWorkflowExecutionMetadata(executionData, { + test1: 'value1', + test2: 'value2', + }); + + expect(metadata).toEqual({ + test1: 'value1', + test2: 'value2', + }); + }); + + test('setWorkflowExecutionMetadata should convert values to strings', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + setWorkflowExecutionMetadata(executionData, 'test1', 1234); + + expect(metadata).toEqual({ + test1: '1234', + }); + }); + + test('setWorkflowExecutionMetadata should limit the number of metadata entries', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + const expected: Record = {}; + for (let i = 0; i < KV_LIMIT; i++) { + expected[`test${i + 1}`] = `value${i + 1}`; + } + + for (let i = 0; i < KV_LIMIT + 10; i++) { + setWorkflowExecutionMetadata(executionData, `test${i + 1}`, `value${i + 1}`); + } + + expect(metadata).toEqual(expected); + }); + + test('getWorkflowExecutionMetadata should return a single value for an existing key', () => { + const metadata: Record = { test1: 'value1' }; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + expect(getWorkflowExecutionMetadata(executionData, 'test1')).toBe('value1'); + }); + + test('getWorkflowExecutionMetadata should return undefined for an unset key', () => { + const metadata: Record = { test1: 'value1' }; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + expect(getWorkflowExecutionMetadata(executionData, 'test2')).toBeUndefined(); + }); + + test('getAllWorkflowExecutionMetadata should return all metadata', () => { + const metadata: Record = { test1: 'value1', test2: 'value2' }; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + expect(getAllWorkflowExecutionMetadata(executionData)).toEqual(metadata); + }); + + test('getAllWorkflowExecutionMetadata should not an object that modifies internal state', () => { + const metadata: Record = { test1: 'value1', test2: 'value2' }; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + getAllWorkflowExecutionMetadata(executionData).test1 = 'changed'; + + expect(metadata.test1).not.toBe('changed'); + expect(metadata.test1).toBe('value1'); + }); + + test('setWorkflowExecutionMetadata should truncate long keys', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + setWorkflowExecutionMetadata( + executionData, + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', + 'value1', + ); + + expect(metadata).toEqual({ + aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: 'value1', + }); + }); + + test('setWorkflowExecutionMetadata should truncate long values', () => { + const metadata = {}; + const executionData = { + resultData: { + metadata, + }, + } as IRunExecutionData; + + setWorkflowExecutionMetadata( + executionData, + 'test1', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab', + ); + + expect(metadata).toEqual({ + test1: + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }); + }); +}); diff --git a/packages/design-system/.gitignore b/packages/design-system/.gitignore index 728ede7e79..6d9b4385c1 100644 --- a/packages/design-system/.gitignore +++ b/packages/design-system/.gitignore @@ -1,3 +1,2 @@ -coverage storybook-static **/*.stories.js diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 3f86c997d0..60ceedd67b 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -1,6 +1,6 @@ { "name": "n8n-design-system", - "version": "0.56.0", + "version": "0.59.0", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", "author": { @@ -16,8 +16,7 @@ "clean": "rimraf dist .turbo", "build": "vite build", "typecheck": "vue-tsc --emitDeclarationOnly", - "test": "vitest run", - "test:ci": "vitest run --coverage", + "test": "vitest run --coverage", "test:dev": "vitest", "build:storybook": "storybook build", "storybook": "storybook dev -p 6006", @@ -54,6 +53,7 @@ "@types/markdown-it-emoji": "^2.0.2", "@types/markdown-it-link-attributes": "^3.0.1", "@types/sanitize-html": "^2.8.0", + "@vitest/coverage-c8": "^0.28.5", "@vitejs/plugin-vue2": "^2.2.0", "autoprefixer": "^10.4.13", "c8": "7.12.0", @@ -67,7 +67,7 @@ "storybook-addon-themes": "^6.1.0", "trim": "^1.0.1", "vite": "^4.0.4", - "vitest": "^0.28.4", + "vitest": "^0.28.5", "vue-class-component": "^7.2.6", "vue-loader": "^15.10.1", "vue-property-decorator": "^9.1.2", @@ -80,7 +80,7 @@ "markdown-it-emoji": "^2.0.2", "markdown-it-link-attributes": "^4.0.1", "markdown-it-task-lists": "^2.1.1", - "sanitize-html": "2.9.0", + "sanitize-html": "2.10.0", "vue": "^2.7.14", "vue-typed-mixins": "^0.2.0", "vue2-boring-avatars": "^0.3.8", diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.stories.ts b/packages/design-system/src/components/N8nDatatable/Datatable.stories.ts new file mode 100644 index 0000000000..3166530de1 --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/Datatable.stories.ts @@ -0,0 +1,21 @@ +import N8nDatatable from './Datatable.vue'; +import type { StoryFn } from '@storybook/vue'; +import { rows, columns } from './__tests__/data'; + +export default { + title: 'Atoms/Datatable', + component: N8nDatatable, +}; + +export const Default: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nDatatable, + }, + template: '', +}); + +Default.args = { + columns, + rows, +}; diff --git a/packages/design-system/src/components/N8nDatatable/Datatable.vue b/packages/design-system/src/components/N8nDatatable/Datatable.vue new file mode 100644 index 0000000000..475e14e8df --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/Datatable.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/Datatable.spec.ts b/packages/design-system/src/components/N8nDatatable/__tests__/Datatable.spec.ts new file mode 100644 index 0000000000..3322246e37 --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/__tests__/Datatable.spec.ts @@ -0,0 +1,23 @@ +import { render } from '@testing-library/vue'; +import N8nDatatable from '../Datatable.vue'; +import { rows, columns } from './data'; + +const stubs = ['n8n-select', 'n8n-option', 'n8n-button', 'n8n-pagination']; + +describe('components', () => { + describe('N8nDatatable', () => { + it('should render correctly', () => { + const wrapper = render(N8nDatatable, { + propsData: { + columns, + rows, + }, + stubs, + }); + + expect(wrapper.container.querySelectorAll('tbody tr').length).toEqual(10); + expect(wrapper.container.querySelectorAll('thead tr').length).toEqual(1); + expect(wrapper.html()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap b/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap new file mode 100644 index 0000000000..3b419dbc69 --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/__tests__/__snapshots__/Datatable.spec.ts.snap @@ -0,0 +1,100 @@ +// Vitest Snapshot v1 + +exports[`components > N8nDatatable > should render correctly 1`] = ` +"
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID Name Age Action
1Richard Hendricks29
2Bertram Gilfoyle44
3Dinesh Chugtai31
4Jared Dunn 38
5Richard Hendricks29
6Bertram Gilfoyle44
7Dinesh Chugtai31
8Jared Dunn 38
9Richard Hendricks29
10Bertram Gilfoyle44
+
+ +
+ + + + + + + +
+
+
" +`; diff --git a/packages/design-system/src/components/N8nDatatable/__tests__/data.ts b/packages/design-system/src/components/N8nDatatable/__tests__/data.ts new file mode 100644 index 0000000000..b1a1315dd0 --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/__tests__/data.ts @@ -0,0 +1,45 @@ +import { defineComponent, h, PropType } from 'vue'; +import { DatatableRow } from '../mixins'; +import N8nButton from '../../N8nButton'; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ActionComponent = defineComponent({ + props: { + row: { + type: Object as PropType, + default: () => ({}), + }, + }, + setup(props) { + return () => h(N8nButton, {}, [`Button ${props.row.id}`]); + }, +}); + +export const columns = [ + { id: 'id', path: 'id', label: 'ID' }, + { id: 'name', path: 'name', label: 'Name' }, + { id: 'age', path: 'meta.age', label: 'Age' }, + { + id: 'action', + label: 'Action', + render: ActionComponent, + }, +]; + +export const rows = [ + { id: 1, name: 'Richard Hendricks', meta: { age: 29 } }, + { id: 2, name: 'Bertram Gilfoyle', meta: { age: 44 } }, + { id: 3, name: 'Dinesh Chugtai', meta: { age: 31 } }, + { id: 4, name: 'Jared Dunn ', meta: { age: 38 } }, + { id: 5, name: 'Richard Hendricks', meta: { age: 29 } }, + { id: 6, name: 'Bertram Gilfoyle', meta: { age: 44 } }, + { id: 7, name: 'Dinesh Chugtai', meta: { age: 31 } }, + { id: 8, name: 'Jared Dunn ', meta: { age: 38 } }, + { id: 9, name: 'Richard Hendricks', meta: { age: 29 } }, + { id: 10, name: 'Bertram Gilfoyle', meta: { age: 44 } }, + { id: 11, name: 'Dinesh Chugtai', meta: { age: 31 } }, + { id: 12, name: 'Jared Dunn ', meta: { age: 38 } }, + { id: 13, name: 'Richard Hendricks', meta: { age: 29 } }, + { id: 14, name: 'Bertram Gilfoyle', meta: { age: 44 } }, + { id: 15, name: 'Dinesh Chugtai', meta: { age: 31 } }, +]; diff --git a/packages/design-system/src/components/N8nDatatable/index.ts b/packages/design-system/src/components/N8nDatatable/index.ts new file mode 100644 index 0000000000..78a4e7dff4 --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/index.ts @@ -0,0 +1,3 @@ +import N8nDatatable from './Datatable.vue'; + +export default N8nDatatable; diff --git a/packages/design-system/src/components/N8nDatatable/mixins.ts b/packages/design-system/src/components/N8nDatatable/mixins.ts new file mode 100644 index 0000000000..907b86c98c --- /dev/null +++ b/packages/design-system/src/components/N8nDatatable/mixins.ts @@ -0,0 +1,16 @@ +import { VNode } from 'vue'; + +export type DatatableRowDataType = string | number | boolean | null | undefined; + +export interface DatatableRow { + id: string | number; + + [key: string]: DatatableRowDataType; +} + +export interface DatatableColumn { + id: string | number; + path: string; + label: string; + render: (row: DatatableRow) => (() => VNode | VNode[]) | DatatableRowDataType; +} diff --git a/packages/design-system/src/components/N8nFormBox/FormBox.vue b/packages/design-system/src/components/N8nFormBox/FormBox.vue index 5cedc6d9aa..465fa957f3 100644 --- a/packages/design-system/src/components/N8nFormBox/FormBox.vue +++ b/packages/design-system/src/components/N8nFormBox/FormBox.vue @@ -33,6 +33,7 @@ {{ redirectText }} + diff --git a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue b/packages/design-system/src/components/N8nInputLabel/InputLabel.vue index e600a80cb5..6dc5d2a840 100644 --- a/packages/design-system/src/components/N8nInputLabel/InputLabel.vue +++ b/packages/design-system/src/components/N8nInputLabel/InputLabel.vue @@ -206,7 +206,7 @@ export default Vue.extend({ border-bottom: var(--border-base); } -.tooltipPopper { +:root .tooltipPopper { max-width: 400px; li { diff --git a/packages/design-system/src/components/N8nPagination/Pagination.stories.ts b/packages/design-system/src/components/N8nPagination/Pagination.stories.ts new file mode 100644 index 0000000000..3fa4d573b7 --- /dev/null +++ b/packages/design-system/src/components/N8nPagination/Pagination.stories.ts @@ -0,0 +1,23 @@ +import type { StoryFn } from '@storybook/vue'; +import N8nPagination from './Pagination.vue'; + +export default { + title: 'Atoms/Pagination', + component: N8nPagination, +}; + +const Template: StoryFn = (args, { argTypes }) => ({ + props: Object.keys(argTypes), + components: { + N8nPagination, + }, + template: '', +}); + +export const Pagination: StoryFn = Template.bind({}); +Pagination.args = { + currentPage: 1, + pagerCount: 5, + pageSize: 10, + total: 100, +}; diff --git a/packages/design-system/src/components/N8nPagination/Pagination.vue b/packages/design-system/src/components/N8nPagination/Pagination.vue new file mode 100644 index 0000000000..e5bc468151 --- /dev/null +++ b/packages/design-system/src/components/N8nPagination/Pagination.vue @@ -0,0 +1,22 @@ + + + diff --git a/packages/design-system/src/components/N8nPagination/index.ts b/packages/design-system/src/components/N8nPagination/index.ts new file mode 100644 index 0000000000..0241347c61 --- /dev/null +++ b/packages/design-system/src/components/N8nPagination/index.ts @@ -0,0 +1,3 @@ +import N8nPagination from './Pagination.vue'; + +export default N8nPagination; diff --git a/packages/design-system/src/components/N8nUserSelect/UserSelect.vue b/packages/design-system/src/components/N8nUserSelect/UserSelect.vue index 5be64e22fd..cdb01b68bd 100644 --- a/packages/design-system/src/components/N8nUserSelect/UserSelect.vue +++ b/packages/design-system/src/components/N8nUserSelect/UserSelect.vue @@ -149,7 +149,7 @@ export default mixins(Locale).extend({ --select-option-line-height: 1; } -.limitPopperWidth { +:root .limitPopperWidth { width: 0; li > span { diff --git a/packages/design-system/src/composables/index.ts b/packages/design-system/src/composables/index.ts new file mode 100644 index 0000000000..19ab1d994e --- /dev/null +++ b/packages/design-system/src/composables/index.ts @@ -0,0 +1 @@ +export * from './useI18n'; diff --git a/packages/design-system/src/composables/useI18n.ts b/packages/design-system/src/composables/useI18n.ts new file mode 100644 index 0000000000..9c18f3e6ac --- /dev/null +++ b/packages/design-system/src/composables/useI18n.ts @@ -0,0 +1,7 @@ +import { t } from '../locale'; + +export function useI18n() { + return { + t: (path: string, options: string[] = []) => t(path, options), + }; +} diff --git a/packages/design-system/src/locale/lang/en.js b/packages/design-system/src/locale/lang/en.js index faaa58e830..3038b3b248 100644 --- a/packages/design-system/src/locale/lang/en.js +++ b/packages/design-system/src/locale/lang/en.js @@ -18,4 +18,5 @@ export default { '8+ characters, at least 1 number and 1 capital letter', 'sticky.markdownHint': `You can style with Markdown`, 'tags.showMore': (count) => `+${count} more`, + 'datatable.pageSize': 'Page size', }; diff --git a/packages/design-system/src/utils/__tests__/valueByPath.spec.ts b/packages/design-system/src/utils/__tests__/valueByPath.spec.ts new file mode 100644 index 0000000000..fc5bfea6ad --- /dev/null +++ b/packages/design-system/src/utils/__tests__/valueByPath.spec.ts @@ -0,0 +1,43 @@ +import { getValueByPath } from '@/utils'; + +describe('getValueByPath()', () => { + const object = { + id: '1', + name: 'Richard Hendricks', + address: { + city: 'Palo Alto', + state: 'California', + country: 'United States', + }, + }; + + it('should return direct field from object', () => { + const path = 'name'; + + expect(getValueByPath(object, path)).toEqual(object.name); + }); + + it('should return nested field from object', () => { + const path = 'address.country'; + + expect(getValueByPath(object, path)).toEqual(object.address.country); + }); + + it('should return undefined if direct field does not exist', () => { + const path = 'other'; + + expect(getValueByPath(object, path)).toEqual(undefined); + }); + + it('should return undefined if nested field does not exist', () => { + const path = 'address.other'; + + expect(getValueByPath(object, path)).toEqual(undefined); + }); + + it('should return undefined if path does not exist', () => { + const path = 'other.other'; + + expect(getValueByPath(object, path)).toEqual(undefined); + }); +}); diff --git a/packages/design-system/src/utils/index.ts b/packages/design-system/src/utils/index.ts index a601378f5d..b2e1c68129 100644 --- a/packages/design-system/src/utils/index.ts +++ b/packages/design-system/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './markdown'; export * from './uid'; +export * from './valueByPath'; diff --git a/packages/design-system/src/utils/valueByPath.ts b/packages/design-system/src/utils/valueByPath.ts new file mode 100644 index 0000000000..a596df7098 --- /dev/null +++ b/packages/design-system/src/utils/valueByPath.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access */ + +/** + * Get a deeply nested value based on a given path string + * + * @param object + * @param path + * @returns {T} + */ +export function getValueByPath(object: any, path: string): T { + return path.split('.').reduce((acc, part) => { + return acc && acc[part]; + }, object); +} diff --git a/packages/design-system/vite.config.ts b/packages/design-system/vite.config.ts index 7496e84b6f..8c1381c524 100644 --- a/packages/design-system/vite.config.ts +++ b/packages/design-system/vite.config.ts @@ -3,6 +3,8 @@ import { resolve } from 'path'; import { defineConfig, mergeConfig } from 'vite'; import { defineConfig as defineVitestConfig } from 'vitest/config'; +const { coverageReporters } = require('../../jest.config.js'); + export default mergeConfig( defineConfig({ plugins: [vue()], @@ -38,6 +40,12 @@ export default mergeConfig( globals: true, environment: 'jsdom', setupFiles: ['./src/__tests__/setup.ts'], + coverage: { + provider: 'c8', + reporter: coverageReporters, + include: ['src/**/*.ts'], + all: true, + }, css: { modules: { classNameStrategy: 'non-scoped', diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 18baff6053..a2330f14cb 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "0.184.0", + "version": "0.187.0", "description": "Workflow Editor UI for n8n", "license": "SEE LICENSE IN LICENSE.md", "homepage": "https://n8n.io", @@ -22,8 +22,7 @@ "lintfix": "eslint --ext .js,.ts,.vue src --fix", "format": "prettier --write . --ignore-path ../../.prettierignore", "serve": "cross-env VUE_APP_URL_BASE_API=http://localhost:5678/ vite --host 0.0.0.0 --port 8080 dev", - "test": "vitest run", - "test:ci": "vitest run --coverage", + "test": "vitest run --coverage", "test:dev": "vitest" }, "dependencies": { @@ -56,7 +55,7 @@ "jquery": "^3.4.1", "jsonpath": "^1.1.1", "lodash-es": "^4.17.21", - "luxon": "^3.1.0", + "luxon": "^3.3.0", "monaco-editor": "^0.33.0", "n8n-design-system": "workspace:*", "n8n-workflow": "workspace:*", @@ -84,8 +83,10 @@ "xss": "^1.0.10" }, "devDependencies": { + "@faker-js/faker": "^7.6.0", "@pinia/testing": "^0.0.14", "@testing-library/jest-dom": "^5.16.5", + "@testing-library/user-event": "^14.4.3", "@testing-library/vue": "^5.8.3", "@types/dateformat": "^3.0.0", "@types/express": "^4.17.6", @@ -96,18 +97,20 @@ "@types/lodash.camelcase": "^4.3.6", "@types/lodash.get": "^4.4.6", "@types/lodash.set": "^4.3.6", - "@types/luxon": "^2.0.9", + "@types/luxon": "^3.2.0", "@types/uuid": "^8.3.2", + "@vitest/coverage-c8": "^0.28.5", "@vitejs/plugin-legacy": "^3.0.1", "@vitejs/plugin-vue2": "^2.2.0", "c8": "^7.12.0", "jshint": "^2.9.7", + "miragejs": "^0.1.47", "sass": "^1.55.0", "sass-loader": "^10.1.1", "string-template-parser": "^1.2.6", "vite": "4.0.4", "vite-plugin-monaco-editor": "^1.0.10", - "vitest": "^0.28.4", + "vitest": "^0.28.5", "vue-tsc": "^1.0.24" } } diff --git a/packages/editor-ui/src/App.vue b/packages/editor-ui/src/App.vue index fa1d40a00e..035416a1da 100644 --- a/packages/editor-ui/src/App.vue +++ b/packages/editor-ui/src/App.vue @@ -46,8 +46,9 @@ import { useRootStore } from './stores/n8nRootStore'; import { useTemplatesStore } from './stores/templates'; import { useNodeTypesStore } from './stores/nodeTypes'; import { historyHelper } from '@/mixins/history'; +import { newVersions } from '@/mixins/newVersions'; -export default mixins(showMessage, userHelpers, restApi, historyHelper).extend({ +export default mixins(newVersions, showMessage, userHelpers, restApi, historyHelper).extend({ name: 'App', components: { LoadingView, @@ -186,6 +187,7 @@ export default mixins(showMessage, userHelpers, restApi, historyHelper).extend({ this.logHiringBanner(); this.authenticate(); this.redirectIfNecessary(); + this.checkForNewVersions(); this.loading = false; diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 8503e38759..d6610f7611 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -71,6 +71,11 @@ declare global { analytics?: { track(event: string, proeprties?: ITelemetryTrackProperties): void; }; + featureFlags?: { + getAll: () => FeatureFlags; + getVariant: (name: string) => string | boolean | undefined; + override: (name: string, value: string) => void; + }; } } @@ -137,9 +142,9 @@ export interface IExternalHooks { export interface IRestApi { getActiveWorkflows(): Promise; getActivationError(id: string): Promise; - getCurrentExecutions(filter: IDataObject): Promise; + getCurrentExecutions(filter: ExecutionsQueryFilter): Promise; getPastExecutions( - filter: IDataObject, + filter: ExecutionsQueryFilter, limit: number, lastId?: string, firstId?: string, @@ -393,7 +398,7 @@ export interface IExecutionsStopData { export interface IExecutionDeleteFilter { deleteBefore?: Date; - filters?: IDataObject; + filters?: ExecutionsQueryFilter; ids?: string[]; } @@ -579,6 +584,7 @@ export interface IUserResponse { firstName?: string; lastName?: string; email?: string; + createdAt?: string; globalRole?: { name: IRole; id: string; @@ -599,7 +605,6 @@ export interface IUser extends IUserResponse { isOwner: boolean; inviteAcceptUrl?: string; fullName?: string; - createdAt?: string; } export interface IVersionNotificationSettings { @@ -623,10 +628,17 @@ export interface IN8nPromptResponse { updated: boolean; } +export enum UserManagementAuthenticationMethod { + Email = 'email', + Ldap = 'ldap', + Saml = 'saml', +} + export interface IUserManagementConfig { enabled: boolean; showSetupOnFirstLoad?: boolean; smtpSetup: boolean; + authenticationMethod: UserManagementAuthenticationMethod; } export interface IPermissionGroup { @@ -1179,11 +1191,22 @@ export type IFakeDoorLocation = export type INodeFilterType = typeof REGULAR_NODE_FILTER | typeof TRIGGER_NODE_FILTER; +export type NodeCreatorOpenSource = + | '' + | 'no_trigger_execution_tooltip' + | 'plus_endpoint' + | 'trigger_placeholder_button' + | 'tab' + | 'node_connection_action' + | 'node_connection_drop' + | 'add_node_button'; + export interface INodeCreatorState { itemsFilter: string; showScrim: boolean; rootViewHistory: INodeFilterType[]; selectedView: INodeFilterType; + openSource: NodeCreatorOpenSource; } export interface ISettingsState { @@ -1437,3 +1460,27 @@ export type NodeAuthenticationOption = { value: string; displayOptions?: IDisplayOptions; }; + +export type ExecutionFilterMetadata = { + key: string; + value: string; +}; + +export type ExecutionFilterType = { + status: string; + workflowId: string; + startDate: string | Date; + endDate: string | Date; + tags: string[]; + metadata: ExecutionFilterMetadata[]; +}; + +export type ExecutionsQueryFilter = { + status?: ExecutionStatus[]; + workflowId?: string; + finished?: boolean; + waitTill?: boolean; + metadata?: Array<{ key: string; value: string }>; + startedAfter?: string; + startedBefore?: string; +}; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/credential.ts b/packages/editor-ui/src/__tests__/server/endpoints/credential.ts new file mode 100644 index 0000000000..949004b487 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/credential.ts @@ -0,0 +1,10 @@ +import { Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; + +export function routesForCredentials(server: Server) { + server.get('/rest/credentials', (schema: AppSchema) => { + const { models: data } = schema.all('credential'); + + return new Response(200, {}, { data }); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts b/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts new file mode 100644 index 0000000000..3f9ee6eaaf --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/credentialType.ts @@ -0,0 +1,10 @@ +import { Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; + +export function routesForCredentialTypes(server: Server) { + server.get('/types/credentials.json', (schema: AppSchema) => { + const { models: data } = schema.all('credentialType'); + + return new Response(200, {}, data); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/endpoints/index.ts b/packages/editor-ui/src/__tests__/server/endpoints/index.ts new file mode 100644 index 0000000000..0b82fa233b --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/index.ts @@ -0,0 +1,12 @@ +import { routesForUsers } from './user'; +import { routesForCredentials } from './credential'; +import { Server } from 'miragejs'; +import { routesForCredentialTypes } from '@/__tests__/server/endpoints/credentialType'; + +const endpoints: Array<(server: Server) => void> = [ + routesForCredentials, + routesForCredentialTypes, + routesForUsers, +]; + +export { endpoints }; diff --git a/packages/editor-ui/src/__tests__/server/endpoints/user.ts b/packages/editor-ui/src/__tests__/server/endpoints/user.ts new file mode 100644 index 0000000000..7822231bac --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/endpoints/user.ts @@ -0,0 +1,10 @@ +import { Response, Server } from 'miragejs'; +import { AppSchema } from '../types'; + +export function routesForUsers(server: Server) { + server.get('/rest/users', (schema: AppSchema) => { + const { models: data } = schema.all('user'); + + return new Response(200, {}, { data }); + }); +} diff --git a/packages/editor-ui/src/__tests__/server/factories/credential.ts b/packages/editor-ui/src/__tests__/server/factories/credential.ts new file mode 100644 index 0000000000..b97bbbaea5 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/credential.ts @@ -0,0 +1,24 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import type { ICredentialsResponse } from '@/Interface'; + +export const credentialFactory = Factory.extend({ + id(i: number) { + return `${i}`; + }, + createdAt() { + return faker.date.recent().toISOString(); + }, + name() { + return faker.company.name(); + }, + nodesAccess() { + return []; + }, + type() { + return 'notionApi'; + }, + updatedAt() { + return ''; + }, +}); diff --git a/packages/editor-ui/src/__tests__/server/factories/credentialType.ts b/packages/editor-ui/src/__tests__/server/factories/credentialType.ts new file mode 100644 index 0000000000..517e4e7207 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/credentialType.ts @@ -0,0 +1,26 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import type { ICredentialType } from 'n8n-workflow'; + +const credentialTypes = [ + 'airtableApi', + 'dropboxApi', + 'figmaApi', + 'googleApi', + 'gitlabApi', + 'jenkinsApi', + 'metabaseApi', + 'notionApi', +]; + +export const credentialTypeFactory = Factory.extend({ + name(i) { + return credentialTypes[i]; + }, + displayName(i) { + return credentialTypes[i]; + }, + properties() { + return []; + }, +}); diff --git a/packages/editor-ui/src/__tests__/server/factories/index.ts b/packages/editor-ui/src/__tests__/server/factories/index.ts new file mode 100644 index 0000000000..181ff9b9a1 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/index.ts @@ -0,0 +1,13 @@ +import { userFactory } from './user'; +import { credentialFactory } from './credential'; +import { credentialTypeFactory } from './credentialType'; + +export * from './user'; +export * from './credential'; +export * from './credentialType'; + +export const factories = { + credential: credentialFactory, + credentialType: credentialTypeFactory, + user: userFactory, +}; diff --git a/packages/editor-ui/src/__tests__/server/factories/user.ts b/packages/editor-ui/src/__tests__/server/factories/user.ts new file mode 100644 index 0000000000..f8d593b564 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/factories/user.ts @@ -0,0 +1,31 @@ +import { Factory } from 'miragejs'; +import { faker } from '@faker-js/faker'; +import { SignInType } from '@/constants'; +import type { IUser } from '@/Interface'; + +export const userFactory = Factory.extend({ + id(i: number) { + return `${i}`; + }, + firstName() { + return faker.name.firstName(); + }, + lastName() { + return faker.name.lastName(); + }, + isDefaultUser() { + return false; + }, + isOwner() { + return false; + }, + isPending() { + return false; + }, + isPendingUser() { + return false; + }, + signInType(): SignInType { + return SignInType.EMAIL; + }, +}); diff --git a/packages/editor-ui/src/__tests__/server/index.ts b/packages/editor-ui/src/__tests__/server/index.ts new file mode 100644 index 0000000000..edff6894e7 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/index.ts @@ -0,0 +1,42 @@ +import { createServer } from 'miragejs'; +import { endpoints } from './endpoints'; +import { models } from './models'; +import { factories } from './factories'; + +export function setupServer() { + const server = createServer({ + models, + factories, + seeds(server) { + server.createList('credentialType', 8); + server.create('user', { + isDefaultUser: true, + }); + }, + }); + + // Set server url prefix + server.urlPrefix = process.env.API_URL || ''; + + // Enable logging + server.logging = false; + + // Handle undefined endpoints + server.post('/rest/:any', () => new Promise(() => {})); + + // Handle defined endpoints + for (const endpointsFn of endpoints) { + endpointsFn(server); + } + + // Reset for everything else + server.namespace = ''; + server.passthrough(); + + if (server.logging) { + console.log('Mirage database'); + console.log(server.db.dump()); + } + + return server; +} diff --git a/packages/editor-ui/src/__tests__/server/models/credential.ts b/packages/editor-ui/src/__tests__/server/models/credential.ts new file mode 100644 index 0000000000..17d4f45dcf --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/credential.ts @@ -0,0 +1,5 @@ +import { ICredentialsResponse } from '@/Interface'; +import { Model } from 'miragejs'; +import type { ModelDefinition } from 'miragejs/-types'; + +export const CredentialModel: ModelDefinition = Model.extend({}); diff --git a/packages/editor-ui/src/__tests__/server/models/credentialType.ts b/packages/editor-ui/src/__tests__/server/models/credentialType.ts new file mode 100644 index 0000000000..216260e0b7 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/credentialType.ts @@ -0,0 +1,5 @@ +import { Model } from 'miragejs'; +import type { ModelDefinition } from 'miragejs/-types'; +import type { ICredentialType } from 'n8n-workflow'; + +export const CredentialTypeModel: ModelDefinition = Model.extend({}); diff --git a/packages/editor-ui/src/__tests__/server/models/index.ts b/packages/editor-ui/src/__tests__/server/models/index.ts new file mode 100644 index 0000000000..310b832a4d --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/index.ts @@ -0,0 +1,9 @@ +import { UserModel } from './user'; +import { CredentialModel } from './credential'; +import { CredentialTypeModel } from './credentialType'; + +export const models = { + credential: CredentialModel, + credentialType: CredentialTypeModel, + user: UserModel, +}; diff --git a/packages/editor-ui/src/__tests__/server/models/user.ts b/packages/editor-ui/src/__tests__/server/models/user.ts new file mode 100644 index 0000000000..cef64c360c --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/models/user.ts @@ -0,0 +1,5 @@ +import { IUser } from '@/Interface'; +import { Model } from 'miragejs'; +import type { ModelDefinition } from 'miragejs/-types'; + +export const UserModel: ModelDefinition = Model.extend({}); diff --git a/packages/editor-ui/src/__tests__/server/types.ts b/packages/editor-ui/src/__tests__/server/types.ts new file mode 100644 index 0000000000..bc3e75e4f3 --- /dev/null +++ b/packages/editor-ui/src/__tests__/server/types.ts @@ -0,0 +1,10 @@ +import { Registry } from 'miragejs'; + +// eslint-disable-next-line import/no-unresolved +import Schema from 'miragejs/orm/schema'; + +import { models } from './models'; +import { factories } from './factories'; + +type AppRegistry = Registry; +export type AppSchema = Schema; diff --git a/packages/editor-ui/src/api/credentials.ts b/packages/editor-ui/src/api/credentials.ts index 54f58b54ca..cf9d13932b 100644 --- a/packages/editor-ui/src/api/credentials.ts +++ b/packages/editor-ui/src/api/credentials.ts @@ -10,7 +10,7 @@ import { import axios from 'axios'; export async function getCredentialTypes(baseUrl: string): Promise { - const { data } = await axios.get(baseUrl + 'types/credentials.json'); + const { data } = await axios.get(baseUrl + 'types/credentials.json', { withCredentials: true }); return data; } diff --git a/packages/editor-ui/src/api/nodeTypes.ts b/packages/editor-ui/src/api/nodeTypes.ts index bd3a9ec677..7c7b97a025 100644 --- a/packages/editor-ui/src/api/nodeTypes.ts +++ b/packages/editor-ui/src/api/nodeTypes.ts @@ -17,7 +17,7 @@ import type { import axios from 'axios'; export async function getNodeTypes(baseUrl: string) { - const { data } = await axios.get(baseUrl + 'types/nodes.json'); + const { data } = await axios.get(baseUrl + 'types/nodes.json', { withCredentials: true }); return data; } diff --git a/packages/editor-ui/src/api/sso.ts b/packages/editor-ui/src/api/sso.ts new file mode 100644 index 0000000000..5019335d35 --- /dev/null +++ b/packages/editor-ui/src/api/sso.ts @@ -0,0 +1,6 @@ +import { makeRestApiRequest } from '@/utils'; +import { IRestApiContext } from '@/Interface'; + +export const initSSO = (context: IRestApiContext): Promise => { + return makeRestApiRequest(context, 'GET', '/sso/saml/initsso'); +}; diff --git a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts index a2abdb1240..cf1e972d8d 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/baseExtensions.ts @@ -14,6 +14,7 @@ import { indentWithTab, insertNewlineAndIndent, toggleComment, + redo, } from '@codemirror/commands'; import { lintGutter } from '@codemirror/lint'; @@ -36,6 +37,7 @@ export const baseExtensions = [ { key: 'Tab', run: acceptCompletion }, { key: 'Enter', run: acceptCompletion }, { key: 'Mod-/', run: toggleComment }, + { key: 'Mod-Shift-z', run: redo }, indentWithTab, ]), EditorView.lineWrapping, diff --git a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts index cb48a3436b..558e321960 100644 --- a/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts +++ b/packages/editor-ui/src/components/CodeNodeEditor/completions/execution.completions.ts @@ -18,6 +18,15 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({ if (!preCursor || (preCursor.from === preCursor.to && !context.explicit)) return null; + const buildLinkNode = (text: string) => { + const wrapper = document.createElement('span'); + // This is being loaded from the locales file. This could + // cause an XSS of some kind but multiple other locales strings + // do the same thing. + wrapper.innerHTML = text; + return () => wrapper; + }; + const options: Completion[] = [ { label: `${matcher}.id`, @@ -31,6 +40,30 @@ export const executionCompletions = (Vue as CodeNodeEditorMixin).extend({ label: `${matcher}.resumeUrl`, info: this.$locale.baseText('codeNodeEditor.completer.$execution.resumeUrl'), }, + { + label: `${matcher}.customData.set("key", "value")`, + info: buildLinkNode( + this.$locale.baseText('codeNodeEditor.completer.$execution.customData.set()'), + ), + }, + { + label: `${matcher}.customData.get("key")`, + info: buildLinkNode( + this.$locale.baseText('codeNodeEditor.completer.$execution.customData.get()'), + ), + }, + { + label: `${matcher}.customData.setAll({})`, + info: buildLinkNode( + this.$locale.baseText('codeNodeEditor.completer.$execution.customData.setAll()'), + ), + }, + { + label: `${matcher}.customData.getAll()`, + info: buildLinkNode( + this.$locale.baseText('codeNodeEditor.completer.$execution.customData.getAll()'), + ), + }, ]; return { diff --git a/packages/editor-ui/src/components/ExecutionFilter.vue b/packages/editor-ui/src/components/ExecutionFilter.vue new file mode 100644 index 0000000000..5cc1244954 --- /dev/null +++ b/packages/editor-ui/src/components/ExecutionFilter.vue @@ -0,0 +1,418 @@ + + + + + diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index 82815dcd9f..b29b7a3abf 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -1,50 +1,45 @@