From a4757cf009839a951e8cd5295cd9d549a21f5714 Mon Sep 17 00:00:00 2001 From: Tomi Turtiainen <10324676+tomi@users.noreply.github.com> Date: Mon, 1 Dec 2025 20:44:59 +0200 Subject: [PATCH] chore: Initial V2 changes (#22553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Iván Ovejero Co-authored-by: yehorkardash Co-authored-by: Daria Co-authored-by: Svetoslav Dekov Co-authored-by: Nikhil Kuriakose Co-authored-by: Charlie Kolb --- .github/workflows/ci-postgres-mysql.yml | 4 +- CONTRIBUTING.md | 6 - docker/images/n8n/Dockerfile | 28 +- docker/images/n8n/README.md | 20 - docker/images/n8n/n8n-task-runners.json | 35 - docker/images/runners/README.md | 1 - .../backend-test-utils/src/db/workflows.ts | 28 - packages/@n8n/benchmark/package.json | 2 +- .../n8n-setups/postgres/docker-compose.yml | 1 - .../scaling-multi-main/docker-compose.yml | 2 - .../scaling-single-main/docker-compose.yml | 2 - .../n8n-setups/sqlite/docker-compose.yml | 1 - .../configs/__tests__/database.config.test.ts | 22 +- .../config/src/configs/database.config.ts | 31 +- .../src/configs/instance-settings-config.ts | 2 +- .../@n8n/config/src/configs/nodes.config.ts | 9 +- .../@n8n/config/src/configs/runners.config.ts | 3 +- .../config/src/configs/scaling-mode.config.ts | 4 - .../config/src/configs/security.config.ts | 17 +- .../config/src/configs/workflows.config.ts | 4 - packages/@n8n/config/test/config.test.ts | 19 +- .../__tests__/workflow.repository.test.ts | 4 + .../src/repositories/workflow.repository.ts | 61 +- packages/cli/bin/n8n | 2 +- packages/cli/package.json | 10 +- packages/cli/src/command-registry.ts | 55 +- .../commands/__tests__/execute-batch.test.ts | 3 +- .../src/commands/__tests__/execute.test.ts | 3 +- packages/cli/src/commands/base-command.ts | 77 +- packages/cli/src/commands/publish/workflow.ts | 56 ++ packages/cli/src/commands/start.ts | 32 +- .../cli/src/commands/unpublish/workflow.ts | 54 ++ packages/cli/src/commands/update/workflow.ts | 55 +- packages/cli/src/config/index.ts | 39 +- .../abstract-oauth.controller.test.ts | 2 +- .../oauth/abstract-oauth.controller.ts | 3 +- .../spec/paths/workflows.id.activate.yml | 19 + .../workflows/spec/paths/workflows.id.yml | 2 +- .../handlers/workflows/workflows.handler.ts | 1 + .../scaling/__tests__/scaling.service.test.ts | 2 +- packages/cli/src/scaling/scaling.service.ts | 2 +- .../cli/src/workflows/workflow.service.ts | 177 ++--- .../commands/publish/workflow.test.ts | 111 +++ .../commands/unpublish/workflow.test.ts | 144 ++++ .../commands/update/workflow.test.ts | 48 +- .../integration/commands/worker.cmd.test.ts | 1 + .../repositories/workflow.repository.test.ts | 101 ++- .../integration/public-api/workflows.test.ts | 203 ++--- .../instance-risk-reporter.test.ts | 5 +- ...-task-runner-execution.integration.test.ts | 1 + .../task-runner-module.internal.test.ts | 1 + .../workflows/workflow.service.test.ts | 123 +-- .../workflows/workflows.controller.ee.test.ts | 47 -- .../workflows/workflows.controller.test.ts | 709 +++++++----------- packages/cli/test/setup-test-folder.ts | 2 +- .../__tests__/binary-data.config.test.ts | 22 +- .../src/binary-data/binary-data.config.ts | 13 +- .../src/binary-data/binary-data.service.ts | 31 +- .../file-system-helper-functions.test.ts | 31 +- .../utils/file-system-helper-functions.ts | 14 +- .../__tests__/instance-settings.test.ts | 53 +- .../instance-settings/instance-settings.ts | 46 +- .../frontend/@n8n/i18n/src/locales/en.json | 25 +- .../src/api/workflowHistory.ts | 16 +- packages/frontend/editor-ui/src/Interface.ts | 2 + .../editor-ui/src/app/api/workflows.ts | 24 + .../src/app/components/ActivationModal.vue | 15 +- .../MainHeader/ActionsDropdownMenu.vue | 434 +++++++++++ .../MainHeader/WorkflowDetails.test.ts | 27 +- .../components/MainHeader/WorkflowDetails.vue | 498 ++---------- .../MainHeader/WorkflowHeaderActions.vue | 194 +++++ .../WorkflowHeaderDraftPublishActions.test.ts | 411 ++++++++++ .../WorkflowHeaderDraftPublishActions.vue | 186 +++++ .../MainHeader/WorkflowPublishModal.vue | 289 +++++++ .../src/app/components/MainHeader/utils.ts | 11 + .../editor-ui/src/app/components/Modals.vue | 27 + .../src/app/components/WorkflowCard.test.ts | 1 - .../src/app/components/WorkflowCard.vue | 59 +- .../app/components/WorkflowPublishForm.vue | 81 ++ .../app/composables/useWorkflowActivate.ts | 124 ++- .../src/app/composables/useWorkflowHelpers.ts | 4 + .../app/composables/useWorkflowSaving.test.ts | 5 +- .../src/app/composables/useWorkflowSaving.ts | 6 +- .../editor-ui/src/app/constants/actions.ts | 2 + .../editor-ui/src/app/constants/modals.ts | 3 + .../editor-ui/src/app/constants/workflows.ts | 5 + .../editor-ui/src/app/stores/ui.store.ts | 6 + .../src/app/stores/workflows.store.ts | 46 +- .../editor-ui/src/app/types/externalHooks.ts | 4 +- .../workflowHistory/__tests__/utils.ts | 3 + .../components/WorkflowHistoryContent.test.ts | 12 +- .../components/WorkflowHistoryContent.vue | 142 +++- .../components/WorkflowHistoryList.test.ts | 24 +- .../components/WorkflowHistoryList.vue | 61 +- .../WorkflowHistoryListItem.test.ts | 41 +- .../components/WorkflowHistoryListItem.vue | 154 +++- .../WorkflowHistoryPublishModal.vue | 155 ++++ .../WorkflowHistoryVersionRestoreModal.vue | 7 +- .../WorkflowHistoryVersionUnpublishModal.vue | 79 ++ .../workflows/workflowHistory/types.ts | 11 + .../workflows/workflowHistory/utils.ts | 22 + .../workflowHistory/views/WorkflowHistory.vue | 161 +++- .../credentials/AutomizyApi.credentials.ts | 26 - .../credentials/CrowdDevApi.credentials.ts | 72 -- .../credentials/KitemakerApi.credentials.ts | 19 - .../credentials/SpontitApi.credentials.ts | 25 - .../nodes/Automizy/Automizy.node.json | 18 - .../nodes/Automizy/Automizy.node.ts | 374 --------- .../nodes/Automizy/ContactDescription.ts | 451 ----------- .../nodes/Automizy/GenericFunctions.ts | 70 -- .../nodes/Automizy/ListDescription.ts | 209 ------ .../nodes-base/nodes/Automizy/automizy.png | Bin 1790 -> 0 bytes .../nodes/CrowdDev/CrowdDev.node.json | 18 - .../nodes/CrowdDev/CrowdDev.node.ts | 35 - .../nodes/CrowdDev/CrowdDevTrigger.node.json | 18 - .../nodes/CrowdDev/CrowdDevTrigger.node.ts | 186 ----- .../nodes/CrowdDev/GenericFunctions.ts | 221 ------ .../nodes/CrowdDev/crowdDev.dark.svg | 4 - .../nodes-base/nodes/CrowdDev/crowdDev.svg | 4 - .../CrowdDev/descriptions/activityFields.ts | 187 ----- .../CrowdDev/descriptions/automationFields.ts | 130 ---- .../nodes/CrowdDev/descriptions/index.ts | 25 - .../CrowdDev/descriptions/memberFields.ts | 276 ------- .../nodes/CrowdDev/descriptions/noteFields.ts | 93 --- .../descriptions/organizationFields.ts | 151 ---- .../nodes/CrowdDev/descriptions/resources.ts | 36 - .../nodes/CrowdDev/descriptions/shared.ts | 27 - .../nodes/CrowdDev/descriptions/taskFields.ts | 164 ---- .../nodes/CrowdDev/descriptions/utils.ts | 57 -- .../nodes/Kitemaker/GenericFunctions.ts | 83 -- .../nodes/Kitemaker/Kitemaker.node.json | 18 - .../nodes/Kitemaker/Kitemaker.node.ts | 331 -------- .../descriptions/OrganizationDescription.ts | 24 - .../descriptions/SpaceDescription.ts | 58 -- .../Kitemaker/descriptions/UserDescription.ts | 58 -- .../descriptions/WorkItemDescription.ts | 352 --------- .../nodes/Kitemaker/descriptions/index.ts | 4 - .../nodes/Kitemaker/kitemaker.dark.svg | 5 - .../nodes-base/nodes/Kitemaker/kitemaker.svg | 5 - .../nodes-base/nodes/Kitemaker/mutations.ts | 69 -- .../nodes-base/nodes/Kitemaker/queries.ts | 200 ----- .../nodes/N8nTrigger/N8nTrigger.node.ts | 18 +- .../nodes/N8nTrigger/test/trigger.test.ts | 2 +- .../nodes/Spontit/GenericFunctions.ts | 43 -- .../nodes/Spontit/PushDescription.ts | 152 ---- .../nodes/Spontit/Spontit.node.json | 18 - .../nodes-base/nodes/Spontit/Spontit.node.ts | 116 --- packages/nodes-base/nodes/Spontit/spontit.png | Bin 1688 -> 0 bytes packages/nodes-base/package.json | 9 - packages/nodes-base/test/setup.ts | 2 + packages/testing/playwright/fixtures/base.ts | 1 + .../testing/playwright/pages/CanvasPage.ts | 45 +- .../playwright/pages/WorkflowSettingsModal.ts | 16 + .../testing/playwright/playwright.config.ts | 1 + .../services/workflow-api-helper.ts | 23 +- .../tests/cli-workflows/credentials.json | 28 - .../workflow-195-schema-mode-workflows.snap | 429 ----------- .../tests/cli-workflows/workflows/65.json | 110 --- .../playwright/tests/ui/13-pinning.spec.ts | 2 +- .../53-workflow-production-checklist.spec.ts | 17 +- .../tests/ui/7-workflow-actions.spec.ts | 92 ++- pnpm-lock.yaml | 24 +- 162 files changed, 4078 insertions(+), 7156 deletions(-) delete mode 100644 docker/images/n8n/n8n-task-runners.json create mode 100644 packages/cli/src/commands/publish/workflow.ts create mode 100644 packages/cli/src/commands/unpublish/workflow.ts create mode 100644 packages/cli/test/integration/commands/publish/workflow.test.ts create mode 100644 packages/cli/test/integration/commands/unpublish/workflow.test.ts create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/ActionsDropdownMenu.vue create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderActions.vue create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.test.ts create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowHeaderDraftPublishActions.vue create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowPublishModal.vue create mode 100644 packages/frontend/editor-ui/src/app/components/MainHeader/utils.ts create mode 100644 packages/frontend/editor-ui/src/app/components/WorkflowPublishForm.vue create mode 100644 packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowHistoryPublishModal.vue create mode 100644 packages/frontend/editor-ui/src/features/workflows/workflowHistory/components/WorkflowHistoryVersionUnpublishModal.vue create mode 100644 packages/frontend/editor-ui/src/features/workflows/workflowHistory/types.ts create mode 100644 packages/frontend/editor-ui/src/features/workflows/workflowHistory/utils.ts delete mode 100644 packages/nodes-base/credentials/AutomizyApi.credentials.ts delete mode 100644 packages/nodes-base/credentials/CrowdDevApi.credentials.ts delete mode 100644 packages/nodes-base/credentials/KitemakerApi.credentials.ts delete mode 100644 packages/nodes-base/credentials/SpontitApi.credentials.ts delete mode 100644 packages/nodes-base/nodes/Automizy/Automizy.node.json delete mode 100644 packages/nodes-base/nodes/Automizy/Automizy.node.ts delete mode 100644 packages/nodes-base/nodes/Automizy/ContactDescription.ts delete mode 100644 packages/nodes-base/nodes/Automizy/GenericFunctions.ts delete mode 100644 packages/nodes-base/nodes/Automizy/ListDescription.ts delete mode 100644 packages/nodes-base/nodes/Automizy/automizy.png delete mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDev.node.json delete mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDev.node.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.json delete mode 100644 packages/nodes-base/nodes/CrowdDev/CrowdDevTrigger.node.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/GenericFunctions.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/crowdDev.dark.svg delete mode 100644 packages/nodes-base/nodes/CrowdDev/crowdDev.svg delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/activityFields.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/automationFields.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/index.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/memberFields.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/noteFields.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/organizationFields.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/resources.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/shared.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/taskFields.ts delete mode 100644 packages/nodes-base/nodes/CrowdDev/descriptions/utils.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/GenericFunctions.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/Kitemaker.node.json delete mode 100644 packages/nodes-base/nodes/Kitemaker/Kitemaker.node.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/descriptions/OrganizationDescription.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/descriptions/SpaceDescription.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/descriptions/UserDescription.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/descriptions/WorkItemDescription.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/descriptions/index.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/kitemaker.dark.svg delete mode 100644 packages/nodes-base/nodes/Kitemaker/kitemaker.svg delete mode 100644 packages/nodes-base/nodes/Kitemaker/mutations.ts delete mode 100644 packages/nodes-base/nodes/Kitemaker/queries.ts delete mode 100644 packages/nodes-base/nodes/Spontit/GenericFunctions.ts delete mode 100644 packages/nodes-base/nodes/Spontit/PushDescription.ts delete mode 100644 packages/nodes-base/nodes/Spontit/Spontit.node.json delete mode 100644 packages/nodes-base/nodes/Spontit/Spontit.node.ts delete mode 100644 packages/nodes-base/nodes/Spontit/spontit.png delete mode 100644 packages/testing/playwright/tests/cli-workflows/workflow-tests.spec.ts-snapshots/workflow-195-schema-mode-workflows.snap delete mode 100644 packages/testing/playwright/tests/cli-workflows/workflows/65.json diff --git a/.github/workflows/ci-postgres-mysql.yml b/.github/workflows/ci-postgres-mysql.yml index 7cd621b653b..4abfb9bc5e8 100644 --- a/.github/workflows/ci-postgres-mysql.yml +++ b/.github/workflows/ci-postgres-mysql.yml @@ -60,6 +60,7 @@ jobs: needs: build runs-on: blacksmith-4vcpu-ubuntu-2204 timeout-minutes: 30 + if: false env: DB_MYSQLDB_PASSWORD: password DB_MYSQLDB_POOL_SIZE: 1 @@ -89,6 +90,7 @@ jobs: needs: build runs-on: blacksmith-2vcpu-ubuntu-2204 timeout-minutes: 20 + if: false env: DB_MYSQLDB_PASSWORD: password DB_MYSQLDB_POOL_SIZE: 1 @@ -140,7 +142,7 @@ jobs: notify-on-failure: name: Notify Slack on failure runs-on: ubuntu-latest - needs: [sqlite-pooled, mariadb, postgres, mysql] + needs: [sqlite-pooled, postgres] steps: - name: Notify Slack on failure uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c58a048393..29293813326 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -178,12 +178,6 @@ To start n8n execute: pnpm start ``` -To start n8n with tunnel: - -``` -./packages/cli/bin/n8n start --tunnel -``` - ## Development cycle While iterating on n8n modules code, you can run `pnpm dev`. It will then diff --git a/docker/images/n8n/Dockerfile b/docker/images/n8n/Dockerfile index 10a40fe7729..7a3456486e5 100644 --- a/docker/images/n8n/Dockerfile +++ b/docker/images/n8n/Dockerfile @@ -1,7 +1,5 @@ ARG NODE_VERSION=22.21.0 ARG N8N_VERSION=snapshot -ARG LAUNCHER_VERSION=1.4.1 -ARG TARGETPLATFORM # ============================================================================== # STAGE 1: System Dependencies & Base Setup @@ -16,29 +14,7 @@ FROM alpine:3.22.2 AS app-artifact-processor COPY ./compiled /app/ # ============================================================================== -# STAGE 3: Task Runner Launcher -# ============================================================================== -FROM alpine:3.22.2 AS launcher-downloader -ARG TARGETPLATFORM -ARG LAUNCHER_VERSION - -RUN set -e; \ - case "$TARGETPLATFORM" in \ - "linux/amd64") ARCH_NAME="amd64" ;; \ - "linux/arm64") ARCH_NAME="arm64" ;; \ - *) echo "Unsupported platform: $TARGETPLATFORM" && exit 1 ;; \ - esac; \ - mkdir /launcher-temp && cd /launcher-temp; \ - wget -q "https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz"; \ - wget -q "https://github.com/n8n-io/task-runner-launcher/releases/download/${LAUNCHER_VERSION}/task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256"; \ - echo "$(cat task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz.sha256) task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz" > checksum.sha256; \ - sha256sum -c checksum.sha256; \ - mkdir -p /launcher-bin; \ - tar xzf task-runner-launcher-${LAUNCHER_VERSION}-linux-${ARCH_NAME}.tar.gz -C /launcher-bin; \ - cd / && rm -rf /launcher-temp - -# ============================================================================== -# STAGE 4: Final Runtime Image +# STAGE 3: Final Runtime Image # ============================================================================== FROM system-deps AS runtime @@ -52,9 +28,7 @@ ENV SHELL=/bin/sh WORKDIR /home/node COPY --from=app-artifact-processor /app /usr/local/lib/node_modules/n8n -COPY --from=launcher-downloader /launcher-bin/* /usr/local/bin/ COPY docker/images/n8n/docker-entrypoint.sh / -COPY docker/images/n8n/n8n-task-runners.json /etc/n8n-task-runners.json RUN cd /usr/local/lib/node_modules/n8n && \ npm rebuild sqlite3 && \ diff --git a/docker/images/n8n/README.md b/docker/images/n8n/README.md index b84b3806141..7707c183d5d 100644 --- a/docker/images/n8n/README.md +++ b/docker/images/n8n/README.md @@ -23,7 +23,6 @@ n8n is a workflow automation platform that gives technical teams the flexibility - [Available integrations](#available-integrations) - [Documentation](#documentation) - [Start n8n in Docker](#start-n8n-in-docker) - - [Start n8n with tunnel](#start-n8n-with-tunnel) - [Use with PostgreSQL](#use-with-postgresql) - [Passing sensitive data using files](#passing-sensitive-data-using-files) - [Example server setups](#example-server-setups) @@ -75,25 +74,6 @@ To save your work between container restarts, it also mounts a docker volume, `n If this data can't be found at startup n8n automatically creates a new key and any existing credentials can no longer be decrypted. -## Start n8n with tunnel - -> **WARNING**: This is only meant for local development and testing and should **NOT** be used in production! - -n8n must be reachable from the internet to make use of webhooks - essential for triggering workflows from external web-based services such as GitHub. To make this easier, n8n has a special tunnel service which redirects requests from our servers to your local n8n instance. You can inspect the code running this service here: [https://github.com/n8n-io/localtunnel](https://github.com/n8n-io/localtunnel) - -To use it simply start n8n with `--tunnel` - -```bash -docker volume create n8n_data - -docker run -it --rm \ - --name n8n \ - -p 5678:5678 \ - -v n8n_data:/home/node/.n8n \ - docker.n8n.io/n8nio/n8n \ - start --tunnel -``` - ## Use with PostgreSQL By default, n8n uses SQLite to save credentials, past executions and workflows. However, n8n also supports using PostgreSQL. diff --git a/docker/images/n8n/n8n-task-runners.json b/docker/images/n8n/n8n-task-runners.json deleted file mode 100644 index 091e5395934..00000000000 --- a/docker/images/n8n/n8n-task-runners.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "task-runners": [ - { - "runner-type": "javascript", - "workdir": "/home/node", - "command": "/usr/local/bin/node", - "args": [ - "--disallow-code-generation-from-strings", - "--disable-proto=delete", - "/usr/local/lib/node_modules/n8n/node_modules/@n8n/task-runner/dist/start.js" - ], - "allowed-env": [ - "PATH", - "GENERIC_TIMEZONE", - "N8N_RUNNERS_GRANT_TOKEN", - "N8N_RUNNERS_TASK_BROKER_URI", - "N8N_RUNNERS_MAX_PAYLOAD", - "N8N_RUNNERS_MAX_CONCURRENCY", - "N8N_RUNNERS_TASK_TIMEOUT", - "N8N_RUNNERS_HEARTBEAT_INTERVAL", - "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED", - "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST", - "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT", - "NODE_FUNCTION_ALLOW_BUILTIN", - "NODE_FUNCTION_ALLOW_EXTERNAL", - "NODE_OPTIONS", - "NODE_PATH", - "N8N_SENTRY_DSN", - "N8N_VERSION", - "ENVIRONMENT", - "DEPLOYMENT_NAME" - ] - } - ] -} diff --git a/docker/images/runners/README.md b/docker/images/runners/README.md index 08c5d1f7ca7..ff9078d930b 100644 --- a/docker/images/runners/README.md +++ b/docker/images/runners/README.md @@ -38,7 +38,6 @@ docker buildx build \ N8N_RUNNERS_ENABLED=true \ N8N_RUNNERS_MODE=external \ N8N_RUNNERS_AUTH_TOKEN=test \ -N8N_NATIVE_PYTHON_RUNNER=true \ N8N_LOG_LEVEL=debug \ pnpm start ``` diff --git a/packages/@n8n/backend-test-utils/src/db/workflows.ts b/packages/@n8n/backend-test-utils/src/db/workflows.ts index d9f03b3f295..0d8274f9ae6 100644 --- a/packages/@n8n/backend-test-utils/src/db/workflows.ts +++ b/packages/@n8n/backend-test-utils/src/db/workflows.ts @@ -286,31 +286,3 @@ export async function createActiveWorkflow( workflow.activeVersionId = workflow.versionId; return workflow; } - -/** - * Create a workflow with a specific active version. - * This simulates a workflow where the active version differs from the current version. - * @param activeVersionId the version ID to set as active - * @param attributes workflow attributes - * @param user user to assign the workflow to - */ -export async function createWorkflowWithActiveVersion( - activeVersionId: string, - attributes: Partial = {}, - user?: User, -) { - const workflow = await createWorkflowWithTriggerAndHistory({ active: true, ...attributes }, user); - - await Container.get(WorkflowHistoryRepository).insert({ - workflowId: workflow.id, - versionId: activeVersionId, - nodes: workflow.nodes, - connections: workflow.connections, - authors: user?.email ?? 'test@example.com', - }); - - await setActiveVersion(workflow.id, activeVersionId); - - workflow.activeVersionId = activeVersionId; - return workflow; -} diff --git a/packages/@n8n/benchmark/package.json b/packages/@n8n/benchmark/package.json index ca70b523bf6..8792b896e1c 100644 --- a/packages/@n8n/benchmark/package.json +++ b/packages/@n8n/benchmark/package.json @@ -35,7 +35,7 @@ "dependencies": { "@oclif/core": "4.0.7", "axios": "catalog:", - "dotenv": "8.6.0", + "dotenv": "17.2.3", "nanoid": "catalog:", "zx": "^8.1.4" }, diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml index 687de86bd04..255db8bd5c8 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/postgres/docker-compose.yml @@ -37,7 +37,6 @@ services: - N8N_RUNNERS_MODE=external - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 - N8N_RUNNERS_AUTH_TOKEN=test - - N8N_NATIVE_PYTHON_RUNNER=true - N8N_METRICS=true ports: - 5678:5678 diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml index fddd24be355..d193a8cd3ce 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-multi-main/docker-compose.yml @@ -55,7 +55,6 @@ services: - N8N_RUNNERS_MODE=external - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 - N8N_RUNNERS_AUTH_TOKEN=test - - N8N_NATIVE_PYTHON_RUNNER=true command: worker volumes: - ${RUN_DIR}/n8n-worker1:/n8n @@ -103,7 +102,6 @@ services: - N8N_RUNNERS_MODE=external - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 - N8N_RUNNERS_AUTH_TOKEN=test - - N8N_NATIVE_PYTHON_RUNNER=true command: worker volumes: - ${RUN_DIR}/n8n-worker2:/n8n diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml index b6a2f61eef8..a765f25f9ad 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/scaling-single-main/docker-compose.yml @@ -53,7 +53,6 @@ services: - N8N_RUNNERS_MODE=external - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 - N8N_RUNNERS_AUTH_TOKEN=test - - N8N_NATIVE_PYTHON_RUNNER=true command: worker volumes: - ${RUN_DIR}/n8n-worker1:/n8n @@ -99,7 +98,6 @@ services: - N8N_RUNNERS_MODE=external - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 - N8N_RUNNERS_AUTH_TOKEN=test - - N8N_NATIVE_PYTHON_RUNNER=true command: worker volumes: - ${RUN_DIR}/n8n-worker2:/n8n diff --git a/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml b/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml index e5b193f00b5..f970eed48de 100644 --- a/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml +++ b/packages/@n8n/benchmark/scripts/n8n-setups/sqlite/docker-compose.yml @@ -19,7 +19,6 @@ services: - N8N_RUNNERS_MODE=external - N8N_RUNNERS_BROKER_LISTEN_ADDRESS=0.0.0.0 - N8N_RUNNERS_AUTH_TOKEN=test - - N8N_NATIVE_PYTHON_RUNNER=true - N8N_METRICS=true ports: - 5678:5678 diff --git a/packages/@n8n/config/src/configs/__tests__/database.config.test.ts b/packages/@n8n/config/src/configs/__tests__/database.config.test.ts index c620fc49c28..e50273462ec 100644 --- a/packages/@n8n/config/src/configs/__tests__/database.config.test.ts +++ b/packages/@n8n/config/src/configs/__tests__/database.config.test.ts @@ -8,32 +8,12 @@ describe('DatabaseConfig', () => { jest.clearAllMocks(); }); - test('`isLegacySqlite` defaults to true', () => { - const databaseConfig = Container.get(DatabaseConfig); - expect(databaseConfig.isLegacySqlite).toBe(true); - }); - - test.each(['mariadb', 'mysqldb', 'postgresdb'] satisfies Array)( + test.each(['sqlite', 'mariadb', 'mysqldb', 'postgresdb'] satisfies Array)( '`isLegacySqlite` returns false if dbType is `%s`', (dbType) => { const databaseConfig = Container.get(DatabaseConfig); - databaseConfig.sqlite.poolSize = 0; databaseConfig.type = dbType; expect(databaseConfig.isLegacySqlite).toBe(false); }, ); - - test('`isLegacySqlite` returns false if dbType is `sqlite` and `poolSize` > 0', () => { - const databaseConfig = Container.get(DatabaseConfig); - databaseConfig.sqlite.poolSize = 1; - databaseConfig.type = 'sqlite'; - expect(databaseConfig.isLegacySqlite).toBe(false); - }); - - test('`isLegacySqlite` returns true if dbType is `sqlite` and `poolSize` is 0', () => { - const databaseConfig = Container.get(DatabaseConfig); - databaseConfig.sqlite.poolSize = 0; - databaseConfig.type = 'sqlite'; - expect(databaseConfig.isLegacySqlite).toBe(true); - }); }); diff --git a/packages/@n8n/config/src/configs/database.config.ts b/packages/@n8n/config/src/configs/database.config.ts index ba08b609c12..fc60af9e418 100644 --- a/packages/@n8n/config/src/configs/database.config.ts +++ b/packages/@n8n/config/src/configs/database.config.ts @@ -5,6 +5,17 @@ import { Config, Env, Nested } from '../decorators'; const dbLoggingOptionsSchema = z.enum(['query', 'error', 'schema', 'warn', 'info', 'log', 'all']); type DbLoggingOptions = z.infer; +class MySqlMariaDbNotSupportedError extends Error { + // Workaround to not get this reported to Sentry + readonly cause: { level: 'warning' } = { + level: 'warning', + }; + + constructor() { + super('MySQL and MariaDB have been removed. Please migrate to PostgreSQL.'); + } +} + @Config class LoggingConfig { /** Whether database logging is enabled. */ @@ -119,15 +130,17 @@ class MysqlConfig { poolSize: number = 10; } +const sqlitePoolSizeSchema = z.coerce.number().int().gte(1); + @Config export class SqliteConfig { /** SQLite database file name */ @Env('DB_SQLITE_DATABASE') database: string = 'database.sqlite'; - /** SQLite database pool size. Set to `0` to disable pooling. */ - @Env('DB_SQLITE_POOL_SIZE') - poolSize: number = 0; + /** SQLite database pool size. Must be equal to or higher than `1`. */ + @Env('DB_SQLITE_POOL_SIZE', sqlitePoolSizeSchema) + poolSize: number = 3; /** * Enable SQLite WAL mode. @@ -154,12 +167,10 @@ export class DatabaseConfig { type: DbType = 'sqlite'; /** - * Is true if the default sqlite data source of TypeORM is used, as opposed - * to any other (e.g. postgres) - * This also returns false if n8n's new pooled sqlite data source is used. + * Legacy sqlite is no longer supported. Setting kept until we clean up all uses. */ get isLegacySqlite() { - return this.type === 'sqlite' && this.sqlite.poolSize === 0; + return false; } /** Prefix for table names */ @@ -183,4 +194,10 @@ export class DatabaseConfig { @Nested sqlite: SqliteConfig; + + sanitize() { + if (this.type === 'mariadb' || this.type === 'mysqldb') { + throw new MySqlMariaDbNotSupportedError(); + } + } } diff --git a/packages/@n8n/config/src/configs/instance-settings-config.ts b/packages/@n8n/config/src/configs/instance-settings-config.ts index 644d0567073..4d3f6ca9b62 100644 --- a/packages/@n8n/config/src/configs/instance-settings-config.ts +++ b/packages/@n8n/config/src/configs/instance-settings-config.ts @@ -11,7 +11,7 @@ export class InstanceSettingsConfig { * attempt change them to 0600 (only owner has rw access) if they are too wide. */ @Env('N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS') - enforceSettingsFilePermissions: boolean = false; + enforceSettingsFilePermissions: boolean = true; /** * Encryption key to use for encrypting and decrypting credentials. diff --git a/packages/@n8n/config/src/configs/nodes.config.ts b/packages/@n8n/config/src/configs/nodes.config.ts index 6ea8f115f55..d8613636a06 100644 --- a/packages/@n8n/config/src/configs/nodes.config.ts +++ b/packages/@n8n/config/src/configs/nodes.config.ts @@ -26,9 +26,14 @@ export class NodesConfig { @Env('NODES_INCLUDE') include: JsonStringArray = []; - /** Node types not to load. Excludes none if unspecified. @example '["n8n-nodes-base.hackerNews"]' */ + /** + * Node types not to load. Defaults to excluding `ExecuteCommand` and `LocalFileTrigger` for security. + * Set to an empty array to enable all node types. + * + * @example '["n8n-nodes-base.hackerNews"]' + */ @Env('NODES_EXCLUDE') - exclude: JsonStringArray = []; + exclude: JsonStringArray = ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger']; /** Node type to use as error trigger */ @Env('NODES_ERROR_TRIGGER_TYPE') diff --git a/packages/@n8n/config/src/configs/runners.config.ts b/packages/@n8n/config/src/configs/runners.config.ts index 90f643cdaf3..a1dc208ba0c 100644 --- a/packages/@n8n/config/src/configs/runners.config.ts +++ b/packages/@n8n/config/src/configs/runners.config.ts @@ -90,6 +90,5 @@ export class TaskRunnersConfig { * adjusted to account for breaking changes: * https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/#python-native-beta */ - @Env('N8N_NATIVE_PYTHON_RUNNER') - isNativePythonRunnerEnabled: boolean = false; + isNativePythonRunnerEnabled: boolean = true; } diff --git a/packages/@n8n/config/src/configs/scaling-mode.config.ts b/packages/@n8n/config/src/configs/scaling-mode.config.ts index 15a3aeb1085..7c79d2edd3f 100644 --- a/packages/@n8n/config/src/configs/scaling-mode.config.ts +++ b/packages/@n8n/config/src/configs/scaling-mode.config.ts @@ -71,10 +71,6 @@ class SettingsConfig { /** How often (in milliseconds) Bull must check for stalled jobs. `0` to disable. */ @Env('QUEUE_WORKER_STALLED_INTERVAL') stalledInterval: number = 30_000; - - /** Max number of times a stalled job will be re-processed. See Bull's [documentation](https://docs.bullmq.io/guide/workers/stalled-jobs). */ - @Env('QUEUE_WORKER_MAX_STALLED_COUNT') - maxStalledCount: number = 1; } @Config diff --git a/packages/@n8n/config/src/configs/security.config.ts b/packages/@n8n/config/src/configs/security.config.ts index 5bba4f960b3..082fce420c9 100644 --- a/packages/@n8n/config/src/configs/security.config.ts +++ b/packages/@n8n/config/src/configs/security.config.ts @@ -3,18 +3,19 @@ import { Config, Env } from '../decorators'; @Config export class SecurityConfig { /** - * Which directories to limit n8n's access to. Separate multiple dirs with semicolon `;`. + * Dirs that the `ReadWriteFile` and `ReadBinaryFiles` nodes are allowed to access. Separate multiple dirs with semicolon `;`. + * Set to an empty string to disable restrictions (insecure, not recommended for production). * - * @example N8N_RESTRICT_FILE_ACCESS_TO=/home/user/.n8n;/home/user/n8n-data + * @example N8N_RESTRICT_FILE_ACCESS_TO=/home/john/my-n8n-files */ @Env('N8N_RESTRICT_FILE_ACCESS_TO') - restrictFileAccessTo: string = ''; + restrictFileAccessTo: string = '~/.n8n-files'; /** - * Whether to block access to all files at: - * - the ".n8n" directory, - * - the static cache dir at ~/.cache/n8n/public, and - * - user-defined config files. + * Whether to block nodes from accessing files at dirs internally used by n8n: + * - `~/.n8n` + * - `~/.cache/n8n/public` + * - any dirs specified by `N8N_CONFIG_FILES`, `N8N_CUSTOM_EXTENSIONS`, `N8N_BINARY_DATA_STORAGE_PATH`, `N8N_UM_EMAIL_TEMPLATES_INVITE`, and `UM_EMAIL_TEMPLATES_PWRESET`. */ @Env('N8N_BLOCK_FILE_ACCESS_TO_N8N_FILES') blockFileAccessToN8nFiles: boolean = true; @@ -50,7 +51,7 @@ export class SecurityConfig { * Whether to disable bare repositories support in the Git node. */ @Env('N8N_GIT_NODE_DISABLE_BARE_REPOS') - disableBareRepos: boolean = false; + disableBareRepos: boolean = true; /** Whether to allow access to AWS system credentials, e.g. in awsAssumeRole credentials */ @Env('N8N_AWS_SYSTEM_CREDENTIALS_ACCESS_ENABLED') diff --git a/packages/@n8n/config/src/configs/workflows.config.ts b/packages/@n8n/config/src/configs/workflows.config.ts index fdd4ba8aa0b..7fe9998d0b6 100644 --- a/packages/@n8n/config/src/configs/workflows.config.ts +++ b/packages/@n8n/config/src/configs/workflows.config.ts @@ -22,8 +22,4 @@ export class WorkflowsConfig { /** Whether to enable workflow dependency indexing */ @Env('N8N_WORKFLOWS_INDEXING_ENABLED') indexingEnabled: boolean = false; - - /** DO NOT USE - Enable draft/publish workflow feature */ - @Env('N8N_ENV_FEAT_WORKFLOWS_DRAFT_PUBLISH_ENABLED') - draftPublishEnabled: boolean = false; } diff --git a/packages/@n8n/config/test/config.test.ts b/packages/@n8n/config/test/config.test.ts index 30a470816d8..9d608a83e95 100644 --- a/packages/@n8n/config/test/config.test.ts +++ b/packages/@n8n/config/test/config.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import type { UserManagementConfig } from '../src/configs/user-management.config'; +import type { DatabaseConfig } from '../src/index'; import { GlobalConfig } from '../src/index'; jest.mock('fs'); @@ -95,15 +96,15 @@ describe('GlobalConfig', () => { }, sqlite: { database: 'database.sqlite', - enableWAL: false, + enableWAL: true, executeVacuumOnStartup: false, - poolSize: 0, + poolSize: 3, }, tablePrefix: '', type: 'sqlite', - isLegacySqlite: true, + isLegacySqlite: false, pingIntervalSeconds: 2, - }, + } as DatabaseConfig, credentials: { defaultName: 'My credentials', overwrite: { @@ -158,7 +159,7 @@ describe('GlobalConfig', () => { nodes: { errorTriggerType: 'n8n-nodes-base.errorTrigger', include: [], - exclude: [], + exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'], pythonEnabled: true, }, publicApi: { @@ -186,7 +187,6 @@ describe('GlobalConfig', () => { callerPolicyDefaultOption: 'workflowsFromSameOwner', activationBatchSize: 1, indexingEnabled: false, - draftPublishEnabled: false, }, endpoints: { metrics: { @@ -259,7 +259,6 @@ describe('GlobalConfig', () => { lockDuration: 60_000, lockRenewTime: 10_000, stalledInterval: 30_000, - maxStalledCount: 1, }, }, }, @@ -277,7 +276,7 @@ describe('GlobalConfig', () => { taskRequestTimeout: 60, heartbeatInterval: 30, insecureMode: false, - isNativePythonRunnerEnabled: false, + isNativePythonRunnerEnabled: true, }, sentry: { backendDsn: '', @@ -318,13 +317,13 @@ describe('GlobalConfig', () => { cert: '', }, security: { - restrictFileAccessTo: '', + restrictFileAccessTo: '~/.n8n-files', blockFileAccessToN8nFiles: true, daysAbandonedWorkflow: 90, contentSecurityPolicy: '{}', contentSecurityPolicyReportOnly: false, disableWebhookHtmlSandboxing: false, - disableBareRepos: false, + disableBareRepos: true, awsSystemCredentialsAccess: false, enableGitNodeHooks: false, }, diff --git a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts index df61b86d85d..0bd999669c2 100644 --- a/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts +++ b/packages/@n8n/db/src/repositories/__tests__/workflow.repository.test.ts @@ -6,6 +6,7 @@ import { WorkflowEntity } from '../../entities'; import { mockEntityManager } from '../../utils/test-utils/mock-entity-manager'; import { mockInstance } from '../../utils/test-utils/mock-instance'; import { FolderRepository } from '../folder.repository'; +import { WorkflowHistoryRepository } from '../workflow-history.repository'; import { WorkflowRepository } from '../workflow.repository'; describe('WorkflowRepository', () => { @@ -14,10 +15,12 @@ describe('WorkflowRepository', () => { database: { type: 'postgresdb' }, }); const folderRepository = mockInstance(FolderRepository); + const workflowHistoryRepository = mockInstance(WorkflowHistoryRepository); const workflowRepository = new WorkflowRepository( entityManager.connection, globalConfig, folderRepository, + workflowHistoryRepository, ); let queryBuilder: jest.Mocked>; @@ -141,6 +144,7 @@ describe('WorkflowRepository', () => { entityManager.connection, sqliteConfig, folderRepository, + workflowHistoryRepository, ); jest.spyOn(sqliteWorkflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder); diff --git a/packages/@n8n/db/src/repositories/workflow.repository.ts b/packages/@n8n/db/src/repositories/workflow.repository.ts index 306ef573d25..e428823ef53 100644 --- a/packages/@n8n/db/src/repositories/workflow.repository.ts +++ b/packages/@n8n/db/src/repositories/workflow.repository.ts @@ -10,9 +10,10 @@ import type { FindOptionsRelations, EntityManager, } from '@n8n/typeorm'; -import { PROJECT_ROOT } from 'n8n-workflow'; +import { PROJECT_ROOT, UserError } from 'n8n-workflow'; import { FolderRepository } from './folder.repository'; +import { WorkflowHistoryRepository } from './workflow-history.repository'; import { WebhookEntity, TagEntity, @@ -54,6 +55,7 @@ export class WorkflowRepository extends Repository { dataSource: DataSource, private readonly globalConfig: GlobalConfig, private readonly folderRepository: FolderRepository, + private readonly workflowHistoryRepository: WorkflowHistoryRepository, ) { super(WorkflowEntity, dataSource.manager); } @@ -824,6 +826,12 @@ export class WorkflowRepository extends Repository { } async updateActiveState(workflowId: string, newState: boolean) { + const workflow = await this.findById(workflowId); + + if (!workflow) { + throw new UserError(`Workflow "${workflowId}" not found.`); + } + if (newState) { return await this.createQueryBuilder() .update(WorkflowEntity) @@ -838,26 +846,49 @@ export class WorkflowRepository extends Repository { } } - async deactivateAll() { + /** + * Publish a specific version of a workflow + * @param workflowId - The ID of the workflow + * @param versionId - The ID of the version to publish (optional; if not provided, uses the current version) + * */ + async publishVersion(workflowId: string, versionId?: string) { + let versionIdToPublish = versionId; + if (!versionIdToPublish) { + const workflow = await this.findOne({ + where: { id: workflowId }, + select: ['id', 'versionId'], + }); + + if (!workflow) { + throw new UserError(`Workflow "${workflowId}" not found.`); + } + + versionIdToPublish = workflow.versionId; + } + + const version = await this.workflowHistoryRepository.findOneBy({ + workflowId, + versionId: versionIdToPublish, + }); + if (!version) { + throw new UserError( + `Version "${versionIdToPublish}" not found for workflow "${workflowId}".`, + ); + } + + return await this.update( + { id: workflowId }, + { active: true, activeVersionId: versionIdToPublish }, + ); + } + + async unpublishAll() { return await this.update( { activeVersionId: Not(IsNull()) }, { active: false, activeVersionId: null }, ); } - // We're planning to remove this command in V2, so for now set activeVersion to the current version - async activateAll() { - await this.manager - .createQueryBuilder() - .update(WorkflowEntity) - .set({ - active: true, - activeVersionId: () => 'versionId', - }) - .where('activeVersionId IS NULL') - .execute(); - } - async findByActiveState(activeState: boolean) { return await this.findBy({ activeVersionId: activeState ? Not(IsNull()) : IsNull() }); } diff --git a/packages/cli/bin/n8n b/packages/cli/bin/n8n index 62241cdb7c4..213d1946e79 100755 --- a/packages/cli/bin/n8n +++ b/packages/cli/bin/n8n @@ -37,7 +37,7 @@ require('reflect-metadata'); // Also, do not use `inE2ETests` from constants here, because that'd end up code that might read from `process.env` before the values are loaded from an `.env` file. if (process.env.E2E_TESTS !== 'true') { // Loading dotenv early ensures that `process.env` is up-to-date everywhere in code - require('dotenv').config(); + require('dotenv').config({ quiet: true }); } // Load config early to ensure `N8N_CONFIG_FILES` values are populated into `GlobalConfig` diff --git a/packages/cli/package.json b/packages/cli/package.json index 953a6268ba3..89495997147 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,14 +26,14 @@ "test:dev": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --watch", "test:sqlite": "N8N_LOG_LEVEL=silent DB_SQLITE_POOL_SIZE=4 DB_TYPE=sqlite jest --config=jest.config.integration.js --no-coverage", "test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage", - "test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage", - "test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --config=jest.config.integration.js --no-coverage --maxWorkers=1", + "test:mariadb": "echo true", + "test:mysql": "echo true", "test:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest", "test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --watch", "test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_SQLITE_POOL_SIZE=4&& set DB_TYPE=sqlite&& jest --config=jest.config.integration.js", "test:postgres:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=postgresdb&& set DB_POSTGRESDB_SCHEMA=alt_schema&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", - "test:mariadb:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mariadb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", - "test:mysql:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mysqldb&& set DB_TABLE_PREFIX=test_&& jest --config=jest.config.integration.js --no-coverage", + "test:mariadb:win": "echo true", + "test:mysql:win": "echo true", "watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\"" }, "bin": { @@ -128,7 +128,7 @@ "convict": "6.2.4", "cookie-parser": "1.4.7", "csrf": "3.1.0", - "dotenv": "8.6.0", + "dotenv": "17.2.3", "express": "5.1.0", "express-handlebars": "8.0.1", "express-openapi-validator": "5.5.3", diff --git a/packages/cli/src/command-registry.ts b/packages/cli/src/command-registry.ts index 96376ef1cbd..48c08ff1001 100644 --- a/packages/cli/src/command-registry.ts +++ b/packages/cli/src/command-registry.ts @@ -3,7 +3,8 @@ import { CommandMetadata, type CommandEntry } from '@n8n/decorators'; import { Container, Service } from '@n8n/di'; import glob from 'fast-glob'; import picocolors from 'picocolors'; -import { z } from 'zod'; +import { z, ZodError } from 'zod'; + import './zod-alias-support'; /** @@ -37,7 +38,9 @@ export class CommandRegistry { // Try to load regular commands try { await import(`./commands/${this.commandName.replaceAll(':', '/')}.js`); - } catch {} + } catch { + // Do nothing + } // Load modules to ensure all module commands are registered await this.moduleRegistry.loadModules(); @@ -53,10 +56,23 @@ export class CommandRegistry { return process.exit(0); } - const { flags } = this.cliParser.parse({ - argv: process.argv, - flagsSchema: commandEntry.flagsSchema, - }); + let flags: Record; + try { + ({ flags } = this.cliParser.parse({ + argv: process.argv, + flagsSchema: commandEntry.flagsSchema, + })); + } catch (error) { + if (error instanceof ZodError) { + this.logger.error(this.formatZodError(error)); + this.logger.info(''); + this.printCommandUsage(commandEntry); + return process.exit(1); + } + + // Preserve previous behavior for non-Zod errors + throw error; + } const command = Container.get(commandEntry.class); command.flags = flags; @@ -163,4 +179,31 @@ export class CommandRegistry { this.logger.info(output); } + + private formatZodError(error: ZodError): string { + const issuesByFlag: Record = {}; + + for (const issue of error.issues) { + const flag = (issue.path[0] as string | undefined) ?? 'flags'; + if (!issuesByFlag[flag]) issuesByFlag[flag] = []; + issuesByFlag[flag].push(issue); + } + + let output = ''; + + output += picocolors.red( + `\nError: Invalid flags provided for command "${this.commandName}".\n\n`, + ); + + for (const [flag, issues] of Object.entries(issuesByFlag)) { + const flagLabel = flag === 'flags' ? '(general)' : `--${flag}`; + output += ` ${picocolors.bold(flagLabel)}\n`; + for (const issue of issues) { + output += ` - ${issue.message}\n`; + } + output += '\n'; + } + + return output.trimEnd(); + } } diff --git a/packages/cli/src/commands/__tests__/execute-batch.test.ts b/packages/cli/src/commands/__tests__/execute-batch.test.ts index 86626f448dd..8ef554eda33 100644 --- a/packages/cli/src/commands/__tests__/execute-batch.test.ts +++ b/packages/cli/src/commands/__tests__/execute-batch.test.ts @@ -1,7 +1,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User, WorkflowEntity } from '@n8n/db'; -import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db'; +import { WorkflowRepository, DbConnection, AuthRolesService, BinaryDataRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { type SelectQueryBuilder } from '@n8n/typeorm'; import { mock } from 'jest-mock-extended'; @@ -40,6 +40,7 @@ const dbConnection = mockInstance(DbConnection); dbConnection.init.mockResolvedValue(undefined); dbConnection.migrate.mockResolvedValue(undefined); mockInstance(AuthRolesService); +mockInstance(BinaryDataRepository); test('should start a task runner when task runners are enabled', async () => { // arrange diff --git a/packages/cli/src/commands/__tests__/execute.test.ts b/packages/cli/src/commands/__tests__/execute.test.ts index abc50ab8980..6317b50ab1f 100644 --- a/packages/cli/src/commands/__tests__/execute.test.ts +++ b/packages/cli/src/commands/__tests__/execute.test.ts @@ -1,7 +1,7 @@ import { mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { User, WorkflowEntity } from '@n8n/db'; -import { WorkflowRepository, DbConnection, AuthRolesService } from '@n8n/db'; +import { WorkflowRepository, DbConnection, AuthRolesService, BinaryDataRepository } from '@n8n/db'; import { Container } from '@n8n/di'; import { mock } from 'jest-mock-extended'; import type { IRun } from 'n8n-workflow'; @@ -39,6 +39,7 @@ const dbConnection = mockInstance(DbConnection); dbConnection.init.mockResolvedValue(undefined); dbConnection.migrate.mockResolvedValue(undefined); mockInstance(AuthRolesService); +mockInstance(BinaryDataRepository); test('should start a task runner when task runners are enabled', async () => { // arrange diff --git a/packages/cli/src/commands/base-command.ts b/packages/cli/src/commands/base-command.ts index 9e738957a84..ea0ed8d4c54 100644 --- a/packages/cli/src/commands/base-command.ts +++ b/packages/cli/src/commands/base-command.ts @@ -20,7 +20,7 @@ import { ErrorReporter, ExecutionContextHookRegistry, } from 'n8n-core'; -import { ensureError, sleep, UnexpectedError, UserError } from 'n8n-workflow'; +import { ensureError, sleep, UnexpectedError } from 'n8n-workflow'; import type { AbstractServer } from '@/abstract-server'; import { N8N_VERSION, N8N_RELEASE_DATE } from '@/constants'; @@ -197,54 +197,41 @@ export abstract class BaseCommand { throw new UnexpectedError(message); } - async initObjectStoreService() { - const binaryDataConfig = Container.get(BinaryDataConfig); - const isSelected = binaryDataConfig.mode === 's3'; - const isAvailable = binaryDataConfig.availableModes.includes('s3'); - - if (!isSelected) return; - - if (isSelected && !isAvailable) { - throw new UserError( - 'External storage selected but unavailable. Please make external storage available by adding "s3" to `N8N_AVAILABLE_BINARY_DATA_MODES`.', - ); - } - - const isLicensed = Container.get(License).isLicensed(LICENSE_FEATURES.BINARY_DATA_S3); - if (!isLicensed) { - this.logger.error( - 'No license found for S3 storage. \n Either set `N8N_DEFAULT_BINARY_DATA_MODE` to something else, or upgrade to a license that supports this feature.', - ); - return process.exit(1); - } - - this.logger.debug('License found for external storage - Initializing object store service'); - try { - await Container.get(ObjectStoreService).init(); - this.logger.debug('Object store init completed'); - } catch (e) { - const error = e instanceof Error ? e : new Error(`${e}`); - this.logger.debug('Object store init failed', { error }); - } - } - async initBinaryDataService() { + const binaryDataConfig = Container.get(BinaryDataConfig); + const binaryDataService = Container.get(BinaryDataService); + const isS3WriteMode = binaryDataConfig.mode === 's3'; + + const { DatabaseManager } = await import('@/binary-data/database.manager'); + binaryDataService.setManager('database', Container.get(DatabaseManager)); + + if (isS3WriteMode) { + const isLicensed = Container.get(License).isLicensed(LICENSE_FEATURES.BINARY_DATA_S3); + if (!isLicensed) { + this.logger.error( + 'S3 binary data storage requires a valid license. Either set `N8N_DEFAULT_BINARY_DATA_MODE` to something else, or upgrade to a license that supports this feature.', + ); + process.exit(1); + } + } + + // we always try to init S3 for reading - silently fail if not configured try { - await this.initObjectStoreService(); - } catch (e) { - const error = e instanceof Error ? e : new Error(`${e}`); - this.logger.error(`Failed to init object store: ${error.message}`, { error }); - process.exit(1); + const objectStoreService = Container.get(ObjectStoreService); + await objectStoreService.init(); + const { ObjectStoreManager } = await import('n8n-core/dist/binary-data/object-store.manager'); + binaryDataService.setManager('s3', new ObjectStoreManager(objectStoreService)); + } catch { + if (isS3WriteMode) { + this.logger.error( + 'Failed to connect to S3 for binary data storage. Please check your S3 configuration.', + ); + process.exit(1); + } + // S3 not configured - users without S3 data are unaffected; users with S3 data will fail at runtime when reading } - if (Container.get(BinaryDataConfig).availableModes.includes('database')) { - const binaryDataService = Container.get(BinaryDataService); - const { DatabaseManager } = await import('@/binary-data/database.manager'); - const databaseManager = Container.get(DatabaseManager); - binaryDataService.setManager('database', databaseManager); - } - - await Container.get(BinaryDataService).init(); + await binaryDataService.init(); } protected async initDataDeduplicationService() { diff --git a/packages/cli/src/commands/publish/workflow.ts b/packages/cli/src/commands/publish/workflow.ts new file mode 100644 index 00000000000..b330de31209 --- /dev/null +++ b/packages/cli/src/commands/publish/workflow.ts @@ -0,0 +1,56 @@ +import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { z } from 'zod'; + +import { BaseCommand } from '../base-command'; + +const flagsSchema = z.object({ + id: z.string().describe('The ID of the workflow to publish'), + versionId: z + .string() + .describe('The version ID to publish. If not provided, publishes the current version') + .optional(), + all: z.boolean().describe('(Deprecated) This flag is no longer supported').optional(), +}); + +@Command({ + name: 'publish:workflow', + description: + 'Publish a specific version of a workflow. If no version is specified, publishes the current version.', + examples: ['--id=5 --versionId=abc123', '--id=5'], + flagsSchema, +}) +export class PublishWorkflowCommand extends BaseCommand> { + async run() { + const { flags } = this; + + // Educate users who try to use --all flag + if (flags.all) { + this.logger.error('The --all flag is no longer supported for workflow publishing.'); + this.logger.error( + 'Please publish workflows individually using: publish:workflow --id= [--versionId=]', + ); + return; + } + + if (flags.versionId) { + this.logger.info(`Publishing workflow with ID: ${flags.id}, version: ${flags.versionId}`); + } else { + this.logger.info(`Publishing workflow with ID: ${flags.id} (current version)`); + } + + await Container.get(WorkflowRepository).publishVersion(flags.id, flags.versionId); + + this.logger.info('Note: Changes will not take effect if n8n is running.'); + this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.'); + } + + async catch(error: Error) { + this.logger.error('Error updating database. See log messages for details.'); + this.logger.error('\nGOT ERROR'); + this.logger.error('===================================='); + this.logger.error(error.message); + this.logger.error(error.stack!); + } +} diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index ed1c2133239..f9e2472b7d5 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -7,7 +7,7 @@ import { Container } from '@n8n/di'; import glob from 'fast-glob'; import { createReadStream, createWriteStream, existsSync } from 'fs'; import { mkdir } from 'fs/promises'; -import { jsonParse, randomString, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; +import { jsonParse, type IWorkflowExecutionDataProcess } from 'n8n-workflow'; import path from 'path'; import replaceStream from 'replacestream'; import { pipeline } from 'stream/promises'; @@ -52,7 +52,7 @@ const flagsSchema = z.object({ @Command({ name: 'start', description: 'Starts n8n. Makes Web-UI available and starts active workflows', - examples: ['', '--tunnel', '-o', '--tunnel -o'], + examples: ['', '-o'], flagsSchema, }) export class Start extends BaseCommand> { @@ -299,34 +299,6 @@ export class Start extends BaseCommand> { } } - if (flags.tunnel) { - this.log('\nWaiting for tunnel ...'); - - let tunnelSubdomain = - process.env.N8N_TUNNEL_SUBDOMAIN ?? this.instanceSettings.tunnelSubdomain ?? ''; - - if (tunnelSubdomain === '') { - // When no tunnel subdomain did exist yet create a new random one - tunnelSubdomain = randomString(24).toLowerCase(); - - this.instanceSettings.update({ tunnelSubdomain }); - } - - const { default: localtunnel } = await import('@n8n/localtunnel'); - const { port } = this.globalConfig; - - const webhookTunnel = await localtunnel(port, { - host: 'https://hooks.n8n.cloud', - subdomain: tunnelSubdomain, - }); - - process.env.WEBHOOK_URL = `${webhookTunnel.url}/`; - this.log(`Tunnel URL: ${process.env.WEBHOOK_URL}\n`); - this.log( - 'IMPORTANT! Do not share with anybody as it would give people access to your n8n instance!', - ); - } - if (this.globalConfig.database.isLegacySqlite) { // Employ lazy loading to avoid unnecessary imports in the CLI // and to ensure that the legacy recovery service is only used when needed. diff --git a/packages/cli/src/commands/unpublish/workflow.ts b/packages/cli/src/commands/unpublish/workflow.ts new file mode 100644 index 00000000000..628986e89ee --- /dev/null +++ b/packages/cli/src/commands/unpublish/workflow.ts @@ -0,0 +1,54 @@ +import { WorkflowRepository } from '@n8n/db'; +import { Command } from '@n8n/decorators'; +import { Container } from '@n8n/di'; +import { z } from 'zod'; + +import { BaseCommand } from '../base-command'; + +const flagsSchema = z.object({ + all: z.boolean().describe('Unpublish all workflows').optional(), + id: z.string().describe('The ID of the workflow to unpublish').optional(), +}); + +@Command({ + name: 'unpublish:workflow', + description: 'Unpublish workflow(s)', + examples: ['--all', '--id=5'], + flagsSchema, +}) +export class UnpublishWorkflowCommand extends BaseCommand> { + async run() { + const { flags } = this; + + if (!flags.all && !flags.id) { + this.logger.error('Either option "--all" or "--id" must be set.'); + return; + } + + if (flags.all && flags.id) { + this.logger.error('Cannot use both "--all" and "--id" flags together.'); + return; + } + + if (flags.id) { + this.logger.info(`Unpublishing workflow with ID: ${flags.id}`); + await Container.get(WorkflowRepository).updateActiveState(flags.id, false); + this.logger.info('Workflow unpublished successfully'); + } else { + this.logger.info('Unpublishing all workflows'); + await Container.get(WorkflowRepository).unpublishAll(); + this.logger.info('All workflows unpublished successfully'); + } + + this.logger.info('Note: Changes will not take effect if n8n is running.'); + this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.'); + } + + async catch(error: Error) { + this.logger.error('Error unpublishing workflow(s). See log messages for details.'); + this.logger.error('\nGOT ERROR'); + this.logger.error('===================================='); + this.logger.error(error.message); + this.logger.error(error.stack!); + } +} diff --git a/packages/cli/src/commands/update/workflow.ts b/packages/cli/src/commands/update/workflow.ts index 3b44f7bd315..caac4cbbf7c 100644 --- a/packages/cli/src/commands/update/workflow.ts +++ b/packages/cli/src/commands/update/workflow.ts @@ -13,14 +13,17 @@ const flagsSchema = z.object({ @Command({ name: 'update:workflow', - description: 'Update workflows', + description: '[DEPRECATED] Update workflows - use publish:workflow or unpublish:workflow instead', examples: ['--all --active=false', '--id=5 --active=true'], flagsSchema, }) export class UpdateWorkflowCommand extends BaseCommand> { async run() { + const workflowRepository = Container.get(WorkflowRepository); const { flags } = this; + this.logger.warn('⚠️ WARNING: The "update:workflow" command is deprecated.\n'); + if (!flags.all && !flags.id) { this.logger.error('Either option "--all" or "--id" have to be set!'); return; @@ -46,19 +49,49 @@ export class UpdateWorkflowCommand extends BaseCommand', + ); + return; + } + + // Show appropriate replacement command suggestion for unpublishing + if (flags.id) { + this.logger.warn(`Please use: unpublish:workflow --id=${flags.id}\n`); + } else { + this.logger.warn('Please use: unpublish:workflow --all\n'); + } + + if (flags.id) { + this.logger.info(`${action} workflow with ID: ${flags.id}`); + await workflowRepository.updateActiveState(flags.id, newState); + } else { + this.logger.info(`${action} all workflows`); + await workflowRepository.unpublishAll(); + } + + this.logger.info('Note: Changes will not take effect if n8n is running.'); this.logger.info('Please restart n8n for changes to take effect if n8n is currently running.'); } diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index 0586aa0b139..8a4b035d2a9 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -1,12 +1,9 @@ -import { inTest, Logger } from '@n8n/backend-common'; +import { inTest } from '@n8n/backend-common'; import { GlobalConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import convict from 'convict'; -import { flatten } from 'flat'; import { readFileSync } from 'fs'; -import merge from 'lodash/merge'; import { setGlobalState, UserError } from 'n8n-workflow'; -import assert from 'node:assert'; import { inE2ETests } from '@/constants'; @@ -33,42 +30,8 @@ const config = convict(schema, { args: [] }); // eslint-disable-next-line @typescript-eslint/unbound-method config.getEnv = config.get; -const logger = Container.get(Logger); - // Load overwrites when not in tests if (!inE2ETests && !inTest) { - // Overwrite default configuration with settings which got defined in - // optional configuration files - const { N8N_CONFIG_FILES } = process.env; - if (N8N_CONFIG_FILES !== undefined) { - const configFiles = N8N_CONFIG_FILES.split(','); - for (const configFile of configFiles) { - if (!configFile) continue; - // NOTE: This is "temporary" code until we have migrated all config to the new package - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const data = JSON.parse(readFileSync(configFile, 'utf8')); - for (const prefix in data) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const innerData = data[prefix]; - if (prefix in globalConfig) { - // @ts-ignore - merge(globalConfig[prefix], innerData); - } else { - const flattenedData: Record = flatten(innerData); - for (const key in flattenedData) { - config.set(`${prefix}.${key}`, flattenedData[key]); - } - } - } - logger.debug(`Loaded config overwrites from ${configFile}`); - } catch (error) { - assert(error instanceof Error); - logger.error(`Error loading config file ${configFile}`, { error }); - } - } - } - // Overwrite config from files defined in "_FILE" environment variables Object.entries(process.env).forEach(([envName, fileName]) => { if (envName.endsWith('_FILE') && fileName) { diff --git a/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts b/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts index 3d4ebb70db7..01f4633340d 100644 --- a/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts +++ b/packages/cli/src/controllers/oauth/__tests__/abstract-oauth.controller.test.ts @@ -18,7 +18,7 @@ describe('shouldSkipAuthOnOAuthCallback', () => { }); it('should return true', () => { - expect(shouldSkipAuthOnOAuthCallback()).toBe(true); + expect(shouldSkipAuthOnOAuthCallback()).toBe(false); }); }); diff --git a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts index 11c011318eb..8ab590b9eff 100644 --- a/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts +++ b/packages/cli/src/controllers/oauth/abstract-oauth.controller.ts @@ -35,8 +35,7 @@ type CsrfStateParam = { const MAX_CSRF_AGE = 5 * Time.minutes.toMilliseconds; export function shouldSkipAuthOnOAuthCallback() { - // TODO: Flip this flag in v2 https://linear.app/n8n/issue/CAT-329 - const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'true'; + const value = process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK?.toLowerCase() ?? 'false'; return value === 'true'; } diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.activate.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.activate.yml index 20c40a7451a..b1aa2216963 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.activate.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.activate.yml @@ -7,6 +7,23 @@ post: description: Active a workflow. parameters: - $ref: '../schemas/parameters/workflowId.yml' + requestBody: + description: Optional parameters for workflow activation. + content: + application/json: + schema: + type: object + properties: + versionId: + type: string + description: The specific version ID to activate. If not provided, activates the latest version. + name: + type: string + description: Optional name for the workflow version during activation. + description: + type: string + description: Optional description for the workflow version during activation. + required: false responses: '200': description: Workflow object @@ -14,6 +31,8 @@ post: application/json: schema: $ref: '../schemas/workflow.yml' + '400': + $ref: '../../../../shared/spec/responses/badRequest.yml' '401': $ref: '../../../../shared/spec/responses/unauthorized.yml' '404': diff --git a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml index c8b2bf51cdc..167fb0d13c0 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml +++ b/packages/cli/src/public-api/v1/handlers/workflows/spec/paths/workflows.id.yml @@ -51,7 +51,7 @@ put: tags: - Workflow summary: Update a workflow - description: Update a workflow. + description: Update a workflow. If the workflow is active, the updated version will be automatically reactivated. parameters: - $ref: '../schemas/parameters/workflowId.yml' requestBody: diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 73397581ade..2b4c56dddb7 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -309,6 +309,7 @@ export = { { forceSave: true, // Skip version conflict check for public API publicApi: true, + publishIfActive: true, }, ); diff --git a/packages/cli/src/scaling/__tests__/scaling.service.test.ts b/packages/cli/src/scaling/__tests__/scaling.service.test.ts index 248c1e27c50..c034ead5b65 100644 --- a/packages/cli/src/scaling/__tests__/scaling.service.test.ts +++ b/packages/cli/src/scaling/__tests__/scaling.service.test.ts @@ -70,7 +70,7 @@ describe('ScalingService', () => { QUEUE_NAME, { prefix: globalConfig.queue.bull.prefix, - settings: globalConfig.queue.bull.settings, + settings: { ...globalConfig.queue.bull.settings, maxStalledCount: 0 }, createClient: expect.any(Function), }, ]; diff --git a/packages/cli/src/scaling/scaling.service.ts b/packages/cli/src/scaling/scaling.service.ts index 9142a6c8d2a..4198eb7ef33 100644 --- a/packages/cli/src/scaling/scaling.service.ts +++ b/packages/cli/src/scaling/scaling.service.ts @@ -67,7 +67,7 @@ export class ScalingService { this.queue = new BullQueue(QUEUE_NAME, { prefix, - settings: this.globalConfig.queue.bull.settings, + settings: { ...this.globalConfig.queue.bull.settings, maxStalledCount: 0 }, createClient: (type) => service.createClient({ type: `${type}(bull)` }), }); diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index e0a06dc69c3..f949d25d2cf 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -6,7 +6,6 @@ import type { ListQueryDb, WorkflowFolderUnionFull, WorkflowHistoryUpdate, - WorkflowHistory, } from '@n8n/db'; import { SharedWorkflow, @@ -24,6 +23,7 @@ import type { EntityManager } from '@n8n/typeorm'; // eslint-disable-next-line n8n-local-rules/misplaced-n8n-typeorm-import import { In } from '@n8n/typeorm'; import type { QueryDeepPartialEntity } from '@n8n/typeorm/query-builder/QueryPartialEntity'; +import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import pick from 'lodash/pick'; import { FileLocation, BinaryDataService } from 'n8n-core'; @@ -34,6 +34,7 @@ import { ActiveWorkflowManager } from '@/active-workflow-manager'; import { FolderNotFoundError } from '@/errors/folder-not-found.error'; import { BadRequestError } from '@/errors/response-errors/bad-request.error'; import { NotFoundError } from '@/errors/response-errors/not-found.error'; +import { WorkflowHistoryVersionNotFoundError } from '@/errors/workflow-history-version-not-found.error'; import { EventService } from '@/events/event.service'; import { ExternalHooks } from '@/external-hooks'; import { validateEntity } from '@/generic-helpers'; @@ -50,12 +51,6 @@ import { WorkflowFinderService } from './workflow-finder.service'; import { WorkflowHistoryService } from './workflow-history/workflow-history.service'; import { WorkflowSharingService } from './workflow-sharing.service'; -type RollbackPayload = { - active: boolean; - activeVersionId: string | null; - activeVersion: WorkflowHistory | null; -} & Partial>; - @Service() export class WorkflowService { constructor( @@ -211,6 +206,15 @@ export class WorkflowService { ); } + /** + * Updates the workflow content (such as name, nodes, connections, settings, etc.). + * + * This method never updates the workflow's active fields (active, activeVersionId) directly. + * However, if settings change and the workflow has an active version, the workflow will be + * automatically reactivated to ensure the ActiveWorkflowManager uses the updated settings. + * For explicit activation or deactivation, use the activate/deactivate methods. + */ + // eslint-disable-next-line complexity async update( user: User, @@ -221,9 +225,16 @@ export class WorkflowService { parentFolderId?: string; forceSave?: boolean; publicApi?: boolean; + publishIfActive?: boolean; } = {}, ): Promise { - const { tagIds, parentFolderId, forceSave = false, publicApi = false } = options; + const { + tagIds, + parentFolderId, + forceSave = false, + publicApi = false, + publishIfActive = false, + } = options; const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, user, @@ -252,8 +263,6 @@ export class WorkflowService { ); } - const isDraftPublishDisabled = !this.globalConfig.workflows.draftPublishEnabled; - if ( Object.keys(omit(workflowUpdateData, ['id', 'versionId', 'active', 'activeVersionId'])) .length > 0 @@ -272,22 +281,9 @@ export class WorkflowService { }, ); } - // Convert 'active' boolean from frontend to 'activeVersionId' for backend - // Forbid updating active fields with FF on - if (isDraftPublishDisabled && 'active' in workflowUpdateData) { - if (workflowUpdateData.active) { - workflowUpdateData.activeVersionId = workflowUpdateData.versionId ?? workflow.versionId; - } else { - workflowUpdateData.activeVersionId = null; - } - } const versionChanged = workflowUpdateData.versionId && workflowUpdateData.versionId !== workflow.versionId; - const wasActive = workflow.activeVersionId !== null; - const isNowActive = workflowUpdateData.active ?? wasActive; - const activationStatusChanged = isNowActive !== wasActive; - const needsActiveVersionUpdate = activationStatusChanged || (versionChanged && isNowActive); if (versionChanged) { // To save a version, we need both nodes and connections @@ -308,18 +304,12 @@ export class WorkflowService { }; } - await this.externalHooks.run('workflow.update', [workflowUpdateData]); + // Check if settings actually changed + const settingsChanged = + workflowUpdateData.settings !== undefined && + !isEqual(workflow.settings, workflowUpdateData.settings); - /** - * If the workflow being updated is stored as `active`, remove it from - * active workflows in memory, and re-add it after the update. - * - * If a trigger or poller in the workflow was updated, the new value - * will take effect only on removing and re-adding. - */ - if (isDraftPublishDisabled && wasActive) { - await this.activeWorkflowManager.remove(workflowId); - } + await this.externalHooks.run('workflow.update', [workflowUpdateData]); const workflowSettings = workflowUpdateData.settings ?? {}; const keysAllowingDefault = [ @@ -359,11 +349,8 @@ export class WorkflowService { 'versionId', 'description', 'updatedAt', + // do not update active fields ]; - // Forbid updating active fields with FF on - if (isDraftPublishDisabled) { - fieldsToUpdate.push('activeVersionId', 'active'); - } const updatePayload: QueryDeepPartialEntity = pick( workflowUpdateData, @@ -374,19 +361,13 @@ export class WorkflowService { if (versionChanged) { await this.workflowHistoryService.saveVersion(user, workflowUpdateData, workflowId); } - if (isDraftPublishDisabled && needsActiveVersionUpdate) { - const versionIdToFetch = versionChanged ? workflowUpdateData.versionId : workflow.versionId; - const version = await this.workflowHistoryService.getVersion( - user, - workflowId, - versionIdToFetch, - ); - updatePayload.activeVersion = WorkflowHelpers.getActiveVersionUpdateValue( - workflow, - version, - isNowActive, - ); + + const publishCurrent = workflow.activeVersionId && publishIfActive; + if (publishCurrent) { + updatePayload.active = true; + updatePayload.activeVersionId = workflowUpdateData.versionId; } + if (parentFolderId) { const project = await this.sharedWorkflowRepository.getWorkflowOwningProject(workflow.id); if (parentFolderId !== PROJECT_ROOT) { @@ -434,50 +415,16 @@ export class WorkflowService { workflow: updatedWorkflow, publicApi, }); - // Skip activation/deactivation logic if draft/publish feature flag is enabled - if (isDraftPublishDisabled) { - if (activationStatusChanged && isNowActive) { - this.eventService.emit('workflow-activated', { - user, - workflowId, - workflow: updatedWorkflow, - publicApi: false, - }); - } else if (activationStatusChanged && !isNowActive) { - // Workflow is being deactivated - this.eventService.emit('workflow-deactivated', { - user, - workflowId, - workflow: updatedWorkflow, - publicApi, - }); - assert(workflow.activeVersionId !== null); - await this.workflowPublishHistoryRepository.addRecord({ - workflowId, - versionId: workflow.activeVersionId, - event: 'deactivated', - userId: user.id, - }); - } - if (isNowActive) { - // When the workflow is supposed to be active add it again - await this._addToActiveWorkflowManager( - user, - workflowId, - updatedWorkflow, - wasActive ? 'update' : 'activate', - // If workflow could not be activated, set it again to inactive - // and revert the versionId and activeVersionId change so UI remains consistent - { - versionId: workflow.versionId, - active: false, - activeVersionId: null, - activeVersion: null, - }, - publicApi, - ); - } + // Activate workflow if requested, or + // Reactivate workflow if settings changed and workflow has an active version + if (updatedWorkflow.activeVersionId && (publishCurrent || settingsChanged)) { + await this.activateWorkflow( + user, + workflowId, + { versionId: updatedWorkflow.activeVersionId }, + publicApi, + ); } return updatedWorkflow; } @@ -491,7 +438,6 @@ export class WorkflowService { workflowId: string, workflow: WorkflowEntity, mode: 'activate' | 'update', - rollbackPayload: RollbackPayload, publicApi: boolean = false, ): Promise { let didPublish = false; @@ -500,6 +446,11 @@ export class WorkflowService { await this.activeWorkflowManager.add(workflowId, mode); didPublish = true; } catch (error) { + const rollbackPayload = { + active: false, + activeVersionId: null, + activeVersion: null, + }; const previouslyActiveId = workflow.activeVersionId; await this.workflowRepository.update(workflowId, rollbackPayload); @@ -557,7 +508,6 @@ export class WorkflowService { options?: { versionId?: string; name?: string; description?: string }, publicApi: boolean = false, ): Promise { - const isDraftPublishEnabled = this.globalConfig.workflows.draftPublishEnabled; const workflow = await this.workflowFinderService.findWorkflowForUser( workflowId, user, @@ -575,15 +525,24 @@ export class WorkflowService { ); } - const versionToActivate = isDraftPublishEnabled - ? (options?.versionId ?? workflow.versionId) - : workflow.versionId; + const versionToActivate = options?.versionId ?? workflow.versionId; + const wasActive = workflow.activeVersionId !== null; - if (workflow.activeVersionId === versionToActivate) { - return workflow; + try { + await this.workflowHistoryService.getVersion(user, workflow.id, versionToActivate, { + includePublishHistory: false, + }); + } catch (error) { + if (error instanceof WorkflowHistoryVersionNotFoundError) { + throw new NotFoundError('Version not found'); + } + throw error; + } + + if (wasActive) { + await this.activeWorkflowManager.remove(workflowId); } - const wasActive = workflow.activeVersionId !== null; const activationMode = wasActive ? 'update' : 'activate'; await this.workflowRepository.update(workflowId, { @@ -614,24 +573,10 @@ export class WorkflowService { workflowId, updatedWorkflow, activationMode, - isDraftPublishEnabled - ? { - active: workflow.active, - activeVersionId: workflow.activeVersionId, - activeVersion: workflow.activeVersion, - } - : { - active: false, - activeVersionId: null, - activeVersion: null, - }, publicApi, ); - if ( - isDraftPublishEnabled && - (options?.name !== undefined || options?.description !== undefined) - ) { + if (options?.name !== undefined || options?.description !== undefined) { const updateFields: WorkflowHistoryUpdate = {}; if (options.name !== undefined) updateFields.name = options.name; if (options.description !== undefined) updateFields.description = options.description; diff --git a/packages/cli/test/integration/commands/publish/workflow.test.ts b/packages/cli/test/integration/commands/publish/workflow.test.ts new file mode 100644 index 00000000000..d1ff459b989 --- /dev/null +++ b/packages/cli/test/integration/commands/publish/workflow.test.ts @@ -0,0 +1,111 @@ +import { + mockInstance, + testDb, + createWorkflowWithTriggerAndHistory, + getWorkflowById, +} from '@n8n/backend-test-utils'; + +import { PublishWorkflowCommand } from '@/commands/publish/workflow'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { setupTestCommand } from '@test-integration/utils/test-command'; + +mockInstance(LoadNodesAndCredentials); +const command = setupTestCommand(PublishWorkflowCommand); + +beforeEach(async () => { + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); +}); + +test('publish:workflow can publish a specific workflow version', async () => { + // + // ARRANGE + // + const workflow = await createWorkflowWithTriggerAndHistory(); + + // + // ACT + // + await command.run([`--id=${workflow.id}`, `--versionId=${workflow.versionId}`]); + + // + // ASSERT + // + const updatedWorkflow = await getWorkflowById(workflow.id); + + expect(updatedWorkflow).toMatchObject({ + activeVersionId: workflow.versionId, + active: true, + }); +}); + +test('publish:workflow does not publish when --all flag is used', async () => { + // + // ARRANGE + // + const workflow = await createWorkflowWithTriggerAndHistory(); + + // + // ACT + // + await command.run(['--all', `--id=${workflow.id}`, `--versionId=${workflow.versionId}`]); + + // + // ASSERT + // + // Verify the workflow was not published (--all flag prevents publishing) + const unchangedWorkflow = await getWorkflowById(workflow.id); + expect(unchangedWorkflow).toMatchObject({ + activeVersionId: null, + active: false, + }); +}); + +test('publish:workflow publishes current version when --versionId is missing', async () => { + // + // ARRANGE + // + const workflow = await createWorkflowWithTriggerAndHistory(); + + // + // ACT + // + await command.run([`--id=${workflow.id}`]); + + // + // ASSERT + // + const updatedWorkflow = await getWorkflowById(workflow.id); + expect(updatedWorkflow).toMatchObject({ + activeVersionId: workflow.versionId, + active: true, + }); +}); + +test('unpublish:workflow throws error when workflow does not exist', async () => { + // + // ARRANGE + // + const nonExistentWorkflowId = 'non-existent-workflow-id'; + + // + // ACT & ASSERT + // + await expect(command.run([`--id=${nonExistentWorkflowId}`])).rejects.toThrow( + `Workflow "${nonExistentWorkflowId}" not found.`, + ); +}); + +test('publish:workflow throws error when version does not exist', async () => { + // + // ARRANGE + // + const workflow = await createWorkflowWithTriggerAndHistory(); + const nonExistentVersionId = 'non-existent-version'; + + // + // ACT & ASSERT + // + await expect( + command.run([`--id=${workflow.id}`, `--versionId=${nonExistentVersionId}`]), + ).rejects.toThrow(`Version "${nonExistentVersionId}" not found for workflow "${workflow.id}".`); +}); diff --git a/packages/cli/test/integration/commands/unpublish/workflow.test.ts b/packages/cli/test/integration/commands/unpublish/workflow.test.ts new file mode 100644 index 00000000000..2807ebea6ca --- /dev/null +++ b/packages/cli/test/integration/commands/unpublish/workflow.test.ts @@ -0,0 +1,144 @@ +import { + mockInstance, + testDb, + createManyActiveWorkflows, + getWorkflowById, +} from '@n8n/backend-test-utils'; + +import { UnpublishWorkflowCommand } from '@/commands/unpublish/workflow'; +import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import { setupTestCommand } from '@test-integration/utils/test-command'; + +mockInstance(LoadNodesAndCredentials); +const command = setupTestCommand(UnpublishWorkflowCommand); + +beforeEach(async () => { + await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); +}); + +test('unpublish:workflow can unpublish all workflows', async () => { + // + // ARRANGE + // + const workflows = await createManyActiveWorkflows(2); + + // + // ACT + // + await command.run(['--all']); + + // + // ASSERT + // + const workflow1 = await getWorkflowById(workflows[0].id); + const workflow2 = await getWorkflowById(workflows[1].id); + + expect(workflow1).toMatchObject({ + activeVersionId: null, + active: false, + }); + + expect(workflow2).toMatchObject({ + activeVersionId: null, + active: false, + }); +}); + +test('unpublish:workflow can unpublish a specific workflow', async () => { + // + // ARRANGE + // + const workflows = await createManyActiveWorkflows(2); + + // + // ACT + // + await command.run([`--id=${workflows[0].id}`]); + + // + // ASSERT + // + const unpublishedWorkflow = await getWorkflowById(workflows[0].id); + const activeWorkflow = await getWorkflowById(workflows[1].id); + + expect(unpublishedWorkflow).toMatchObject({ + activeVersionId: null, + active: false, + }); + expect(activeWorkflow).toMatchObject({ + activeVersionId: workflows[1].versionId, + active: true, + }); +}); + +test('unpublish:workflow does nothing when neither --all nor --id is provided', async () => { + // + // ARRANGE + // + const workflows = await createManyActiveWorkflows(2); + + // + // ACT + // + await command.run([]); + + // + // ASSERT + // + // Verify workflows are still active + const workflow1 = await getWorkflowById(workflows[0].id); + const workflow2 = await getWorkflowById(workflows[1].id); + + expect(workflow1).toMatchObject({ + activeVersionId: workflows[0].versionId, + active: true, + }); + + expect(workflow2).toMatchObject({ + activeVersionId: workflows[1].versionId, + active: true, + }); +}); + +test('unpublish:workflow does nothing when both --all and --id are provided', async () => { + // + // ARRANGE + // + const workflows = await createManyActiveWorkflows(2); + + // + // ACT + // + await command.run(['--all', '--id=123']); + + // + // ASSERT + // + // Verify workflows are still active + const workflow1 = await getWorkflowById(workflows[0].id); + const workflow2 = await getWorkflowById(workflows[1].id); + + expect(workflow1).toMatchObject({ + activeVersionId: workflows[0].versionId, + active: true, + }); + + expect(workflow2).toMatchObject({ + activeVersionId: workflows[1].versionId, + active: true, + }); +}); + +test('unpublish:workflow throws error when workflow does not exist', async () => { + // + // ARRANGE + // + const nonExistentWorkflowId = 'non-existent-workflow-id'; + + // + // ACT & ASSERT + // + await expect(command.run([`--id=${nonExistentWorkflowId}`])).rejects.toThrow( + `Workflow "${nonExistentWorkflowId}" not found.`, + ); +}); diff --git a/packages/cli/test/integration/commands/update/workflow.test.ts b/packages/cli/test/integration/commands/update/workflow.test.ts index ee6fab23760..290ed1ad98e 100644 --- a/packages/cli/test/integration/commands/update/workflow.test.ts +++ b/packages/cli/test/integration/commands/update/workflow.test.ts @@ -19,7 +19,7 @@ beforeEach(async () => { await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); }); -test('update:workflow can activate all workflows', async () => { +test('update:workflow does not publish when trying to publish all workflows', async () => { // // ARRANGE // @@ -27,8 +27,6 @@ test('update:workflow can activate all workflows', async () => { createWorkflowWithTriggerAndHistory({}), createWorkflowWithTriggerAndHistory({}), ]); - expect(workflows[0].activeVersionId).toBeNull(); - expect(workflows[1].activeVersionId).toBeNull(); // // ACT @@ -38,24 +36,16 @@ test('update:workflow can activate all workflows', async () => { // // ASSERT // - // Verify activeVersionId is now set to the current versionId + // Verify workflows were NOT published (publishing all is no longer supported) const workflowRepo = Container.get(WorkflowRepository); - const workflow1 = await workflowRepo.findOne({ - where: { id: workflows[0].id }, - relations: ['activeVersion'], - }); - const workflow2 = await workflowRepo.findOne({ - where: { id: workflows[1].id }, - relations: ['activeVersion'], - }); + const workflow1 = await workflowRepo.findOneBy({ id: workflows[0].id }); + const workflow2 = await workflowRepo.findOneBy({ id: workflows[1].id }); - expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); - expect(workflow1?.activeVersion?.versionId).toBe(workflows[0].versionId); - expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); - expect(workflow2?.activeVersion?.versionId).toBe(workflows[1].versionId); + expect(workflow1?.activeVersionId).toBeNull(); + expect(workflow2?.activeVersionId).toBeNull(); }); -test('update:workflow can deactivate all workflows', async () => { +test('update:workflow can unpublish all workflows', async () => { // // ARRANGE // @@ -92,33 +82,29 @@ test('update:workflow can deactivate all workflows', async () => { expect(workflow2?.activeVersion).toBeNull(); }); -test('update:workflow can activate a specific workflow', async () => { +test('update:workflow publishes current version when --active=true (backwards compatibility)', async () => { // // ARRANGE // - const workflows = ( - await Promise.all([ - createWorkflowWithTriggerAndHistory(), - createWorkflowWithTriggerAndHistory(), - ]) - ).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); + const workflow = await createWorkflowWithTriggerAndHistory(); // // ACT // - await command.run([`--id=${workflows[0].id}`, '--active=true']); + await command.run([`--id=${workflow.id}`, '--active=true']); // // ASSERT // - const after = (await getAllWorkflows()).sort((wf1, wf2) => wf1.id.localeCompare(wf2.id)); - expect(after).toMatchObject([ - { activeVersionId: workflows[0].versionId }, - { activeVersionId: null }, - ]); + // Verify workflow was published with current version + const workflowRepo = Container.get(WorkflowRepository); + const updatedWorkflow = await workflowRepo.findOneBy({ id: workflow.id }); + + expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); + expect(updatedWorkflow?.active).toBe(true); }); -test('update:workflow can deactivate a specific workflow', async () => { +test('update:workflow can unpublish a specific workflow', async () => { // // ARRANGE // diff --git a/packages/cli/test/integration/commands/worker.cmd.test.ts b/packages/cli/test/integration/commands/worker.cmd.test.ts index 61533061439..ec29649fdfd 100644 --- a/packages/cli/test/integration/commands/worker.cmd.test.ts +++ b/packages/cli/test/integration/commands/worker.cmd.test.ts @@ -25,6 +25,7 @@ import { setupTestCommand } from '@test-integration/utils/test-command'; Container.get(ExecutionsConfig).mode = 'queue'; config.set('binaryDataManager.availableModes', 'filesystem'); Container.get(TaskRunnersConfig).enabled = true; +Container.get(TaskRunnersConfig).isNativePythonRunnerEnabled = false; mockInstance(LoadNodesAndCredentials); const binaryDataService = mockInstance(BinaryDataService); const communityPackagesService = mockInstance(CommunityPackagesService); diff --git a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts index 56404c00200..9f6f4594afd 100644 --- a/packages/cli/test/integration/database/repositories/workflow.repository.test.ts +++ b/packages/cli/test/integration/database/repositories/workflow.repository.test.ts @@ -3,14 +3,16 @@ import { createWorkflowWithHistory, createActiveWorkflow, createManyActiveWorkflows, - createWorkflowWithActiveVersion, createWorkflow, testDb, + getWorkflowById, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { WorkflowRepository, WorkflowDependencyRepository, WorkflowDependencies } from '@n8n/db'; import { Container } from '@n8n/di'; +import { createWorkflowHistoryItem } from '@test-integration/db/workflow-history'; + import { createTestRun } from '../../shared/db/evaluation'; describe('WorkflowRepository', () => { @@ -26,73 +28,96 @@ describe('WorkflowRepository', () => { await testDb.terminate(); }); - describe('activateAll', () => { - it('should activate all workflows', async () => { + describe('publishVersion', () => { + it('should publish a specific workflow version', async () => { // // ARRANGE // const workflowRepository = Container.get(WorkflowRepository); - const workflows = await Promise.all([ - createWorkflowWithTriggerAndHistory(), - createWorkflowWithTriggerAndHistory(), - ]); - expect(workflows[0].activeVersionId).toBeNull(); - expect(workflows[1].activeVersionId).toBeNull(); + const workflow = await createWorkflowWithTriggerAndHistory(); + const targetVersionId = 'custom-version-123'; + await createWorkflowHistoryItem(workflow.id, { versionId: targetVersionId }); // // ACT // - await workflowRepository.activateAll(); + await workflowRepository.publishVersion(workflow.id, targetVersionId); // // ASSERT // - const workflow1 = await workflowRepository.findOne({ - where: { id: workflows[0].id }, - }); - const workflow2 = await workflowRepository.findOne({ - where: { id: workflows[1].id }, - }); + const updatedWorkflow = await getWorkflowById(workflow.id); - expect(workflow1?.activeVersionId).toBe(workflows[0].versionId); - expect(workflow2?.activeVersionId).toBe(workflows[1].versionId); + expect(updatedWorkflow?.activeVersionId).toBe(targetVersionId); + expect(updatedWorkflow?.active).toBe(true); }); - it('should not change activeVersionId for already-active workflows', async () => { + it('should update activeVersionId when publishing an already published workflow', async () => { // // ARRANGE // const workflowRepository = Container.get(WorkflowRepository); - const activeVersionId = 'old-active-version-id'; - - // Create workflow with different active and current versions - const workflow = await createWorkflowWithActiveVersion(activeVersionId, {}); - const currentVersionId = workflow.versionId; - - expect(workflow.active).toBe(true); - expect(workflow.activeVersionId).toBe(activeVersionId); - expect(workflow.versionId).toBe(currentVersionId); + const workflow = await createActiveWorkflow(); + const newVersionId = 'new-version-id'; + await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); // // ACT // - await workflowRepository.activateAll(); + await workflowRepository.publishVersion(workflow.id, newVersionId); // // ASSERT // - // activeVersionId should remain unchanged - const after = await workflowRepository.findOne({ - where: { id: workflow.id }, - }); + const updatedWorkflow = await getWorkflowById(workflow.id); - expect(after?.activeVersionId).toBe(activeVersionId); // Unchanged - expect(after?.versionId).toBe(currentVersionId); + expect(updatedWorkflow?.activeVersionId).toBe(newVersionId); + expect(updatedWorkflow?.active).toBe(true); + expect(updatedWorkflow?.versionId).toBe(workflow.versionId); + }); + + it('should throw error when version does not exist for workflow', async () => { + // + // ARRANGE + // + const workflowRepository = Container.get(WorkflowRepository); + const workflow = await createWorkflowWithTriggerAndHistory(); + const nonExistentVersionId = 'non-existent-version'; + + // + // ACT & ASSERT + // + await expect( + workflowRepository.publishVersion(workflow.id, nonExistentVersionId), + ).rejects.toThrow( + `Version "${nonExistentVersionId}" not found for workflow "${workflow.id}".`, + ); + }); + + it('should publish current version when versionId is not provided', async () => { + // + // ARRANGE + // + const workflowRepository = Container.get(WorkflowRepository); + const workflow = await createWorkflowWithTriggerAndHistory(); + + // + // ACT + // + await workflowRepository.publishVersion(workflow.id); + + // + // ASSERT + // + const updatedWorkflow = await getWorkflowById(workflow.id); + + expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); + expect(updatedWorkflow?.active).toBe(true); }); }); - describe('deactivateAll', () => { - it('should deactivate all workflows and clear activeVersionId', async () => { + describe('unpublishAll', () => { + it('should unpublish all workflows and clear activeVersionId', async () => { // // ARRANGE // @@ -106,7 +131,7 @@ describe('WorkflowRepository', () => { // // ACT // - await workflowRepository.deactivateAll(); + await workflowRepository.unpublishAll(); // // ASSERT // diff --git a/packages/cli/test/integration/public-api/workflows.test.ts b/packages/cli/test/integration/public-api/workflows.test.ts index b5b1ebda874..97b24da0762 100644 --- a/packages/cli/test/integration/public-api/workflows.test.ts +++ b/packages/cli/test/integration/public-api/workflows.test.ts @@ -5,11 +5,13 @@ import { testDb, mockInstance, createActiveWorkflow, + createWorkflowWithHistory, } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db'; import { ProjectRepository, WorkflowHistoryRepository, SharedWorkflowRepository } from '@n8n/db'; import { Container } from '@n8n/di'; +import { Not } from '@n8n/typeorm'; import { InstanceSettings } from 'n8n-core'; import type { INode } from 'n8n-workflow'; import { v4 as uuid } from 'uuid'; @@ -97,9 +99,9 @@ describe('GET /workflows', () => { test('should return all owned workflows', async () => { await Promise.all([ - createWorkflow({}, member), - createWorkflow({}, member), - createWorkflow({}, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), ]); const response = await authMemberAgent.get('/workflows'); @@ -139,9 +141,9 @@ describe('GET /workflows', () => { test('should return all owned workflows with pagination', async () => { await Promise.all([ - createWorkflow({}, member), - createWorkflow({}, member), - createWorkflow({}, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, member), ]); const response = await authMemberAgent.get('/workflows?limit=1'); @@ -197,8 +199,8 @@ describe('GET /workflows', () => { const tag = await createTag({}); const [workflow] = await Promise.all([ - createWorkflow({ tags: [tag] }, member), - createWorkflow({}, member), + createWorkflowWithHistory({ tags: [tag] }, member), + createWorkflowWithHistory({}, member), ]); const response = await authMemberAgent.get(`/workflows?tags=${tag.name}`); @@ -240,11 +242,11 @@ describe('GET /workflows', () => { const tagNames = tags.map((tag) => tag.name).join(','); const [workflow1, workflow2] = await Promise.all([ - createWorkflow({ tags }, member), - createWorkflow({ tags }, member), - createWorkflow({}, member), - createWorkflow({ tags: [tags[0]] }, member), - createWorkflow({ tags: [tags[1]] }, member), + createWorkflowWithHistory({ tags }, member), + createWorkflowWithHistory({ tags }, member), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({ tags: [tags[0]] }, member), + createWorkflowWithHistory({ tags: [tags[1]] }, member), ]); const response = await authMemberAgent.get(`/workflows?tags=${tagNames}`); @@ -296,8 +298,8 @@ describe('GET /workflows', () => { }); await Promise.all([ - createWorkflow({ name: 'First workflow' }, firstProject), - createWorkflow({ name: 'Second workflow' }, secondProject), + createWorkflowWithHistory({ name: 'First workflow' }, firstProject), + createWorkflowWithHistory({ name: 'Second workflow' }, secondProject), ]); const firstResponse = await authOwnerAgent.get(`/workflows?projectId=${firstProject.id}`); @@ -319,8 +321,8 @@ describe('GET /workflows', () => { }); await Promise.all([ - createWorkflow({}, member), - createWorkflow({ name: 'Other workflow' }, otherProject), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({ name: 'Other workflow' }, otherProject), ]); const response = await authMemberAgent.get(`/workflows?projectId=${otherProject.id}`); @@ -333,7 +335,10 @@ describe('GET /workflows', () => { test('should return all owned workflows filtered by name', async () => { const workflowName = 'Workflow 1'; - await Promise.all([createWorkflow({ name: workflowName }, member), createWorkflow({}, member)]); + await Promise.all([ + createWorkflowWithHistory({ name: workflowName }, member), + createWorkflowWithHistory({}, member), + ]); const response = await authMemberAgent.get(`/workflows?name=${workflowName}`); @@ -369,11 +374,11 @@ describe('GET /workflows', () => { test('should return all workflows for owner', async () => { await Promise.all([ - createWorkflow({}, owner), - createWorkflow({}, member), - createWorkflow({}, owner), - createWorkflow({}, member), - createWorkflow({}, owner), + createWorkflowWithHistory({}, owner), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, owner), + createWorkflowWithHistory({}, member), + createWorkflowWithHistory({}, owner), ]); const response = await authOwnerAgent.get('/workflows'); @@ -413,7 +418,7 @@ describe('GET /workflows', () => { test('should return all owned workflows without pinned data', async () => { await Promise.all([ - createWorkflow( + createWorkflowWithHistory( { pinData: { Webhook1: [{ json: { first: 'first' } }], @@ -421,7 +426,7 @@ describe('GET /workflows', () => { }, member, ), - createWorkflow( + createWorkflowWithHistory( { pinData: { Webhook2: [{ json: { second: 'second' } }], @@ -429,7 +434,7 @@ describe('GET /workflows', () => { }, member, ), - createWorkflow( + createWorkflowWithHistory( { pinData: { Webhook3: [{ json: { third: 'third' } }], @@ -453,7 +458,7 @@ describe('GET /workflows', () => { }); test('should return activeVersion for all workflows', async () => { - const inactiveWorkflow = await createWorkflow({}, member); + const inactiveWorkflow = await createWorkflowWithHistory({}, member); const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`); @@ -485,7 +490,7 @@ describe('GET /workflows', () => { }); test('should return activeVersion when filtering by active=true', async () => { - await createWorkflow({}, member); + await createWorkflowWithHistory({}, member); const activeWorkflow = await createWorkflowWithTriggerAndHistory({}, member); await authMemberAgent.post(`/workflows/${activeWorkflow.id}/activate`); @@ -516,7 +521,7 @@ describe('GET /workflows/:id', () => { test('should retrieve workflow', async () => { // create and assign workflow to owner - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const response = await authMemberAgent.get(`/workflows/${workflow.id}`); @@ -551,7 +556,7 @@ describe('GET /workflows/:id', () => { test('should retrieve non-owned workflow for owner', async () => { // create and assign workflow to owner - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const response = await authOwnerAgent.get(`/workflows/${workflow.id}`); @@ -584,7 +589,7 @@ describe('GET /workflows/:id', () => { test('should retrieve workflow without pinned data', async () => { // create and assign workflow to owner - const workflow = await createWorkflow( + const workflow = await createWorkflowWithHistory( { pinData: { Webhook1: [{ json: { first: 'first' } }], @@ -603,7 +608,7 @@ describe('GET /workflows/:id', () => { }); test('should return activeVersion as null for inactive workflow', async () => { - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const response = await authMemberAgent.get(`/workflows/${workflow.id}`); @@ -732,7 +737,7 @@ describe('DELETE /workflows/:id', () => { test('should delete the workflow', async () => { // create and assign workflow to owner - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const response = await authMemberAgent.delete(`/workflows/${workflow.id}`); @@ -772,7 +777,7 @@ describe('DELETE /workflows/:id', () => { test('should delete non-owned workflow when owner', async () => { // create and assign workflow to owner - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const response = await authMemberAgent.delete(`/workflows/${workflow.id}`); @@ -825,13 +830,13 @@ describe('POST /workflows/:id/activate', () => { }); test('should fail due to trying to activate a workflow without any nodes', async () => { - const workflow = await createWorkflow({ nodes: [] }, owner); + const workflow = await createWorkflowWithHistory({ nodes: [] }, owner); const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`); expect(response.statusCode).toBe(400); }); test('should fail due to trying to activate a workflow without a trigger', async () => { - const workflow = await createWorkflow( + const workflow = await createWorkflowWithHistory( { nodes: [ { @@ -1341,7 +1346,7 @@ describe('PUT /workflows/:id', () => { }); test('should update workflow', async () => { - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const payload = { name: 'name updated', nodes: [ @@ -1419,61 +1424,7 @@ describe('PUT /workflows/:id', () => { ); }); - test('should always create workflow history version', async () => { - const workflow = await 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 } = response.body; - - expect(response.statusCode).toBe(200); - - expect(id).toBe(workflow.id); - expect( - await Container.get(WorkflowHistoryRepository).count({ where: { workflowId: id } }), - ).toBe(1); - const historyVersion = await Container.get(WorkflowHistoryRepository).findOne({ - where: { - workflowId: id, - }, - }); - expect(historyVersion).not.toBeNull(); - expect(historyVersion!.connections).toEqual(payload.connections); - expect(historyVersion!.nodes).toEqual(payload.nodes); - }); - - test('should update activeVersionId when updating an active workflow', async () => { + test('should update active version if workflow is published', async () => { const workflow = await createActiveWorkflow({}, member); const updatedPayload = { @@ -1498,64 +1449,24 @@ describe('PUT /workflows/:id', () => { .send(updatedPayload); expect(updateResponse.statusCode).toBe(200); + expect(updateResponse.body.active).toBe(true); + expect(updateResponse.body.activeVersionId).not.toBeNull(); + expect(updateResponse.body.activeVersionId).not.toBe(workflow.versionId); + expect(updateResponse.body.nodes).toEqual(updatedPayload.nodes); - await authMemberAgent.post(`/workflows/${workflow.id}/activate`); - - const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ + const versionInTheDb = await Container.get(WorkflowHistoryRepository).findOne({ where: { - projectId: memberPersonalProject.id, workflowId: workflow.id, + versionId: Not(workflow.versionId), }, - relations: ['workflow', 'workflow.activeVersion'], }); - expect(sharedWorkflow?.workflow.activeVersionId).toBe(sharedWorkflow?.workflow.versionId); - expect(sharedWorkflow?.workflow.activeVersionId).not.toBe(workflow.activeVersionId); - expect(sharedWorkflow?.workflow.activeVersion?.nodes).toEqual(updatedPayload.nodes); + expect(versionInTheDb).not.toBeNull(); + expect(updateResponse.body.activeVersionId).toBe(versionInTheDb!.versionId); + expect(versionInTheDb!.nodes).toEqual(updatedPayload.nodes); }); - test('should not update activeVersionId when updating an inactive workflow', async () => { - const workflow = await createWorkflow({}, member); - - // Update workflow without activating it - const updatedPayload = { - name: 'Updated inactive workflow', - nodes: [ - { - id: 'uuid-inactive', - parameters: {}, - name: 'Start Node', - type: 'n8n-nodes-base.start', - typeVersion: 1, - position: [200, 300], - }, - ], - connections: {}, - staticData: workflow.staticData, - settings: workflow.settings, - }; - - const updateResponse = await authMemberAgent - .put(`/workflows/${workflow.id}`) - .send(updatedPayload); - - expect(updateResponse.statusCode).toBe(200); - expect(updateResponse.body.active).toBe(false); - expect(updateResponse.body.activeVersionId).toBeNull(); - - // Verify activeVersion is still null - const sharedWorkflow = await Container.get(SharedWorkflowRepository).findOne({ - where: { - projectId: memberPersonalProject.id, - workflowId: workflow.id, - }, - relations: ['workflow'], - }); - - expect(sharedWorkflow?.workflow.activeVersionId).toBeNull(); - }); - - test('should not allow setting active field via PUT request', async () => { + test('should not allow updating active field', async () => { const workflow = await createWorkflowWithTriggerAndHistory({}, member); const updatePayload = { @@ -1587,7 +1498,7 @@ describe('PUT /workflows/:id', () => { }); test('should update non-owned workflow if owner', async () => { - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const payload = { name: 'name owner updated', @@ -1677,7 +1588,7 @@ describe('PUT /workflows/:id', () => { }); test('should merge settings when updating workflow', async () => { - const workflow = await createWorkflow( + const workflow = await createWorkflowWithHistory( { name: 'Test Workflow', nodes: [], @@ -1744,7 +1655,7 @@ describe('GET /workflows/:id/tags', () => { test('should return all tags of owned workflow', async () => { const tags = await Promise.all([await createTag({}), await createTag({})]); - const workflow = await createWorkflow({ tags }, member); + const workflow = await createWorkflowWithHistory({ tags }, member); const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`); @@ -1766,7 +1677,7 @@ describe('GET /workflows/:id/tags', () => { }); test('should return empty array if workflow does not have tags', async () => { - const workflow = await createWorkflow({}, member); + const workflow = await createWorkflowWithHistory({}, member); const response = await authMemberAgent.get(`/workflows/${workflow.id}/tags`); diff --git a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts index 110a4d41bd5..d46996dae33 100644 --- a/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts +++ b/packages/cli/test/integration/security-audit/instance-risk-reporter.test.ts @@ -241,7 +241,10 @@ test('should report security settings', async () => { templatesEnabled: true, publicApiEnabled: false, }, - nodes: { nodesExclude: 'none', nodesInclude: 'none' }, + nodes: { + nodesExclude: 'n8n-nodes-base.executeCommand, n8n-nodes-base.localFileTrigger', + nodesInclude: 'none', + }, telemetry: { diagnosticsEnabled: true }, }); }); diff --git a/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts b/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts index fd708e7d15c..9f2f3909f84 100644 --- a/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts +++ b/packages/cli/test/integration/task-runners/js-task-runner-execution.integration.test.ts @@ -30,6 +30,7 @@ describe('JS TaskRunner execution on internal mode', () => { runnerConfig.mode = 'internal'; runnerConfig.enabled = true; runnerConfig.port = 45678; + runnerConfig.isNativePythonRunnerEnabled = false; const taskRunnerModule = Container.get(TaskRunnerModule); const taskRequester = Container.get(LocalTaskRequester); diff --git a/packages/cli/test/integration/task-runners/task-runner-module.internal.test.ts b/packages/cli/test/integration/task-runners/task-runner-module.internal.test.ts index 688a6346c85..b65fde27d4e 100644 --- a/packages/cli/test/integration/task-runners/task-runner-module.internal.test.ts +++ b/packages/cli/test/integration/task-runners/task-runner-module.internal.test.ts @@ -10,6 +10,7 @@ describe('TaskRunnerModule in internal mode', () => { runnerConfig.port = 0; // Random port runnerConfig.mode = 'internal'; runnerConfig.enabled = true; + runnerConfig.isNativePythonRunnerEnabled = false; const module = Container.get(TaskRunnerModule); afterEach(async () => { diff --git a/packages/cli/test/integration/workflows/workflow.service.test.ts b/packages/cli/test/integration/workflows/workflow.service.test.ts index 36a81e335af..aab59faf82a 100644 --- a/packages/cli/test/integration/workflows/workflow.service.test.ts +++ b/packages/cli/test/integration/workflows/workflow.service.test.ts @@ -1,9 +1,4 @@ -import { - createWorkflowWithHistory, - createActiveWorkflow, - testDb, - mockInstance, -} from '@n8n/backend-test-utils'; +import { createWorkflowWithHistory, testDb, mockInstance } from '@n8n/backend-test-utils'; import { GlobalConfig } from '@n8n/config'; import { SharedWorkflowRepository, @@ -63,71 +58,9 @@ beforeAll(async () => { afterEach(async () => { await testDb.truncate(['WorkflowEntity', 'WorkflowHistory']); jest.restoreAllMocks(); - - globalConfig.workflows.draftPublishEnabled = false; }); describe('update()', () => { - test('should remove and re-add to active workflows on `active: true` payload', async () => { - const owner = await createOwner(); - const workflow = await createActiveWorkflow({}, owner); - - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); - const addSpy = jest.spyOn(activeWorkflowManager, 'add'); - - const updateData = { - active: true, - versionId: workflow.versionId, - }; - - await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); - - expect(removeSpy).toHaveBeenCalledTimes(1); - const [removedWorkflowId] = removeSpy.mock.calls[0]; - expect(removedWorkflowId).toBe(workflow.id); - - expect(addSpy).toHaveBeenCalledTimes(1); - const [addedWorkflowId, activationMode] = addSpy.mock.calls[0]; - expect(addedWorkflowId).toBe(workflow.id); - expect(activationMode).toBe('update'); - - expect(addRecordSpy).toBeCalledWith({ - event: 'activated', - workflowId: workflow.id, - versionId: workflow.versionId, - userId: owner.id, - }); - }); - - test('should remove from active workflows on `active: false` payload', async () => { - const owner = await createOwner(); - const workflow = await createActiveWorkflow({}, owner); - - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - const removeSpy = jest.spyOn(activeWorkflowManager, 'remove'); - const addSpy = jest.spyOn(activeWorkflowManager, 'add'); - - const updateData = { - active: false, - versionId: workflow.versionId, - }; - - await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); - - expect(removeSpy).toHaveBeenCalledTimes(1); - const [removedWorkflowId] = removeSpy.mock.calls[0]; - expect(removedWorkflowId).toBe(workflow.id); - - expect(addSpy).not.toHaveBeenCalled(); - expect(addRecordSpy).toBeCalledWith({ - event: 'deactivated', - workflowId: workflow.id, - versionId: workflow.versionId, - userId: owner.id, - }); - }); - test('should fetch missing connections from DB when updating nodes', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); @@ -157,29 +90,6 @@ describe('update()', () => { expect(updatedWorkflow.versionId).not.toBe(workflow.versionId); }); - test('should not save workflow history version when updating only active status', async () => { - const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({}, owner); - - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion'); - - const updateData = { - active: true, - versionId: workflow.versionId, - }; - - await workflowService.update(owner, updateData as WorkflowEntity, workflow.id); - - expect(saveVersionSpy).not.toHaveBeenCalled(); - expect(addRecordSpy).toBeCalledWith({ - event: 'activated', - workflowId: workflow.id, - versionId: workflow.versionId, - userId: owner.id, - }); - }); - test('should save workflow history version with backfilled data when versionId changes', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); @@ -209,7 +119,7 @@ describe('update()', () => { }); describe('activateWorkflow()', () => { - test('should activate current workflow version', async () => { + test('should activate current workflow version if no version provided', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); @@ -227,34 +137,7 @@ describe('activateWorkflow()', () => { }); }); - test('should ignore provided workflow versionId', async () => { - const owner = await createOwner(); - const workflow = await createWorkflowWithHistory({}, owner); - - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - - const newVersionId = uuid(); - await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); - - const updatedWorkflow = await workflowService.activateWorkflow(owner, workflow.id, { - versionId: newVersionId, - }); - - expect(updatedWorkflow.active).toBe(true); - expect(updatedWorkflow.activeVersionId).toBe(workflow.versionId); - expect(updatedWorkflow.versionId).toBe(workflow.versionId); - - expect(addRecordSpy).toBeCalledWith({ - event: 'activated', - workflowId: workflow.id, - versionId: workflow.versionId, - userId: owner.id, - }); - }); - - test('with draft/publish enabled: should activate the provided workflow version', async () => { - globalConfig.workflows.draftPublishEnabled = true; - + test('should activate the provided workflow version', async () => { const owner = await createOwner(); const workflow = await createWorkflowWithHistory({}, owner); diff --git a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts index abd98d05dd4..334d5c9346c 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.ee.test.ts @@ -1419,53 +1419,6 @@ describe('PATCH /workflows/:workflowId', () => { expect(historyVersion!.nodes).toEqual(payload.nodes); }); }); - - describe('activate workflow', () => { - test('should activate workflow without changing version ID', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const payload = { - versionId: workflow.versionId, - active: true, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManager.add).toBeCalled(); - - const { - data: { id, versionId, active, activeVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(versionId).toBe(workflow.versionId); - expect(active).toBe(true); - expect(activeVersionId).toBe(workflow.versionId); - }); - - test('should deactivate workflow without changing version ID', async () => { - const workflow = await createActiveWorkflow({}, owner); - const payload = { - versionId: workflow.versionId, - active: false, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManager.add).not.toBeCalled(); - expect(activeWorkflowManager.remove).toBeCalled(); - - const { - data: { id, versionId, active, activeVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(versionId).toBe(workflow.versionId); - expect(active).toBe(false); - expect(activeVersionId).toBeNull(); - }); - }); }); describe('PUT /:workflowId/transfer', () => { diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index 872b97b741d..33eefd3559d 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -6,14 +6,12 @@ import { createActiveWorkflow, setActiveVersion, createWorkflowWithHistory, - createWorkflowWithTriggerAndHistory, shareWorkflowWithProjects, shareWorkflowWithUsers, randomCredentialPayload, testDb, mockInstance, } from '@n8n/backend-test-utils'; -import { GlobalConfig } from '@n8n/config'; import type { User, ListQueryDb, @@ -74,7 +72,6 @@ let projectRepository: ProjectRepository; let workflowRepository: WorkflowRepository; let workflowHistoryRepository: WorkflowHistoryRepository; let eventService: EventService; -let globalConfig: GlobalConfig; let folderListMissingRole: Role; let workflowPublishHistoryRepository: WorkflowPublishHistoryRepository; @@ -94,7 +91,6 @@ beforeEach(async () => { workflowRepository = Container.get(WorkflowRepository); workflowHistoryRepository = Container.get(WorkflowHistoryRepository); eventService = Container.get(EventService); - globalConfig = Container.get(GlobalConfig); workflowPublishHistoryRepository = Container.get(WorkflowPublishHistoryRepository); owner = await createOwner(); authOwnerAgent = testServer.authAgentFor(owner); @@ -107,9 +103,6 @@ beforeEach(async () => { displayName: 'Workflow Read-Only', description: 'Can only read and list workflows', }); - - // Default: draft/publish feature disabled - globalConfig.workflows.draftPublishEnabled = false; }); afterEach(() => { @@ -1514,7 +1507,10 @@ describe('GET /workflows', () => { }); test('should handle skip with take parameter', async () => { - const response = await authOwnerAgent.get('/workflows').query('skip=2&take=2').expect(200); + const response = await authOwnerAgent + .get('/workflows') + .query('skip=2&take=2&sortBy=name:asc') + .expect(200); const body = response.body as { data: WorkflowEntity[]; count: number }; expect(body.count).toBe(5); @@ -2372,7 +2368,7 @@ describe('GET /workflows?includeFolders=true', () => { test('should handle skip with take parameter', async () => { const response = await authOwnerAgent .get('/workflows') - .query('skip=2&take=4&includeFolders=true'); + .query('skip=2&take=4&includeFolders=true&sortBy=name:asc'); const body = response.body as { data: WorkflowEntity[]; count: number }; expect(body.count).toBe(6); @@ -2519,180 +2515,27 @@ describe('PATCH /workflows/:workflowId', () => { expect(versionCounter).toBe(workflow.versionCounter + 1); }); - test('should activate workflow without changing version ID', async () => { - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - - const workflow = await createWorkflowWithHistory({}, owner); - const payload = { - versionId: workflow.versionId, - active: true, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalled(); - - const { - data: { id, versionId, active, activeVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(versionId).toBe(workflow.versionId); - expect(active).toBe(true); - expect(activeVersionId).toBe(workflow.versionId); - expect(addRecordSpy).toBeCalledWith({ - event: 'activated', - userId: owner.id, - versionId: workflow.versionId, - workflowId: workflow.id, - }); - }); - - test('should deactivate workflow without changing version ID', async () => { + test('should update workflow without updating its active version', async () => { const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); const workflow = await createActiveWorkflow({}, owner); + await setActiveVersion(workflow.id, workflow.versionId); + const payload = { + name: 'Updated Workflow', versionId: workflow.versionId, - active: false, }; const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.remove).not.toBeCalled(); expect(activeWorkflowManagerLike.add).not.toBeCalled(); - expect(activeWorkflowManagerLike.remove).toBeCalled(); + expect(addRecordSpy).not.toBeCalled(); - const { - data: { id, versionId, active, activeVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(versionId).toBe(workflow.versionId); - expect(active).toBe(false); - expect(activeVersionId).toBeNull(); - expect(addRecordSpy).toBeCalledWith({ - event: 'deactivated', - userId: owner.id, - versionId: workflow.versionId, - workflowId: workflow.id, - }); - }); - - test('should set activeVersionId when activating via PATCH', async () => { - const workflow = await createWorkflowWithTriggerAndHistory({}, owner); - - const payload = { - versionId: workflow.versionId, - active: true, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalled(); - - const { - data: { id, activeVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(activeVersionId).toBe(workflow.versionId); - - // Verify activeVersion is set - const updatedWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - relations: ['activeVersion'], - }); - - expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); - expect(updatedWorkflow?.activeVersion).not.toBeNull(); - expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); - expect(updatedWorkflow?.activeVersion?.nodes).toEqual(workflow.nodes); - expect(updatedWorkflow?.activeVersion?.connections).toEqual(workflow.connections); - }); - - test('should clear activeVersionId when deactivating via PATCH', async () => { - const workflow = await createActiveWorkflow({}, owner); - - await setActiveVersion(workflow.id, workflow.versionId); - - const payload = { - versionId: workflow.versionId, - active: false, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.remove).toBeCalled(); - - const { - data: { id, activeVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(activeVersionId).toBeNull(); - - // Verify activeVersion is cleared - const updatedWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - }); - - expect(updatedWorkflow?.activeVersionId).toBeNull(); - }); - - test('should update activeVersionId when updating an active workflow', async () => { - const workflow = await createActiveWorkflow({}, owner); - - await setActiveVersion(workflow.id, workflow.versionId); - - // Verify initial state - const initialWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - relations: ['activeVersion'], - }); - expect(initialWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); - - // Update workflow nodes - const updatedNodes: INode[] = [ - { - id: 'uuid-updated', - parameters: { triggerTimes: { item: [{ mode: 'everyHour' }] } }, - name: 'Cron Updated', - type: 'n8n-nodes-base.cron', - typeVersion: 1, - position: [500, 400], - }, - ]; - - const payload = { - versionId: workflow.versionId, - nodes: updatedNodes, - connections: {}, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - - const { - data: { id, versionId: newVersionId }, - } = response.body; - - expect(id).toBe(workflow.id); - expect(newVersionId).not.toBe(workflow.versionId); - - // Verify activeVersion points to the new version - const updatedWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - relations: ['activeVersion'], - }); - - expect(updatedWorkflow?.active).toBe(true); - expect(updatedWorkflow?.activeVersionId).not.toBeNull(); - expect(updatedWorkflow?.activeVersion?.versionId).toBe(newVersionId); - expect(updatedWorkflow?.activeVersion?.nodes).toEqual(updatedNodes); + const { data } = response.body; + expect(data.name).toBe('Updated Workflow'); + expect(data.versionId).not.toBe(workflow.versionId); // New version created + expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active }); test('should update workflow meta', async () => { @@ -2791,127 +2634,106 @@ describe('PATCH /workflows/:workflowId', () => { expect(response.statusCode).toBe(500); }); - describe('with draft/publish feature enabled', () => { - beforeEach(() => { - globalConfig.workflows.draftPublishEnabled = true; + test('should not activate when updating with active: true', async () => { + const workflow = await createWorkflow({}, owner); + const payload = { + versionId: workflow.versionId, + active: true, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).not.toBeCalled(); + + const { data } = response.body; + expect(data.active).toBe(false); + expect(data.activeVersionId).toBeNull(); + }); + + test('should not deactivate workflow when updating with active: false', async () => { + const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); + const workflow = await createActiveWorkflow({}, owner); + await setActiveVersion(workflow.id, workflow.versionId); + + const payload = { + versionId: workflow.versionId, + active: false, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.remove).not.toBeCalled(); + expect(addRecordSpy).not.toBeCalled(); + + const { data } = response.body; + expect(data.active).toBe(true); + expect(data.activeVersionId).toBe(workflow.versionId); + }); + + test('should not modify activeVersionId when explicitly provided', async () => { + const workflow = await createWorkflowWithHistory({}, owner); + const payload = { + versionId: workflow.versionId, + activeVersionId: workflow.versionId, + }; + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).not.toBeCalled(); + + const { data } = response.body; + expect(data.activeVersionId).toBeNull(); // Should not be activated + }); + + test('should reactivate workflow when settings change', async () => { + const workflow = await createActiveWorkflow({}, owner); + await setActiveVersion(workflow.id, workflow.versionId); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + settings: { + timezone: 'America/New_York', + }, }); - afterEach(() => { - globalConfig.workflows.draftPublishEnabled = false; + expect(response.statusCode).toBe(200); + + expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id); + expect(activeWorkflowManagerLike.add).toHaveBeenCalledWith(workflow.id, 'update'); + }); + + test('should not reactivate when settings unchanged', async () => { + const workflow = await createActiveWorkflow({}, owner); + await setActiveVersion(workflow.id, workflow.versionId); + + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + name: 'New Name', }); - test('should not update activeVersionId when updating with active: true', async () => { - const workflow = await createWorkflow({}, owner); - const payload = { - versionId: workflow.versionId, - active: true, - }; + expect(response.statusCode).toBe(200); - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); + expect(activeWorkflowManagerLike.remove).not.toHaveBeenCalled(); + expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled(); + }); - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).not.toBeCalled(); + test('should not reactivate inactive workflow even when settings change', async () => { + const workflow = await createWorkflow({}, owner); - const { data } = response.body; - expect(data.activeVersionId).toBeNull(); + const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({ + versionId: workflow.versionId, + settings: { + timezone: 'America/New_York', + }, }); - test('should not deactivate workflow when updating with active: false', async () => { - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - const workflow = await createActiveWorkflow({}, owner); - await setActiveVersion(workflow.id, workflow.versionId); + expect(response.statusCode).toBe(200); - const payload = { - versionId: workflow.versionId, - active: false, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.remove).not.toBeCalled(); - - const { data } = response.body; - expect(data.activeVersionId).toBe(workflow.versionId); - expect(addRecordSpy).not.toBeCalled(); - }); - - test('should NOT write "active" field to database when updating with active: true', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const payload = { - versionId: workflow.versionId, - active: true, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - - const updatedWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - }); - - expect(updatedWorkflow?.active).toBe(false); - expect(updatedWorkflow?.activeVersionId).toBeNull(); - }); - - test('should NOT write "active" field to database when updating with active: false', async () => { - const workflow = await createActiveWorkflow({}, owner); - const payload = { - versionId: workflow.versionId, - active: false, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - - const updatedWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - }); - - expect(updatedWorkflow?.active).toBe(true); - expect(updatedWorkflow?.activeVersionId).toBe(workflow.activeVersionId); - }); - - test('should not modify activeVersionId when explicitly provided', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const payload = { - versionId: workflow.versionId, - activeVersionId: workflow.versionId, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).not.toBeCalled(); - - const { data } = response.body; - expect(data.activeVersionId).toBeNull(); // Should not be activated - }); - - test('should allow updating active workflow without updating its active version', async () => { - const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); - const workflow = await createActiveWorkflow({}, owner); - await setActiveVersion(workflow.id, workflow.versionId); - - const payload = { - name: 'Updated Active Workflow', - versionId: workflow.versionId, - }; - - const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.remove).not.toBeCalled(); - expect(activeWorkflowManagerLike.add).not.toBeCalled(); - - const { data } = response.body; - expect(data.name).toBe('Updated Active Workflow'); - expect(data.versionId).not.toBe(workflow.versionId); // New version created - expect(data.activeVersionId).toBe(workflow.versionId); // Should remain active - expect(addRecordSpy).not.toBeCalled(); - }); + expect(activeWorkflowManagerLike.remove).not.toHaveBeenCalled(); + expect(activeWorkflowManagerLike.add).not.toHaveBeenCalled(); }); }); @@ -2919,22 +2741,25 @@ describe('POST /workflows/:workflowId/activate', () => { test('should activate workflow with provided versionId', async () => { const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord'); const workflow = await createWorkflowWithHistory({}, owner); + const newVersionId = uuid(); + await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); const response = await authOwnerAgent .post(`/workflows/${workflow.id}/activate`) - .send({ versionId: workflow.versionId }); + .send({ versionId: newVersionId }); expect(response.statusCode).toBe(200); expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); const { data } = response.body; expect(data.id).toBe(workflow.id); - expect(data.activeVersionId).toBe(workflow.versionId); - expect(data.activeVersion.versionId).toBe(workflow.versionId); + expect(data.activeVersionId).toBe(newVersionId); + expect(data.activeVersion.versionId).toBe(newVersionId); + expect(addRecordSpy).toBeCalledWith({ event: 'activated', userId: owner.id, - versionId: workflow.versionId, + versionId: newVersionId, workflowId: workflow.id, }); }); @@ -2967,6 +2792,19 @@ describe('POST /workflows/:workflowId/activate', () => { expect(response.statusCode).toBe(404); }); + test('should return 404 if version does not exist', async () => { + const workflow = await createWorkflowWithHistory({}, owner); + const newVersionId = uuid(); + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/activate`) + .send({ versionId: newVersionId }); + + expect(response.statusCode).toBe(404); + expect(response.body.message).toBe('Version not found'); + expect(activeWorkflowManagerLike.add).not.toBeCalled(); + }); + test('should return 403 when user does not have update permission', async () => { const workflow = await createWorkflow({}, owner); @@ -2994,175 +2832,16 @@ describe('POST /workflows/:workflowId/activate', () => { expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); }); - describe('with draft/publish feature enabled', () => { - beforeEach(() => { - globalConfig.workflows.draftPublishEnabled = true; - }); - - afterEach(() => { - globalConfig.workflows.draftPublishEnabled = false; - }); - - test('should activate workflow with provided versionId', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const newVersionId = uuid(); - await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); - - const response = await authOwnerAgent - .post(`/workflows/${workflow.id}/activate`) - .send({ versionId: newVersionId }); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); - - const { data } = response.body; - expect(data.id).toBe(workflow.id); - expect(data.activeVersionId).toBe(newVersionId); - expect(data.activeVersion.versionId).toBe(newVersionId); - }); - - test('should update version name when provided during activation', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const newVersionName = 'Production Version'; - - const response = await authOwnerAgent - .post(`/workflows/${workflow.id}/activate`) - .send({ versionId: workflow.versionId, name: newVersionName }); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); - - const { data } = response.body; - expect(data.activeVersionId).toBe(workflow.versionId); - - const historyVersion = await workflowHistoryRepository.findOne({ - where: { workflowId: workflow.id, versionId: workflow.versionId }, - }); - - expect(historyVersion?.name).toBe(newVersionName); - }); - - test('should update version description when provided during activation', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const newDescription = 'This is the stable production release'; - - const response = await authOwnerAgent - .post(`/workflows/${workflow.id}/activate`) - .send({ versionId: workflow.versionId, description: newDescription }); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); - - const { data } = response.body; - expect(data.activeVersionId).toBe(workflow.versionId); - - const historyVersion = await workflowHistoryRepository.findOne({ - where: { workflowId: workflow.id, versionId: workflow.versionId }, - }); - - expect(historyVersion?.description).toBe(newDescription); - }); - - test('should update both version name and description when provided during activation', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const newVersionName = 'Production Version'; - const newDescription = 'Major update with new features'; - - const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({ - versionId: workflow.versionId, - name: newVersionName, - description: newDescription, - }); - - expect(response.statusCode).toBe(200); - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); - - const { data } = response.body; - expect(data.activeVersionId).toBe(workflow.versionId); - - const historyVersion = await workflowHistoryRepository.findOne({ - where: { workflowId: workflow.id, versionId: workflow.versionId }, - }); - - expect(historyVersion?.name).toBe(newVersionName); - expect(historyVersion?.description).toBe(newDescription); - }); - - test('should not update version name and description when activation fails', async () => { - const workflow = await createWorkflowWithHistory({}, owner); - const newVersionName = 'Production Version'; - const newDescription = 'Major update with new features'; - - activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed')); - - await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({ - versionId: workflow.versionId, - name: newVersionName, - description: newDescription, - }); - - const updatedVersion = await workflowHistoryRepository.findOne({ - where: { versionId: workflow.versionId }, - }); - - expect(updatedVersion?.name).toBeNull(); - expect(updatedVersion?.description).toBeNull(); - }); - - test('should preserve current active version when activation fails', async () => { - const workflow = await createActiveWorkflow({}, owner); - - const newVersionId = uuid(); - await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); - - const emitSpy = jest.spyOn(eventService, 'emit'); - - activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed')); - - const response = await authOwnerAgent - .post(`/workflows/${workflow.id}/activate`) - .send({ versionId: newVersionId }); - - expect(response.statusCode).toBe(400); - expect(response.body.message).toBe('Validation failed'); - - const updatedWorkflow = await workflowRepository.findOne({ - where: { id: workflow.id }, - relations: ['activeVersion'], - }); - - expect(updatedWorkflow?.active).toBe(true); - expect(updatedWorkflow?.activeVersionId).toBe(workflow.versionId); - expect(updatedWorkflow?.activeVersion?.versionId).toBe(workflow.versionId); - - expect(emitSpy).not.toHaveBeenCalledWith('workflow-deactivated', expect.anything()); - }); - - test('should call active workflow manager with update mode if workflow is active', async () => { - const workflow = await createActiveWorkflow({}, owner); - - const newVersionId = uuid(); - await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); - - await authOwnerAgent - .post(`/workflows/${workflow.id}/activate`) - .send({ versionId: newVersionId }); - - expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update'); - }); - }); - - test('should ignore version id, name and description', async () => { + test('should update version name when provided during activation', async () => { const workflow = await createWorkflowWithHistory({}, owner); - const newVersionId = uuid(); const newVersionName = 'Production Version'; - const newDescription = 'Major update with new features'; - const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({ - versionId: newVersionId, - name: newVersionName, - description: newDescription, - }); + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/activate`) + .send({ versionId: workflow.versionId, name: newVersionName }); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); const { data } = response.body; expect(data.activeVersionId).toBe(workflow.versionId); @@ -3171,8 +2850,123 @@ describe('POST /workflows/:workflowId/activate', () => { where: { workflowId: workflow.id, versionId: workflow.versionId }, }); - expect(historyVersion?.name).toBeNull(); - expect(historyVersion?.description).toBeNull(); + expect(historyVersion?.name).toBe(newVersionName); + }); + + test('should update version description when provided during activation', async () => { + const workflow = await createWorkflowWithHistory({}, owner); + const newDescription = 'This is the stable production release'; + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/activate`) + .send({ versionId: workflow.versionId, description: newDescription }); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + + const { data } = response.body; + expect(data.activeVersionId).toBe(workflow.versionId); + + const historyVersion = await workflowHistoryRepository.findOne({ + where: { workflowId: workflow.id, versionId: workflow.versionId }, + }); + + expect(historyVersion?.description).toBe(newDescription); + }); + + test('should update both version name and description when provided during activation', async () => { + const workflow = await createWorkflowWithHistory({}, owner); + const newVersionName = 'Production Version'; + const newDescription = 'Major update with new features'; + + const response = await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({ + versionId: workflow.versionId, + name: newVersionName, + description: newDescription, + }); + + expect(response.statusCode).toBe(200); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); + + const { data } = response.body; + expect(data.activeVersionId).toBe(workflow.versionId); + + const historyVersion = await workflowHistoryRepository.findOne({ + where: { workflowId: workflow.id, versionId: workflow.versionId }, + }); + + expect(historyVersion?.name).toBe(newVersionName); + expect(historyVersion?.description).toBe(newDescription); + }); + + test('should not update version name and description when activation fails', async () => { + const workflow = await createWorkflowWithHistory({}, owner); + const newVersionName = 'Production Version'; + const newDescription = 'Major update with new features'; + + activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed')); + + await authOwnerAgent.post(`/workflows/${workflow.id}/activate`).send({ + versionId: workflow.versionId, + name: newVersionName, + description: newDescription, + }); + + const updatedVersion = await workflowHistoryRepository.findOne({ + where: { versionId: workflow.versionId }, + }); + + expect(updatedVersion?.name).toBeNull(); + expect(updatedVersion?.description).toBeNull(); + }); + + test('should deactivate workflow when activation fails', async () => { + const workflow = await createActiveWorkflow({}, owner); + + const newVersionId = uuid(); + await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); + + const emitSpy = jest.spyOn(eventService, 'emit'); + + activeWorkflowManagerLike.add.mockRejectedValueOnce(new Error('Validation failed')); + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/activate`) + .send({ versionId: newVersionId }); + + expect(response.statusCode).toBe(400); + expect(response.body.message).toBe('Validation failed'); + + const updatedWorkflow = await workflowRepository.findOne({ + where: { id: workflow.id }, + relations: ['activeVersion'], + }); + + // Workflow should be deactivated after failed activation + expect(updatedWorkflow?.active).toBe(false); + expect(updatedWorkflow?.activeVersionId).toBeNull(); + + // Should emit deactivation event + expect(emitSpy).toHaveBeenCalledWith('workflow-deactivated', expect.anything()); + + // Verify workflow was removed once (no re-add) + expect(activeWorkflowManagerLike.remove).toHaveBeenCalledWith(workflow.id); + expect(activeWorkflowManagerLike.add).toHaveBeenCalledTimes(1); + }); + + test('should call active workflow manager with update mode if workflow is active', async () => { + const workflow = await createActiveWorkflow({}, owner); + + const newVersionId = uuid(); + await createWorkflowHistoryItem(workflow.id, { versionId: newVersionId }); + + await authOwnerAgent + .post(`/workflows/${workflow.id}/activate`) + .send({ versionId: newVersionId }); + + // First remove active version + expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id); + expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'update'); }); test('should call active workflow manager with activate mode if workflow is not active', async () => { @@ -3183,6 +2977,7 @@ describe('POST /workflows/:workflowId/activate', () => { .post(`/workflows/${workflow.id}/activate`) .send({ versionId: workflow.versionId }); + expect(activeWorkflowManagerLike.remove).not.toBeCalledWith(workflow.id); expect(activeWorkflowManagerLike.add).toBeCalledWith(workflow.id, 'activate'); expect(addRecordSpy).toBeCalledWith({ event: 'activated', @@ -3569,8 +3364,8 @@ describe('POST /workflows/:workflowId/unarchive', () => { const { data: unarchivedWorkflow } = unarchiveResponse.body; const activateResponse = await authOwnerAgent - .patch(`/workflows/${workflow.id}`) - .send({ active: true, versionId: unarchivedWorkflow.versionId }) + .post(`/workflows/${workflow.id}/activate`) + .send({ versionId: unarchivedWorkflow.versionId }) .expect(200); expect(activateResponse.body.data.active).toBe(true); diff --git a/packages/cli/test/setup-test-folder.ts b/packages/cli/test/setup-test-folder.ts index 80b9953333e..6d88d1ddf3c 100644 --- a/packages/cli/test/setup-test-folder.ts +++ b/packages/cli/test/setup-test-folder.ts @@ -10,7 +10,7 @@ mkdirSync(baseDir, { recursive: true }); const testDir = mkdtempSync(baseDir); mkdirSync(join(testDir, '.n8n')); process.env.N8N_USER_FOLDER = testDir; -process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false'; +process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'true'; writeFileSync( join(testDir, '.n8n/config'), diff --git a/packages/core/src/binary-data/__tests__/binary-data.config.test.ts b/packages/core/src/binary-data/__tests__/binary-data.config.test.ts index c5e6996654b..9b6b66f7603 100644 --- a/packages/core/src/binary-data/__tests__/binary-data.config.test.ts +++ b/packages/core/src/binary-data/__tests__/binary-data.config.test.ts @@ -23,13 +23,12 @@ describe('BinaryDataConfig', () => { it('should use default values when no env variables are defined', () => { const config = Container.get(BinaryDataConfig); - expect(config.availableModes).toEqual(['filesystem']); - expect(config.mode).toBe('default'); + expect(config.availableModes).toEqual(['filesystem', 's3', 'database']); + expect(config.mode).toBe('filesystem'); expect(config.localStoragePath).toBe('/test/n8n/binaryData'); }); it('should use values from env variables when defined', () => { - process.env.N8N_AVAILABLE_BINARY_DATA_MODES = 'filesystem,s3'; process.env.N8N_DEFAULT_BINARY_DATA_MODE = 's3'; process.env.N8N_BINARY_DATA_STORAGE_PATH = '/custom/storage/path'; process.env.N8N_BINARY_DATA_SIGNING_SECRET = 'super-secret'; @@ -37,7 +36,7 @@ describe('BinaryDataConfig', () => { const config = Container.get(BinaryDataConfig); expect(config.mode).toEqual('s3'); - expect(config.availableModes).toEqual(['filesystem', 's3']); + expect(config.availableModes).toEqual(['filesystem', 's3', 'database']); expect(config.localStoragePath).toEqual('/custom/storage/path'); expect(config.signingSecret).toBe('super-secret'); }); @@ -48,25 +47,14 @@ describe('BinaryDataConfig', () => { expect(config.signingSecret).toBe('96eHYcXMF6J1Pn6dhdkOEt6H2BMa6kR5oR0ce7llWyA='); }); - it('should fallback to default for mode', () => { + it('should fallback to filesystem for invalid mode', () => { process.env.N8N_DEFAULT_BINARY_DATA_MODE = 'invalid-mode'; const config = Container.get(BinaryDataConfig); - expect(config.mode).toEqual('default'); + expect(config.mode).toEqual('filesystem'); expect(console.warn).toHaveBeenCalledWith( expect.stringContaining('Invalid value for N8N_DEFAULT_BINARY_DATA_MODE'), ); }); - - it('should fallback to default for available modes', () => { - process.env.N8N_AVAILABLE_BINARY_DATA_MODES = 'filesystem,invalid-mode,s3'; - - const config = Container.get(BinaryDataConfig); - - expect(config.availableModes).toEqual(['filesystem']); - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('Invalid value for N8N_AVAILABLE_BINARY_DATA_MODES'), - ); - }); }); diff --git a/packages/core/src/binary-data/binary-data.config.ts b/packages/core/src/binary-data/binary-data.config.ts index 5ce6046ae6b..fb438afa5b2 100644 --- a/packages/core/src/binary-data/binary-data.config.ts +++ b/packages/core/src/binary-data/binary-data.config.ts @@ -1,4 +1,4 @@ -import { Config, Env } from '@n8n/config'; +import { Config, Env, ExecutionsConfig } from '@n8n/config'; import { createHash } from 'node:crypto'; import path from 'node:path'; import { z } from 'zod'; @@ -21,12 +21,11 @@ const dbMaxFileSizeSchema = z @Config export class BinaryDataConfig { /** Available modes of binary data storage, as comma separated strings. */ - @Env('N8N_AVAILABLE_BINARY_DATA_MODES', availableModesSchema) - availableModes: z.infer = ['filesystem']; + availableModes: z.infer = ['filesystem', 's3', 'database']; - /** Storage mode for binary data. */ + /** Storage mode for binary data. Defaults to 'filesystem' in regular mode, 'database' in scaling mode. */ @Env('N8N_DEFAULT_BINARY_DATA_MODE', binaryDataModesSchema) - mode: z.infer = 'default'; + mode!: z.infer; /** Path for binary data storage in "filesystem" mode. */ @Env('N8N_BINARY_DATA_STORAGE_PATH') @@ -43,10 +42,12 @@ export class BinaryDataConfig { @Env('N8N_BINARY_DATA_DATABASE_MAX_FILE_SIZE', dbMaxFileSizeSchema) dbMaxFileSize: number = 512; - constructor({ encryptionKey, n8nFolder }: InstanceSettings) { + constructor({ encryptionKey, n8nFolder }: InstanceSettings, executionsConfig: ExecutionsConfig) { this.localStoragePath = path.join(n8nFolder, 'binaryData'); this.signingSecret = createHash('sha256') .update(`url-signing:${encryptionKey}`) .digest('base64'); + + this.mode ??= executionsConfig.mode === 'queue' ? 'database' : 'filesystem'; } } diff --git a/packages/core/src/binary-data/binary-data.service.ts b/packages/core/src/binary-data/binary-data.service.ts index 7dfc4eab549..4496dfc5435 100644 --- a/packages/core/src/binary-data/binary-data.service.ts +++ b/packages/core/src/binary-data/binary-data.service.ts @@ -1,5 +1,5 @@ import { Logger } from '@n8n/backend-common'; -import { Container, Service } from '@n8n/di'; +import { Service } from '@n8n/di'; import jwt from 'jsonwebtoken'; import type { StringValue as TimeUnitValue } from 'ms'; import { BINARY_ENCODING, UnexpectedError } from 'n8n-workflow'; @@ -17,7 +17,7 @@ import { InvalidManagerError } from '../errors/invalid-manager.error'; @Service() export class BinaryDataService { - private mode: BinaryData.ServiceMode = 'default'; + private mode: BinaryData.ServiceMode = 'filesystem-v2'; private managers: Record = {}; @@ -36,29 +36,12 @@ export class BinaryDataService { this.mode = config.mode === 'filesystem' ? 'filesystem-v2' : config.mode; - if (config.availableModes.includes('filesystem')) { - const { FileSystemManager } = await import('./file-system.manager'); + const { FileSystemManager } = await import('./file-system.manager'); + this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter); + this.managers['filesystem-v2'] = this.managers.filesystem; + await this.managers.filesystem.init(); - this.managers.filesystem = new FileSystemManager(config.localStoragePath, this.errorReporter); - this.managers['filesystem-v2'] = this.managers.filesystem; - - await this.managers.filesystem.init(); - } - - if (config.availableModes.includes('s3')) { - const { ObjectStoreManager } = await import('./object-store.manager'); - const { ObjectStoreService } = await import('./object-store/object-store.service.ee'); - - this.managers.s3 = new ObjectStoreManager(Container.get(ObjectStoreService)); - - await this.managers.s3.init(); - } - - /** - * DB manager is set directly at `BaseCommand` in `cli`. - * to prevent a circular dependency (`core` -> `@n8n/db` - * -> `core`) until we reorganize our dependency graph. - */ + // DB and S3 managers are set via `setManager()` from `cli` } createSignedToken(binaryData: IBinaryData, expiresIn: TimeUnitValue = '1 day') { diff --git a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts index e3c1e072e81..6710ff04fdf 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/__tests__/file-system-helper-functions.test.ts @@ -1,3 +1,4 @@ +import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { INode } from 'n8n-workflow'; import { createReadStream } from 'node:fs'; @@ -9,7 +10,6 @@ import { BLOCK_FILE_ACCESS_TO_N8N_FILES, CONFIG_FILES, CUSTOM_EXTENSION_ENV, - RESTRICT_FILE_ACCESS_TO, UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, } from '@/constants'; @@ -23,6 +23,7 @@ jest.mock('node:fs/promises'); const originalProcessEnv = { ...process.env }; let instanceSettings: InstanceSettings; +let securityConfig: SecurityConfig; beforeEach(() => { process.env = { ...originalProcessEnv }; @@ -33,6 +34,8 @@ beforeEach(() => { (fsRealpath as jest.Mock).mockImplementation((path: string) => path); instanceSettings = Container.get(InstanceSettings); + securityConfig = Container.get(SecurityConfig); + securityConfig.restrictFileAccessTo = ''; }); describe('isFilePathBlocked', () => { @@ -51,25 +54,25 @@ describe('isFilePathBlocked', () => { }); it('should handle empty allowed paths', async () => { - delete process.env[RESTRICT_FILE_ACCESS_TO]; + securityConfig.restrictFileAccessTo = ''; const result = await isFilePathBlocked('/some/random/path'); expect(result).toBe(false); }); it('should handle multiple allowed paths', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = '/path1;/path2;/path3'; + securityConfig.restrictFileAccessTo = '/path1;/path2;/path3'; const allowedPath = '/path2/somefile'; expect(await isFilePathBlocked(allowedPath)).toBe(false); }); it('should handle empty strings in allowed paths', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = '/path1;;/path2'; + securityConfig.restrictFileAccessTo = '/path1;;/path2'; const allowedPath = '/path2/somefile'; expect(await isFilePathBlocked(allowedPath)).toBe(false); }); it('should trim whitespace in allowed paths', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = ' /path1 ; /path2 ; /path3 '; + securityConfig.restrictFileAccessTo = ' /path1 ; /path2 ; /path3 '; const allowedPath = '/path2/somefile'; expect(await isFilePathBlocked(allowedPath)).toBe(false); }); @@ -81,14 +84,14 @@ describe('isFilePathBlocked', () => { }); it('should return true when path is in allowed paths but still restricted', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = '/some/allowed/path'; + securityConfig.restrictFileAccessTo = '/some/allowed/path'; const restrictedPath = instanceSettings.n8nFolder; expect(await isFilePathBlocked(restrictedPath)).toBe(true); }); it('should return false when path is in allowed paths', async () => { const allowedPath = '/some/allowed/path'; - process.env[RESTRICT_FILE_ACCESS_TO] = allowedPath; + securityConfig.restrictFileAccessTo = allowedPath; expect(await isFilePathBlocked(allowedPath)).toBe(false); }); @@ -125,7 +128,7 @@ describe('isFilePathBlocked', () => { const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); - process.env[RESTRICT_FILE_ACCESS_TO] = userHome; + securityConfig.restrictFileAccessTo = userHome; process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; const restrictedPath = instanceSettings.n8nFolder; expect(await isFilePathBlocked(restrictedPath)).toBe(true); @@ -135,7 +138,7 @@ describe('isFilePathBlocked', () => { const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); - process.env[RESTRICT_FILE_ACCESS_TO] = userHome; + securityConfig.restrictFileAccessTo = userHome; process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; const restrictedPath = join(userHome, 'somefile.txt'); expect(await isFilePathBlocked(restrictedPath)).toBe(false); @@ -145,14 +148,14 @@ describe('isFilePathBlocked', () => { const homeVarName = process.platform === 'win32' ? 'USERPROFILE' : 'HOME'; const userHome = process.env.N8N_USER_FOLDER ?? process.env[homeVarName] ?? process.cwd(); - process.env[RESTRICT_FILE_ACCESS_TO] = userHome; + securityConfig.restrictFileAccessTo = userHome; process.env[BLOCK_FILE_ACCESS_TO_N8N_FILES] = 'true'; const restrictedPath = join(userHome, '.n8n_x'); expect(await isFilePathBlocked(restrictedPath)).toBe(false); }); it('should return true for a symlink in a allowed path to a restricted path', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = '/path1'; + securityConfig.restrictFileAccessTo = '/path1'; const allowedPath = '/path1/symlink'; const actualPath = '/path2/realfile'; (fsRealpath as jest.Mock).mockImplementation((path: string) => @@ -173,7 +176,7 @@ describe('isFilePathBlocked', () => { it('should handle non-existent file when it is not allowed', async () => { const filePath = '/non/existent/file'; const allowedPath = '/some/allowed/path'; - process.env[RESTRICT_FILE_ACCESS_TO] = allowedPath; + securityConfig.restrictFileAccessTo = allowedPath; const error = new Error('ENOENT'); // @ts-expect-error undefined property error.code = 'ENOENT'; @@ -216,7 +219,7 @@ describe('getFileSystemHelperFunctions', () => { }); it('should throw when file access is blocked', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = '/allowed/path'; + securityConfig.restrictFileAccessTo = '/allowed/path'; (fsAccess as jest.Mock).mockResolvedValueOnce({}); await expect(helperFunctions.createReadStream('/blocked/path')).rejects.toThrow( 'Access to the file is not allowed', @@ -224,7 +227,7 @@ describe('getFileSystemHelperFunctions', () => { }); it('should not reveal if file exists if it is within restricted path', async () => { - process.env[RESTRICT_FILE_ACCESS_TO] = '/allowed/path'; + securityConfig.restrictFileAccessTo = '/allowed/path'; const error = new Error('ENOENT'); // @ts-expect-error undefined property diff --git a/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts b/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts index 97e4472183d..24c0c27360d 100644 --- a/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts +++ b/packages/core/src/execution-engine/node-execution-context/utils/file-system-helper-functions.ts @@ -1,4 +1,5 @@ import { isContainedWithin, safeJoinPath } from '@n8n/backend-common'; +import { SecurityConfig } from '@n8n/config'; import { Container } from '@n8n/di'; import type { FileSystemHelperFunctions, INode } from 'n8n-workflow'; import { NodeOperationError } from 'n8n-workflow'; @@ -8,6 +9,7 @@ import { writeFile as fsWriteFile, realpath as fsRealpath, } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { @@ -15,21 +17,21 @@ import { BLOCK_FILE_ACCESS_TO_N8N_FILES, CONFIG_FILES, CUSTOM_EXTENSION_ENV, - RESTRICT_FILE_ACCESS_TO, UM_EMAIL_TEMPLATES_INVITE, UM_EMAIL_TEMPLATES_PWRESET, } from '@/constants'; import { InstanceSettings } from '@/instance-settings'; const getAllowedPaths = () => { - const restrictFileAccessTo = process.env[RESTRICT_FILE_ACCESS_TO]; - if (!restrictFileAccessTo) { - return []; - } + const { restrictFileAccessTo } = Container.get(SecurityConfig); + if (restrictFileAccessTo === '') return []; + const allowedPaths = restrictFileAccessTo .split(';') .map((path) => path.trim()) - .filter((path) => path); + .filter((path) => path) + .map((path) => (path.startsWith('~') ? path.replace('~', homedir()) : path)); + return allowedPaths; }; diff --git a/packages/core/src/instance-settings/__tests__/instance-settings.test.ts b/packages/core/src/instance-settings/__tests__/instance-settings.test.ts index 24923d2e3d5..8ddf20ab881 100644 --- a/packages/core/src/instance-settings/__tests__/instance-settings.test.ts +++ b/packages/core/src/instance-settings/__tests__/instance-settings.test.ts @@ -57,7 +57,10 @@ describe('InstanceSettings', () => { it('should check if the settings file has the correct permissions', () => { mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' })); mockFs.statSync.mockReturnValueOnce({ mode: 0o600 } as fs.Stats); - const settings = createInstanceSettings({ encryptionKey: 'test_key' }); + const settings = createInstanceSettings({ + encryptionKey: 'test_key', + enforceSettingsFilePermissions: true, + }); expect(settings.encryptionKey).toEqual('test_key'); expect(settings.instanceId).toEqual( '6ce26c63596f0cc4323563c529acfca0cccb0e57f6533d79a60a42c9ff862ae7', @@ -65,18 +68,22 @@ describe('InstanceSettings', () => { expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config'); }); - it('should check the permissions but not fix them if settings file has incorrect permissions by default', () => { + it('should check the permissions and fix them if settings file has incorrect permissions by default', () => { mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' })); mockFs.statSync.mockReturnValueOnce({ mode: 0o644 } as fs.Stats); - createInstanceSettings(); + createInstanceSettings({ + enforceSettingsFilePermissions: true, + }); expect(mockFs.statSync).toHaveBeenCalledWith('/test/.n8n/config'); - expect(mockFs.chmodSync).not.toHaveBeenCalled(); + expect(mockFs.chmodSync).toHaveBeenCalledWith('/test/.n8n/config', 0o600); }); it("should not check the permissions if 'N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS' is false", () => { process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false'; mockFs.readFileSync.mockReturnValueOnce(JSON.stringify({ encryptionKey: 'test_key' })); - createInstanceSettings(); + createInstanceSettings({ + enforceSettingsFilePermissions: false, + }); expect(mockFs.statSync).not.toHaveBeenCalled(); expect(mockFs.chmodSync).not.toHaveBeenCalled(); }); @@ -100,8 +107,11 @@ describe('InstanceSettings', () => { mockFs.writeFileSync.mockReturnValue(); }); - it('should create a new settings file without explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is not set', () => { - const settings = createInstanceSettings({ encryptionKey: 'key_2' }); + it('should create a new settings file with explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS is not set', () => { + const settings = createInstanceSettings({ + encryptionKey: 'key_2', + enforceSettingsFilePermissions: true, + }); expect(settings.encryptionKey).not.toEqual('test_key'); expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true }); expect(mockFs.writeFileSync).toHaveBeenCalledWith( @@ -109,14 +119,17 @@ describe('InstanceSettings', () => { expect.stringContaining('"encryptionKey":'), { encoding: 'utf-8', - mode: undefined, + mode: 0o600, }, ); }); it('should create a new settings file without explicit permissions if N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false', () => { process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = 'false'; - const settings = createInstanceSettings({ encryptionKey: 'key_2' }); + const settings = createInstanceSettings({ + encryptionKey: 'key_2', + enforceSettingsFilePermissions: false, + }); expect(settings.encryptionKey).not.toEqual('test_key'); expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true }); expect(mockFs.writeFileSync).toHaveBeenCalledWith( @@ -148,7 +161,10 @@ describe('InstanceSettings', () => { }); it('should pick up the encryption key from config', () => { - const settings = createInstanceSettings({ encryptionKey: 'env_key' }); + const settings = createInstanceSettings({ + encryptionKey: 'env_key', + enforceSettingsFilePermissions: true, + }); expect(settings.encryptionKey).toEqual('env_key'); expect(settings.instanceId).toEqual( '2c70e12b7a0646f92279f427c7b38e7334d8e5389cff167a1dc30e73f826b683', @@ -160,22 +176,7 @@ describe('InstanceSettings', () => { expect.stringContaining('"encryptionKey":'), { encoding: 'utf-8', - mode: undefined, - }, - ); - }); - - it("should not set the permissions of the settings file if 'N8N_IGNORE_SETTINGS_FILE_PERMISSIONS' is true", () => { - process.env.N8N_IGNORE_SETTINGS_FILE_PERMISSIONS = 'true'; - const settings = createInstanceSettings({ encryptionKey: 'key_2' }); - expect(settings.encryptionKey).not.toEqual('test_key'); - expect(mockFs.mkdirSync).toHaveBeenCalledWith('/test/.n8n', { recursive: true }); - expect(mockFs.writeFileSync).toHaveBeenCalledWith( - '/test/.n8n/config', - expect.stringContaining('"encryptionKey":'), - { - encoding: 'utf-8', - mode: undefined, + mode: 0o600, }, ); }); diff --git a/packages/core/src/instance-settings/instance-settings.ts b/packages/core/src/instance-settings/instance-settings.ts index 4fc1c9095ca..e4c905bb6ed 100644 --- a/packages/core/src/instance-settings/instance-settings.ts +++ b/packages/core/src/instance-settings/instance-settings.ts @@ -273,19 +273,15 @@ export class InstanceSettings { * Ensures that the settings file has the r/w permissions only for the owner. */ private ensureSettingsFilePermissions() { - // If the flag is explicitly set to false, skip the check - if (this.enforceSettingsFilePermissions.isSet && !this.enforceSettingsFilePermissions.enforce) { - return; - } - if (this.isWindows()) { - // Ignore windows as it does not support chmod. We have already logged a warning - return; - } + if (!this.enforceSettingsFilePermissions.enforce) return; + + if (this.isWindows()) return; // ignore windows as it does not support chmod const permissionsResult = toResult(() => { const stats = statSync(this.settingsFile); return stats?.mode & 0o777; }); + // If we can't determine the permissions, log a warning and skip the check if (!permissionsResult.ok) { this.logger.warn( @@ -295,32 +291,22 @@ export class InstanceSettings { } const arePermissionsCorrect = permissionsResult.result === 0o600; - if (arePermissionsCorrect) { - return; - } - // If the permissions are incorrect and the flag is not set, log a warning - if (!this.enforceSettingsFilePermissions.isSet) { - this.logger.warn( - `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. This is ignored for now, but in the future n8n will attempt to change the permissions automatically. To automatically enforce correct permissions now set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true (recommended), or turn this check off set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, - ); - // The default is false so we skip the enforcement for now - return; - } + if (arePermissionsCorrect) return; - if (this.enforceSettingsFilePermissions.enforce) { + this.logger.error( + `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`, + ); + + const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600)); + + if (!chmodResult.ok) { + // Some filesystems don't support permissions. In this case we log the + // error and ignore it. We might want to prevent the app startup in the + // future in this case. this.logger.warn( - `Permissions 0${permissionsResult.result.toString(8)} for n8n settings file ${this.settingsFile} are too wide. Changing permissions to 0600..`, + `Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, ); - const chmodResult = toResult(() => chmodSync(this.settingsFile, 0o600)); - if (!chmodResult.ok) { - // Some filesystems don't support permissions. In this case we log the - // error and ignore it. We might want to prevent the app startup in the - // future in this case. - this.logger.warn( - `Could not enforce settings file permissions: ${chmodResult.error.message}. To skip this check, set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false.`, - ); - } } } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 66cbc0cd1d4..4f126150dbd 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -138,6 +138,8 @@ "generic.your": "Your", "generic.apiKey": "API Key", "generic.search": "Search", + "generic.showMore": "Show more", + "generic.showLess": "Show less", "about.aboutN8n": "About n8n", "about.close": "Close", "about.license": "License", @@ -162,6 +164,7 @@ "activationModal.saveExecutions": "save executions.", "activationModal.theseExecutionsWillNotShowUp": "These executions will not show up immediately in the editor,", "activationModal.workflowActivated": "Workflow activated", + "activationModal.workflowPublished": "Workflow published", "activationModal.yourTriggerWillNowFire": "Your trigger will now fire production executions automatically.", "activationModal.yourTriggersWillNowFire": "Your triggers will now fire production executions automatically.", "activationModal.yourWorkflowWillNowListenForEvents": "Your workflow will now listen for events from {serviceName} and trigger executions.", @@ -1311,6 +1314,7 @@ "menuActions.delete": "Delete", "menuActions.archive": "Archive", "menuActions.unarchive": "Unarchive", + "menuActions.unpublish": "Unpublish", "multipleParameter.addItem": "Add item", "multipleParameter.currentlyNoItemsExist": "Currently no items exist", "multipleParameter.deleteItem": "Delete item", @@ -3156,12 +3160,17 @@ "workflowHistory.content.actions": "Actions", "workflowHistory.item.id": "ID: {id}", "workflowHistory.item.createdAt": "{date} at {time}", + "workflowHistory.item.savedAtLabel": "Saved:", + "workflowHistory.item.publishedAtLabel": "Published:", "workflowHistory.item.actions.restore": "Restore this version", + "workflowHistory.item.actions.publish": "Publish this version", + "workflowHistory.item.actions.unpublish": "Unpublish workflow", "workflowHistory.item.actions.clone": "Clone to new workflow", "workflowHistory.item.actions.open": "Open version in new tab", "workflowHistory.item.actions.download": "Download", "workflowHistory.item.unsaved.title": "Unsaved version", "workflowHistory.item.latest": "Latest saved", + "workflowHistory.item.active": "Published", "workflowHistory.empty": "No versions yet.", "workflowHistory.hint": "Save the workflow to create the first version!", "workflowHistory.limit": "Version history is limited to {days} days", @@ -3170,17 +3179,23 @@ "workflowHistory.action.error.title": "Failed to {action}", "workflowHistory.action.restore.modal.title": "Restore previous workflow version?", "workflowHistory.action.restore.modal.subtitle": "Your workflow will revert to the version from {date}", - "workflowHistory.action.restore.modal.text": "Your workflow is currently active, so production executions will immediately start using the restored version. If you'd like to deactivate it before restoring, click {buttonText}.", + "workflowHistory.action.restore.modal.publishedNote": "This will not affect the published version", "workflowHistory.action.restore.modal.button.deactivateAndRestore": "Deactivate and restore", "workflowHistory.action.restore.modal.button.restore": "Restore", "workflowHistory.action.restore.modal.button.cancel": "Cancel", "workflowHistory.action.restore.success.title": "Successfully restored workflow version", "workflowHistory.action.clone.success.title": "Successfully cloned workflow version", "workflowHistory.action.clone.success.message": "Open cloned workflow in a new tab", + "workflowHistory.action.unpublish.success.title": "Workflow unpublished successfully", + "workflowHistory.action.unpublish.modal.title": "Unpublish {versionName}", + "workflowHistory.action.unpublish.modal.description": "This will prevent all production executions to this workflow until you publish again.", + "workflowHistory.action.unpublish.modal.button.unpublish": "Unpublish", "workflowHistory.button.tooltip.empty": "This workflow currently has no history to view. Once you've made your first save, you'll be able to view previous versions", "workflowHistory.button.tooltip": "Workflow history to view and restore previous versions of your workflows", + "workflowHistory.publishModal.title": "Publish {versionName}", "workflows.heading": "Workflows", "workflows.add": "Add workflow", + "workflows.publish": "Publish", "workflows.project.add": "Add workflow to project", "workflows.item.open": "Open", "workflows.item.share": "Share...", @@ -3197,6 +3212,8 @@ "workflows.item.availableInMCP": "Available in MCP", "workflows.item.enableMCPAccess": "Enable MCP access", "workflows.item.disableMCPAccess": "Remove MCP access", + "workflows.item.published": "Published", + "workflows.item.notPublished": "Not published", "workflows.itemSuggestion.try": "Try template", "workflows.templateRecoV2.starterTemplates": "Starter templates", "workflows.templateRecoV2.seeMoreStarterTemplates": "See more starter templates", @@ -3297,6 +3314,12 @@ "workflows.preBuiltAgents.callout": "Get started faster with our", "workflows.preBuiltAgents.linkText": "pre-built agents", "workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow", + "workflows.publishModal.title": "Publish workflow", + "workflows.publishModal.noTriggerMessage": "This workflow has no trigger nodes that require publishing", + "workflows.publishModal.versionNameLabel": "Version name", + "workflows.publishModal.descriptionPlaceholder": "Describe changes (optional)", + "workflows.publishModal.noChanges": "No changes to publish", + "workflows.publishModal.lastPublished": "Last published by {author}, {date} at {time}", "importCurlModal.title": "Import cURL command", "importCurlModal.input.label": "cURL Command", "importCurlModal.input.placeholder": "Paste the cURL command here", diff --git a/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts b/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts index 9df5c2845e0..15ee05c8de8 100644 --- a/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts +++ b/packages/frontend/@n8n/rest-api-client/src/api/workflowHistory.ts @@ -8,6 +8,18 @@ export type WorkflowHistory = { authors: string; createdAt: string; updatedAt: string; + workflowPublishHistory: WorkflowPublishHistory[]; + name: string | null; + description: string | null; +}; + +export type WorkflowPublishHistory = { + createdAt: string; + id: number; + event: 'activated' | 'deactivated'; + userId: string | null; + versionId: string; + workflowId: string; }; export type WorkflowVersionId = WorkflowHistory['versionId']; @@ -18,7 +30,9 @@ export type WorkflowVersion = WorkflowHistory & { connections: IConnections; }; -export type WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download']; +export type WorkflowHistoryActionTypes = Array< + 'restore' | 'publish' | 'unpublish' | 'clone' | 'open' | 'download' +>; export type WorkflowHistoryRequestParams = { take: number; skip?: number }; diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 1f120054c94..1970d850865 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -62,6 +62,7 @@ import type { FolderListItem, ResourceParentFolder, } from '@/features/core/folders/folders.types'; +import type { WorkflowHistory } from '@n8n/rest-api-client/api/workflowHistory'; export * from '@n8n/design-system/types'; @@ -260,6 +261,7 @@ export interface IWorkflowDb { createdAt?: string; updatedAt?: string; }; + activeVersion?: WorkflowHistory | null; } // For workflow list we don't need the full workflow data diff --git a/packages/frontend/editor-ui/src/app/api/workflows.ts b/packages/frontend/editor-ui/src/app/api/workflows.ts index 51b5d6fd1f0..934ee1a6a14 100644 --- a/packages/frontend/editor-ui/src/app/api/workflows.ts +++ b/packages/frontend/editor-ui/src/app/api/workflows.ts @@ -115,3 +115,27 @@ export async function getLastSuccessfulExecution( `/workflows/${workflowId}/executions/last-successful`, ); } + +export async function activateWorkflow( + context: IRestApiContext, + workflowId: string, + data: { versionId: string; name?: string; description?: string }, +): Promise { + return await makeRestApiRequest( + context, + 'POST', + `/workflows/${workflowId}/activate`, + data, + ); +} + +export async function deactivateWorkflow( + context: IRestApiContext, + workflowId: string, +): Promise { + return await makeRestApiRequest( + context, + 'POST', + `/workflows/${workflowId}/deactivate`, + ); +} diff --git a/packages/frontend/editor-ui/src/app/components/ActivationModal.vue b/packages/frontend/editor-ui/src/app/components/ActivationModal.vue index d679cee39e8..c77e84d3d2e 100644 --- a/packages/frontend/editor-ui/src/app/components/ActivationModal.vue +++ b/packages/frontend/editor-ui/src/app/components/ActivationModal.vue @@ -19,16 +19,23 @@ import { useI18n } from '@n8n/i18n'; import { ElCheckbox } from 'element-plus'; import { N8nButton, N8nText } from '@n8n/design-system'; +import { IS_DRAFT_PUBLISH_ENABLED } from '@/app/constants'; const checked = ref(false); const executionsStore = useExecutionsStore(); const workflowsStore = useWorkflowsStore(); const nodeTypesStore = useNodeTypesStore(); const uiStore = useUIStore(); - const router = useRouter(); const i18n = useI18n(); +const modalTitle = computed(() => { + if (IS_DRAFT_PUBLISH_ENABLED) { + return i18n.baseText('activationModal.workflowPublished'); + } + return i18n.baseText('activationModal.workflowActivated'); +}); + const triggerContent = computed(() => { const foundTriggers = getActivatableTriggerNodes(workflowsStore.workflowTriggerNodes); if (!foundTriggers.length) { @@ -94,11 +101,7 @@ const handleCheckboxChange = (checkboxValue: string | number | boolean) => {