mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-13 00:20:27 +02:00
Compare commits
26 Commits
master
...
n8n@2.19.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e52729330 | ||
|
|
7e94cf8081 | ||
|
|
17fa22b416 | ||
|
|
103b7c8787 | ||
|
|
4b73a25077 | ||
|
|
c967cb23d4 | ||
|
|
492a0b7c01 | ||
|
|
b6eb793a54 | ||
|
|
f6561da6fe | ||
|
|
b1f220013f | ||
|
|
8a9f427e22 | ||
|
|
d874ec26eb | ||
|
|
616f255e2d | ||
|
|
5352606c74 | ||
|
|
ddad28ac95 | ||
|
|
cd4a3f5795 | ||
|
|
fa798fb379 | ||
|
|
8630712455 | ||
|
|
3907b0e081 | ||
|
|
f443a58fde | ||
|
|
d6e4645ab3 | ||
|
|
2aedc53d8f | ||
|
|
e8db9111ce | ||
|
|
020293d8fc | ||
|
|
8333c0343b | ||
|
|
41ade461ac |
54
CHANGELOG.md
54
CHANGELOG.md
|
|
@ -1,3 +1,57 @@
|
|||
## [2.19.5](https://github.com/n8n-io/n8n/compare/n8n@2.19.4...n8n@2.19.5) (2026-05-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Simple-git update broke https connection ([#30004](https://github.com/n8n-io/n8n/issues/30004)) ([17fa22b](https://github.com/n8n-io/n8n/commit/17fa22b4169e74bded8bedafe19bce11944bd17b))
|
||||
|
||||
|
||||
## [2.19.4](https://github.com/n8n-io/n8n/compare/n8n@2.19.3...n8n@2.19.4) (2026-05-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add fully dynamic disclaimer to Quick Connect offer ([#29865](https://github.com/n8n-io/n8n/issues/29865)) ([492a0b7](https://github.com/n8n-io/n8n/commit/492a0b7c01c5552685e859c432fa92b82d3b300c))
|
||||
* **core:** Add support for context establishment hooks in webhook mode ([#29901](https://github.com/n8n-io/n8n/issues/29901)) ([c967cb2](https://github.com/n8n-io/n8n/commit/c967cb23d4e6bb349d13f639869e81c46da9dc93))
|
||||
* **core:** Allow GIT_SSH_COMMAND in simple-git after 3.36.0 upgrade ([#29921](https://github.com/n8n-io/n8n/issues/29921)) ([4b73a25](https://github.com/n8n-io/n8n/commit/4b73a25077af5dec798175ac1998c4cccb106434))
|
||||
* **Snowflake Node:** Fix issue with Insert and Update operations not working ([#29811](https://github.com/n8n-io/n8n/issues/29811)) ([b1f2200](https://github.com/n8n-io/n8n/commit/b1f220013fdaadf477d31c0bf521aa7b72784768))
|
||||
|
||||
|
||||
## [2.19.3](https://github.com/n8n-io/n8n/compare/n8n@2.19.2...n8n@2.19.3) (2026-05-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Acquire expression isolate for dynamic node parameter requests ([#29711](https://github.com/n8n-io/n8n/issues/29711)) ([cd4a3f5](https://github.com/n8n-io/n8n/commit/cd4a3f579545736be33921c6f7dd9337165e37dc))
|
||||
* **core:** Add file path validation to localFile source ([#29789](https://github.com/n8n-io/n8n/issues/29789)) ([5352606](https://github.com/n8n-io/n8n/commit/5352606c74989cfdee36f8890b889a0478c19516))
|
||||
* **core:** Show AI Builder draft workflows in workflow list ([#29678](https://github.com/n8n-io/n8n/issues/29678)) ([fa798fb](https://github.com/n8n-io/n8n/commit/fa798fb379eabdb24274324e782dad62b01a9514))
|
||||
* Restore broken stdlib calls in Python Code node ([#29782](https://github.com/n8n-io/n8n/issues/29782)) ([616f255](https://github.com/n8n-io/n8n/commit/616f255e2d4b590ae5ac455612d6e927324c0812))
|
||||
|
||||
|
||||
## [2.19.2](https://github.com/n8n-io/n8n/compare/n8n@2.19.1...n8n@2.19.2) (2026-05-01)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Persist execution context before writing to db ([#29529](https://github.com/n8n-io/n8n/issues/29529)) ([f443a58](https://github.com/n8n-io/n8n/commit/f443a58fdec51f02d61d747578dae5624771ee20))
|
||||
* **core:** Respect global admin scope when listing favorites ([#29517](https://github.com/n8n-io/n8n/issues/29517)) ([e8db911](https://github.com/n8n-io/n8n/commit/e8db9111ce34478576d89407095c0b03f9185159))
|
||||
* **core:** Restore peer project discovery in share dropdowns ([#29564](https://github.com/n8n-io/n8n/issues/29564)) ([3907b0e](https://github.com/n8n-io/n8n/commit/3907b0e0813d3273cf3632217c0325d86f33ed78))
|
||||
* **editor:** Remove clipping for focus panel textarea ([#29510](https://github.com/n8n-io/n8n/issues/29510)) ([020293d](https://github.com/n8n-io/n8n/commit/020293d8fca7519fc91750a6a847564e9b109721))
|
||||
|
||||
|
||||
## [2.19.1](https://github.com/n8n-io/n8n/compare/n8n@2.19.0...n8n@2.19.1) (2026-04-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **editor:** Keep publish actions menu enabled for published workflows ([#29411](https://github.com/n8n-io/n8n/issues/29411)) ([41ade46](https://github.com/n8n-io/n8n/commit/41ade461ac78c6304e91e4a90666248727bb5e2f))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **core:** Warn and skip on duplicate scheduled executions ([#29503](https://github.com/n8n-io/n8n/issues/29503)) ([8333c03](https://github.com/n8n-io/n8n/commit/8333c0343be04ef3b30dc780612797abdbb2ddfe))
|
||||
|
||||
|
||||
# [2.19.0](https://github.com/n8n-io/n8n/compare/n8n@2.18.0...n8n@2.19.0) (2026-04-28)
|
||||
|
||||
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.5",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.16",
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
"jest-mock-extended": "^3.0.4",
|
||||
"lefthook": "^1.7.15",
|
||||
"license-checker": "^25.0.1",
|
||||
"nock": "^14.0.1",
|
||||
"nock": "^14.0.14",
|
||||
"nodemon": "^3.0.1",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"p-limit": "^3.1.0",
|
||||
|
|
@ -103,7 +103,6 @@
|
|||
"@mistralai/mistralai": "^1.10.0",
|
||||
"@n8n/typeorm>@sentry/node": "catalog:sentry",
|
||||
"@types/node": "^20.17.50",
|
||||
"axios": "1.15.0",
|
||||
"chokidar": "4.0.3",
|
||||
"esbuild": "^0.25.0",
|
||||
"expr-eval@2.0.2": "npm:expr-eval-fork@3.0.0",
|
||||
|
|
@ -140,7 +139,6 @@
|
|||
"undici@6": "^6.24.0",
|
||||
"undici@7": "^7.24.0",
|
||||
"tar": "^7.5.11",
|
||||
"hono": "4.12.14",
|
||||
"ajv@6": "6.14.0",
|
||||
"ajv@7": "8.18.0",
|
||||
"ajv@8": "8.18.0",
|
||||
|
|
@ -167,7 +165,9 @@
|
|||
"@xmldom/xmldom": "0.8.13",
|
||||
"langsmith": "0.5.19",
|
||||
"yaml@<=2.8.3": "2.8.3",
|
||||
"fast-xml-parser": "5.7.0"
|
||||
"hono": "4.12.16",
|
||||
"axios": "1.16.0",
|
||||
"fast-xml-parser": "5.7.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"bull@4.16.4": "patches/bull@4.16.4.patch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.10.0",
|
||||
"version": "0.10.1",
|
||||
"description": "SDK for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-utilities",
|
||||
"version": "0.13.0",
|
||||
"version": "0.13.1",
|
||||
"description": "Utilities for building AI nodes in n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-workflow-builder",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
export type QuickConnectDisclaimer = {
|
||||
text: string;
|
||||
linkUrl: string;
|
||||
linkLabel?: string;
|
||||
};
|
||||
|
||||
type QuickConnectGenericOption = {
|
||||
packageName: string;
|
||||
credentialType: string;
|
||||
|
|
@ -5,6 +11,7 @@ type QuickConnectGenericOption = {
|
|||
quickConnectType: string;
|
||||
consentText?: string;
|
||||
consentCheckbox?: string;
|
||||
disclaimer?: QuickConnectDisclaimer;
|
||||
config?: never;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-benchmark",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"description": "Cli for running benchmark tests for n8n",
|
||||
"main": "dist/index",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.12.0",
|
||||
"version": "1.12.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/config",
|
||||
"version": "2.18.0",
|
||||
"version": "2.18.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -13,4 +13,8 @@ export class MultiMainSetupConfig {
|
|||
/** Interval in seconds between leader eligibility checks in multi-main setup. */
|
||||
@Env('N8N_MULTI_MAIN_SETUP_CHECK_INTERVAL')
|
||||
interval: number = 3;
|
||||
|
||||
/** Whether to use the new leader election implementation (Lua-script based). */
|
||||
@Env('N8N_NEW_LEADER_ELECTION_IMPLEMENTATION')
|
||||
newLeaderElection: boolean = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ export { ChatTriggerConfig } from './configs/chat-trigger.config';
|
|||
export { InstanceAiConfig } from './configs/instance-ai.config';
|
||||
export { ExpressionEngineConfig } from './configs/expression-engine.config';
|
||||
export { PasswordConfig } from './configs/password.config';
|
||||
export { RedisConfig } from './configs/redis.config';
|
||||
|
||||
const protocolSchema = z.enum(['http', 'https']);
|
||||
|
||||
|
|
|
|||
|
|
@ -367,6 +367,7 @@ describe('GlobalConfig', () => {
|
|||
enabled: false,
|
||||
ttl: 10,
|
||||
interval: 3,
|
||||
newLeaderElection: false,
|
||||
},
|
||||
generic: {
|
||||
timezone: 'America/New_York',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.28.0",
|
||||
"version": "0.28.1",
|
||||
"description": "Official CLI to create new community nodes for n8n",
|
||||
"bin": {
|
||||
"create-node": "bin/create-node.cjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/db",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { GlobalConfig } from '@n8n/config';
|
|||
import { In, type SelectQueryBuilder } from '@n8n/typeorm';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { AiBuilderTemporaryWorkflow, WorkflowEntity } from '../../entities';
|
||||
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';
|
||||
|
|
@ -32,17 +32,9 @@ describe('WorkflowRepository', () => {
|
|||
jest.resetAllMocks();
|
||||
|
||||
queryBuilder = mock<SelectQueryBuilder<WorkflowEntity>>();
|
||||
const subQueryBuilder = mock<SelectQueryBuilder<AiBuilderTemporaryWorkflow>>();
|
||||
subQueryBuilder.select.mockReturnThis();
|
||||
subQueryBuilder.from.mockReturnThis();
|
||||
subQueryBuilder.where.mockReturnThis();
|
||||
subQueryBuilder.getQuery.mockReturnValue(
|
||||
'(SELECT 1 FROM "ai_builder_temporary_workflow" "aitw" WHERE aitw."workflowId" = workflow.id)',
|
||||
);
|
||||
|
||||
queryBuilder.where.mockReturnThis();
|
||||
queryBuilder.andWhere.mockReturnThis();
|
||||
queryBuilder.subQuery.mockReturnValue(subQueryBuilder);
|
||||
queryBuilder.orWhere.mockReturnThis();
|
||||
queryBuilder.select.mockReturnThis();
|
||||
queryBuilder.addSelect.mockReturnThis();
|
||||
|
|
@ -65,18 +57,6 @@ describe('WorkflowRepository', () => {
|
|||
jest.spyOn(workflowRepository, 'createQueryBuilder').mockReturnValue(queryBuilder);
|
||||
});
|
||||
|
||||
describe('applyAiBuilderTemporaryFilter', () => {
|
||||
it('hides marker-table rows through a prefix-safe entity subquery', async () => {
|
||||
await workflowRepository.getMany(['workflow1']);
|
||||
|
||||
expect(queryBuilder.subQuery).toHaveBeenCalled();
|
||||
expect(queryBuilder.subQuery().from).toHaveBeenCalledWith(AiBuilderTemporaryWorkflow, 'aitw');
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
expect.stringContaining('NOT EXISTS (SELECT 1 FROM "ai_builder_temporary_workflow"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyNameFilter', () => {
|
||||
it('should search for workflows containing all words from the query', async () => {
|
||||
const workflowIds = ['workflow1'];
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ export class ProjectRepository extends Repository<Project> {
|
|||
return await query.getManyAndCount();
|
||||
}
|
||||
|
||||
// Strict semantics: returns only projects the user has a relation to
|
||||
// (their personal project + projects they are explicitly a member of).
|
||||
// Do not broaden — peer-personal-project discovery for the share modal lives
|
||||
// in `getShareableProjectsAndCount` below; conflating the two has regressed
|
||||
// the share dropdown before (see IAM-591).
|
||||
async getAccessibleProjectsAndCount(
|
||||
userId: string,
|
||||
options: ProjectListOptions,
|
||||
|
|
@ -62,6 +67,40 @@ export class ProjectRepository extends Repository<Project> {
|
|||
.innerJoin('p.projectRelations', 'pr')
|
||||
.where('pr.userId = :userId', { userId });
|
||||
|
||||
this.applyIdsQueryFilters(idsQuery, options);
|
||||
return await this.runProjectListByIdsQuery(idsQuery, options);
|
||||
}
|
||||
|
||||
// Wide semantics: returns peer personal projects in addition to projects
|
||||
// the user has a relation to. Used only by the sharing-discovery endpoint
|
||||
// (`GET /rest/projects/sharing-candidates`) so the workflow / credential
|
||||
// share dropdowns can list other users as share targets.
|
||||
async getShareableProjectsAndCount(
|
||||
userId: string,
|
||||
options: ProjectListOptions,
|
||||
): Promise<[Project[], number]> {
|
||||
// DISTINCT + LEFT JOIN avoids duplicate rows from the relation join
|
||||
// while still allowing personal projects with no caller relation to match.
|
||||
const idsQuery = this.createQueryBuilder('p')
|
||||
.select('DISTINCT p.id', 'id')
|
||||
.leftJoin('p.projectRelations', 'pr')
|
||||
.where(
|
||||
new Brackets((qb) => {
|
||||
qb.where('p.type = :personalType', { personalType: 'personal' }).orWhere(
|
||||
'pr.userId = :userId',
|
||||
{ userId },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
this.applyIdsQueryFilters(idsQuery, options);
|
||||
return await this.runProjectListByIdsQuery(idsQuery, options);
|
||||
}
|
||||
|
||||
private applyIdsQueryFilters(
|
||||
idsQuery: SelectQueryBuilder<Project>,
|
||||
options: ProjectListOptions,
|
||||
): void {
|
||||
if (options.search) {
|
||||
idsQuery.andWhere('LOWER(p.name) LIKE LOWER(:search)', {
|
||||
search: `%${options.search}%`,
|
||||
|
|
@ -81,7 +120,12 @@ export class ProjectRepository extends Repository<Project> {
|
|||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async runProjectListByIdsQuery(
|
||||
idsQuery: SelectQueryBuilder<Project>,
|
||||
options: ProjectListOptions,
|
||||
): Promise<[Project[], number]> {
|
||||
const query = this.createQueryBuilder('project')
|
||||
.leftJoin('project.creator', 'creator')
|
||||
.where(`project.id IN (${idsQuery.getQuery()})`);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import { SharedWorkflowRepository } from './shared-workflow.repository';
|
|||
import { WorkflowHistoryRepository } from './workflow-history.repository';
|
||||
import {
|
||||
WebhookEntity,
|
||||
AiBuilderTemporaryWorkflow,
|
||||
TagEntity,
|
||||
WorkflowEntity,
|
||||
WorkflowTagMapping,
|
||||
|
|
@ -883,23 +882,6 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
|||
this.applyParentFolderFilter(qb, filter);
|
||||
this.applyNodeTypesFilter(qb, filter);
|
||||
this.applyAvailableInMCPFilter(qb, filter);
|
||||
this.applyAiBuilderTemporaryFilter(qb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide workflows the AI builder created and has not yet promoted to the
|
||||
* main deliverable. The orchestrator clears the marker on the main at
|
||||
* build-time and reaps the rest at run-finish, but in the window between
|
||||
* create and reap, marked rows must not surface in the workflows list.
|
||||
*/
|
||||
private applyAiBuilderTemporaryFilter(qb: SelectQueryBuilder<WorkflowEntity>): void {
|
||||
const markerSubquery = qb
|
||||
.subQuery()
|
||||
.select('1')
|
||||
.from(AiBuilderTemporaryWorkflow, 'aitw')
|
||||
.where('aitw."workflowId" = workflow.id')
|
||||
.getQuery();
|
||||
qb.andWhere(`NOT EXISTS ${markerSubquery}`);
|
||||
}
|
||||
|
||||
private applyAvailableInMCPFilter(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.19.0",
|
||||
"version": "1.19.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.4.0",
|
||||
"version": "1.4.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-cli",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.1",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scan-community-package",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.1",
|
||||
"description": "Static code analyser for n8n community packages",
|
||||
"license": "none",
|
||||
"bin": "scanner/cli.mjs",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import multiprocessing
|
||||
import traceback
|
||||
import textwrap
|
||||
import types
|
||||
import json
|
||||
import io
|
||||
import os
|
||||
|
|
@ -436,7 +435,49 @@ class TaskExecutor:
|
|||
|
||||
filtered["__import__"] = TaskExecutor._create_safe_import(security_config)
|
||||
|
||||
return types.MappingProxyType(filtered)
|
||||
class _ImmutableBuiltins:
|
||||
__slots__ = ()
|
||||
|
||||
def __getitem__(self, key):
|
||||
return filtered[key]
|
||||
|
||||
def __contains__(self, key):
|
||||
return key in filtered
|
||||
|
||||
def __iter__(self):
|
||||
return iter(filtered)
|
||||
|
||||
def __len__(self):
|
||||
return len(filtered)
|
||||
|
||||
def keys(self):
|
||||
return filtered.keys()
|
||||
|
||||
def values(self):
|
||||
return filtered.values()
|
||||
|
||||
def items(self):
|
||||
return filtered.items()
|
||||
|
||||
def get(self, key, default=None):
|
||||
return filtered.get(key, default)
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return filtered[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name) from None
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
raise AttributeError("read-only")
|
||||
|
||||
def __delattr__(self, name):
|
||||
raise AttributeError("read-only")
|
||||
|
||||
def __repr__(self):
|
||||
return f"ImmutableBuiltins({len(filtered)} keys)"
|
||||
|
||||
return _ImmutableBuiltins()
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_sys_modules(security_config: SecurityConfig):
|
||||
|
|
|
|||
|
|
@ -185,6 +185,99 @@ async def test_per_item_with_continue_on_fail(broker, manager):
|
|||
assert "division by zero" in done_msg["data"]["result"][0]["json"]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_datetime_strptime_works(broker, manager_with_stdlib_wildcard):
|
||||
task_id = nanoid()
|
||||
items = [{"json": {"value": "2026-04-23T16:04:28+0000"}}]
|
||||
code = textwrap.dedent("""
|
||||
from datetime import datetime
|
||||
parsed = datetime.strptime(_item['json']['value'], "%Y-%m-%dT%H:%M:%S%z")
|
||||
return {'parsed': parsed.isoformat()}
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert done_msg["data"]["result"] == [
|
||||
{"json": {"parsed": "2026-04-23T16:04:28+00:00"}, "pairedItem": {"item": 0}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_datetime_strftime_works(broker, manager_with_stdlib_wildcard):
|
||||
task_id = nanoid()
|
||||
items = [{"json": {}}]
|
||||
code = textwrap.dedent("""
|
||||
from datetime import datetime
|
||||
return {'formatted': datetime(2026, 4, 23).strftime('%Y-%m-%d')}
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert done_msg["data"]["result"] == [
|
||||
{"json": {"formatted": "2026-04-23"}, "pairedItem": {"item": 0}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_time_strptime_works(broker, manager_with_stdlib_wildcard):
|
||||
task_id = nanoid()
|
||||
items = [{"json": {"value": "2026-04-23"}}]
|
||||
code = textwrap.dedent("""
|
||||
import time
|
||||
parsed = time.strptime(_item['json']['value'], "%Y-%m-%d")
|
||||
return {'year': parsed.tm_year, 'month': parsed.tm_mon, 'day': parsed.tm_mday}
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert done_msg["data"]["result"] == [
|
||||
{"json": {"year": 2026, "month": 4, "day": 23}, "pairedItem": {"item": 0}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_item_warnings_warn_works(broker, manager_with_stdlib_wildcard):
|
||||
task_id = nanoid()
|
||||
items = [{"json": {}}]
|
||||
code = textwrap.dedent("""
|
||||
import warnings
|
||||
warnings.warn('sample', stacklevel=1)
|
||||
return {'ok': True}
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="per_item", items=items)
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert done_msg["data"]["result"] == [
|
||||
{"json": {"ok": True}, "pairedItem": {"item": 0}}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_items_datetime_strptime_works(broker, manager_with_stdlib_wildcard):
|
||||
task_id = nanoid()
|
||||
code = textwrap.dedent("""
|
||||
from datetime import datetime
|
||||
parsed = datetime.strptime("2026-04-23T16:04:28+0000", "%Y-%m-%dT%H:%M:%S%z")
|
||||
return [{'json': {'parsed': parsed.isoformat()}}]
|
||||
""")
|
||||
task_settings = create_task_settings(code=code, node_mode="all_items")
|
||||
await broker.send_task(task_id=task_id, task_settings=task_settings)
|
||||
|
||||
done_msg = await wait_for_task_done(broker, task_id)
|
||||
|
||||
assert done_msg["data"]["result"] == [
|
||||
{"json": {"parsed": "2026-04-23T16:04:28+00:00"}}
|
||||
]
|
||||
|
||||
|
||||
# ========== Security ===========
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import pytest
|
||||
import json
|
||||
import types
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.task_executor import TaskExecutor
|
||||
|
|
@ -209,7 +208,70 @@ class TestFilterBuiltins:
|
|||
runner_env_deny=False,
|
||||
)
|
||||
|
||||
def test_returns_mapping_proxy(self):
|
||||
def test_supports_item_access(self):
|
||||
result = TaskExecutor._filter_builtins(self._make_security_config())
|
||||
|
||||
assert isinstance(result, types.MappingProxyType)
|
||||
assert result["__import__"] is not None
|
||||
assert result["len"] is len
|
||||
assert "len" in result
|
||||
|
||||
def test_supports_attribute_access(self):
|
||||
result = TaskExecutor._filter_builtins(self._make_security_config())
|
||||
|
||||
assert result.__import__ is not None
|
||||
assert result.len is len
|
||||
|
||||
def test_is_not_a_dict(self):
|
||||
result = TaskExecutor._filter_builtins(self._make_security_config())
|
||||
|
||||
assert not isinstance(result, dict)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name,mutate,expected",
|
||||
[
|
||||
(
|
||||
"item_assignment",
|
||||
lambda r: r.__setitem__("len", None),
|
||||
(TypeError, AttributeError),
|
||||
),
|
||||
(
|
||||
"attribute_assignment",
|
||||
lambda r: setattr(r, "__import__", None),
|
||||
AttributeError,
|
||||
),
|
||||
(
|
||||
"dict_class_setitem",
|
||||
lambda r: dict.__setitem__(r, "len", None),
|
||||
TypeError,
|
||||
),
|
||||
("dict_class_init", lambda r: dict.__init__(r, {"pwned": True}), TypeError),
|
||||
("dict_class_update", lambda r: dict.update(r, {"len": None}), TypeError),
|
||||
("dict_class_clear", lambda r: dict.clear(r), TypeError),
|
||||
("class_swap", lambda r: setattr(r, "__class__", dict), AttributeError),
|
||||
("vars_injection", lambda r: vars(r), TypeError),
|
||||
(
|
||||
"object_setattr",
|
||||
lambda r: object.__setattr__(r, "_x", {"pwned": True}),
|
||||
AttributeError,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_rejects_mutation(self, name, mutate, expected):
|
||||
result = TaskExecutor._filter_builtins(self._make_security_config())
|
||||
|
||||
with pytest.raises(expected):
|
||||
mutate(result)
|
||||
|
||||
def test_applies_builtins_deny(self):
|
||||
config = SecurityConfig(
|
||||
stdlib_allow=set(),
|
||||
external_allow=set(),
|
||||
builtins_deny={"open", "eval"},
|
||||
runner_env_deny=False,
|
||||
)
|
||||
result = TaskExecutor._filter_builtins(config)
|
||||
|
||||
assert "open" not in result
|
||||
assert "eval" not in result
|
||||
assert "len" in result
|
||||
assert result["__import__"] is not None
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.4",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.5",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,21 @@ describe('ActiveExecutions', () => {
|
|||
expect(executionRepository.updateExistingExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Should forward deduplicationKey to executionPersistence.create', async () => {
|
||||
const executionDataWithKey: IWorkflowExecutionDataProcess = {
|
||||
...executionData,
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
};
|
||||
|
||||
await activeExecutions.add(executionDataWithKey);
|
||||
|
||||
expect(executionPersistence.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('Should throw ExecutionAlreadyResumingError when another process is resuming execution', async () => {
|
||||
// Mock updateExistingExecution to return false (status check failed)
|
||||
executionRepository.updateExistingExecution.mockResolvedValue(false);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import type { WorkflowEntity, WorkflowHistory, WorkflowRepository } from '@n8n/db';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
|
@ -7,16 +8,18 @@ import type {
|
|||
ExecutionError,
|
||||
INodeExecutionData,
|
||||
INode,
|
||||
IRun,
|
||||
IWorkflowExecuteAdditionalData,
|
||||
Workflow,
|
||||
WorkflowActivateMode,
|
||||
WorkflowExecuteMode,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { WorkflowActivationError } from 'n8n-workflow';
|
||||
import { createDeferredPromise, WorkflowActivationError } from 'n8n-workflow';
|
||||
|
||||
import type { ActivationErrorsService } from '@/activation-errors.service';
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
|
||||
import type { EventService } from '@/events/event.service';
|
||||
import type { ExecutionService } from '@/executions/execution.service';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
|
|
@ -294,6 +297,7 @@ describe('ActiveWorkflowManager', () => {
|
|||
const activeWorkflows = mock<ActiveWorkflows>();
|
||||
const activationErrorsService = mock<ActivationErrorsService>();
|
||||
const executionService = mock<ExecutionService>();
|
||||
let scopedLogger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -303,8 +307,11 @@ describe('ActiveWorkflowManager', () => {
|
|||
activationErrorsService.register.mockResolvedValue(undefined);
|
||||
executionService.createErrorExecution.mockResolvedValue(undefined);
|
||||
|
||||
scopedLogger = mock<Logger>();
|
||||
const rootLogger = mock<Logger>({ scoped: jest.fn().mockReturnValue(scopedLogger) });
|
||||
|
||||
activeWorkflowManager = new ActiveWorkflowManager(
|
||||
mockLogger(),
|
||||
rootLogger,
|
||||
mock(),
|
||||
activeWorkflows,
|
||||
mock(),
|
||||
|
|
@ -358,6 +365,7 @@ describe('ActiveWorkflowManager', () => {
|
|||
additionalData,
|
||||
mode,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
|
@ -369,6 +377,91 @@ describe('ActiveWorkflowManager', () => {
|
|||
source: 'trigger',
|
||||
});
|
||||
});
|
||||
|
||||
test('forwards deduplicationKey to workflowExecutionService.runWorkflow', async () => {
|
||||
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const mode: WorkflowExecuteMode = 'trigger';
|
||||
const activation: WorkflowActivateMode = 'activate';
|
||||
const workflow = mock<Workflow>({ name: 'Test Workflow' });
|
||||
const node = mock<INode>({ name: 'Trigger Node', id: 'node-1' });
|
||||
const triggerData: INodeExecutionData[][] = [[]];
|
||||
|
||||
const getTriggerFunctions = activeWorkflowManager.getExecuteTriggerFunctions(
|
||||
workflowData,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
|
||||
|
||||
context.emit(triggerData, undefined, undefined, 'wf-1:node-1:1700000000000');
|
||||
|
||||
expect(workflowExecutionService.runWorkflow).toHaveBeenCalledWith(
|
||||
workflowData,
|
||||
node,
|
||||
triggerData,
|
||||
additionalData,
|
||||
mode,
|
||||
undefined,
|
||||
'wf-1:node-1:1700000000000',
|
||||
);
|
||||
});
|
||||
|
||||
test('skips event emission when runWorkflow rejects with DuplicateExecutionError', async () => {
|
||||
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const mode: WorkflowExecuteMode = 'trigger';
|
||||
const activation: WorkflowActivateMode = 'activate';
|
||||
const workflow = mock<Workflow>({ name: 'Test Workflow' });
|
||||
const node = mock<INode>({ name: 'Trigger Node', id: 'node-1' });
|
||||
const triggerData: INodeExecutionData[][] = [[]];
|
||||
|
||||
workflowExecutionService.runWorkflow.mockRejectedValueOnce(
|
||||
new DuplicateExecutionError('wf-1:node-1:1700000000000'),
|
||||
);
|
||||
|
||||
const getTriggerFunctions = activeWorkflowManager.getExecuteTriggerFunctions(
|
||||
workflowData,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
|
||||
|
||||
context.emit(triggerData, undefined, undefined, 'wf-1:node-1:1700000000000');
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(eventService.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('resolves donePromise with undefined when runWorkflow rejects with DuplicateExecutionError', async () => {
|
||||
const workflowData = mock<WorkflowEntity>({ id: 'wf-1', name: 'Test Workflow' });
|
||||
const additionalData = mock<IWorkflowExecuteAdditionalData>();
|
||||
const mode: WorkflowExecuteMode = 'trigger';
|
||||
const activation: WorkflowActivateMode = 'activate';
|
||||
const workflow = mock<Workflow>({ name: 'Test Workflow' });
|
||||
const node = mock<INode>({ name: 'Trigger Node', id: 'node-1' });
|
||||
const triggerData: INodeExecutionData[][] = [[]];
|
||||
|
||||
workflowExecutionService.runWorkflow.mockRejectedValueOnce(
|
||||
new DuplicateExecutionError('wf-1:node-1:1700000000000'),
|
||||
);
|
||||
|
||||
const getTriggerFunctions = activeWorkflowManager.getExecuteTriggerFunctions(
|
||||
workflowData,
|
||||
additionalData,
|
||||
mode,
|
||||
activation,
|
||||
);
|
||||
const context = getTriggerFunctions(workflow, node, additionalData, mode, activation);
|
||||
|
||||
const donePromise = createDeferredPromise<IRun>();
|
||||
context.emit(triggerData, undefined, donePromise, 'wf-1:node-1:1700000000000');
|
||||
|
||||
await expect(donePromise.promise).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('emitError', () => {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
type IExecuteData,
|
||||
type INode,
|
||||
type IRun,
|
||||
type IRunExecutionData,
|
||||
type ITaskData,
|
||||
type IWaitingForExecution,
|
||||
type IWaitingForExecutionSource,
|
||||
|
|
@ -23,9 +24,11 @@ import {
|
|||
type IWorkflowExecutionDataProcess,
|
||||
type StartNodeData,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type WorkflowExecuteMode,
|
||||
Workflow,
|
||||
ExecutionError,
|
||||
TimeoutExecutionCancelledError,
|
||||
createRunExecutionData,
|
||||
} from 'n8n-workflow';
|
||||
import PCancelable from 'p-cancelable';
|
||||
|
||||
|
|
@ -630,6 +633,202 @@ describe('needsFullExecutionData', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('pre-persist context establishment', () => {
|
||||
const callOrder: string[] = [];
|
||||
let establishSpy: jest.SpyInstance;
|
||||
let addSpy: jest.SpyInstance;
|
||||
let capturedAddData: IWorkflowExecutionDataProcess | undefined;
|
||||
|
||||
const buildRunData = (
|
||||
executionData: IRunExecutionData | undefined,
|
||||
executionMode: WorkflowExecuteMode = 'webhook',
|
||||
): IWorkflowExecutionDataProcess =>
|
||||
mock<IWorkflowExecutionDataProcess>({
|
||||
executionMode,
|
||||
workflowData: {
|
||||
id: 'wf-1',
|
||||
name: 'Test',
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: undefined,
|
||||
},
|
||||
executionData,
|
||||
userId: 'u1',
|
||||
});
|
||||
|
||||
const buildExecutionDataWithHeader = (): IRunExecutionData =>
|
||||
createRunExecutionData({
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [
|
||||
{
|
||||
node: {
|
||||
id: 'n1',
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
typeVersion: 2,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
},
|
||||
data: {
|
||||
main: [[{ json: { headers: { authorization: 'Bearer eyJtest' } } }]],
|
||||
},
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
callOrder.length = 0;
|
||||
capturedAddData = undefined;
|
||||
|
||||
const permissionChecker = Container.get(CredentialsPermissionChecker);
|
||||
jest.spyOn(permissionChecker, 'check').mockResolvedValue();
|
||||
|
||||
establishSpy = jest
|
||||
.spyOn(core, 'establishExecutionContext')
|
||||
.mockImplementation(async (_workflow, runExecutionData) => {
|
||||
callOrder.push('establishExecutionContext');
|
||||
// Simulate real behaviour: mask header, set runtimeData.
|
||||
const stack = runExecutionData.executionData?.nodeExecutionStack;
|
||||
const item = stack?.[0]?.data?.main?.[0]?.[0];
|
||||
const headers = item && (item.json as { headers?: Record<string, string> }).headers;
|
||||
if (headers && typeof headers.authorization === 'string') {
|
||||
headers.authorization = '**********';
|
||||
}
|
||||
if (runExecutionData.executionData) {
|
||||
(runExecutionData.executionData as { runtimeData?: unknown }).runtimeData = {
|
||||
version: 1,
|
||||
establishedAt: Date.now(),
|
||||
source: 'webhook',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const activeExecutions = Container.get(ActiveExecutions);
|
||||
addSpy = jest
|
||||
.spyOn(activeExecutions, 'add')
|
||||
.mockImplementation(async (data: IWorkflowExecutionDataProcess) => {
|
||||
capturedAddData = data;
|
||||
callOrder.push('activeExecutions.add');
|
||||
throw new Error('short-circuit for test');
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
establishSpy.mockRestore();
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls establishExecutionContext before activeExecutions.add', async () => {
|
||||
const data = buildRunData(buildExecutionDataWithHeader());
|
||||
|
||||
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
|
||||
|
||||
expect(callOrder).toEqual(['establishExecutionContext', 'activeExecutions.add']);
|
||||
});
|
||||
|
||||
it('passes masked executionData to activeExecutions.add', async () => {
|
||||
const data = buildRunData(buildExecutionDataWithHeader());
|
||||
|
||||
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
|
||||
|
||||
expect(capturedAddData).toBeDefined();
|
||||
const stack = capturedAddData!.executionData!.executionData!.nodeExecutionStack;
|
||||
expect(stack[0].data.main[0]![0].json).toMatchObject({
|
||||
headers: { authorization: '**********' },
|
||||
});
|
||||
expect(capturedAddData!.executionData!.executionData!.runtimeData).toBeDefined();
|
||||
});
|
||||
|
||||
it('skips establishExecutionContext when data.executionData is undefined', async () => {
|
||||
const data = buildRunData(undefined);
|
||||
|
||||
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
|
||||
|
||||
expect(establishSpy).not.toHaveBeenCalled();
|
||||
expect(callOrder).toEqual(['activeExecutions.add']);
|
||||
});
|
||||
|
||||
it('skips establishExecutionContext when nodeExecutionStack has not been populated yet', async () => {
|
||||
// Queue mode with OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true creates
|
||||
// the outer IRunExecutionData with `executionData: null`, which
|
||||
// `createRunExecutionData` normalises to an object whose inner
|
||||
// `.executionData` is undefined. The worker establishes context
|
||||
// later, once it populates the trigger-item stack.
|
||||
const data = buildRunData(createRunExecutionData({ executionData: null }), 'manual');
|
||||
|
||||
await expect(runner.run(data)).rejects.toThrow('short-circuit for test');
|
||||
|
||||
expect(establishSpy).not.toHaveBeenCalled();
|
||||
expect(callOrder).toEqual(['activeExecutions.add']);
|
||||
});
|
||||
|
||||
describe('when establishExecutionContext throws', () => {
|
||||
const lifecycleRunHook = jest.fn().mockResolvedValue(undefined);
|
||||
const responseReject = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
establishSpy.mockReset();
|
||||
establishSpy.mockImplementation(async () => {
|
||||
callOrder.push('establishExecutionContext');
|
||||
throw new Error('hook augmentation failed');
|
||||
});
|
||||
|
||||
addSpy.mockReset();
|
||||
addSpy.mockImplementation(async (data: IWorkflowExecutionDataProcess) => {
|
||||
capturedAddData = data;
|
||||
callOrder.push('activeExecutions.add');
|
||||
return 'exec-1';
|
||||
});
|
||||
|
||||
lifecycleRunHook.mockClear();
|
||||
responseReject.mockClear();
|
||||
jest
|
||||
.spyOn(ExecutionLifecycleHooks, 'getLifecycleHooksForRegularMain')
|
||||
.mockReturnValue(mock<core.ExecutionLifecycleHooks>({ runHook: lifecycleRunHook }));
|
||||
jest.spyOn(Container.get(ActiveExecutions), 'finalizeExecution').mockReturnValue();
|
||||
});
|
||||
|
||||
it('clears the trigger-item stack so raw headers do not get persisted', async () => {
|
||||
const data = buildRunData(buildExecutionDataWithHeader());
|
||||
|
||||
await runner.run(data, undefined, undefined, undefined, {
|
||||
reject: responseReject,
|
||||
resolve: jest.fn(),
|
||||
promise: Promise.resolve() as never,
|
||||
} as never);
|
||||
|
||||
expect(capturedAddData).toBeDefined();
|
||||
expect(capturedAddData!.executionData!.executionData!.nodeExecutionStack).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates a failed-execution record and rejects the responsePromise', async () => {
|
||||
const data = buildRunData(buildExecutionDataWithHeader());
|
||||
|
||||
const executionId = await runner.run(data, undefined, undefined, undefined, {
|
||||
reject: responseReject,
|
||||
resolve: jest.fn(),
|
||||
promise: Promise.resolve() as never,
|
||||
} as never);
|
||||
|
||||
expect(executionId).toBe('exec-1');
|
||||
expect(lifecycleRunHook).toHaveBeenCalledWith('workflowExecuteBefore', [
|
||||
undefined,
|
||||
data.executionData,
|
||||
]);
|
||||
expect(lifecycleRunHook).toHaveBeenCalledWith('workflowExecuteAfter', [expect.any(Object)]);
|
||||
expect(responseReject).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ message: 'hook augmentation failed' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('streaming functionality', () => {
|
||||
it('should setup heartbeat interval and sendChunk handler when streaming is enabled', async () => {
|
||||
// ARRANGE
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export class ActiveExecutions {
|
|||
workflowId: executionData.workflowData.id,
|
||||
retryOf: executionData.retryOf ?? undefined,
|
||||
tracingContext: executionData.tracingContext ?? null,
|
||||
deduplicationKey: executionData.deduplicationKey,
|
||||
};
|
||||
|
||||
const workflowId = executionData.workflowData.id;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import {
|
|||
import { strict } from 'node:assert';
|
||||
|
||||
import { ActivationErrorsService } from '@/activation-errors.service';
|
||||
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
|
@ -364,20 +365,39 @@ export class ActiveWorkflowManager {
|
|||
data: INodeExecutionData[][],
|
||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||
donePromise?: IDeferredPromise<IRun | undefined>,
|
||||
deduplicationKey?: string,
|
||||
) => {
|
||||
this.logger.debug(`Received trigger for workflow "${workflow.name}"`);
|
||||
void this.workflowStaticDataService.saveStaticData(workflow);
|
||||
|
||||
const executePromise = this.workflowExecutionService.runWorkflow(
|
||||
workflowData,
|
||||
node,
|
||||
data,
|
||||
additionalData,
|
||||
mode,
|
||||
responsePromise,
|
||||
);
|
||||
const executePromise = this.workflowExecutionService
|
||||
.runWorkflow(
|
||||
workflowData,
|
||||
node,
|
||||
data,
|
||||
additionalData,
|
||||
mode,
|
||||
responsePromise,
|
||||
deduplicationKey,
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof DuplicateExecutionError) {
|
||||
const context = {
|
||||
workflowId: workflowData.id,
|
||||
nodeId: node.id,
|
||||
deduplicationKey: error.deduplicationKey,
|
||||
};
|
||||
this.logger.warn('Scheduled execution skipped: duplicate deduplication key', context);
|
||||
this.errorReporter.warn(error, { extra: context, shouldBeLogged: false });
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
void executePromise.then((executionId) => {
|
||||
// `executionId` is undefined when the catch above swallowed a
|
||||
// duplicate scheduled execution; nothing ran, so nothing to emit.
|
||||
if (executionId === undefined) return;
|
||||
this.eventService.emit('workflow-executed', {
|
||||
workflowId: workflowData.id,
|
||||
workflowName: workflowData.name,
|
||||
|
|
@ -388,6 +408,12 @@ export class ActiveWorkflowManager {
|
|||
|
||||
if (donePromise) {
|
||||
void executePromise.then((executionId) => {
|
||||
// Same as above: a duplicate scheduled execution was skipped,
|
||||
// so resolve with undefined and don't wait on a non-existent run.
|
||||
if (executionId === undefined) {
|
||||
donePromise.resolve(undefined);
|
||||
return;
|
||||
}
|
||||
this.activeExecutions
|
||||
.getPostExecutePromise(executionId)
|
||||
.then(donePromise.resolve)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { DbConnection, DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { BinaryDataConfig } from 'n8n-core';
|
||||
|
||||
import { DeprecationService } from '@/deprecation/deprecation.service';
|
||||
import { MessageEventBus } from '@/eventbus/message-event-bus/message-event-bus';
|
||||
import { LogStreamingEventRelay } from '@/events/relays/log-streaming.event-relay';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { PubSubRegistry } from '@/scaling/pubsub/pubsub.registry';
|
||||
import { Subscriber } from '@/scaling/pubsub/subscriber.service';
|
||||
import { JwtService } from '@/services/jwt.service';
|
||||
import { RedisClientService } from '@/services/redis-client.service';
|
||||
import { WebhookServer } from '@/webhooks/webhook-server';
|
||||
|
||||
import { BaseCommand } from '../base-command';
|
||||
import { Webhook } from '../webhook';
|
||||
|
||||
jest.mock('@/scaling/scaling.service', () => ({
|
||||
|
|
@ -26,7 +32,14 @@ mockInstance(RedisClientService);
|
|||
mockInstance(PubSubRegistry);
|
||||
const mockSubscriber = mockInstance(Subscriber);
|
||||
const mockWebhookServer = mockInstance(WebhookServer);
|
||||
mockInstance(LoadNodesAndCredentials);
|
||||
const mockLoadNodesAndCredentials = mockInstance(LoadNodesAndCredentials);
|
||||
mockLoadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
|
||||
|
||||
mockInstance(DeprecationService);
|
||||
mockInstance(JwtService, { initialize: jest.fn().mockResolvedValue(undefined) });
|
||||
mockInstance(BinaryDataConfig, { initialize: jest.fn().mockResolvedValue(undefined) });
|
||||
mockInstance(MessageEventBus, { initialize: jest.fn().mockResolvedValue(undefined) });
|
||||
mockInstance(LogStreamingEventRelay);
|
||||
|
||||
describe('Webhook', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -74,4 +87,57 @@ describe('Webhook', () => {
|
|||
expect(mockWebhookServer.markAsReady).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
let baseInitSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
baseInitSpy = jest.spyOn(BaseCommand.prototype, 'init').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
baseInitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should call executionContextHookRegistry.init before LoadNodesAndCredentials.postProcessLoaders', async () => {
|
||||
const webhook = new Webhook();
|
||||
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
webhook.globalConfig = { executions: { mode: 'queue' } };
|
||||
// @ts-expect-error - Accessing protected method for testing
|
||||
webhook.initCrashJournal = jest.fn().mockResolvedValue(undefined);
|
||||
webhook.initLicense = jest.fn().mockResolvedValue(undefined);
|
||||
// @ts-expect-error - Accessing protected method for testing
|
||||
webhook.initCommunityPackages = jest.fn().mockResolvedValue(undefined);
|
||||
webhook.initOrchestration = jest.fn().mockResolvedValue(undefined);
|
||||
webhook.initBinaryDataService = jest.fn().mockResolvedValue(undefined);
|
||||
// @ts-expect-error - Accessing protected method for testing
|
||||
webhook.initDataDeduplicationService = jest.fn().mockResolvedValue(undefined);
|
||||
webhook.initExternalHooks = jest.fn().mockResolvedValue(undefined);
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
webhook.moduleRegistry = { initModules: jest.fn().mockResolvedValue(undefined) };
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
webhook.instanceSettings = {
|
||||
hostId: 'test',
|
||||
instanceType: 'webhook',
|
||||
initialize: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
webhook.executionContextHookRegistry = {
|
||||
init: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await webhook.init();
|
||||
|
||||
// @ts-expect-error - Accessing protected property for testing
|
||||
const hookInitMock = webhook.executionContextHookRegistry.init as jest.Mock;
|
||||
const postProcessMock = mockLoadNodesAndCredentials.postProcessLoaders as jest.Mock;
|
||||
|
||||
expect(hookInitMock).toHaveBeenCalled();
|
||||
expect(postProcessMock).toHaveBeenCalled();
|
||||
expect(hookInitMock.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
postProcessMock.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ export class Webhook extends BaseCommand {
|
|||
Container.get(LogStreamingEventRelay).init();
|
||||
|
||||
await this.moduleRegistry.initModules(this.instanceSettings.instanceType);
|
||||
|
||||
await this.executionContextHookRegistry.init();
|
||||
await Container.get(LoadNodesAndCredentials).postProcessLoaders();
|
||||
}
|
||||
|
||||
async run() {
|
||||
|
|
|
|||
|
|
@ -78,6 +78,45 @@ describe('ProjectController', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getSharingCandidates', () => {
|
||||
it('calls service with query options and returns enriched { count, data }', async () => {
|
||||
const projects = [
|
||||
{ id: 'p1', name: 'Project 1' },
|
||||
{ id: 'p2', name: 'Peer personal project' },
|
||||
];
|
||||
const enriched = projects.map((p) => ({
|
||||
...p,
|
||||
role: 'global:member',
|
||||
scopes: ['user:list'],
|
||||
}));
|
||||
(projectsService.getShareableProjectsAndCount as jest.Mock).mockResolvedValue([projects, 2]);
|
||||
(projectsService.addUserScopes as jest.Mock).mockResolvedValue(enriched);
|
||||
|
||||
const res = makeRes();
|
||||
const query = { skip: 0, take: 50, search: '' };
|
||||
|
||||
await controller.getSharingCandidates(req, res, query as any);
|
||||
|
||||
expect(projectsService.getShareableProjectsAndCount).toHaveBeenCalledWith(req.user, query);
|
||||
expect(projectsService.addUserScopes).toHaveBeenCalledWith(req.user, projects);
|
||||
expect(res.json).toHaveBeenCalledWith({ count: 2, data: enriched });
|
||||
});
|
||||
|
||||
it('always returns the { count, data } envelope (no bare-array path)', async () => {
|
||||
(projectsService.getShareableProjectsAndCount as jest.Mock).mockResolvedValue([[], 0]);
|
||||
(projectsService.addUserScopes as jest.Mock).mockResolvedValue([]);
|
||||
|
||||
const res = makeRes();
|
||||
const parsed = ListProjectsQueryDto.safeParse({});
|
||||
expect(parsed.success).toBe(true);
|
||||
const query = parsed.data!;
|
||||
|
||||
await controller.getSharingCandidates(req, res, query);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({ count: 0, data: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('emits team-project-updated with full members list on addProjectUsers', async () => {
|
||||
// Arrange
|
||||
const projectId = 'p1';
|
||||
|
|
|
|||
|
|
@ -75,6 +75,26 @@ export class ProjectController {
|
|||
return await this.projectsService.getProjectCounts();
|
||||
}
|
||||
|
||||
// Lists projects a caller can pick as share targets, including peer
|
||||
// personal projects so the workflow / credential share dropdowns can
|
||||
// surface other users. Gated on `user:list` (the same boundary that
|
||||
// `GET /rest/users` enforces) — restricted roles without that scope
|
||||
// (e.g. chat-only users) cannot enumerate peer personal projects here.
|
||||
@Get('/sharing-candidates')
|
||||
@GlobalScope('user:list')
|
||||
async getSharingCandidates(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
@Query payload: ListProjectsQueryDto,
|
||||
) {
|
||||
const [data, count] = await this.projectsService.getShareableProjectsAndCount(
|
||||
req.user,
|
||||
payload,
|
||||
);
|
||||
const enriched = await this.projectsService.addUserScopes(req.user, data);
|
||||
return res.json({ count, data: enriched });
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
@GlobalScope('project:create')
|
||||
// Using admin as all plans that contain projects should allow admins at the very least
|
||||
|
|
|
|||
12
packages/cli/src/errors/duplicate-execution.error.ts
Normal file
12
packages/cli/src/errors/duplicate-execution.error.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { OperationalError } from 'n8n-workflow';
|
||||
|
||||
export class DuplicateExecutionError extends OperationalError {
|
||||
constructor(
|
||||
readonly deduplicationKey: string,
|
||||
cause?: Error,
|
||||
) {
|
||||
super(`Execution with deduplication key "${deduplicationKey}" already exists`, {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable id-denylist */
|
||||
/* eslint-disable @typescript-eslint/unbound-method */
|
||||
|
||||
import type { ExecutionsConfig } from '@n8n/config';
|
||||
import type { DatabaseConfig, ExecutionsConfig } from '@n8n/config';
|
||||
import {
|
||||
ExecutionData,
|
||||
ExecutionEntity,
|
||||
|
|
@ -9,11 +9,13 @@ import {
|
|||
type EntityManager,
|
||||
type ExecutionRepository,
|
||||
} from '@n8n/db';
|
||||
import { QueryFailedError } from '@n8n/typeorm';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { BinaryDataService, StorageConfig } from 'n8n-core';
|
||||
import type { IWorkflowBase } from 'n8n-workflow';
|
||||
import { createEmptyRunExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
|
||||
import type { FsStore } from '@/executions/execution-data/fs-store';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
|
||||
|
|
@ -54,13 +56,17 @@ describe('ExecutionPersistence', () => {
|
|||
const createMockTx = (tx: EntityManager) =>
|
||||
jest.fn().mockImplementation(async <T>(cb: (em: EntityManager) => Promise<T>) => await cb(tx));
|
||||
|
||||
const createPersistenceService = (modeTag: 'db' | 'fs') =>
|
||||
const createPersistenceService = (
|
||||
modeTag: 'db' | 'fs',
|
||||
dbType: DatabaseConfig['type'] = 'postgresdb',
|
||||
) =>
|
||||
new ExecutionPersistence(
|
||||
executionRepository,
|
||||
binaryDataService,
|
||||
fsStore,
|
||||
mock<StorageConfig>({ modeTag }),
|
||||
executionsConfig,
|
||||
mock<DatabaseConfig>({ type: dbType }),
|
||||
);
|
||||
|
||||
describe('create', () => {
|
||||
|
|
@ -160,6 +166,127 @@ describe('ExecutionPersistence', () => {
|
|||
await expect(executionPersistence.create(createPayload)).rejects.toThrow(fsWriteError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deduplication key handling', () => {
|
||||
const executionPersistence = createPersistenceService('db');
|
||||
|
||||
const makeUniqueViolationError = (
|
||||
message = 'duplicate key value violates unique constraint on deduplicationKey',
|
||||
) => {
|
||||
const driverError = Object.assign(new Error(message), { code: '23505' });
|
||||
return new QueryFailedError('Query', [], driverError);
|
||||
};
|
||||
|
||||
it('converts unique-violation into DuplicateExecutionError when payload has a deduplicationKey', async () => {
|
||||
const uniqueViolation = makeUniqueViolationError();
|
||||
executionRepository.manager.transaction = jest.fn().mockRejectedValue(uniqueViolation);
|
||||
|
||||
const payloadWithKey: CreateExecutionPayload = {
|
||||
...createPayload,
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
};
|
||||
|
||||
await expect(executionPersistence.create(payloadWithKey)).rejects.toBeInstanceOf(
|
||||
DuplicateExecutionError,
|
||||
);
|
||||
await expect(executionPersistence.create(payloadWithKey)).rejects.toMatchObject({
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
cause: uniqueViolation,
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows original unique-violation when payload has no deduplicationKey', async () => {
|
||||
const uniqueViolation = makeUniqueViolationError();
|
||||
executionRepository.manager.transaction = jest.fn().mockRejectedValue(uniqueViolation);
|
||||
|
||||
await expect(executionPersistence.create(createPayload)).rejects.toBe(uniqueViolation);
|
||||
});
|
||||
|
||||
it('rethrows non-unique-violation QueryFailedError unchanged', async () => {
|
||||
const otherError = new QueryFailedError(
|
||||
'Query',
|
||||
[],
|
||||
Object.assign(new Error('not null'), { code: '23502' }),
|
||||
);
|
||||
executionRepository.manager.transaction = jest.fn().mockRejectedValue(otherError);
|
||||
|
||||
const payloadWithKey: CreateExecutionPayload = {
|
||||
...createPayload,
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
};
|
||||
|
||||
await expect(executionPersistence.create(payloadWithKey)).rejects.toBe(otherError);
|
||||
});
|
||||
|
||||
it('rethrows unique-violation on a different column unchanged', async () => {
|
||||
const otherUniqueViolation = makeUniqueViolationError(
|
||||
'duplicate key value violates unique constraint on someOtherColumn',
|
||||
);
|
||||
executionRepository.manager.transaction = jest.fn().mockRejectedValue(otherUniqueViolation);
|
||||
|
||||
const payloadWithKey: CreateExecutionPayload = {
|
||||
...createPayload,
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
};
|
||||
|
||||
await expect(executionPersistence.create(payloadWithKey)).rejects.toBe(
|
||||
otherUniqueViolation,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'extended code',
|
||||
code: 'SQLITE_CONSTRAINT_UNIQUE',
|
||||
message: 'UNIQUE constraint failed: execution_entity.deduplicationKey',
|
||||
},
|
||||
{
|
||||
name: 'base code',
|
||||
code: 'SQLITE_CONSTRAINT',
|
||||
message: 'SQLITE_CONSTRAINT: UNIQUE constraint failed: execution_entity.deduplicationKey',
|
||||
},
|
||||
])(
|
||||
'converts SQLite unique-violation ($name) into DuplicateExecutionError',
|
||||
async ({ code, message }) => {
|
||||
const sqlitePersistence = createPersistenceService('db', 'sqlite');
|
||||
const sqliteError = new QueryFailedError(
|
||||
'Query',
|
||||
[],
|
||||
Object.assign(new Error(message), { code }),
|
||||
);
|
||||
executionRepository.manager.transaction = jest.fn().mockRejectedValue(sqliteError);
|
||||
|
||||
const payloadWithKey: CreateExecutionPayload = {
|
||||
...createPayload,
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
};
|
||||
|
||||
await expect(sqlitePersistence.create(payloadWithKey)).rejects.toBeInstanceOf(
|
||||
DuplicateExecutionError,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('returns executionId on happy path when deduplicationKey is provided', async () => {
|
||||
const mockTx = createMockTransaction();
|
||||
executionRepository.manager.transaction = createMockTx(mockTx);
|
||||
|
||||
const payloadWithKey: CreateExecutionPayload = {
|
||||
...createPayload,
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
};
|
||||
|
||||
const executionId = await executionPersistence.create(payloadWithKey);
|
||||
|
||||
expect(executionId).toBe('exec-1');
|
||||
expect(mockTx.insert).toHaveBeenCalledWith(
|
||||
ExecutionEntity,
|
||||
expect.objectContaining({
|
||||
deduplicationKey: 'wf-1:node-1:1700000000000',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hardDelete', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ExecutionsConfig } from '@n8n/config';
|
||||
import { DatabaseConfig, ExecutionsConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
import type {
|
||||
CreateExecutionPayload,
|
||||
|
|
@ -12,6 +12,7 @@ import { BinaryDataService, StorageConfig } from 'n8n-core';
|
|||
|
||||
import { FsStore } from './execution-data/fs-store';
|
||||
import type { ExecutionRef, WorkflowSnapshot } from './execution-data/types';
|
||||
import { DuplicateExecutionError } from '../errors/duplicate-execution.error';
|
||||
|
||||
type DeletionTarget = ExecutionRef & { storedAt: ExecutionDataStorageLocation };
|
||||
|
||||
|
|
@ -27,6 +28,7 @@ export class ExecutionPersistence {
|
|||
private readonly fsStore: FsStore,
|
||||
private readonly storageConfig: StorageConfig,
|
||||
private readonly executionsConfig: ExecutionsConfig,
|
||||
private readonly databaseConfig: DatabaseConfig,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -43,26 +45,60 @@ export class ExecutionPersistence {
|
|||
const data = stringify(rawData);
|
||||
const workflowVersionId = workflowData.versionId ?? null;
|
||||
|
||||
return await this.executionRepository.manager.transaction(async (tx) => {
|
||||
const { identifiers } = await tx.insert(ExecutionEntity, executionEntity);
|
||||
const executionId = String(identifiers[0].id);
|
||||
try {
|
||||
return await this.executionRepository.manager.transaction(async (tx) => {
|
||||
const { identifiers } = await tx.insert(ExecutionEntity, executionEntity);
|
||||
const executionId = String(identifiers[0].id);
|
||||
|
||||
if (storedAt === 'db') {
|
||||
await tx.insert(ExecutionData, {
|
||||
executionId,
|
||||
workflowData: workflowSnapshot,
|
||||
data,
|
||||
workflowVersionId,
|
||||
});
|
||||
if (storedAt === 'db') {
|
||||
await tx.insert(ExecutionData, {
|
||||
executionId,
|
||||
workflowData: workflowSnapshot,
|
||||
data,
|
||||
workflowVersionId,
|
||||
});
|
||||
return executionId;
|
||||
}
|
||||
|
||||
await this.fsStore.write(
|
||||
{ workflowId: id, executionId },
|
||||
{ data, workflowData: workflowSnapshot, workflowVersionId },
|
||||
);
|
||||
return executionId;
|
||||
});
|
||||
} catch (error) {
|
||||
if (executionEntity.deduplicationKey && this.isDuplicateExecutionError(error)) {
|
||||
throw new DuplicateExecutionError(executionEntity.deduplicationKey, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await this.fsStore.write(
|
||||
{ workflowId: id, executionId },
|
||||
{ data, workflowData: workflowSnapshot, workflowVersionId },
|
||||
);
|
||||
return executionId;
|
||||
});
|
||||
/**
|
||||
* Detect whether the DB rejected the insert because of the unique index on
|
||||
* `execution_entity.deduplicationKey`. We expect TypeORM to surface the
|
||||
* driver's error code at `error.driverError.code` as a string, with the
|
||||
* code's exact value depending on the configured DB.
|
||||
*/
|
||||
private isDuplicateExecutionError(error: unknown): error is Error {
|
||||
if (!(error instanceof Error) || !('driverError' in error)) return false;
|
||||
const { driverError } = error;
|
||||
if (typeof driverError !== 'object' || driverError === null || !('code' in driverError)) {
|
||||
return false;
|
||||
}
|
||||
const { code } = driverError;
|
||||
if (typeof code !== 'string') return false;
|
||||
if (!error.message.includes('deduplicationKey')) return false;
|
||||
|
||||
if (this.databaseConfig.type === 'postgresdb') {
|
||||
return code === '23505';
|
||||
}
|
||||
// SQLite reports `SQLITE_CONSTRAINT_UNIQUE` when extended result codes are
|
||||
// enabled, and falls back to the base `SQLITE_CONSTRAINT` otherwise.
|
||||
return (
|
||||
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
|
||||
(code === 'SQLITE_CONSTRAINT' && error.message.includes('UNIQUE constraint failed'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe('FavoritesController', () => {
|
|||
|
||||
const result = await controller.getFavorites(req);
|
||||
|
||||
expect(favoritesService.getEnrichedFavorites).toHaveBeenCalledWith('user1');
|
||||
expect(favoritesService.getEnrichedFavorites).toHaveBeenCalledWith(req.user);
|
||||
expect(result).toBe(favorites);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type {
|
||||
FolderRepository,
|
||||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowRepository,
|
||||
import {
|
||||
GLOBAL_ADMIN_ROLE,
|
||||
GLOBAL_MEMBER_ROLE,
|
||||
type FolderRepository,
|
||||
type ProjectRepository,
|
||||
type SharedWorkflowRepository,
|
||||
type User,
|
||||
type WorkflowRepository,
|
||||
} from '@n8n/db';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
|
|
@ -23,6 +26,20 @@ const makeUserFavorite = (overrides: Partial<UserFavorite> = {}): UserFavorite =
|
|||
...overrides,
|
||||
}) as UserFavorite;
|
||||
|
||||
const makeUser = (overrides: Partial<User> = {}): User =>
|
||||
({
|
||||
id: 'user1',
|
||||
role: GLOBAL_MEMBER_ROLE,
|
||||
...overrides,
|
||||
}) as unknown as User;
|
||||
|
||||
const makeAdmin = (overrides: Partial<User> = {}): User =>
|
||||
makeUser({
|
||||
id: 'admin1',
|
||||
role: GLOBAL_ADMIN_ROLE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('FavoritesService', () => {
|
||||
const repo = mock<UserFavoriteRepository>();
|
||||
const workflowRepository = mock<WorkflowRepository>();
|
||||
|
|
@ -45,7 +62,7 @@ describe('FavoritesService', () => {
|
|||
it('should return empty array when user has no favorites', async () => {
|
||||
repo.findByUser.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(repo.findByUser).toHaveBeenCalledWith('user1');
|
||||
expect(result).toEqual([]);
|
||||
|
|
@ -60,7 +77,7 @@ describe('FavoritesService', () => {
|
|||
workflowRepository.findByIds.mockResolvedValue([{ id: 'wf1', name: 'My Workflow' } as never]);
|
||||
sharedWorkflowRepository.find.mockResolvedValue([{ workflowId: 'wf1' } as never]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ resourceId: 'wf1', resourceName: 'My Workflow' });
|
||||
|
|
@ -76,7 +93,7 @@ describe('FavoritesService', () => {
|
|||
// Not in accessible workflows via SharedWorkflow
|
||||
sharedWorkflowRepository.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -86,7 +103,7 @@ describe('FavoritesService', () => {
|
|||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(workflowRepository.findByIds).not.toHaveBeenCalled();
|
||||
expect(result).toHaveLength(0);
|
||||
|
|
@ -99,7 +116,7 @@ describe('FavoritesService', () => {
|
|||
{ id: 'proj1', name: 'My Project' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ resourceId: 'proj1', resourceName: 'My Project' });
|
||||
|
|
@ -112,7 +129,7 @@ describe('FavoritesService', () => {
|
|||
{ id: 'proj1', name: 'My Project' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -127,7 +144,7 @@ describe('FavoritesService', () => {
|
|||
{ id: 'dt1', name: 'My Table', projectId: 'proj1' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
|
|
@ -147,7 +164,7 @@ describe('FavoritesService', () => {
|
|||
{ id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -166,7 +183,7 @@ describe('FavoritesService', () => {
|
|||
} as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
|
|
@ -190,7 +207,7 @@ describe('FavoritesService', () => {
|
|||
} as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -209,7 +226,7 @@ describe('FavoritesService', () => {
|
|||
} as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -233,10 +250,98 @@ describe('FavoritesService', () => {
|
|||
{ id: 'folder1', name: 'My Folder', homeProject: { id: 'proj1' } } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites('user1');
|
||||
const result = await service.getEnrichedFavorites(makeUser());
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
describe('global admin access', () => {
|
||||
it('should enrich workflow favorites without explicit project membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'wf1', resourceType: 'workflow' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([]);
|
||||
workflowRepository.findByIds.mockResolvedValue([
|
||||
{ id: 'wf1', name: 'My Workflow' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeAdmin());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ resourceId: 'wf1', resourceName: 'My Workflow' });
|
||||
expect(sharedWorkflowRepository.find).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should enrich project favorites without explicit project membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'proj-other', resourceType: 'project' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([]);
|
||||
projectRepository.find.mockResolvedValue([
|
||||
{ id: 'proj-other', name: 'Other Project' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeAdmin());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceId: 'proj-other',
|
||||
resourceName: 'Other Project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should enrich dataTable favorites without explicit project membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'dt1', resourceType: 'dataTable' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([]);
|
||||
dataTableRepository.find.mockResolvedValue([
|
||||
{ id: 'dt1', name: 'My Table', projectId: 'proj-other' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeAdmin());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceId: 'dt1',
|
||||
resourceName: 'My Table',
|
||||
resourceProjectId: 'proj-other',
|
||||
});
|
||||
});
|
||||
|
||||
it('should enrich folder favorites without explicit project membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'folder1', resourceType: 'folder' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([]);
|
||||
folderRepository.find.mockResolvedValue([
|
||||
{
|
||||
id: 'folder1',
|
||||
name: 'My Folder',
|
||||
homeProject: { id: 'proj-other' },
|
||||
} as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeAdmin());
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
resourceId: 'folder1',
|
||||
resourceName: 'My Folder',
|
||||
resourceProjectId: 'proj-other',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not query for additional projects when admin already has membership', async () => {
|
||||
const favorite = makeUserFavorite({ resourceId: 'proj1', resourceType: 'project' });
|
||||
repo.findByUser.mockResolvedValue([favorite]);
|
||||
projectRepository.getAccessibleProjects.mockResolvedValue([
|
||||
{ id: 'proj1', name: 'My Project' } as never,
|
||||
]);
|
||||
|
||||
const result = await service.getEnrichedFavorites(makeAdmin());
|
||||
|
||||
expect(projectRepository.find).not.toHaveBeenCalled();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ resourceId: 'proj1', resourceName: 'My Project' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFavorite', () => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export class FavoritesController {
|
|||
|
||||
@Get('/')
|
||||
async getFavorites(req: AuthenticatedRequest) {
|
||||
return await this.favoritesService.getEnrichedFavorites(req.user.id);
|
||||
return await this.favoritesService.getEnrichedFavorites(req.user);
|
||||
}
|
||||
|
||||
@Post('/')
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import {
|
|||
ProjectRepository,
|
||||
SharedWorkflowRepository,
|
||||
WorkflowRepository,
|
||||
type Project,
|
||||
type User,
|
||||
} from '@n8n/db';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import { In } from '@n8n/typeorm';
|
||||
|
||||
import { UserFavoriteRepository } from './database/repositories/user-favorite.repository';
|
||||
|
|
@ -15,6 +18,27 @@ import { DataTableRepository } from '@/modules/data-table/data-table.repository'
|
|||
|
||||
import type { FavoriteResourceType } from '@n8n/api-types';
|
||||
|
||||
type ResourceMeta = { name: string; projectId: string };
|
||||
|
||||
type Favorite = { resourceId: string; resourceType: FavoriteResourceType };
|
||||
|
||||
const idsOfType = (favorites: Favorite[], type: FavoriteResourceType): string[] =>
|
||||
favorites.filter((f) => f.resourceType === type).map((f) => f.resourceId);
|
||||
|
||||
const enrichWithName = <F>(
|
||||
fav: F,
|
||||
name: string | undefined,
|
||||
): Array<F & { resourceName: string }> =>
|
||||
name === undefined ? [] : [{ ...fav, resourceName: name }];
|
||||
|
||||
const enrichWithMeta = <F>(
|
||||
fav: F,
|
||||
meta: ResourceMeta | undefined,
|
||||
): Array<F & { resourceName: string; resourceProjectId: string }> =>
|
||||
meta === undefined
|
||||
? []
|
||||
: [{ ...fav, resourceName: meta.name, resourceProjectId: meta.projectId }];
|
||||
|
||||
@Service()
|
||||
export class FavoritesService {
|
||||
private readonly MAX_FAVORITES = 200;
|
||||
|
|
@ -27,103 +51,137 @@ export class FavoritesService {
|
|||
private readonly folderRepository: FolderRepository,
|
||||
) {}
|
||||
|
||||
async getEnrichedFavorites(userId: string) {
|
||||
const favorites = await this.userFavoriteRepository.findByUser(userId);
|
||||
async getEnrichedFavorites(user: User) {
|
||||
const favorites = await this.userFavoriteRepository.findByUser(user.id);
|
||||
if (favorites.length === 0) return [];
|
||||
|
||||
const workflowIds = favorites
|
||||
.filter((f) => f.resourceType === 'workflow')
|
||||
.map((f) => f.resourceId);
|
||||
const dataTableIds = favorites
|
||||
.filter((f) => f.resourceType === 'dataTable')
|
||||
.map((f) => f.resourceId);
|
||||
const folderIds = favorites.filter((f) => f.resourceType === 'folder').map((f) => f.resourceId);
|
||||
const ids = {
|
||||
workflow: idsOfType(favorites, 'workflow'),
|
||||
project: idsOfType(favorites, 'project'),
|
||||
dataTable: idsOfType(favorites, 'dataTable'),
|
||||
folder: idsOfType(favorites, 'folder'),
|
||||
};
|
||||
|
||||
// Get accessible projects for user
|
||||
const accessibleProjects = await this.projectRepository.getAccessibleProjects(userId);
|
||||
const accessibleProjects = await this.projectRepository.getAccessibleProjects(user.id);
|
||||
const accessibleProjectIds = new Set(accessibleProjects.map((p) => p.id));
|
||||
const projectNameMap = new Map(accessibleProjects.map((p) => [p.id, p.name ?? '']));
|
||||
|
||||
// Workflow, data table, and folder enrichment run in parallel
|
||||
const [workflows, sharedWorkflows, dataTables, folders] = await Promise.all([
|
||||
workflowIds.length > 0 && accessibleProjectIds.size > 0
|
||||
? this.workflowRepository.findByIds(workflowIds, { fields: ['id', 'name'] })
|
||||
: [],
|
||||
workflowIds.length > 0 && accessibleProjectIds.size > 0
|
||||
? this.sharedWorkflowRepository.find({
|
||||
select: { workflowId: true },
|
||||
where: { workflowId: In(workflowIds), projectId: In([...accessibleProjectIds]) },
|
||||
})
|
||||
: [],
|
||||
dataTableIds.length > 0
|
||||
? this.dataTableRepository.find({ where: { id: In(dataTableIds) } })
|
||||
: [],
|
||||
folderIds.length > 0
|
||||
? this.folderRepository.find({
|
||||
where: { id: In(folderIds) },
|
||||
relations: { homeProject: true },
|
||||
})
|
||||
: [],
|
||||
const [workflowNames, projectNames, dataTableMeta, folderMeta] = await Promise.all([
|
||||
this.enrichWorkflowFavorites(user, ids.workflow, accessibleProjectIds),
|
||||
this.enrichProjectFavorites(user, ids.project, accessibleProjects),
|
||||
this.enrichDataTableFavorites(user, ids.dataTable, accessibleProjectIds),
|
||||
this.enrichFolderFavorites(user, ids.folder, accessibleProjectIds),
|
||||
]);
|
||||
|
||||
// Workflow enrichment with access control
|
||||
const workflowNameMap = new Map<string, string>();
|
||||
if (workflowIds.length > 0 && accessibleProjectIds.size > 0) {
|
||||
const wfMap = new Map(workflows.map((wf) => [wf.id, wf.name]));
|
||||
const accessibleWfIds = new Set(sharedWorkflows.map((sw) => sw.workflowId));
|
||||
for (const id of workflowIds) {
|
||||
const name = wfMap.get(id);
|
||||
if (accessibleWfIds.has(id) && name !== undefined) {
|
||||
workflowNameMap.set(id, name);
|
||||
}
|
||||
return favorites.flatMap((fav) => {
|
||||
switch (fav.resourceType) {
|
||||
case 'workflow':
|
||||
return enrichWithName(fav, workflowNames.get(fav.resourceId));
|
||||
case 'project':
|
||||
return enrichWithName(fav, projectNames.get(fav.resourceId));
|
||||
case 'dataTable':
|
||||
return enrichWithMeta(fav, dataTableMeta.get(fav.resourceId));
|
||||
case 'folder':
|
||||
return enrichWithMeta(fav, folderMeta.get(fav.resourceId));
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async enrichWorkflowFavorites(
|
||||
user: User,
|
||||
workflowIds: string[],
|
||||
accessibleProjectIds: Set<string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
if (workflowIds.length === 0) return result;
|
||||
|
||||
const hasGlobalAccess = hasGlobalScope(user, 'workflow:read');
|
||||
if (!hasGlobalAccess && accessibleProjectIds.size === 0) return result;
|
||||
|
||||
const [workflows, sharedWorkflows] = await Promise.all([
|
||||
this.workflowRepository.findByIds(workflowIds, { fields: ['id', 'name'] }),
|
||||
hasGlobalAccess
|
||||
? Promise.resolve([])
|
||||
: this.sharedWorkflowRepository.find({
|
||||
select: { workflowId: true },
|
||||
where: { workflowId: In(workflowIds), projectId: In([...accessibleProjectIds]) },
|
||||
}),
|
||||
]);
|
||||
|
||||
const accessibleIds = hasGlobalAccess
|
||||
? new Set(workflows.map((wf) => wf.id))
|
||||
: new Set(sharedWorkflows.map((sw) => sw.workflowId));
|
||||
|
||||
for (const wf of workflows) {
|
||||
if (accessibleIds.has(wf.id)) result.set(wf.id, wf.name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async enrichProjectFavorites(
|
||||
user: User,
|
||||
projectFavoriteIds: string[],
|
||||
accessibleProjects: Project[],
|
||||
): Promise<Map<string, string>> {
|
||||
const result = new Map(accessibleProjects.map((p) => [p.id, p.name ?? '']));
|
||||
if (projectFavoriteIds.length === 0) return result;
|
||||
|
||||
if (!hasGlobalScope(user, 'project:read')) return result;
|
||||
|
||||
const missingIds = projectFavoriteIds.filter((id) => !result.has(id));
|
||||
if (missingIds.length === 0) return result;
|
||||
|
||||
const additional = await this.projectRepository.find({
|
||||
where: { id: In(missingIds) },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
for (const p of additional) {
|
||||
result.set(p.id, p.name ?? '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async enrichDataTableFavorites(
|
||||
user: User,
|
||||
dataTableIds: string[],
|
||||
accessibleProjectIds: Set<string>,
|
||||
): Promise<Map<string, ResourceMeta>> {
|
||||
const result = new Map<string, ResourceMeta>();
|
||||
if (dataTableIds.length === 0) return result;
|
||||
|
||||
const hasGlobalAccess = hasGlobalScope(user, 'dataTable:read');
|
||||
const dataTables = await this.dataTableRepository.find({ where: { id: In(dataTableIds) } });
|
||||
|
||||
// Data table enrichment with access control
|
||||
const dataTableMetaMap = new Map<string, { name: string; projectId: string }>();
|
||||
for (const dt of dataTables) {
|
||||
if (accessibleProjectIds.has(dt.projectId)) {
|
||||
dataTableMetaMap.set(dt.id, { name: dt.name, projectId: dt.projectId });
|
||||
if (hasGlobalAccess || accessibleProjectIds.has(dt.projectId)) {
|
||||
result.set(dt.id, { name: dt.name, projectId: dt.projectId });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async enrichFolderFavorites(
|
||||
user: User,
|
||||
folderIds: string[],
|
||||
accessibleProjectIds: Set<string>,
|
||||
): Promise<Map<string, ResourceMeta>> {
|
||||
const result = new Map<string, ResourceMeta>();
|
||||
if (folderIds.length === 0) return result;
|
||||
|
||||
const hasGlobalAccess = hasGlobalScope(user, 'folder:read');
|
||||
const folders = await this.folderRepository.find({
|
||||
where: { id: In(folderIds) },
|
||||
relations: { homeProject: true },
|
||||
});
|
||||
|
||||
// Folder enrichment with access control
|
||||
const folderMetaMap = new Map<string, { name: string; projectId: string }>();
|
||||
for (const folder of folders) {
|
||||
const projectId = folder.homeProject?.id;
|
||||
if (projectId && accessibleProjectIds.has(projectId)) {
|
||||
folderMetaMap.set(folder.id, { name: folder.name, projectId });
|
||||
if (projectId && (hasGlobalAccess || accessibleProjectIds.has(projectId))) {
|
||||
result.set(folder.id, { name: folder.name, projectId });
|
||||
}
|
||||
}
|
||||
|
||||
// Build enriched result, filtering out inaccessible resources
|
||||
const enriched: Array<
|
||||
(typeof favorites)[0] & { resourceName: string; resourceProjectId?: string }
|
||||
> = [];
|
||||
|
||||
for (const fav of favorites) {
|
||||
if (fav.resourceType === 'workflow') {
|
||||
const name = workflowNameMap.get(fav.resourceId);
|
||||
if (name !== undefined) enriched.push({ ...fav, resourceName: name });
|
||||
} else if (fav.resourceType === 'project') {
|
||||
const name = projectNameMap.get(fav.resourceId);
|
||||
if (name !== undefined) {
|
||||
enriched.push({ ...fav, resourceName: name });
|
||||
}
|
||||
} else if (fav.resourceType === 'dataTable') {
|
||||
const meta = dataTableMetaMap.get(fav.resourceId);
|
||||
if (meta !== undefined) {
|
||||
enriched.push({ ...fav, resourceName: meta.name, resourceProjectId: meta.projectId });
|
||||
}
|
||||
} else if (fav.resourceType === 'folder') {
|
||||
const meta = folderMetaMap.get(fav.resourceId);
|
||||
if (meta !== undefined) {
|
||||
enriched.push({ ...fav, resourceName: meta.name, resourceProjectId: meta.projectId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return enriched;
|
||||
return result;
|
||||
}
|
||||
|
||||
async addFavorite(userId: string, resourceId: string, resourceType: FavoriteResourceType) {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,48 @@ describe('QuickConnectConfig', () => {
|
|||
expect(options[0].consentText).toBe('Allow access to your account?');
|
||||
});
|
||||
|
||||
it('parses valid config with disclaimer', () => {
|
||||
const testConfig = [
|
||||
{
|
||||
packageName: '@n8n/superagent',
|
||||
credentialType: 'agentApi',
|
||||
text: 'Superagent for everyone',
|
||||
quickConnectType: 'oauth',
|
||||
disclaimer: {
|
||||
text: 'Offer subject to terms (available {link}).',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
linkLabel: 'here',
|
||||
},
|
||||
},
|
||||
];
|
||||
process.env.N8N_QUICK_CONNECT_OPTIONS = JSON.stringify(testConfig);
|
||||
|
||||
const { options } = Container.get(QuickConnectConfig);
|
||||
|
||||
expect(options).toEqual(testConfig);
|
||||
expect(options[0].disclaimer?.linkUrl).toBe('https://example.com/terms');
|
||||
});
|
||||
|
||||
it('parses disclaimer without optional linkLabel', () => {
|
||||
const testConfig = [
|
||||
{
|
||||
packageName: '@n8n/superagent',
|
||||
credentialType: 'agentApi',
|
||||
text: 'Superagent for everyone',
|
||||
quickConnectType: 'oauth',
|
||||
disclaimer: {
|
||||
text: 'Offer subject to terms (available {link}).',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
},
|
||||
},
|
||||
];
|
||||
process.env.N8N_QUICK_CONNECT_OPTIONS = JSON.stringify(testConfig);
|
||||
|
||||
const { options } = Container.get(QuickConnectConfig);
|
||||
|
||||
expect(options).toEqual(testConfig);
|
||||
});
|
||||
|
||||
it('handles empty JSON array', () => {
|
||||
process.env.N8N_QUICK_CONNECT_OPTIONS = '[]';
|
||||
|
||||
|
|
@ -159,6 +201,50 @@ describe('QuickConnectConfig', () => {
|
|||
},
|
||||
]),
|
||||
],
|
||||
[
|
||||
'disclaimer text missing {link} placeholder',
|
||||
JSON.stringify([
|
||||
{
|
||||
packageName: '@n8n/superagent',
|
||||
credentialType: 'agentApi',
|
||||
text: 'Superagent for everyone',
|
||||
quickConnectType: 'oauth',
|
||||
disclaimer: {
|
||||
text: 'No placeholder here.',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[
|
||||
'disclaimer linkUrl is not a valid URL',
|
||||
JSON.stringify([
|
||||
{
|
||||
packageName: '@n8n/superagent',
|
||||
credentialType: 'agentApi',
|
||||
text: 'Superagent for everyone',
|
||||
quickConnectType: 'oauth',
|
||||
disclaimer: {
|
||||
text: 'Subject to terms (available {link}).',
|
||||
linkUrl: 'not-a-url',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
[
|
||||
'disclaimer missing linkUrl',
|
||||
JSON.stringify([
|
||||
{
|
||||
packageName: '@n8n/superagent',
|
||||
credentialType: 'agentApi',
|
||||
text: 'Superagent for everyone',
|
||||
quickConnectType: 'oauth',
|
||||
disclaimer: {
|
||||
text: 'Subject to terms (available {link}).',
|
||||
},
|
||||
},
|
||||
]),
|
||||
],
|
||||
])('uses default if configuration is invalid: %s', (_description, config) => {
|
||||
process.env.N8N_QUICK_CONNECT_OPTIONS = config;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { Config, Env } from '@n8n/config';
|
||||
import { z } from 'zod';
|
||||
|
||||
const disclaimerSchema = z.object({
|
||||
text: z.string().refine((s) => s.includes('{link}'), {
|
||||
message: '`disclaimer.text` must contain the {link} placeholder',
|
||||
}),
|
||||
linkUrl: z.string().url(),
|
||||
linkLabel: z.string().optional(),
|
||||
});
|
||||
|
||||
const baseQuickConnectOptionSchema = z.object({
|
||||
packageName: z.string(),
|
||||
credentialType: z.string(),
|
||||
|
|
@ -8,6 +16,7 @@ const baseQuickConnectOptionSchema = z.object({
|
|||
quickConnectType: z.string(),
|
||||
consentText: z.string().optional(),
|
||||
consentCheckbox: z.string().optional(),
|
||||
disclaimer: disclaimerSchema.optional(),
|
||||
config: z.never().optional(),
|
||||
backendFlowConfig: z.never().optional(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -329,6 +329,7 @@ describe('SourceControlGitService', () => {
|
|||
maxConcurrentProcesses: 6,
|
||||
trimmed: false,
|
||||
config: [`credential.helper=${expectedCredentialScript}`, 'credential.useHttpPath=true'],
|
||||
unsafe: { allowUnsafeCredentialHelper: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
|
@ -366,9 +367,15 @@ describe('SourceControlGitService', () => {
|
|||
mockSourceControlPreferencesService.getPreferences.mockReturnValue({
|
||||
connectionType: 'ssh',
|
||||
} as never);
|
||||
(simpleGit as jest.Mock).mockClear();
|
||||
|
||||
await sourceControlGitService.setGitCommand();
|
||||
|
||||
expect(simpleGit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
unsafe: { allowUnsafeSshCommand: true },
|
||||
}),
|
||||
);
|
||||
expect(mockGitInstance.env).toHaveBeenCalledWith(
|
||||
'GIT_SSH_COMMAND',
|
||||
'ssh -o UserKnownHostsFile=".ssh/known_hosts" -o StrictHostKeyChecking=accept-new -i "private-key"',
|
||||
|
|
|
|||
|
|
@ -140,6 +140,7 @@ export class SourceControlGitService {
|
|||
// ensures that the credentials are only used for the configured repositoryUrl of the environment
|
||||
'credential.useHttpPath=true',
|
||||
],
|
||||
unsafe: { allowUnsafeCredentialHelper: true },
|
||||
};
|
||||
|
||||
// Add proxy configuration if proxy environment variables are set
|
||||
|
|
@ -171,7 +172,12 @@ export class SourceControlGitService {
|
|||
// - Subsequent connections: verifies against saved key
|
||||
const sshCommand = `ssh -o UserKnownHostsFile="${escapedKnownHostsPath}" -o StrictHostKeyChecking=accept-new -i "${escapedPrivateKeyPath}"`;
|
||||
|
||||
this.git = simpleGit(this.gitOptions)
|
||||
// Allow GIT_SSH_COMMAND so we can point SSH at n8n's own private key and known_hosts.
|
||||
// This is safe because the command is constructed internally above, not from user input.
|
||||
this.git = simpleGit({
|
||||
...this.gitOptions,
|
||||
unsafe: { allowUnsafeSshCommand: true },
|
||||
})
|
||||
.env('GIT_SSH_COMMAND', sshCommand)
|
||||
.env('GIT_TERMINAL_PROMPT', '0');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,110 +1,382 @@
|
|||
import type { LeaderElectionClient } from '@/scaling/leader-election-client';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { RedisClientService } from '@/services/redis-client.service';
|
||||
import { mockLogger } from '@n8n/backend-test-utils';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import { MultiMainMetadata } from '@n8n/decorators';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { RedisClientService } from '@/services/redis-client.service';
|
||||
import { createResultOk, createResultError } from 'n8n-workflow';
|
||||
|
||||
import { MultiMainSetup } from '../multi-main-setup.ee';
|
||||
|
||||
function createInstanceSettings(hostId: string) {
|
||||
let isLeader = false;
|
||||
const settings = mock<InstanceSettings>({ hostId });
|
||||
Object.defineProperty(settings, 'isLeader', {
|
||||
get: () => isLeader,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(settings, 'markAsLeader', {
|
||||
value: jest.fn(() => {
|
||||
isLeader = true;
|
||||
}),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(settings, 'markAsFollower', {
|
||||
value: jest.fn(() => {
|
||||
isLeader = false;
|
||||
}),
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return settings;
|
||||
}
|
||||
|
||||
describe('MultiMainSetup', () => {
|
||||
const hostId = 'main-n8n-main-0';
|
||||
|
||||
const logger = mockLogger();
|
||||
const publisher = mock<Publisher>();
|
||||
const redisClientService = mock<RedisClientService>();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
const metadata = new MultiMainMetadata();
|
||||
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
redis: { prefix: 'n8n' },
|
||||
multiMainSetup: { ttl: 10, interval: 3, enabled: true },
|
||||
describe('with legacy implementation (flag off)', () => {
|
||||
const logger = mockLogger();
|
||||
const publisher = mock<Publisher>();
|
||||
const redisClientService = mock<RedisClientService>();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
redis: { prefix: 'n8n' },
|
||||
multiMainSetup: { ttl: 10, interval: 3, enabled: true, newLeaderElection: false },
|
||||
});
|
||||
|
||||
let instanceSettings: InstanceSettings;
|
||||
let multiMainSetup: MultiMainSetup;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
instanceSettings = createInstanceSettings(hostId);
|
||||
redisClientService.toValidPrefix.mockReturnValue('n8n');
|
||||
multiMainSetup = new MultiMainSetup(
|
||||
logger,
|
||||
instanceSettings,
|
||||
globalConfig,
|
||||
metadata,
|
||||
errorReporter,
|
||||
publisher,
|
||||
redisClientService,
|
||||
);
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should become leader if setIfNotExists succeeds', async () => {
|
||||
publisher.setIfNotExists.mockResolvedValue(true);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup.init();
|
||||
|
||||
expect(publisher.setIfNotExists).toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
it('should remain follower if setIfNotExists returns false', async () => {
|
||||
publisher.setIfNotExists.mockResolvedValue(false);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup.init();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkLeader', () => {
|
||||
it('should renew TTL when this instance is the leader', async () => {
|
||||
publisher.setIfNotExists.mockResolvedValue(true);
|
||||
await multiMainSetup.init();
|
||||
jest.clearAllMocks();
|
||||
|
||||
publisher.get.mockResolvedValue(hostId);
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(publisher.setExpiration).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should emit leader-takeover on mismatch recovery', async () => {
|
||||
publisher.setIfNotExists.mockResolvedValue(false);
|
||||
await multiMainSetup.init();
|
||||
jest.clearAllMocks();
|
||||
|
||||
publisher.get.mockResolvedValue(hostId);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
it('should step down when another instance is leader', async () => {
|
||||
publisher.setIfNotExists.mockResolvedValue(true);
|
||||
await multiMainSetup.init();
|
||||
jest.clearAllMocks();
|
||||
|
||||
publisher.get.mockResolvedValue('main-n8n-main-1');
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should attempt to become leader when leadership is vacant', async () => {
|
||||
publisher.setIfNotExists.mockResolvedValue(true);
|
||||
await multiMainSetup.init();
|
||||
jest.clearAllMocks();
|
||||
|
||||
publisher.get.mockResolvedValue(null);
|
||||
publisher.setIfNotExists.mockResolvedValue(true);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
expect(publisher.setIfNotExists).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
redisClientService.toValidPrefix.mockReturnValue('n8n');
|
||||
describe('with new implementation (flag on)', () => {
|
||||
const logger = mockLogger();
|
||||
const publisher = mock<Publisher>();
|
||||
const redisClientService = mock<RedisClientService>();
|
||||
const errorReporter = mock<ErrorReporter>();
|
||||
const client = mock<LeaderElectionClient>();
|
||||
|
||||
let instanceSettings: InstanceSettings;
|
||||
let multiMainSetup: MultiMainSetup;
|
||||
|
||||
beforeEach(() => {
|
||||
instanceSettings = mock<InstanceSettings>({ hostId, isLeader: false });
|
||||
multiMainSetup = new MultiMainSetup(
|
||||
logger,
|
||||
instanceSettings,
|
||||
publisher,
|
||||
redisClientService,
|
||||
globalConfig,
|
||||
metadata,
|
||||
errorReporter,
|
||||
);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkLeader', () => {
|
||||
beforeEach(async () => {
|
||||
await multiMainSetup.init();
|
||||
const globalConfig = mock<GlobalConfig>({
|
||||
redis: { prefix: 'n8n' },
|
||||
multiMainSetup: { ttl: 10, interval: 3, enabled: true, newLeaderElection: true },
|
||||
});
|
||||
|
||||
it('should emit `leader-takeover` when Redis has own `hostId` but instance thinks it is follower', async () => {
|
||||
publisher.get.mockResolvedValue(hostId);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
let instanceSettings: InstanceSettings;
|
||||
let multiMainSetup: MultiMainSetup;
|
||||
|
||||
// @ts-expect-error - private method
|
||||
await multiMainSetup.checkLeader();
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
instanceSettings = createInstanceSettings(hostId);
|
||||
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
jest.mock('@/scaling/leader-election-client', () => ({
|
||||
LeaderElectionClient: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Container } = jest.requireActual('@n8n/di');
|
||||
Container.set(
|
||||
jest.requireMock('@/scaling/leader-election-client').LeaderElectionClient,
|
||||
client,
|
||||
);
|
||||
|
||||
multiMainSetup = new MultiMainSetup(
|
||||
logger,
|
||||
instanceSettings,
|
||||
globalConfig,
|
||||
metadata,
|
||||
errorReporter,
|
||||
publisher,
|
||||
redisClientService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not emit `leader-takeover` when already leader', async () => {
|
||||
publisher.get.mockResolvedValue(hostId);
|
||||
Object.defineProperty(instanceSettings, 'isLeader', { get: () => true });
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
describe('init', () => {
|
||||
it('should become leader if setLeaderIfNotExists succeeds', async () => {
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
// @ts-expect-error - private method
|
||||
await multiMainSetup.checkLeader();
|
||||
await multiMainSetup.init();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
expect(publisher.setExpiration).toHaveBeenCalled();
|
||||
expect(client.setLeaderIfNotExists).toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
it('should remain follower if setLeaderIfNotExists returns false', async () => {
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup.init();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
it('should remain follower if setLeaderIfNotExists fails', async () => {
|
||||
client.setLeaderIfNotExists.mockResolvedValue(
|
||||
createResultError(new Error('Command timed out')),
|
||||
);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup.init();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit `leader-stepdown` when another instance is leader', async () => {
|
||||
publisher.get.mockResolvedValue('main-n8n-main-1');
|
||||
Object.defineProperty(instanceSettings, 'isLeader', { get: () => true });
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
describe('checkLeader (leader path)', () => {
|
||||
beforeEach(async () => {
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
|
||||
await multiMainSetup.init();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// @ts-expect-error - private method
|
||||
await multiMainSetup.checkLeader();
|
||||
it('should stay leader when TTL renewal succeeds', async () => {
|
||||
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'success' }));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(client.tryRenewLeaderTtl).toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should step down when another host is leader', async () => {
|
||||
client.tryRenewLeaderTtl.mockResolvedValue(
|
||||
createResultOk({ id: 'other-host-is-leader', currentLeaderId: 'main-n8n-main-1' }),
|
||||
);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should try to re-acquire leader key when key is missing', async () => {
|
||||
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'key-missing' }));
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(client.setLeaderIfNotExists).toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should step down when key is missing and re-acquire fails', async () => {
|
||||
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'key-missing' }));
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should step down when key is missing and Redis command fails', async () => {
|
||||
client.tryRenewLeaderTtl.mockResolvedValue(createResultOk({ id: 'key-missing' }));
|
||||
client.setLeaderIfNotExists.mockResolvedValue(
|
||||
createResultError(new Error('Command timed out')),
|
||||
);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should stay leader when TTL renewal Redis command fails', async () => {
|
||||
client.tryRenewLeaderTtl.mockResolvedValue(
|
||||
createResultError(new Error('Command timed out')),
|
||||
);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
});
|
||||
|
||||
it('should not emit `leader-stepdown` when already a follower', async () => {
|
||||
publisher.get.mockResolvedValue('main-n8n-main-1');
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
describe('checkLeader (follower path)', () => {
|
||||
beforeEach(async () => {
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
|
||||
await multiMainSetup.init();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// @ts-expect-error - private method
|
||||
await multiMainSetup.checkLeader();
|
||||
it('should become leader when Redis shows own hostId as leader (mismatch recovery)', async () => {
|
||||
client.getLeader.mockResolvedValue(createResultOk(hostId));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
it('should attempt to become leader when leadership is vacant', async () => {
|
||||
publisher.get.mockResolvedValue(null);
|
||||
publisher.setIfNotExists.mockResolvedValue(true);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
expect(errorReporter.info).toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
// @ts-expect-error - private method
|
||||
await multiMainSetup.checkLeader();
|
||||
it('should stay follower when another instance is leader', async () => {
|
||||
client.getLeader.mockResolvedValue(createResultOk('main-n8n-main-1'));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
expect(instanceSettings.markAsFollower).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-stepdown');
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should attempt to become leader when leadership is vacant', async () => {
|
||||
client.getLeader.mockResolvedValue(createResultOk(null));
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(true));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(client.setLeaderIfNotExists).toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsLeader).toHaveBeenCalled();
|
||||
expect(emit).toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
it('should stay follower when leadership is vacant but setLeaderIfNotExists fails', async () => {
|
||||
client.getLeader.mockResolvedValue(createResultOk(null));
|
||||
client.setLeaderIfNotExists.mockResolvedValue(createResultOk(false));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
|
||||
it('should stay follower when Redis is unreachable', async () => {
|
||||
client.getLeader.mockResolvedValue(createResultError(new Error('Command timed out')));
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(instanceSettings.markAsFollower).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-stepdown');
|
||||
});
|
||||
|
||||
it('should stay follower when leadership is vacant and Redis command fails', async () => {
|
||||
client.getLeader.mockResolvedValue(createResultOk(null));
|
||||
client.setLeaderIfNotExists.mockResolvedValue(
|
||||
createResultError(new Error('Command timed out')),
|
||||
);
|
||||
const emit = jest.spyOn(multiMainSetup, 'emit');
|
||||
|
||||
await multiMainSetup['strategy'].checkLeader();
|
||||
|
||||
expect(instanceSettings.markAsLeader).not.toHaveBeenCalled();
|
||||
expect(emit).not.toHaveBeenCalledWith('leader-takeover');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
151
packages/cli/src/scaling/leader-election-client.ts
Normal file
151
packages/cli/src/scaling/leader-election-client.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { RedisClientService } from '@/services/redis-client.service';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { Cluster, Redis } from 'ioredis';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { ensureError, type Result, createResultOk, createResultError } from 'n8n-workflow';
|
||||
|
||||
const COMMAND_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Atomically extends the leader key TTL only if the current value matches the
|
||||
* provided hostId
|
||||
*
|
||||
* KEYS[1] - leader key
|
||||
* ARGV[1] - hostId
|
||||
* ARGV[2] - TTL in seconds
|
||||
*
|
||||
* Returns:
|
||||
* -1 key does not exist
|
||||
* "actual-value" key exists but value is not the expected value
|
||||
* 1 value matched and TTL was extended
|
||||
* 0 value matched, but TTL was not extended
|
||||
*/
|
||||
const INCREASE_TTL_IF_LEADER = `
|
||||
-- Renew only if we still hold the lock
|
||||
local currentValue = redis.call("GET", KEYS[1])
|
||||
|
||||
if not currentValue then
|
||||
return -1
|
||||
end
|
||||
|
||||
if currentValue ~= ARGV[1] then
|
||||
return currentValue
|
||||
end
|
||||
|
||||
return redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))
|
||||
`;
|
||||
|
||||
export type TtlRenewalResultKeyMissing = { id: 'key-missing' };
|
||||
export type TtlRenewalResultOtherHostIsLeader = {
|
||||
id: 'other-host-is-leader';
|
||||
currentLeaderId: string;
|
||||
};
|
||||
export type TtlRenewalResultSuccess = { id: 'success' };
|
||||
|
||||
export type TtlRenewalResult =
|
||||
| TtlRenewalResultKeyMissing
|
||||
| TtlRenewalResultOtherHostIsLeader
|
||||
| TtlRenewalResultSuccess;
|
||||
|
||||
/**
|
||||
* Redis-backed client for leader election in multi-main setups. Uses a TTL-based key to
|
||||
* track which instance is the current leader.
|
||||
*/
|
||||
@Service()
|
||||
export class LeaderElectionClient {
|
||||
private readonly redisClient: Redis | Cluster;
|
||||
|
||||
private readonly leaderKey: string;
|
||||
|
||||
private readonly leaderKeyTtlInS: number;
|
||||
|
||||
private get hostId() {
|
||||
return this.instanceSettings.hostId;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
globalConfig: GlobalConfig,
|
||||
redisClientService: RedisClientService,
|
||||
) {
|
||||
const prefix = redisClientService.toValidPrefix(globalConfig.redis.prefix);
|
||||
this.leaderKey = prefix + ':main_instance_leader';
|
||||
|
||||
this.leaderKeyTtlInS = globalConfig.multiMainSetup.ttl;
|
||||
|
||||
this.redisClient = redisClientService.createClient({
|
||||
type: 'leader(n8n)',
|
||||
extraOptions: { commandTimeout: COMMAND_TIMEOUT_MS },
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the current leader's hostId, or `null` if the key is absent. */
|
||||
async getLeader(): Promise<Result<string | null, Error>> {
|
||||
try {
|
||||
return createResultOk(await this.redisClient.get(this.leaderKey));
|
||||
} catch (e) {
|
||||
return createResultError(ensureError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Claim leadership with a TTL. Returns `true` if the key was set (i.e. no leader yet). */
|
||||
async setLeaderIfNotExists(): Promise<Result<boolean, Error>> {
|
||||
try {
|
||||
const result = await this.redisClient.set(
|
||||
this.leaderKey,
|
||||
this.hostId,
|
||||
'EX',
|
||||
this.leaderKeyTtlInS,
|
||||
'NX',
|
||||
);
|
||||
return createResultOk(result === 'OK');
|
||||
} catch (e) {
|
||||
return createResultError(ensureError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically extend the leader key TTL only if this host still holds it. */
|
||||
async tryRenewLeaderTtl(): Promise<Result<TtlRenewalResult, Error>> {
|
||||
try {
|
||||
const result = await this.redisClient.eval(
|
||||
INCREASE_TTL_IF_LEADER,
|
||||
1,
|
||||
this.leaderKey,
|
||||
this.hostId,
|
||||
this.leaderKeyTtlInS,
|
||||
);
|
||||
|
||||
if (result === -1 || result === 0) {
|
||||
return createResultOk({ id: 'key-missing' });
|
||||
}
|
||||
if (result === 1) {
|
||||
return createResultOk({ id: 'success' });
|
||||
}
|
||||
if (typeof result === 'string') {
|
||||
return createResultOk({ id: 'other-host-is-leader', currentLeaderId: result });
|
||||
}
|
||||
|
||||
return createResultError(
|
||||
new Error(`Unexpected result from Redis script: ${JSON.stringify(result)}`),
|
||||
);
|
||||
} catch (e) {
|
||||
return createResultError(ensureError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the leader key so another instance can claim leadership. */
|
||||
async clearLeader(): Promise<Result<void, Error>> {
|
||||
try {
|
||||
await this.redisClient.del(this.leaderKey);
|
||||
return createResultOk(undefined);
|
||||
} catch (e) {
|
||||
return createResultError(ensureError(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** Disconnect the underlying Redis client. */
|
||||
destroy() {
|
||||
this.redisClient.disconnect();
|
||||
}
|
||||
}
|
||||
120
packages/cli/src/scaling/multi-main-setup-legacy.ts
Normal file
120
packages/cli/src/scaling/multi-main-setup-legacy.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { GlobalConfig } from '@n8n/config';
|
||||
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { RedisClientService } from '@/services/redis-client.service';
|
||||
|
||||
import type { MultiMainStrategy } from './multi-main-setup.types';
|
||||
|
||||
type EmitFn = (event: 'leader-takeover' | 'leader-stepdown') => void;
|
||||
|
||||
export class MultiMainSetupLegacy implements MultiMainStrategy {
|
||||
private leaderKey: string;
|
||||
|
||||
private readonly leaderKeyTtl: number;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
private readonly emit: EmitFn,
|
||||
) {
|
||||
this.leaderKeyTtl = this.globalConfig.multiMainSetup.ttl;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const prefix = this.globalConfig.redis.prefix;
|
||||
const validPrefix = this.redisClientService.toValidPrefix(prefix);
|
||||
this.leaderKey = validPrefix + ':main_instance_leader';
|
||||
|
||||
await this.tryBecomeLeader();
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
const { isLeader } = this.instanceSettings;
|
||||
|
||||
if (isLeader) await this.publisher.clear(this.leaderKey);
|
||||
}
|
||||
|
||||
async checkLeader() {
|
||||
const leaderId = await this.publisher.get(this.leaderKey);
|
||||
|
||||
const { hostId } = this.instanceSettings;
|
||||
|
||||
if (leaderId === hostId) {
|
||||
if (!this.instanceSettings.isLeader) {
|
||||
this.errorReporter.info(
|
||||
`[Instance ID ${hostId}] Remote/Local leadership mismatch, marking self as leader`,
|
||||
{
|
||||
shouldBeLogged: true,
|
||||
shouldReport: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.instanceSettings.markAsLeader();
|
||||
|
||||
this.emit('leader-takeover');
|
||||
}
|
||||
|
||||
this.logger.debug(`[Instance ID ${hostId}] Leader is this instance`);
|
||||
|
||||
await this.publisher.setExpiration(this.leaderKey, this.leaderKeyTtl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderId && leaderId !== hostId) {
|
||||
this.logger.debug(`[Instance ID ${hostId}] Leader is other instance "${leaderId}"`);
|
||||
|
||||
if (this.instanceSettings.isLeader) {
|
||||
this.instanceSettings.markAsFollower();
|
||||
|
||||
this.emit('leader-stepdown');
|
||||
|
||||
this.logger.warn('[Multi-main setup] Leader failed to renew leader key');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!leaderId) {
|
||||
this.logger.debug(
|
||||
`[Instance ID ${hostId}] Leadership vacant, attempting to become leader...`,
|
||||
);
|
||||
|
||||
this.instanceSettings.markAsFollower();
|
||||
|
||||
this.emit('leader-stepdown');
|
||||
|
||||
await this.tryBecomeLeader();
|
||||
}
|
||||
}
|
||||
|
||||
private async tryBecomeLeader() {
|
||||
const { hostId } = this.instanceSettings;
|
||||
|
||||
const keySetSuccessfully = await this.publisher.setIfNotExists(
|
||||
this.leaderKey,
|
||||
hostId,
|
||||
this.leaderKeyTtl,
|
||||
);
|
||||
|
||||
if (keySetSuccessfully) {
|
||||
this.logger.info(`[Instance ID ${hostId}] Leader is now this instance`);
|
||||
|
||||
this.instanceSettings.markAsLeader();
|
||||
|
||||
this.emit('leader-takeover');
|
||||
} else {
|
||||
this.instanceSettings.markAsFollower();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLeaderKey() {
|
||||
return await this.publisher.get(this.leaderKey);
|
||||
}
|
||||
}
|
||||
191
packages/cli/src/scaling/multi-main-setup-v2.ts
Normal file
191
packages/cli/src/scaling/multi-main-setup-v2.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import type { LeaderElectionClient } from '@/scaling/leader-election-client';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
import assert from 'node:assert';
|
||||
|
||||
import type { MultiMainStrategy } from './multi-main-setup.types';
|
||||
|
||||
type EmitFn = (event: 'leader-takeover' | 'leader-stepdown') => void;
|
||||
|
||||
export class MultiMainSetupV2 implements MultiMainStrategy {
|
||||
private leaderCheckInProgress = false;
|
||||
|
||||
private get hostId() {
|
||||
return this.instanceSettings.hostId;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
private readonly client: LeaderElectionClient,
|
||||
private readonly emit: EmitFn,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
const result = await this.client.setLeaderIfNotExists();
|
||||
if (!result.ok) {
|
||||
this.logRedisCommandFailure('Failed to set leader key in Redis during init', result.error);
|
||||
this.instanceSettings.markAsFollower();
|
||||
} else if (result.result) {
|
||||
this.takeOverAsLeader();
|
||||
} else {
|
||||
this.instanceSettings.markAsFollower();
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
const { isLeader } = this.instanceSettings;
|
||||
|
||||
if (isLeader) {
|
||||
// TODO: We should guard here that we only remove the key the key in Redis matches
|
||||
// our host ID.
|
||||
const result = await this.client.clearLeader();
|
||||
if (!result.ok) {
|
||||
this.logger.warn('Failed to clear leader key from Redis', { error: result.error });
|
||||
}
|
||||
}
|
||||
|
||||
this.client.destroy();
|
||||
}
|
||||
|
||||
async checkLeader() {
|
||||
if (this.leaderCheckInProgress) {
|
||||
this.logger.warn('Previous leader check is still in progress, skipping this check');
|
||||
return;
|
||||
}
|
||||
|
||||
this.leaderCheckInProgress = true;
|
||||
try {
|
||||
if (this.instanceSettings.isLeader) {
|
||||
await this.checkAreWeStillLeader();
|
||||
} else {
|
||||
await this.checkCanBecomeLeader();
|
||||
}
|
||||
} finally {
|
||||
this.leaderCheckInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Renew our leadership lease. If we've lost the lease, step down to follower. */
|
||||
private async checkAreWeStillLeader() {
|
||||
assert(this.instanceSettings.isLeader);
|
||||
|
||||
const renewTtlResult = await this.client.tryRenewLeaderTtl();
|
||||
if (!renewTtlResult.ok) {
|
||||
this.logRedisCommandFailure('Failed to renew leader TTL', renewTtlResult.error);
|
||||
// There's a decision to be made here: Do we step down or not? Redis might
|
||||
// be unavailable for all clients or only for us. We could also track the TTL
|
||||
// locally, but this would make the implementation more complex and error-prone.
|
||||
// For now we accept that this might cause some inconsistencies in a network
|
||||
// partition scenario, but eventually the system will recover once Redis is available again.
|
||||
return;
|
||||
}
|
||||
|
||||
const renewalResult = renewTtlResult.result;
|
||||
if (renewalResult.id === 'success') {
|
||||
this.logger.debug(`[Instance ID ${this.hostId}] Leader is this instance`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.warn('[Multi-main setup] Leader failed to renew leader key');
|
||||
|
||||
if (renewalResult.id === 'other-host-is-leader') {
|
||||
this.logger.debug(
|
||||
`[Instance ID ${this.hostId}] Leader is other instance "${renewalResult.currentLeaderId}"`,
|
||||
);
|
||||
this.stepDownToFollower();
|
||||
return;
|
||||
}
|
||||
|
||||
// The only remaining case is 'key-missing', which means we lost leadership
|
||||
// (e.g. due to Redis unavailability or a network partition). In this case
|
||||
// we try to become leader and step down if that fails.
|
||||
assert(renewalResult.id === 'key-missing');
|
||||
|
||||
const result = await this.client.setLeaderIfNotExists();
|
||||
if (!result.ok) {
|
||||
this.logRedisCommandFailure('Failed to set leader key in Redis', result.error);
|
||||
this.stepDownToFollower();
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSet = result.result;
|
||||
if (!wasSet) {
|
||||
this.stepDownToFollower();
|
||||
}
|
||||
}
|
||||
|
||||
private async checkCanBecomeLeader() {
|
||||
assert(!this.instanceSettings.isLeader);
|
||||
|
||||
const getResult = await this.client.getLeader();
|
||||
if (!getResult.ok) {
|
||||
this.logRedisCommandFailure('Failed to get leader key from Redis', getResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const leaderId = getResult.result;
|
||||
if (leaderId && leaderId === this.hostId) {
|
||||
this.errorReporter.info(
|
||||
`[Instance ID ${this.hostId}] Remote/Local leadership mismatch, marking self as leader`,
|
||||
{
|
||||
shouldBeLogged: true,
|
||||
shouldReport: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.takeOverAsLeader();
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderId) {
|
||||
this.logger.debug(`[Instance ID ${this.hostId}] Leader is other instance "${leaderId}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`[Instance ID ${this.hostId}] Leadership vacant, attempting to become leader...`,
|
||||
);
|
||||
|
||||
const result = await this.client.setLeaderIfNotExists();
|
||||
if (!result.ok) {
|
||||
this.logger.warn('Failed to try leader key set in Redis', { error: result.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const wasSet = result.result;
|
||||
if (wasSet) {
|
||||
this.takeOverAsLeader();
|
||||
}
|
||||
}
|
||||
|
||||
private takeOverAsLeader() {
|
||||
assert(!this.instanceSettings.isLeader);
|
||||
|
||||
this.logger.info(`[Instance ID ${this.hostId}] Leader is now this instance`);
|
||||
|
||||
this.instanceSettings.markAsLeader();
|
||||
|
||||
this.emit('leader-takeover');
|
||||
}
|
||||
|
||||
private stepDownToFollower() {
|
||||
assert(this.instanceSettings.isLeader);
|
||||
|
||||
this.logger.info(`[Instance ID ${this.hostId}] This is now a follower instance`);
|
||||
|
||||
this.instanceSettings.markAsFollower();
|
||||
|
||||
this.emit('leader-stepdown');
|
||||
}
|
||||
|
||||
async fetchLeaderKey() {
|
||||
const result = await this.client.getLeader();
|
||||
return result.ok ? result.result : null;
|
||||
}
|
||||
|
||||
private logRedisCommandFailure(message: string, error: Error) {
|
||||
this.logger.warn(`${message}: ${error.message}`, { error });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { TypedEmitter } from '@/typed-emitter';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Time } from '@n8n/constants';
|
||||
|
|
@ -5,9 +6,13 @@ import { MultiMainMetadata } from '@n8n/decorators';
|
|||
import { Container, Service } from '@n8n/di';
|
||||
import { ErrorReporter, InstanceSettings } from 'n8n-core';
|
||||
|
||||
import type * as LeaderElectionClientModule from '@/scaling/leader-election-client';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { RedisClientService } from '@/services/redis-client.service';
|
||||
import { TypedEmitter } from '@/typed-emitter';
|
||||
|
||||
import { MultiMainSetupLegacy } from './multi-main-setup-legacy';
|
||||
import type { MultiMainStrategy } from './multi-main-setup.types';
|
||||
import { MultiMainSetupV2 } from './multi-main-setup-v2';
|
||||
|
||||
type MultiMainEvents = {
|
||||
/**
|
||||
|
|
@ -28,34 +33,53 @@ type MultiMainEvents = {
|
|||
/** Designates leader and followers when running multiple main processes. */
|
||||
@Service()
|
||||
export class MultiMainSetup extends TypedEmitter<MultiMainEvents> {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly metadata: MultiMainMetadata,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
) {
|
||||
super();
|
||||
this.logger = this.logger.scoped(['scaling', 'multi-main-setup']);
|
||||
}
|
||||
|
||||
private leaderKey: string;
|
||||
|
||||
private readonly leaderKeyTtl = this.globalConfig.multiMainSetup.ttl;
|
||||
private readonly strategy: MultiMainStrategy;
|
||||
|
||||
private leaderCheckInterval: NodeJS.Timeout | undefined;
|
||||
|
||||
async init() {
|
||||
const prefix = this.globalConfig.redis.prefix;
|
||||
const validPrefix = this.redisClientService.toValidPrefix(prefix);
|
||||
this.leaderKey = validPrefix + ':main_instance_leader';
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly metadata: MultiMainMetadata,
|
||||
private readonly errorReporter: ErrorReporter,
|
||||
private readonly publisher: Publisher,
|
||||
private readonly redisClientService: RedisClientService,
|
||||
) {
|
||||
super();
|
||||
this.logger = this.logger.scoped(['scaling', 'multi-main-setup']);
|
||||
|
||||
await this.tryBecomeLeader(); // prevent initial wait
|
||||
const emitFn = (event: 'leader-takeover' | 'leader-stepdown') => this.emit(event);
|
||||
|
||||
if (this.globalConfig.multiMainSetup.newLeaderElection) {
|
||||
const { LeaderElectionClient } =
|
||||
require('@/scaling/leader-election-client') as typeof LeaderElectionClientModule;
|
||||
const client = Container.get(LeaderElectionClient);
|
||||
this.strategy = new MultiMainSetupV2(
|
||||
this.logger,
|
||||
this.instanceSettings,
|
||||
this.errorReporter,
|
||||
client,
|
||||
emitFn,
|
||||
);
|
||||
} else {
|
||||
this.strategy = new MultiMainSetupLegacy(
|
||||
this.logger,
|
||||
this.instanceSettings,
|
||||
this.publisher,
|
||||
this.redisClientService,
|
||||
this.globalConfig,
|
||||
this.errorReporter,
|
||||
emitFn,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.strategy.init();
|
||||
|
||||
this.leaderCheckInterval = setInterval(async () => {
|
||||
await this.checkLeader();
|
||||
await this.strategy.checkLeader();
|
||||
}, this.globalConfig.multiMainSetup.interval * Time.seconds.toMilliseconds);
|
||||
}
|
||||
|
||||
|
|
@ -63,90 +87,11 @@ export class MultiMainSetup extends TypedEmitter<MultiMainEvents> {
|
|||
async shutdown() {
|
||||
clearInterval(this.leaderCheckInterval);
|
||||
|
||||
const { isLeader } = this.instanceSettings;
|
||||
|
||||
if (isLeader) await this.publisher.clear(this.leaderKey);
|
||||
await this.strategy.shutdown();
|
||||
}
|
||||
|
||||
private async checkLeader() {
|
||||
const leaderId = await this.publisher.get(this.leaderKey);
|
||||
|
||||
const { hostId } = this.instanceSettings;
|
||||
|
||||
if (leaderId === hostId) {
|
||||
if (!this.instanceSettings.isLeader) {
|
||||
// This indicates that the remote state indicated that this host is the leader, but this
|
||||
// host believed it was a follower. See CAT-2200 for more context.
|
||||
this.errorReporter.info(
|
||||
`[Instance ID ${hostId}] Remote/Local leadership mismatch, marking self as leader`,
|
||||
{
|
||||
shouldBeLogged: true,
|
||||
shouldReport: true,
|
||||
},
|
||||
);
|
||||
|
||||
this.instanceSettings.markAsLeader();
|
||||
|
||||
this.emit('leader-takeover');
|
||||
}
|
||||
|
||||
this.logger.debug(`[Instance ID ${hostId}] Leader is this instance`);
|
||||
|
||||
await this.publisher.setExpiration(this.leaderKey, this.leaderKeyTtl);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (leaderId && leaderId !== hostId) {
|
||||
this.logger.debug(`[Instance ID ${hostId}] Leader is other instance "${leaderId}"`);
|
||||
|
||||
if (this.instanceSettings.isLeader) {
|
||||
this.instanceSettings.markAsFollower();
|
||||
|
||||
this.emit('leader-stepdown');
|
||||
|
||||
this.logger.warn('[Multi-main setup] Leader failed to renew leader key');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!leaderId) {
|
||||
this.logger.debug(
|
||||
`[Instance ID ${hostId}] Leadership vacant, attempting to become leader...`,
|
||||
);
|
||||
|
||||
this.instanceSettings.markAsFollower();
|
||||
|
||||
this.emit('leader-stepdown');
|
||||
|
||||
await this.tryBecomeLeader();
|
||||
}
|
||||
}
|
||||
|
||||
private async tryBecomeLeader() {
|
||||
const { hostId } = this.instanceSettings;
|
||||
|
||||
// this can only succeed if leadership is currently vacant
|
||||
const keySetSuccessfully = await this.publisher.setIfNotExists(
|
||||
this.leaderKey,
|
||||
hostId,
|
||||
this.leaderKeyTtl,
|
||||
);
|
||||
|
||||
if (keySetSuccessfully) {
|
||||
this.logger.info(`[Instance ID ${hostId}] Leader is now this instance`);
|
||||
|
||||
this.instanceSettings.markAsLeader();
|
||||
|
||||
this.emit('leader-takeover');
|
||||
} else {
|
||||
this.instanceSettings.markAsFollower();
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLeaderKey() {
|
||||
return await this.publisher.get(this.leaderKey);
|
||||
async fetchLeaderKey(): Promise<string | null> {
|
||||
return await this.strategy.fetchLeaderKey();
|
||||
}
|
||||
|
||||
registerEventHandlers() {
|
||||
|
|
@ -155,7 +100,6 @@ export class MultiMainSetup extends TypedEmitter<MultiMainEvents> {
|
|||
for (const { eventHandlerClass, methodName, eventName } of handlers) {
|
||||
const instance = Container.get(eventHandlerClass);
|
||||
this.on(eventName, async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return await instance[methodName].call(instance);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
6
packages/cli/src/scaling/multi-main-setup.types.ts
Normal file
6
packages/cli/src/scaling/multi-main-setup.types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export interface MultiMainStrategy {
|
||||
init(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
checkLeader(): Promise<void>;
|
||||
fetchLeaderKey(): Promise<string | null>;
|
||||
}
|
||||
|
|
@ -118,9 +118,7 @@ export class Publisher {
|
|||
|
||||
// #endregion
|
||||
|
||||
// #region Utils for multi-main setup
|
||||
|
||||
// @TODO: The following methods are not pubsub-specific. Consider a dedicated client for multi-main setup.
|
||||
// #region Key-value utils (used by MCP session store and legacy leader election)
|
||||
|
||||
async setIfNotExists(key: string, value: string, ttl: number) {
|
||||
const result = await this.client.set(key, value, 'EX', ttl, 'NX');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,12 @@ export type RedisClientType = N8nRedisClientType | BullRedisClientType;
|
|||
* - `publisher(n8n)` to send messages into scaling mode pubsub channels
|
||||
* - `cache(n8n)` for caching operations (variables, resource ownership, etc.)
|
||||
*/
|
||||
type N8nRedisClientType = 'subscriber(n8n)' | 'publisher(n8n)' | 'cache(n8n)' | 'registry(n8n)';
|
||||
type N8nRedisClientType =
|
||||
| 'subscriber(n8n)'
|
||||
| 'publisher(n8n)'
|
||||
| 'cache(n8n)'
|
||||
| 'registry(n8n)'
|
||||
| 'leader(n8n)';
|
||||
|
||||
/**
|
||||
* Redis client used internally by Bull. Suffixed with `(bull)` at `ScalingService.setupQueue`.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
type INodeType,
|
||||
type IWorkflowExecuteAdditionalData,
|
||||
type ResourceMapperFields,
|
||||
Expression,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { DynamicNodeParametersService } from '../dynamic-node-parameters.service';
|
||||
|
|
@ -84,6 +85,128 @@ describe('DynamicNodeParametersService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('expression isolate lifecycle', () => {
|
||||
let acquireSpy: jest.SpyInstance;
|
||||
let releaseSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
acquireSpy = jest.spyOn(Expression.prototype, 'acquireIsolate').mockResolvedValue(undefined);
|
||||
releaseSpy = jest.spyOn(Expression.prototype, 'releaseIsolate').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('should acquire and release isolate around getOptionsViaMethodName', async () => {
|
||||
const loadOptionsMethod = jest.fn().mockResolvedValue([{ name: 'opt', value: 'v' }]);
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||
mock<INodeType>({
|
||||
description: { properties: [] },
|
||||
methods: { loadOptions: { getOptions: loadOptionsMethod } },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.getOptionsViaMethodName(
|
||||
'getOptions',
|
||||
'',
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
{ name: 'TestNode', version: 1 },
|
||||
mock<INodeParameters>(),
|
||||
);
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should release isolate even when the inner method throws', async () => {
|
||||
const loadOptionsMethod = jest.fn().mockRejectedValue(new Error('boom'));
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||
mock<INodeType>({
|
||||
description: { properties: [] },
|
||||
methods: { loadOptions: { getOptions: loadOptionsMethod } },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getOptionsViaMethodName(
|
||||
'getOptions',
|
||||
'',
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
{ name: 'TestNode', version: 1 },
|
||||
mock<INodeParameters>(),
|
||||
),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should acquire and release isolate around getResourceLocatorResults', async () => {
|
||||
const listSearchMethod = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ results: [{ name: 'r', value: 'v' }] });
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||
mock<INodeType>({
|
||||
description: { properties: [] },
|
||||
methods: { listSearch: { searchModels: listSearchMethod } },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.getResourceLocatorResults(
|
||||
'searchModels',
|
||||
'',
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
{ name: 'TestNode', version: 1 },
|
||||
mock<INodeParameters>(),
|
||||
);
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should acquire and release isolate around getResourceMappingFields', async () => {
|
||||
const resourceMappingMethod = jest.fn().mockResolvedValue({
|
||||
fields: [{ id: '1', displayName: 'F', defaultMatch: false, required: true, display: true }],
|
||||
});
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||
mock<INodeType>({
|
||||
description: { properties: [] },
|
||||
methods: { resourceMapping: { getFields: resourceMappingMethod } },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.getResourceMappingFields(
|
||||
'getFields',
|
||||
'',
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
{ name: 'TestNode', version: 1 },
|
||||
mock<INodeParameters>(),
|
||||
);
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should acquire and release isolate around getActionResult', async () => {
|
||||
const actionHandler = jest.fn().mockResolvedValue({ key: 'value' });
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||
mock<INodeType>({
|
||||
description: { properties: [] },
|
||||
methods: { actionHandler: { handle: actionHandler } },
|
||||
}),
|
||||
);
|
||||
|
||||
await service.getActionResult(
|
||||
'handle',
|
||||
'',
|
||||
mock<IWorkflowExecuteAdditionalData>(),
|
||||
{ name: 'TestNode', version: 1 },
|
||||
mock<INodeParameters>(),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(acquireSpy).toHaveBeenCalledTimes(1);
|
||||
expect(releaseSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLocalResourceMappingFields', () => {
|
||||
it('should remove duplicate resource mapping fields', async () => {
|
||||
const resourceMappingMethod = jest.fn();
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { mock } from 'jest-mock-extended';
|
|||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { UserService } from '@/services/user.service';
|
||||
|
|
@ -656,15 +655,12 @@ describe('UserService', () => {
|
|||
});
|
||||
|
||||
describe('assertGetUsersAccess', () => {
|
||||
it('should allow project admin to list all users', async () => {
|
||||
it('should allow global member to list all users without project filter', async () => {
|
||||
const member = Object.assign(new User(), { role: GLOBAL_MEMBER_ROLE });
|
||||
projectService.getProjectIdsWithScope.mockResolvedValueOnce(['project-1']);
|
||||
|
||||
await expect(userService.assertGetUsersAccess(member)).resolves.toBeUndefined();
|
||||
|
||||
expect(projectService.getProjectIdsWithScope).toHaveBeenCalledWith(member, [
|
||||
'project:update',
|
||||
]);
|
||||
expect(projectService.getProjectIdsWithScope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow non-admin members to list users by projectId', async () => {
|
||||
|
|
@ -678,13 +674,6 @@ describe('UserService', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenError for member without project admin scope', async () => {
|
||||
const member = Object.assign(new User(), { role: GLOBAL_MEMBER_ROLE });
|
||||
projectService.getProjectIdsWithScope.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(userService.assertGetUsersAccess(member)).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('should throw NotFoundError when filtering by unknown projectId', async () => {
|
||||
const member = Object.assign(new User(), { role: GLOBAL_MEMBER_ROLE });
|
||||
projectService.getProjectWithScope.mockResolvedValueOnce(null);
|
||||
|
|
|
|||
|
|
@ -134,10 +134,11 @@ export class DynamicNodeParametersService {
|
|||
const method = this.getMethod('loadOptions', methodName, nodeType);
|
||||
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
|
||||
const thisArgs = this.getThisArg(path, additionalData, workflow);
|
||||
// Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply`
|
||||
// enabled in `tsconfig.json`
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return await method.call(thisArgs);
|
||||
return await this.withExpressionIsolate(workflow, async () => {
|
||||
// Need to use untyped call since `this` usage is widespread and we don't have `strictBindCallApply`
|
||||
// enabled in `tsconfig.json`
|
||||
return await method.call(thisArgs);
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the available options via a loadOptions param */
|
||||
|
|
@ -210,17 +211,19 @@ export class DynamicNodeParametersService {
|
|||
[],
|
||||
);
|
||||
const routingNode = new RoutingNode(executeFunctions, tempNodeType);
|
||||
const optionsData = await routingNode.runNode();
|
||||
return await this.withExpressionIsolate(workflow, async () => {
|
||||
const optionsData = await routingNode.runNode();
|
||||
|
||||
if (optionsData?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (optionsData?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(optionsData)) {
|
||||
throw new UnexpectedError('The returned data is not an array');
|
||||
}
|
||||
if (!Array.isArray(optionsData)) {
|
||||
throw new UnexpectedError('The returned data is not an array');
|
||||
}
|
||||
|
||||
return optionsData[0].map((item) => item.json) as unknown as INodePropertyOptions[];
|
||||
return optionsData[0].map((item) => item.json) as unknown as INodePropertyOptions[];
|
||||
});
|
||||
}
|
||||
|
||||
async getResourceLocatorResults(
|
||||
|
|
@ -237,8 +240,9 @@ export class DynamicNodeParametersService {
|
|||
const method = this.getMethod('listSearch', methodName, nodeType);
|
||||
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
|
||||
const thisArgs = this.getThisArg(path, additionalData, workflow);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return await method.call(thisArgs, filter, paginationToken);
|
||||
return await this.withExpressionIsolate(workflow, async () => {
|
||||
return await method.call(thisArgs, filter, paginationToken);
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns the available mapping fields for the ResourceMapper component */
|
||||
|
|
@ -254,7 +258,9 @@ export class DynamicNodeParametersService {
|
|||
const method = this.getMethod('resourceMapping', methodName, nodeType);
|
||||
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
|
||||
const thisArgs = this.getThisArg(path, additionalData, workflow);
|
||||
return this.removeDuplicateResourceMappingFields(await method.call(thisArgs));
|
||||
return await this.withExpressionIsolate(workflow, async () =>
|
||||
this.removeDuplicateResourceMappingFields(await method.call(thisArgs)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the available workflow input mapping fields for the ResourceMapper component */
|
||||
|
|
@ -284,8 +290,22 @@ export class DynamicNodeParametersService {
|
|||
const method = this.getMethod('actionHandler', handler, nodeType);
|
||||
const workflow = this.getWorkflow(nodeTypeAndVersion, currentNodeParameters, credentials);
|
||||
const thisArgs = this.getThisArg(path, additionalData, workflow);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return await method.call(thisArgs, payload);
|
||||
return await this.withExpressionIsolate(workflow, async () => {
|
||||
return await method.call(thisArgs, payload);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When N8N_EXPRESSION_ENGINE=vm, expressions run in an isolate that must be acquired
|
||||
* for this workflow before any code resolves {{ }} in parameters or credentials.
|
||||
*/
|
||||
private async withExpressionIsolate<T>(workflow: Workflow, fn: () => Promise<T>): Promise<T> {
|
||||
await workflow.expression.acquireIsolate();
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
await workflow.expression.releaseIsolate();
|
||||
}
|
||||
}
|
||||
|
||||
private getMethod(
|
||||
|
|
|
|||
|
|
@ -278,6 +278,20 @@ export class ProjectService {
|
|||
return await this.projectRepository.getAccessibleProjectsAndCount(user.id, options);
|
||||
}
|
||||
|
||||
// Returns the projects a caller can pick as share targets, including peer
|
||||
// personal projects. Admins (project:read) still see everything; non-admin
|
||||
// callers also see all personal projects so the share dropdown can surface
|
||||
// other users. See `ProjectRepository.getShareableProjectsAndCount`.
|
||||
async getShareableProjectsAndCount(
|
||||
user: User,
|
||||
options: ProjectListOptions,
|
||||
): Promise<[Project[], number]> {
|
||||
if (hasGlobalScope(user, 'project:read')) {
|
||||
return await this.projectRepository.findAllProjectsAndCount(options);
|
||||
}
|
||||
return await this.projectRepository.getShareableProjectsAndCount(user.id, options);
|
||||
}
|
||||
|
||||
async getPersonalProjectOwners(projectIds: string[]): Promise<ProjectRelation[]> {
|
||||
return await this.projectRelationRepository.getPersonalProjectOwners(projectIds);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import type { IUserSettings } from 'n8n-workflow';
|
|||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { InternalServerError } from '@/errors/response-errors/internal-server.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { EventService } from '@/events/event.service';
|
||||
|
|
@ -80,17 +79,6 @@ export class UserService {
|
|||
}
|
||||
return;
|
||||
}
|
||||
const isInstanceAdmin = ['global:owner', 'global:admin'].includes(user.role.slug);
|
||||
if (isInstanceAdmin) {
|
||||
return;
|
||||
}
|
||||
const hasProjectUpdateScope =
|
||||
(await this.projectService.getProjectIdsWithScope(user, ['project:update'])).length > 0;
|
||||
if (!hasProjectUpdateScope) {
|
||||
throw new ForbiddenError(
|
||||
'Listing all users is limited to instance administrators and project admins. Filter by project to list project members.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateSettings(userId: string, newSettings: Partial<IUserSettings>) {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,18 @@ import { ExecutionsConfig } from '@n8n/config';
|
|||
import { ExecutionRepository } from '@n8n/db';
|
||||
import { Container, Service } from '@n8n/di';
|
||||
import type { ExecutionLifecycleHooks } from 'n8n-core';
|
||||
import { ErrorReporter, InstanceSettings, StorageConfig, WorkflowExecute } from 'n8n-core';
|
||||
import {
|
||||
ErrorReporter,
|
||||
establishExecutionContext,
|
||||
InstanceSettings,
|
||||
StorageConfig,
|
||||
WorkflowExecute,
|
||||
} from 'n8n-core';
|
||||
import type {
|
||||
ExecutionError,
|
||||
IDeferredPromise,
|
||||
IExecuteResponsePromiseData,
|
||||
INode,
|
||||
IPinData,
|
||||
IRun,
|
||||
WorkflowExecuteMode,
|
||||
|
|
@ -149,6 +156,28 @@ export class WorkflowRunner {
|
|||
await hooks?.runHook('workflowExecuteAfter', [fullRunData]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a failed execution record for an already-registered execution and
|
||||
* finalize it, so a pre-flight failure surfaces as a normal failed run.
|
||||
*/
|
||||
private async failExecution(
|
||||
data: IWorkflowExecutionDataProcess,
|
||||
executionId: string,
|
||||
error: ExecutionError & { node?: INode },
|
||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||
): Promise<void> {
|
||||
const runData = this.failedRunFactory.generateFailedExecutionFromError(
|
||||
data.executionMode,
|
||||
error,
|
||||
error.node,
|
||||
);
|
||||
const lifecycleHooks = getLifecycleHooksForRegularMain(data, executionId);
|
||||
await lifecycleHooks.runHook('workflowExecuteBefore', [undefined, data.executionData]);
|
||||
await lifecycleHooks.runHook('workflowExecuteAfter', [runData]);
|
||||
responsePromise?.reject(error);
|
||||
this.activeExecutions.finalizeExecution(executionId);
|
||||
}
|
||||
|
||||
/** Run the workflow
|
||||
* @param realtime This is used in queue mode to change the priority of an execution, making sure they are picked up quicker.
|
||||
*/
|
||||
|
|
@ -159,25 +188,64 @@ export class WorkflowRunner {
|
|||
restartExecutionId?: string,
|
||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||
): Promise<string> {
|
||||
// Establish the execution context before persisting to the DB.
|
||||
// activeExecutions.add() -> executionPersistence.create() writes
|
||||
// data.executionData to the DB; any header masking or runtimeData
|
||||
// population must happen before that write so the persisted record
|
||||
// does not contain raw trigger-item data (e.g. Authorization headers).
|
||||
// The runtimeData early-exit guard in establishExecutionContext keeps
|
||||
// the subsequent worker-side call at workflow-execute.ts idempotent.
|
||||
// Guard on the inner executionData: in queue mode with manual offload
|
||||
// the outer IRunExecutionData is created with `executionData: null`
|
||||
// so the trigger-item stack is undefined here; nothing to mask yet,
|
||||
// the worker will establish context once it populates the stack.
|
||||
let establishContextError: (ExecutionError & { node?: INode }) | undefined;
|
||||
if (data.executionData?.executionData) {
|
||||
// Deliberately lightweight: no pinData, no staticData loading,
|
||||
// no additionalData. establishExecutionContext only needs the
|
||||
// workflow's settings (for redactionPolicy) and node lookups.
|
||||
// runMainProcess() builds its own fully-configured Workflow for
|
||||
// actual execution.
|
||||
const contextWorkflow = new Workflow({
|
||||
id: data.workflowData.id,
|
||||
name: data.workflowData.name,
|
||||
nodes: data.workflowData.nodes,
|
||||
connections: data.workflowData.connections,
|
||||
active: data.workflowData.activeVersionId !== null,
|
||||
nodeTypes: this.nodeTypes,
|
||||
staticData: data.workflowData.staticData,
|
||||
settings: data.workflowData.settings ?? {},
|
||||
});
|
||||
try {
|
||||
await establishExecutionContext(
|
||||
contextWorkflow,
|
||||
data.executionData,
|
||||
undefined,
|
||||
data.executionMode,
|
||||
);
|
||||
} catch (error) {
|
||||
// Masking may have failed partway through, so the trigger-item
|
||||
// stack can still contain raw header data. Drop it before
|
||||
// activeExecutions.add() persists the execution row.
|
||||
data.executionData.executionData.nodeExecutionStack = [];
|
||||
establishContextError = error as ExecutionError & { node?: INode };
|
||||
}
|
||||
}
|
||||
|
||||
// Register a new execution
|
||||
const executionId = await this.activeExecutions.add(data, restartExecutionId);
|
||||
|
||||
if (establishContextError) {
|
||||
await this.failExecution(data, executionId, establishContextError, responsePromise);
|
||||
return executionId;
|
||||
}
|
||||
|
||||
const { id: workflowId, nodes } = data.workflowData;
|
||||
|
||||
try {
|
||||
await this.credentialsPermissionChecker.check(workflowId, nodes);
|
||||
} catch (error) {
|
||||
// Create a failed execution with the data for the node, save it and abort execution
|
||||
const runData = this.failedRunFactory.generateFailedExecutionFromError(
|
||||
data.executionMode,
|
||||
error,
|
||||
error.node,
|
||||
);
|
||||
const lifecycleHooks = getLifecycleHooksForRegularMain(data, executionId);
|
||||
await lifecycleHooks.runHook('workflowExecuteBefore', [undefined, data.executionData]);
|
||||
await lifecycleHooks.runHook('workflowExecuteAfter', [runData]);
|
||||
responsePromise?.reject(error);
|
||||
this.activeExecutions.finalizeExecution(executionId);
|
||||
await this.failExecution(data, executionId, error, responsePromise);
|
||||
return executionId;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -120,6 +120,35 @@ describe('WorkflowExecutionService', () => {
|
|||
|
||||
expect(workflowRunner.run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should forward deduplicationKey to `WorkflowRunner.run()`', async () => {
|
||||
const node = mock<INode>();
|
||||
const workflow = mock<IWorkflowBase>({
|
||||
active: true,
|
||||
activeVersionId: 'some-version-id',
|
||||
nodes: [node],
|
||||
});
|
||||
|
||||
workflowRunner.run.mockResolvedValue('fake-execution-id');
|
||||
|
||||
await workflowExecutionService.runWorkflow(
|
||||
workflow,
|
||||
node,
|
||||
[[]],
|
||||
mock(),
|
||||
'trigger',
|
||||
undefined,
|
||||
'wf-1:node-1:1700000000000',
|
||||
);
|
||||
|
||||
expect(workflowRunner.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deduplicationKey: 'wf-1:node-1:1700000000000' }),
|
||||
true,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeManually()', () => {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export class WorkflowExecutionService {
|
|||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
mode: WorkflowExecuteMode,
|
||||
responsePromise?: IDeferredPromise<IExecuteResponsePromiseData>,
|
||||
deduplicationKey?: string,
|
||||
) {
|
||||
const nodeExecutionStack: IExecuteData[] = [
|
||||
{
|
||||
|
|
@ -84,6 +85,7 @@ export class WorkflowExecutionService {
|
|||
executionMode: mode,
|
||||
executionData,
|
||||
workflowData,
|
||||
deduplicationKey,
|
||||
};
|
||||
|
||||
return await this.workflowRunner.run(runData, true, undefined, undefined, responsePromise);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { WorkflowEntity } from '@n8n/db';
|
|||
import { generateNanoId, WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
import { MultiMainSetup } from '@/scaling/multi-main-setup.ee';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { createWorkflow, testDb } from '@n8n/backend-test-utils';
|
||||
import type { CreateExecutionPayload, WorkflowEntity } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { createEmptyRunExecutionData } from 'n8n-workflow';
|
||||
|
||||
import { DuplicateExecutionError } from '@/errors/duplicate-execution.error';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
|
||||
describe('ExecutionPersistence', () => {
|
||||
let executionPersistence: ExecutionPersistence;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
executionPersistence = Container.get(ExecutionPersistence);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['ExecutionEntity']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
describe('create with deduplicationKey', () => {
|
||||
const buildPayload = (
|
||||
workflow: WorkflowEntity,
|
||||
deduplicationKey?: string,
|
||||
): CreateExecutionPayload => ({
|
||||
data: createEmptyRunExecutionData(),
|
||||
workflowData: workflow,
|
||||
mode: 'trigger',
|
||||
finished: false,
|
||||
status: 'new',
|
||||
workflowId: workflow.id,
|
||||
deduplicationKey,
|
||||
});
|
||||
|
||||
it('creates multiple executions when deduplicationKey is null', async () => {
|
||||
const workflow = await createWorkflow();
|
||||
|
||||
const id1 = await executionPersistence.create(buildPayload(workflow));
|
||||
const id2 = await executionPersistence.create(buildPayload(workflow));
|
||||
const id3 = await executionPersistence.create(buildPayload(workflow));
|
||||
|
||||
expect(new Set([id1, id2, id3]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('creates executions with distinct deduplicationKeys', async () => {
|
||||
const workflow = await createWorkflow();
|
||||
|
||||
const id1 = await executionPersistence.create(buildPayload(workflow, 'wf:node:t1'));
|
||||
const id2 = await executionPersistence.create(buildPayload(workflow, 'wf:node:t2'));
|
||||
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it('throws DuplicateExecutionError when deduplicationKey is reused', async () => {
|
||||
const workflow = await createWorkflow();
|
||||
const key = 'wf:node:t1';
|
||||
|
||||
await executionPersistence.create(buildPayload(workflow, key));
|
||||
|
||||
await expect(executionPersistence.create(buildPayload(workflow, key))).rejects.toBeInstanceOf(
|
||||
DuplicateExecutionError,
|
||||
);
|
||||
await expect(executionPersistence.create(buildPayload(workflow, key))).rejects.toMatchObject({
|
||||
deduplicationKey: key,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -34,7 +34,7 @@ import {
|
|||
saveCredential,
|
||||
shareCredentialWithProjects,
|
||||
} from './shared/db/credentials';
|
||||
import { createMember, createOwner, createUser } from './shared/db/users';
|
||||
import { createChatUser, createMember, createOwner, createUser } from './shared/db/users';
|
||||
import * as utils from './shared/utils/';
|
||||
|
||||
import { ActiveWorkflowManager } from '@/active-workflow-manager';
|
||||
|
|
@ -143,6 +143,133 @@ describe('GET /projects/', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('GET /projects/sharing-candidates', () => {
|
||||
test('member sees own personal project plus all peer personal projects', async () => {
|
||||
const [member1, member2, member3] = await Promise.all([
|
||||
createMember(),
|
||||
createMember(),
|
||||
createMember(),
|
||||
]);
|
||||
const [teamProject1, teamProject2] = await Promise.all([
|
||||
createTeamProject(undefined, member1),
|
||||
createTeamProject(),
|
||||
]);
|
||||
const [personal1, personal2, personal3] = await Promise.all([
|
||||
getPersonalProject(member1),
|
||||
getPersonalProject(member2),
|
||||
getPersonalProject(member3),
|
||||
]);
|
||||
|
||||
const resp = await testServer
|
||||
.authAgentFor(member1)
|
||||
.get('/projects/sharing-candidates')
|
||||
.query({ take: 50, skip: 0 });
|
||||
expect(resp.status).toBe(200);
|
||||
const respProjects = resp.body.data as Project[];
|
||||
|
||||
// Own + peer personal projects appear (3 personal projects total)
|
||||
expect(respProjects.find((p) => p.id === personal1.id)).not.toBeUndefined();
|
||||
expect(respProjects.find((p) => p.id === personal2.id)).not.toBeUndefined();
|
||||
expect(respProjects.find((p) => p.id === personal3.id)).not.toBeUndefined();
|
||||
|
||||
// Team project the caller is a member of appears
|
||||
expect(respProjects.find((p) => p.id === teamProject1.id)).not.toBeUndefined();
|
||||
|
||||
// Team project the caller is NOT a member of does not appear
|
||||
expect(respProjects.find((p) => p.id === teamProject2.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('member does not see peer team projects they are not a member of', async () => {
|
||||
const [member1, member2] = await Promise.all([createMember(), createMember()]);
|
||||
const peerOnlyTeam = await createTeamProject(undefined, member2);
|
||||
|
||||
const resp = await testServer
|
||||
.authAgentFor(member1)
|
||||
.get('/projects/sharing-candidates')
|
||||
.query({ take: 50, skip: 0 });
|
||||
expect(resp.status).toBe(200);
|
||||
const respProjects = resp.body.data as Project[];
|
||||
|
||||
expect(respProjects.find((p) => p.id === peerOnlyTeam.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('search filter narrows results across personal and relation branches', async () => {
|
||||
const [member1, peer] = await Promise.all([
|
||||
createUser({ firstName: 'Alice', lastName: 'Anderson' }),
|
||||
createUser({ firstName: 'Bob', lastName: 'Banana' }),
|
||||
]);
|
||||
const matchingTeam = await createTeamProject('Banana Republic', member1);
|
||||
const nonMatchingTeam = await createTeamProject('Other Project', member1);
|
||||
|
||||
const resp = await testServer
|
||||
.authAgentFor(member1)
|
||||
.get('/projects/sharing-candidates')
|
||||
.query({ take: 50, skip: 0, search: 'banana' });
|
||||
expect(resp.status).toBe(200);
|
||||
const respProjects = resp.body.data as Project[];
|
||||
const peerPersonal = await getPersonalProject(peer);
|
||||
|
||||
// Matches by team name
|
||||
expect(respProjects.find((p) => p.id === matchingTeam.id)).not.toBeUndefined();
|
||||
// Matches peer personal project (name contains "Banana")
|
||||
expect(respProjects.find((p) => p.id === peerPersonal.id)).not.toBeUndefined();
|
||||
// Non-matching team does not appear
|
||||
expect(respProjects.find((p) => p.id === nonMatchingTeam.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('type=team filter excludes peer personal projects', async () => {
|
||||
const [member1, member2] = await Promise.all([createMember(), createMember()]);
|
||||
const team = await createTeamProject(undefined, member1);
|
||||
const peerPersonal = await getPersonalProject(member2);
|
||||
|
||||
const resp = await testServer
|
||||
.authAgentFor(member1)
|
||||
.get('/projects/sharing-candidates')
|
||||
.query({ take: 50, skip: 0, type: 'team' });
|
||||
expect(resp.status).toBe(200);
|
||||
const respProjects = resp.body.data as Project[];
|
||||
|
||||
expect(respProjects.find((p) => p.id === team.id)).not.toBeUndefined();
|
||||
expect(respProjects.find((p) => p.id === peerPersonal.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('owner sees all projects via the admin path', async () => {
|
||||
const [owner, peer1, peer2] = await Promise.all([
|
||||
createOwner(),
|
||||
createMember(),
|
||||
createMember(),
|
||||
]);
|
||||
const [team1, team2] = await Promise.all([createTeamProject(), createTeamProject()]);
|
||||
const [ownerPersonal, peer1Personal, peer2Personal] = await Promise.all([
|
||||
getPersonalProject(owner),
|
||||
getPersonalProject(peer1),
|
||||
getPersonalProject(peer2),
|
||||
]);
|
||||
|
||||
const resp = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/projects/sharing-candidates')
|
||||
.query({ take: 50, skip: 0 });
|
||||
expect(resp.status).toBe(200);
|
||||
const respProjects = resp.body.data as Project[];
|
||||
|
||||
// All five projects accessible to the admin
|
||||
for (const expected of [ownerPersonal, peer1Personal, peer2Personal, team1, team2]) {
|
||||
expect(respProjects.find((p) => p.id === expected.id)).not.toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
test('caller without user:list global scope receives 403', async () => {
|
||||
const chatUser = await createChatUser();
|
||||
|
||||
const resp = await testServer
|
||||
.authAgentFor(chatUser)
|
||||
.get('/projects/sharing-candidates')
|
||||
.query({ take: 50, skip: 0 });
|
||||
expect(resp.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project members endpoints', () => {
|
||||
test('POST /projects/:projectId/users adds a member and emits telemetry', async () => {
|
||||
const owner = await createOwner();
|
||||
|
|
|
|||
|
|
@ -48,11 +48,13 @@ describe('With license unlimited quota:users', () => {
|
|||
await authOwnerAgent.get('/users').expect(401);
|
||||
});
|
||||
|
||||
test('should forbid global user list for a member API key', async () => {
|
||||
test('should allow global user list for a member API key with user:list scope', async () => {
|
||||
const member = await createMemberWithApiKey();
|
||||
await createUser();
|
||||
|
||||
await testServer.publicApiAgentFor(member).get('/users').expect(403);
|
||||
const response = await testServer.publicApiAgentFor(member).get('/users').expect(200);
|
||||
|
||||
expect(response.body.data.length).toBe(2);
|
||||
});
|
||||
|
||||
test('should allow member to list users of a project they belong to', async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.4",
|
||||
"description": "Core functionality of n8n",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -456,6 +456,73 @@ describe('establishExecutionContext', () => {
|
|||
'parent-exec-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip hook augmentation when runtimeData is already set (queue-mode worker resume)', async () => {
|
||||
// When the main process established the context before persisting,
|
||||
// the worker fetches the execution with runtimeData already populated.
|
||||
// The function must return immediately without re-running hook augmentation.
|
||||
const existingContext: IExecutionContext = {
|
||||
version: 1,
|
||||
establishedAt: 1234567890,
|
||||
source: 'webhook',
|
||||
credentials: 'encrypted-credentials-blob',
|
||||
};
|
||||
|
||||
const webhookNode = mock<INode>({
|
||||
name: 'Webhook',
|
||||
type: 'n8n-nodes-base.webhook',
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
const runExecutionData = createRunExecutionData({
|
||||
startData: {},
|
||||
resultData: { runData: {} },
|
||||
executionData: {
|
||||
contextData: {},
|
||||
nodeExecutionStack: [
|
||||
{
|
||||
node: webhookNode,
|
||||
data: {
|
||||
main: [
|
||||
[
|
||||
{
|
||||
json: {
|
||||
headers: { authorization: 'original-header-value' },
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
source: null,
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
waitingExecution: {},
|
||||
waitingExecutionSource: {},
|
||||
runtimeData: existingContext,
|
||||
},
|
||||
});
|
||||
|
||||
await establishExecutionContext(
|
||||
mockWorkflow,
|
||||
runExecutionData,
|
||||
mockAdditionalData,
|
||||
'webhook',
|
||||
);
|
||||
|
||||
// runtimeData reference is preserved — same object, same values
|
||||
expect(runExecutionData.executionData!.runtimeData).toBe(existingContext);
|
||||
expect(runExecutionData.executionData!.runtimeData!.credentials).toBe(
|
||||
'encrypted-credentials-blob',
|
||||
);
|
||||
|
||||
// Trigger items are left untouched (hook augmentation was skipped).
|
||||
// The main process already applied any transformations before persisting;
|
||||
// this assertion just verifies the worker-side call is a no-op.
|
||||
const headers = runExecutionData.executionData!.nodeExecutionStack[0].data.main[0]![0].json
|
||||
.headers as Record<string, string>;
|
||||
expect(headers.authorization).toBe('original-header-value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sub-workflow context inheritance', () => {
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ import { ExecutionContextService } from './execution-context.service';
|
|||
export const establishExecutionContext = async (
|
||||
workflow: Workflow,
|
||||
runExecutionData: IRunExecutionData,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
additionalData: IWorkflowExecuteAdditionalData | undefined,
|
||||
mode: WorkflowExecuteMode,
|
||||
): Promise<void> => {
|
||||
assertExecutionDataExists(runExecutionData.executionData, workflow, additionalData, mode);
|
||||
|
|
|
|||
|
|
@ -91,4 +91,5 @@ export * from './execution-context-hook-registry.service';
|
|||
export { ExecutionLifecycleHooks } from './execution-lifecycle-hooks';
|
||||
export { ExternalSecretsProxy, type IExternalSecretsManager } from './external-secrets-proxy';
|
||||
export { ExecutionContextService } from './execution-context.service';
|
||||
export { establishExecutionContext } from './execution-context';
|
||||
export { isEngineRequest } from './requests-response';
|
||||
|
|
|
|||
|
|
@ -500,7 +500,7 @@ describe('Request Helper Functions', () => {
|
|||
hostname: 'example.de',
|
||||
href: requestObject.uri,
|
||||
};
|
||||
axiosOptions.beforeRedirect!(redirectOptions, mock());
|
||||
axiosOptions.beforeRedirect!(redirectOptions, mock(), mock());
|
||||
expect(redirectOptions.agent).toEqual(redirectOptions.agents.https);
|
||||
expect((redirectOptions.agent as HttpsAgent).options).toMatchObject({
|
||||
servername: 'example.de',
|
||||
|
|
@ -1917,7 +1917,7 @@ describe('Request Helper Functions', () => {
|
|||
};
|
||||
|
||||
expect(axiosOptions.beforeRedirect).toBeDefined();
|
||||
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock())).toThrow(
|
||||
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock(), mock())).toThrow(
|
||||
'Domain not allowed',
|
||||
);
|
||||
});
|
||||
|
|
@ -1936,7 +1936,9 @@ describe('Request Helper Functions', () => {
|
|||
};
|
||||
|
||||
expect(axiosOptions.beforeRedirect).toBeDefined();
|
||||
expect(() => axiosOptions.beforeRedirect!(redirectOptions, mock())).not.toThrow();
|
||||
expect(() =>
|
||||
axiosOptions.beforeRedirect!(redirectOptions, mock(), mock()),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1955,7 +1957,7 @@ describe('Request Helper Functions', () => {
|
|||
};
|
||||
|
||||
expect(axiosConfig.beforeRedirect).toBeDefined();
|
||||
expect(() => axiosConfig.beforeRedirect!(redirectOptions, mock())).toThrow(
|
||||
expect(() => axiosConfig.beforeRedirect!(redirectOptions, mock(), mock())).toThrow(
|
||||
'Domain not allowed',
|
||||
);
|
||||
});
|
||||
|
|
@ -1975,7 +1977,9 @@ describe('Request Helper Functions', () => {
|
|||
};
|
||||
|
||||
expect(axiosConfig.beforeRedirect).toBeDefined();
|
||||
expect(() => axiosConfig.beforeRedirect!(redirectOptions, mock())).not.toThrow();
|
||||
expect(() =>
|
||||
axiosConfig.beforeRedirect!(redirectOptions, mock(), mock()),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -424,7 +424,11 @@ export async function proxyRequestToAxios(
|
|||
): Promise<any> {
|
||||
let axiosConfig: AxiosRequestConfig = {
|
||||
maxBodyLength: Infinity,
|
||||
maxContentLength: Infinity,
|
||||
// -1 is the Axios sentinel for "no limit". Infinity also means no limit but
|
||||
// Axios 1.15.1+ treats any value > -1 as a finite cap, wrapping stream responses
|
||||
// in Readable.from() even when the limit is Infinity. That breaks the downstream
|
||||
// `instanceof IncomingMessage` checks in parseIncomingMessage / prepareBinaryData.
|
||||
maxContentLength: -1,
|
||||
};
|
||||
let configObject: IRequestOptions;
|
||||
if (typeof uriOrObject === 'string') {
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import {
|
|||
export function assertExecutionDataExists(
|
||||
executionData: IRunExecutionData['executionData'],
|
||||
workflow: Workflow,
|
||||
additionalData: IWorkflowExecuteAdditionalData,
|
||||
additionalData: IWorkflowExecuteAdditionalData | undefined,
|
||||
mode: WorkflowExecuteMode,
|
||||
): asserts executionData is NonNullable<IRunExecutionData['executionData']> {
|
||||
if (!executionData) {
|
||||
throw new UnexpectedError('Failed to run workflow due to missing execution data', {
|
||||
extra: {
|
||||
workflowId: workflow.id,
|
||||
executionId: additionalData.executionId,
|
||||
executionId: additionalData?.executionId,
|
||||
mode,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/rest-api-client",
|
||||
"type": "module",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.2",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "2.19.0",
|
||||
"version": "2.19.4",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -702,6 +702,11 @@ function onRenameNode(value: string) {
|
|||
align-items: normal;
|
||||
font-size: var(--font-size--2xs);
|
||||
|
||||
textarea {
|
||||
height: 100%;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
:global(.cm-editor) {
|
||||
background-color: var(--code--color--background);
|
||||
width: 100%;
|
||||
|
|
|
|||
|
|
@ -509,6 +509,29 @@ describe('WorkflowHeaderDraftPublishActions', () => {
|
|||
expect(getByTestId('version-menu-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should keep the version menu enabled when workflow is published with no changes and unpublish is unavailable', () => {
|
||||
workflowsStore.workflowTriggerNodes = [triggerNode];
|
||||
workflowsStore.workflow.versionId = 'version-1';
|
||||
workflowDocumentStore.setActiveState({
|
||||
activeVersionId: 'version-1',
|
||||
activeVersion: createMockActiveVersion('version-1'),
|
||||
});
|
||||
uiStore.markStateClean();
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...defaultWorkflowProps,
|
||||
workflowPermissions: {
|
||||
...defaultWorkflowProps.workflowPermissions,
|
||||
unpublish: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('workflow-open-publish-modal-button')).toBeDisabled();
|
||||
expect(getByTestId('version-menu-button')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('should show publish button enabled when workflow has never been published (no active version)', () => {
|
||||
workflowsStore.workflowTriggerNodes = [triggerNode];
|
||||
workflowsStore.workflow.versionId = 'version-1';
|
||||
|
|
|
|||
|
|
@ -353,6 +353,10 @@ const versionMenuActions = computed<Array<ActionDropdownItem<VERSION_ACTIONS>>>(
|
|||
});
|
||||
|
||||
const shouldDisableActionDropdown = computed(() => {
|
||||
if (activeVersion.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return versionMenuActions.value.every((action) => action.disabled);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ describe('WorkflowShareModal.ee.vue', () => {
|
|||
settingsStore.settings.enterprise = { sharing: true } as FrontendSettings['enterprise'];
|
||||
workflowsEEStore.getWorkflowOwnerName = vi.fn(() => 'Owner Name');
|
||||
projectsStore.personalProjects = [createProjectListItem()];
|
||||
projectsStore.searchProjects.mockResolvedValue({
|
||||
projectsStore.searchShareableProjects.mockResolvedValue({
|
||||
count: projectsStore.personalProjects.length,
|
||||
data: projectsStore.personalProjects,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,6 +21,27 @@ export const searchProjects = async (
|
|||
return await getFullApiResponse<ProjectListItem[]>(context, 'GET', '/projects', params);
|
||||
};
|
||||
|
||||
// Returns projects the caller can pick as share targets, including peer
|
||||
// personal projects so the workflow / credential share dropdowns can list
|
||||
// other users. Backed by `GET /rest/projects/sharing-candidates`.
|
||||
export const searchShareableProjects = async (
|
||||
context: IRestApiContext,
|
||||
params: {
|
||||
search?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
type?: 'personal' | 'team';
|
||||
activated?: boolean;
|
||||
},
|
||||
): Promise<{ count: number; data: ProjectListItem[] }> => {
|
||||
return await getFullApiResponse<ProjectListItem[]>(
|
||||
context,
|
||||
'GET',
|
||||
'/projects/sharing-candidates',
|
||||
params,
|
||||
);
|
||||
};
|
||||
|
||||
export const getMyProjects = async (context: IRestApiContext): Promise<ProjectListItem[]> => {
|
||||
return await makeRestApiRequest(context, 'GET', '/projects/my-projects');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -126,6 +126,16 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
return await projectsApi.searchProjects(rootStore.restApiContext, params);
|
||||
};
|
||||
|
||||
const searchShareableProjects = async (params: {
|
||||
search?: string;
|
||||
take?: number;
|
||||
skip?: number;
|
||||
type?: 'personal' | 'team';
|
||||
activated?: boolean;
|
||||
}) => {
|
||||
return await projectsApi.searchShareableProjects(rootStore.restApiContext, params);
|
||||
};
|
||||
|
||||
const fetchProject = async (id: string) =>
|
||||
await projectsApi.getProject(rootStore.restApiContext, id);
|
||||
|
||||
|
|
@ -345,6 +355,7 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
|||
projectNavActiveId,
|
||||
setCurrentProject,
|
||||
searchProjects,
|
||||
searchShareableProjects,
|
||||
getAllProjects,
|
||||
getMyProjects,
|
||||
getPersonalProject,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { splitName } from './projects.utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import {
|
||||
splitName,
|
||||
useRemoteProjectSearch,
|
||||
DEFAULT_PROJECT_SEARCH_PAGE_SIZE,
|
||||
} from './projects.utils';
|
||||
import { useProjectsStore } from './projects.store';
|
||||
|
||||
describe('splitName', () => {
|
||||
test.each([
|
||||
|
|
@ -76,3 +82,24 @@ describe('splitName', () => {
|
|||
expect(splitName(input)).toEqual(result);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRemoteProjectSearch', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('routes to the sharing-candidates endpoint via store.searchShareableProjects', async () => {
|
||||
const store = useProjectsStore();
|
||||
const spy = vi
|
||||
.spyOn(store, 'searchShareableProjects')
|
||||
.mockResolvedValue({ count: 0, data: [] });
|
||||
|
||||
const search = useRemoteProjectSearch();
|
||||
await search('alice');
|
||||
|
||||
expect(spy).toHaveBeenCalledWith({
|
||||
search: 'alice',
|
||||
take: DEFAULT_PROJECT_SEARCH_PAGE_SIZE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,13 +8,15 @@ export type ProjectSearchResult = { count: number; data: ProjectListItem[] };
|
|||
export type ProjectSearchFn = (query: string) => Promise<ProjectSearchResult>;
|
||||
|
||||
/**
|
||||
* Remote search for Group 1 consumers (sharing/transfer modals).
|
||||
* Always searches via GET /projects?search=&take= for ALL roles.
|
||||
* Remote search for Group 1 consumers (sharing / transfer / user-deletion modals).
|
||||
* Hits `GET /projects/sharing-candidates` so non-admin callers receive peer
|
||||
* personal projects in addition to projects they have a relation to — without
|
||||
* that, the share dropdown would be empty for `global:member` users.
|
||||
*/
|
||||
export function useRemoteProjectSearch(): ProjectSearchFn {
|
||||
const store = useProjectsStore();
|
||||
return async (query: string) => {
|
||||
return await store.searchProjects({
|
||||
return await store.searchShareableProjects({
|
||||
search: query,
|
||||
take: DEFAULT_PROJECT_SEARCH_PAGE_SIZE,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -293,7 +293,11 @@ watch(showOAuthSuccessBanner, (newValue, oldValue) => {
|
|||
/>
|
||||
|
||||
<template v-if="isQuickConnectMode">
|
||||
<QuickConnectBanner v-if="quickConnectBannerText" :text="quickConnectBannerText" />
|
||||
<QuickConnectBanner
|
||||
v-if="quickConnectBannerText || quickConnectOption?.disclaimer"
|
||||
:text="quickConnectBannerText"
|
||||
:disclaimer="quickConnectOption?.disclaimer"
|
||||
/>
|
||||
<QuickConnectButton
|
||||
:service-name="serviceName"
|
||||
:credential-type-name="credentialType.name"
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ describe('CredentialSharing.ee', () => {
|
|||
// Mock store methods
|
||||
vi.spyOn(usersStore, 'fetchUsers').mockResolvedValue();
|
||||
vi.spyOn(projectsStore, 'getAllProjects').mockResolvedValue();
|
||||
vi.spyOn(projectsStore, 'searchProjects').mockResolvedValue({
|
||||
vi.spyOn(projectsStore, 'searchShareableProjects').mockResolvedValue({
|
||||
count: testProjects.length,
|
||||
data: testProjects,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,9 +23,105 @@ describe('QuickConnectBanner', () => {
|
|||
expect(wrapper.getByText(text)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render callout when text is empty', () => {
|
||||
it('should not render when text is empty and no disclaimer is provided', () => {
|
||||
const wrapper = renderComponent({ pinia, props: { text: '' } });
|
||||
|
||||
expect(wrapper.queryByTestId('quick-connect-banner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the disclaimer text and link with provided linkUrl and linkLabel', () => {
|
||||
const wrapper = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
disclaimer: {
|
||||
text: 'Subject to terms (available {link}).',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
linkLabel: 'over here',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const disclaimer = wrapper.getByTestId('quick-connect-banner-disclaimer');
|
||||
expect(disclaimer).toBeInTheDocument();
|
||||
expect(disclaimer).toHaveTextContent('Subject to terms (available over here).');
|
||||
const link = disclaimer.querySelector('a');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/terms');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveTextContent('over here');
|
||||
});
|
||||
|
||||
it('should default linkLabel to "here" when omitted', () => {
|
||||
const wrapper = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
disclaimer: {
|
||||
text: 'Subject to terms (available {link}).',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const disclaimer = wrapper.getByTestId('quick-connect-banner-disclaimer');
|
||||
expect(disclaimer.querySelector('a')).toHaveTextContent('here');
|
||||
});
|
||||
|
||||
it('should escape HTML in disclaimer text, linkLabel, and linkUrl', () => {
|
||||
const wrapper = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
disclaimer: {
|
||||
text: 'Read <terms> (available {link}).',
|
||||
linkUrl: 'https://example.com/terms?a=1&b=2',
|
||||
linkLabel: '<b>evil</b>',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const disclaimer = wrapper.getByTestId('quick-connect-banner-disclaimer');
|
||||
expect(disclaimer.querySelector('b')).toBeNull();
|
||||
expect(disclaimer).toHaveTextContent('Read <terms> (available <b>evil</b>).');
|
||||
|
||||
const link = disclaimer.querySelector('a');
|
||||
expect(link).not.toBeNull();
|
||||
expect(link).toHaveTextContent('<b>evil</b>');
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/terms?a=1&b=2');
|
||||
});
|
||||
|
||||
it('should render disclaimer alongside callout text', () => {
|
||||
const wrapper = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
text: 'Offer text',
|
||||
disclaimer: {
|
||||
text: 'Subject to terms (available {link}).',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getByText('Offer text')).toBeInTheDocument();
|
||||
expect(wrapper.getByTestId('quick-connect-banner-disclaimer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the disclaimer paragraph when disclaimer prop is omitted', () => {
|
||||
const wrapper = renderComponent({ pinia, props: { text: 'Offer text' } });
|
||||
|
||||
expect(wrapper.queryByTestId('quick-connect-banner-disclaimer')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render only the disclaimer when text is empty but disclaimer is provided', () => {
|
||||
const wrapper = renderComponent({
|
||||
pinia,
|
||||
props: {
|
||||
disclaimer: {
|
||||
text: 'Subject to terms (available {link}).',
|
||||
linkUrl: 'https://example.com/terms',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.getByTestId('quick-connect-banner')).toBeInTheDocument();
|
||||
expect(wrapper.getByTestId('quick-connect-banner-disclaimer')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import type { QuickConnectDisclaimer } from '@n8n/api-types';
|
||||
import { N8nCallout } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { text } = defineProps<{
|
||||
text: string;
|
||||
const { text, disclaimer } = defineProps<{
|
||||
text?: string;
|
||||
disclaimer?: QuickConnectDisclaimer;
|
||||
}>();
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const disclaimerHtml = computed(() => {
|
||||
if (!disclaimer) return '';
|
||||
const label = escapeHtml(disclaimer.linkLabel ?? 'here');
|
||||
const url = escapeHtml(disclaimer.linkUrl);
|
||||
const link = `<a href="${url}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
const parts = disclaimer.text.split('{link}').map(escapeHtml);
|
||||
return parts.join(link);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCallout v-if="text" theme="secondary" iconless data-test-id="quick-connect-banner">
|
||||
<div v-n8n-html="text"></div>
|
||||
</N8nCallout>
|
||||
<div v-if="text || disclaimer" :class="$style.wrapper" data-test-id="quick-connect-banner">
|
||||
<N8nCallout v-if="text" theme="secondary" iconless>
|
||||
<div v-n8n-html="text"></div>
|
||||
</N8nCallout>
|
||||
<div
|
||||
v-if="disclaimer"
|
||||
:class="$style.disclaimer"
|
||||
data-test-id="quick-connect-banner-disclaimer"
|
||||
v-n8n-html="disclaimerHtml"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
margin: 0;
|
||||
font-size: var(--font-size--2xs);
|
||||
font-style: italic;
|
||||
color: var(--color--text--tint-1);
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -764,6 +764,7 @@ function handleSelectAction(params: INodeParameters) {
|
|||
<QuickConnectBanner
|
||||
v-if="showQuickConnectBanner"
|
||||
:text="quickConnect?.text ?? ''"
|
||||
:disclaimer="quickConnect?.disclaimer"
|
||||
:class="$style.quickConnectBanner"
|
||||
/>
|
||||
<NodeCredentials
|
||||
|
|
|
|||
|
|
@ -175,7 +175,11 @@ onMounted(async () => {
|
|||
</N8nTooltip>
|
||||
</div>
|
||||
|
||||
<QuickConnectBanner v-if="quickConnect" :text="quickConnect?.text" />
|
||||
<QuickConnectBanner
|
||||
v-if="quickConnect"
|
||||
:text="quickConnect?.text"
|
||||
:disclaimer="quickConnect?.disclaimer"
|
||||
/>
|
||||
<ContactAdministratorToInstall v-if="!isAdminOrOwner && !communityNodeDetails?.installed" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ describe('DeleteUserModal', () => {
|
|||
usersStore = mockedStore(useUsersStore);
|
||||
|
||||
const projectsStore = mockedStore(useProjectsStore);
|
||||
projectsStore.searchProjects.mockResolvedValue({
|
||||
projectsStore.searchShareableProjects.mockResolvedValue({
|
||||
count: initialState[STORES.PROJECTS].projects.length,
|
||||
data: initialState[STORES.PROJECTS].projects,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -273,7 +273,7 @@ const callouts = computed<INodeCreateElement[]>(() => []);
|
|||
|
||||
<CommunityNodeInfo v-if="communityNodeDetails" />
|
||||
<div :class="$style.banner" v-if="quickConnect">
|
||||
<QuickConnectBanner :text="quickConnect.text" />
|
||||
<QuickConnectBanner :text="quickConnect.text" :disclaimer="quickConnect.disclaimer" />
|
||||
</div>
|
||||
<OrderSwitcher v-if="rootView" :root-view="rootView">
|
||||
<template v-if="shouldShowTriggers" #triggers>
|
||||
|
|
|
|||
|
|
@ -1,30 +1,64 @@
|
|||
import { readFile as fsReadFile } from 'fs/promises';
|
||||
import { mockDeep, type DeepMockProxy } from 'jest-mock-extended';
|
||||
import type { IExecuteFunctions, ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import { getWorkflowInfo } from './GenericFunctions';
|
||||
|
||||
jest.mock('fs/promises', () => ({
|
||||
readFile: jest.fn().mockResolvedValue('sensitive data'),
|
||||
}));
|
||||
jest.mock('fs/promises', () => ({ readFile: jest.fn() }));
|
||||
|
||||
const mockReadFile = jest.mocked(fsReadFile);
|
||||
const enoentError = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
|
||||
describe('ExecuteWorkflow node - GenericFunctions', () => {
|
||||
let executeFunctionsMock: DeepMockProxy<ILoadOptionsFunctions | IExecuteFunctions>;
|
||||
let executeFunctionsMock: DeepMockProxy<IExecuteFunctions>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
executeFunctionsMock = mockDeep<ILoadOptionsFunctions | IExecuteFunctions>();
|
||||
executeFunctionsMock = mockDeep<IExecuteFunctions>();
|
||||
executeFunctionsMock.helpers.resolvePath.mockResolvedValue('path/to/file' as never);
|
||||
});
|
||||
|
||||
describe('getWorkflowInfo', () => {
|
||||
it('should throw an error without the file content when source is localFile and the file is not json', async () => {
|
||||
executeFunctionsMock.getNode.mockReturnValue({
|
||||
typeVersion: 1,
|
||||
} as INode);
|
||||
executeFunctionsMock.getNodeParameter.mockReturnValue('path/to/file');
|
||||
describe('when source is localFile', () => {
|
||||
it('should throw an error without the file content when the file is not json', async () => {
|
||||
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
|
||||
executeFunctionsMock.getNodeParameter.mockReturnValue('path/to/file');
|
||||
mockReadFile.mockResolvedValueOnce('non-json data');
|
||||
|
||||
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
|
||||
'The file content is not valid JSON',
|
||||
);
|
||||
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
|
||||
'The file content is not valid JSON',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error when the file is in a blocked path', async () => {
|
||||
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
|
||||
executeFunctionsMock.getNodeParameter.mockReturnValue('path/to/file');
|
||||
executeFunctionsMock.helpers.isFilePathBlocked.mockReturnValue(true);
|
||||
|
||||
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
|
||||
'Access to the workflow file path is not allowed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a friendly error when the parent directory does not exist', async () => {
|
||||
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
|
||||
executeFunctionsMock.getNodeParameter.mockReturnValue('/nonexistent/dir/file.json');
|
||||
executeFunctionsMock.helpers.resolvePath.mockRejectedValue(enoentError());
|
||||
|
||||
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
|
||||
'The file "/nonexistent/dir/file.json" could not be found, [item 0]',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw a friendly error when the file does not exist', async () => {
|
||||
executeFunctionsMock.getNode.mockReturnValue({ typeVersion: 1 } as INode);
|
||||
executeFunctionsMock.getNodeParameter.mockReturnValue('/existing/dir/missing.json');
|
||||
mockReadFile.mockRejectedValueOnce(enoentError());
|
||||
|
||||
await expect(getWorkflowInfo.call(executeFunctionsMock, 'localFile', 0)).rejects.toThrow(
|
||||
'The file "/existing/dir/missing.json" could not be found, [item 0]',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,16 +3,11 @@ import { NodeOperationError, jsonParse } from 'n8n-workflow';
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
IExecuteWorkflowInfo,
|
||||
ILoadOptionsFunctions,
|
||||
INodeParameterResourceLocator,
|
||||
IRequestOptions,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export async function getWorkflowInfo(
|
||||
this: ILoadOptionsFunctions | IExecuteFunctions,
|
||||
source: string,
|
||||
itemIndex = 0,
|
||||
) {
|
||||
export async function getWorkflowInfo(this: IExecuteFunctions, source: string, itemIndex = 0) {
|
||||
const workflowInfo: IExecuteWorkflowInfo = {};
|
||||
const nodeVersion = this.getNode().typeVersion;
|
||||
if (source === 'database') {
|
||||
|
|
@ -31,20 +26,29 @@ export async function getWorkflowInfo(
|
|||
// Read workflow from filesystem
|
||||
const workflowPath = this.getNodeParameter('workflowPath', itemIndex) as string;
|
||||
|
||||
let workflowJson;
|
||||
try {
|
||||
workflowJson = await fsReadFile(workflowPath, { encoding: 'utf8' });
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
const handleFileError = (error: unknown): never => {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
`The file "${workflowPath}" could not be found, [item ${itemIndex}]`,
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
const resolvedPath = await this.helpers.resolvePath(workflowPath).catch(handleFileError);
|
||||
|
||||
if (this.helpers.isFilePathBlocked(resolvedPath)) {
|
||||
throw new NodeOperationError(
|
||||
this.getNode(),
|
||||
'Access to the workflow file path is not allowed',
|
||||
);
|
||||
}
|
||||
|
||||
const workflowJson = await fsReadFile(resolvedPath, { encoding: 'utf8' }).catch(
|
||||
handleFileError,
|
||||
);
|
||||
|
||||
workflowInfo.code = jsonParse(workflowJson, {
|
||||
errorMessage: 'The file content is not valid JSON', // pass a custom error message to not expose the file contents
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import nock from 'nock';
|
|||
describe('Test Binary Data Download', () => {
|
||||
const baseUrl = 'https://dummy.domain';
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeAll(() => {
|
||||
nock(baseUrl)
|
||||
.persist()
|
||||
.get('/path/to/image.png')
|
||||
|
|
|
|||
|
|
@ -73,6 +73,44 @@ export async function destroy(conn: snowflake.Connection) {
|
|||
});
|
||||
}
|
||||
|
||||
export function escapeSnowflakeIdentifier(identifier: string): string {
|
||||
if (identifier.startsWith('"') && identifier.endsWith('"') && identifier.length > 2) {
|
||||
// Already quoted — preserve case (Snowflake quoted identifiers are case-sensitive)
|
||||
const bare = identifier.slice(1, -1).replace(/""/g, '"');
|
||||
return `"${bare.replace(/"/g, '""')}"`;
|
||||
}
|
||||
// Snowflake stores unquoted identifiers as UPPERCASE by default; uppercase for compatibility
|
||||
return `"${identifier.toUpperCase().replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function escapeSnowflakeObjectIdentifier(identifier: string): string {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < identifier.length; i++) {
|
||||
const char = identifier[i];
|
||||
if (char === '"') {
|
||||
if (inQuotes && identifier[i + 1] === '"') {
|
||||
// Escaped double-quote inside a quoted identifier
|
||||
current += '""';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '.' && !inQuotes) {
|
||||
parts.push(current);
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
parts.push(current);
|
||||
|
||||
return parts.map(escapeSnowflakeIdentifier).join('.');
|
||||
}
|
||||
|
||||
export async function execute(
|
||||
conn: snowflake.Connection,
|
||||
sqlText: string,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user