mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-21 20:20:03 +02:00
Compare commits
9 Commits
master
...
n8n@2.39.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55ae555ef6 | ||
|
|
d1d58017c8 | ||
|
|
720d9a7f0c | ||
|
|
1feb9f2dac | ||
|
|
3e4f082109 | ||
|
|
387eeae868 | ||
|
|
514e20b83d | ||
|
|
63e2795e01 | ||
|
|
082305a895 |
22
CHANGELOG.md
22
CHANGELOG.md
|
|
@ -1,3 +1,25 @@
|
|||
## [2.39.2](https://github.com/n8n-io/n8n/compare/n8n@2.39.1...n8n@2.39.2) (2026-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Memoize pairedItem ancestry traversal ([#38212](https://github.com/n8n-io/n8n/issues/38212)) ([1feb9f2](https://github.com/n8n-io/n8n/commit/1feb9f2dac99744748e79b382b6f606d63c0ca5f))
|
||||
* **core:** Reuse an existing consent grant after the visitor authenticates mid-flow ([#38248](https://github.com/n8n-io/n8n/issues/38248)) ([d1d5801](https://github.com/n8n-io/n8n/commit/d1d58017c81f5d76a3860ba0c216d2faee538617))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **core:** Stop copying user workflows into the Instance AI sandbox ([#38199](https://github.com/n8n-io/n8n/issues/38199)) ([387eeae](https://github.com/n8n-io/n8n/commit/387eeae8683df9dd316a3809325807af3e54e26c))
|
||||
|
||||
|
||||
## [2.39.1](https://github.com/n8n-io/n8n/compare/n8n@2.39.0...n8n@2.39.1) (2026-09-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **core:** Repair legacy-format data-encryption keys during bootstrap ([#38142](https://github.com/n8n-io/n8n/issues/38142)) ([514e20b](https://github.com/n8n-io/n8n/commit/514e20b83d509bd07618cc48650fe783c4bf74f7))
|
||||
|
||||
|
||||
# [2.39.0](https://github.com/n8n-io/n8n/compare/n8n@2.38.1...n8n@2.39.0) (2026-09-08)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-monorepo",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/agents",
|
||||
"version": "0.24.0",
|
||||
"version": "0.24.2",
|
||||
"description": "AI agent SDK for n8n's code-first execution engine",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/ai-node-sdk",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.2",
|
||||
"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.32.0",
|
||||
"version": "0.32.2",
|
||||
"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.39.0",
|
||||
"version": "1.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/api-types",
|
||||
"version": "1.39.0",
|
||||
"version": "1.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export type BreakingChangeVersion = z.infer<typeof breakingChangeVersionSchema>;
|
|||
* When a new major version is released, update this value and add the
|
||||
* corresponding breaking-change rules on the backend.
|
||||
*/
|
||||
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = null;
|
||||
export const MIGRATION_REPORT_TARGET_VERSION: BreakingChangeVersion | null = 'v3';
|
||||
|
||||
// Common schemas
|
||||
const recommendationSchema = z.object({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-common",
|
||||
"version": "1.39.0",
|
||||
"version": "1.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-network",
|
||||
"version": "1.13.0",
|
||||
"version": "1.13.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/backend-test-utils",
|
||||
"version": "1.39.0",
|
||||
"version": "1.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/blob-storage",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.2",
|
||||
"description": "Blob storage layer for n8n, with filesystem, S3, and Azure Blob backends",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat-hub",
|
||||
"version": "1.32.0",
|
||||
"version": "1.32.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/client-oauth2",
|
||||
"version": "1.21.0",
|
||||
"version": "1.21.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/create-node",
|
||||
"version": "0.47.0",
|
||||
"version": "0.47.2",
|
||||
"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.39.0",
|
||||
"version": "1.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo .tmp-schema-docs",
|
||||
"gen:migration-index": "node scripts/generate-migration-index.mjs",
|
||||
|
|
|
|||
|
|
@ -130,6 +130,18 @@ export class DeploymentKeyRepository extends Repository<DeploymentKey> {
|
|||
return await this.find({ where: { type } });
|
||||
}
|
||||
|
||||
async findDataEncryptionKeys(): Promise<DeploymentKey[]> {
|
||||
return await this.find({ where: { type: 'data_encryption' } });
|
||||
}
|
||||
|
||||
async rewrapLegacyDataEncryptionValue(
|
||||
id: string,
|
||||
oldValue: string,
|
||||
wrappedValue: string,
|
||||
): Promise<void> {
|
||||
await this.update({ id, type: 'data_encryption', value: oldValue }, { value: wrappedValue });
|
||||
}
|
||||
|
||||
async findAndCountForList(
|
||||
opts: ListDeploymentKeysOptions,
|
||||
): Promise<{ items: DeploymentKey[]; count: number }> {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/decorators",
|
||||
"version": "1.39.0",
|
||||
"version": "1.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -116,7 +116,6 @@ reattached. The setup creates or materializes:
|
|||
| `node-types/index.txt` | Searchable node-type catalog |
|
||||
| `src/` | Workflow source files |
|
||||
| `chunks/` | Reusable source modules |
|
||||
| `workflows/` | Existing workflows materialized as WorkflowJSON |
|
||||
| `knowledge-base/` | Best-practice, template, and SDK reference material |
|
||||
| `.sandbox-initialized` | Setup marker |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/instance-ai",
|
||||
"version": "1.24.0",
|
||||
"version": "1.24.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"typecheck": "tsc --noEmit && tsc -p evaluations/tsconfig.json",
|
||||
|
|
|
|||
|
|
@ -308,7 +308,7 @@ describe('setupSandboxWorkspace', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('always creates workflows/, src/, and chunks/ even when no workflows exist', async () => {
|
||||
it('always creates src/ and chunks/', async () => {
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
|
|
@ -330,16 +330,11 @@ describe('setupSandboxWorkspace', () => {
|
|||
async () => {},
|
||||
);
|
||||
|
||||
// Setup context defaults to an empty workflow list, mirroring a fresh DB.
|
||||
await setupSandboxWorkspace(createFilesystemWorkspace(writeFile, mkdir), createSetupContext());
|
||||
|
||||
const mkdirPaths = mkdir.mock.calls.map(([path]) => path);
|
||||
expect(mkdirPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'/home/daytona/workspace/src',
|
||||
'/home/daytona/workspace/chunks',
|
||||
'/home/daytona/workspace/workflows',
|
||||
]),
|
||||
expect.arrayContaining(['/home/daytona/workspace/src', '/home/daytona/workspace/chunks']),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -422,43 +417,6 @@ describe('setupSandboxWorkspace', () => {
|
|||
expect(writtenPaths.some((p) => p.includes('/knowledge-base/templates/'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects setup file paths that escape the workspace root', async () => {
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
(
|
||||
...args: [SandboxWorkspace, string, string?]
|
||||
) => Promise<{ exitCode: number; stdout: string; stderr: string }>
|
||||
>();
|
||||
runInSandbox.mockImplementation(async (_workspace, command) => {
|
||||
await Promise.resolve();
|
||||
if (command.startsWith('cat ')) {
|
||||
return { exitCode: 1, stdout: '', stderr: '' };
|
||||
}
|
||||
return { exitCode: 0, stdout: '/home/daytona\n', stderr: '' };
|
||||
});
|
||||
const readFileViaSandbox: ReadFileViaSandboxMock =
|
||||
vi.fn<(...args: [SandboxWorkspace, string]) => Promise<string | null>>();
|
||||
readFileViaSandbox.mockResolvedValue(null);
|
||||
const setupSandboxWorkspace = loadSetupSandboxWorkspaceWithFsMocks(
|
||||
runInSandbox,
|
||||
readFileViaSandbox,
|
||||
);
|
||||
const writeFile = vi.fn<
|
||||
(...args: [string, string | Buffer, { recursive?: boolean }?]) => Promise<void>
|
||||
>(async () => {});
|
||||
const context = createSetupContext();
|
||||
const workflowService = context.workflowService as unknown as {
|
||||
list: Mock<(...args: [{ limit: number }]) => Promise<{ workflows: Array<{ id: string }> }>>;
|
||||
get: Mock<(...args: [string]) => Promise<Record<string, unknown>>>;
|
||||
};
|
||||
workflowService.list.mockResolvedValue({ workflows: [{ id: '../escape' }] });
|
||||
workflowService.get.mockResolvedValue({ id: '../escape' });
|
||||
|
||||
await expect(
|
||||
setupSandboxWorkspace(createFilesystemWorkspace(writeFile), context),
|
||||
).rejects.toThrow('Sandbox workspace setup failed during write-workspace-files');
|
||||
});
|
||||
|
||||
it('does not write the initialized marker when npm install fails', async () => {
|
||||
const runInSandbox: RunInSandboxMock =
|
||||
vi.fn<
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@
|
|||
* package.json # @n8n/workflow-sdk dependency
|
||||
* tsconfig.json # strict, noEmit, skipLibCheck
|
||||
* node_modules/@n8n/workflow-sdk/ # full SDK with .d.ts types
|
||||
* workflows/ # existing n8n workflows as JSON
|
||||
* node-types/
|
||||
* index.txt # searchable catalog: nodeType | displayName | description | version
|
||||
* src/
|
||||
|
|
@ -307,7 +306,7 @@ export function formatNodeCatalogLine(node: SearchableNodeDescription): string {
|
|||
}
|
||||
|
||||
/** Dirs the agent's `list_files` may probe; some providers 404 on missing dirs. */
|
||||
const ALWAYS_PRESENT_DIRS: readonly string[] = ['src', 'chunks', 'workflows'];
|
||||
const ALWAYS_PRESENT_DIRS: readonly string[] = ['src', 'chunks'];
|
||||
|
||||
async function writeWorkspaceFiles(
|
||||
workspace: SandboxWorkspace,
|
||||
|
|
@ -464,24 +463,6 @@ export async function setupSandboxWorkspace(
|
|||
const catalogLines = nodeTypes.map(formatNodeCatalogLine);
|
||||
files.set('node-types/index.txt', catalogLines.join('\n'));
|
||||
|
||||
// Existing workflows as JSON (fetch in parallel)
|
||||
try {
|
||||
const { workflows } = await context.workflowService.list({ limit: 100 });
|
||||
const results = await Promise.allSettled(
|
||||
workflows.map(async (summary) => {
|
||||
const detail = await context.workflowService.get(summary.id);
|
||||
return { id: summary.id, json: JSON.stringify(detail, null, 2) };
|
||||
}),
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
files.set(`workflows/${r.value.id}.json`, r.value.json);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Workflow listing failed — continue without syncing
|
||||
}
|
||||
|
||||
// ── Write workspace files ──────────────────────────────────────────────
|
||||
|
||||
await setupStep(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-apps",
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.2",
|
||||
"description": "MCP Apps UI resources and server helpers for n8n",
|
||||
"main": "dist/server/index.js",
|
||||
"types": "dist/server/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-cli",
|
||||
"version": "0.48.0",
|
||||
"version": "0.48.2",
|
||||
"description": "Official CLI for developing community nodes for n8n",
|
||||
"bin": {
|
||||
"n8n-node": "bin/n8n-node.mjs"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/node-engine-compatibility",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.2",
|
||||
"description": "Adapts v1 nodes and workflows to the Engine 2.0 execution model",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/n8n-nodes-langchain",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/scheduler",
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm watch",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/task-runner",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"start": "node dist/start.js",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/workflow-sdk",
|
||||
"version": "0.32.0",
|
||||
"version": "0.32.1",
|
||||
"description": "TypeScript SDK for programmatically creating n8n workflows",
|
||||
"exports": {
|
||||
".": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"description": "n8n Workflow Automation Tool",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ describe('DeprecationService', () => {
|
|||
['N8N_OUTBOUND_PROXY_MODE', 'main-only', true],
|
||||
['N8N_OUTBOUND_PROXY_MODE', 'all', false],
|
||||
['N8N_OUTBOUND_PROXY_MODE', undefined, false],
|
||||
['N8N_RUNNERS_MODE', 'internal', true],
|
||||
['N8N_RUNNERS_MODE', 'external', false],
|
||||
['N8N_RUNNERS_MODE', undefined, false],
|
||||
['N8N_SSRF_PROTECTION_ENABLED', 'true', true],
|
||||
['N8N_SSRF_PROTECTION_ENABLED', '1', true],
|
||||
['N8N_SSRF_PROTECTION_ENABLED', 'false', false],
|
||||
['N8N_SSRF_PROTECTION_ENABLED', undefined, false],
|
||||
])('should detect when %s is `%s`', (envVar, value, mustWarn) => {
|
||||
toTest(envVar, value, mustWarn);
|
||||
});
|
||||
|
|
@ -199,6 +206,28 @@ describe('DeprecationService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('N8N_SSRF_PROTECTION_ENABLED', () => {
|
||||
beforeEach(() => {
|
||||
process.env.N8N_SSRF_PROTECTION_ENABLED = 'true';
|
||||
});
|
||||
|
||||
test.each([undefined, 'default', 'DEFAULT , 100.64.0.0/10'])(
|
||||
'should warn when N8N_SSRF_BLOCKED_IP_RANGES is `%s`',
|
||||
(ranges) => {
|
||||
if (ranges === undefined) delete process.env.N8N_SSRF_BLOCKED_IP_RANGES;
|
||||
else process.env.N8N_SSRF_BLOCKED_IP_RANGES = ranges;
|
||||
deprecationService.warn();
|
||||
expect(logger.warn.mock.lastCall?.[0] ?? '').toContain('N8N_SSRF_PROTECTION_ENABLED');
|
||||
},
|
||||
);
|
||||
|
||||
test('should not warn when N8N_SSRF_BLOCKED_IP_RANGES lists literal ranges only', () => {
|
||||
process.env.N8N_SSRF_BLOCKED_IP_RANGES = '10.0.0.0/8,192.168.0.0/16';
|
||||
deprecationService.warn();
|
||||
expect(logger.warn.mock.lastCall?.[0] ?? '').not.toContain('N8N_SSRF_PROTECTION_ENABLED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('running outside a container', () => {
|
||||
const message = 'Running n8n outside a container is deprecated';
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,29 @@ export class DeprecationService {
|
|||
'The default for this variable will change to `false` in a future version. Set it to `true` explicitly to keep installing unverified community packages.',
|
||||
checkValue: (value?: string) => value === undefined,
|
||||
},
|
||||
{
|
||||
envVar: 'N8N_RUNNERS_MODE',
|
||||
message:
|
||||
'The `internal` mode is deprecated and will be removed in a future version. Run task runners as a separate process and set this variable to `external`.',
|
||||
checkValue: (value?: string) => value === 'internal',
|
||||
},
|
||||
{
|
||||
envVar: 'N8N_SSRF_PROTECTION_ENABLED',
|
||||
message:
|
||||
"The built-in blocked IP ranges will expand in a future version to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. To keep the current list, set N8N_SSRF_BLOCKED_IP_RANGES to the literal ranges instead of the `default` keyword, which always expands to the running version's built-in list.",
|
||||
checkValue: (value?: string) => ['true', '1'].includes(value?.toLowerCase() ?? ''),
|
||||
// Literal block lists without the `default` keyword do not pick up the expanded built-in list.
|
||||
disableIf: () => {
|
||||
const ranges = process.env.N8N_SSRF_BLOCKED_IP_RANGES;
|
||||
return (
|
||||
ranges !== undefined &&
|
||||
!ranges
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.some((r) => r.trim() === 'default')
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
envVar: 'N8N_RUNNERS_TASK_TIMEOUT',
|
||||
message:
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { mockInstance, testDb } from '@n8n/backend-test-utils';
|
|||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { EncryptionBootstrapService } from '../encryption-bootstrap.service';
|
||||
import { KeyManagerService } from '../key-manager.service';
|
||||
|
||||
const INSTANCE_ENCRYPTION_KEY = 'legacy-encryption-key';
|
||||
|
||||
|
|
@ -124,4 +126,90 @@ describe('EncryptionBootstrapService (integration)', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy DEK repair', () => {
|
||||
const seed = async (value: string) => {
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
await repo.save(
|
||||
repo.create({ type: 'data_encryption', value, algorithm: 'aes-256-gcm', status: 'active' }),
|
||||
);
|
||||
};
|
||||
|
||||
const onlyRow = async () => {
|
||||
const rows = await Container.get(DeploymentKeyRepository).find({
|
||||
where: { type: 'data_encryption' },
|
||||
});
|
||||
expect(rows).toHaveLength(1);
|
||||
return rows[0];
|
||||
};
|
||||
|
||||
// Seeds an inactive row and returns it — the partial unique index allows
|
||||
// only one active row per type, so a multi-row test must stay inactive.
|
||||
const seedRow = async (value: string) => {
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
return await repo.save(
|
||||
repo.create({
|
||||
type: 'data_encryption',
|
||||
value,
|
||||
algorithm: 'aes-256-gcm',
|
||||
status: 'inactive',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
it.each<[string, (cipher: Cipher, rawKey: string) => string]>([
|
||||
['raw 2.18.x', (_cipher, rawKey) => rawKey],
|
||||
['CBC 2.19.x', (cipher, rawKey) => cipher.encryptWithInstanceKey(rawKey)],
|
||||
])('repairs a seeded %s key losslessly and idempotently', async (_name, build) => {
|
||||
const cipher = Container.get(Cipher);
|
||||
const rawKey = randomBytes(32).toString('hex');
|
||||
await seed(build(cipher, rawKey));
|
||||
const km = Container.get(KeyManagerService);
|
||||
|
||||
await km.repairLegacyDataEncryptionKeys();
|
||||
const afterFirst = await onlyRow();
|
||||
expect(cipher.decryptDEKWithInstanceKey(afterFirst.value)).toBe(rawKey);
|
||||
|
||||
// A second run leaves the now-GCM value untouched.
|
||||
await km.repairLegacyDataEncryptionKeys();
|
||||
const afterSecond = await onlyRow();
|
||||
expect(afterSecond.value).toBe(afterFirst.value);
|
||||
});
|
||||
|
||||
it('repairs every legacy row in a mixed set and keeps going past a skipped row', async () => {
|
||||
const cipher = Container.get(Cipher);
|
||||
const repo = Container.get(DeploymentKeyRepository);
|
||||
|
||||
const rawA = randomBytes(32).toString('hex');
|
||||
const rawB = randomBytes(32).toString('hex');
|
||||
const rawC = randomBytes(32).toString('hex');
|
||||
|
||||
const rawRow = await seedRow(rawA);
|
||||
const gcmRow = await seedRow(cipher.encryptDEKWithInstanceKey(rawB));
|
||||
const cbcRow = await seedRow(cipher.encryptWithInstanceKey(rawC));
|
||||
|
||||
await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys();
|
||||
|
||||
const rows = await repo.find({ where: { type: 'data_encryption' } });
|
||||
const byId = (id: string) => rows.find((r) => r.id === id)!;
|
||||
|
||||
// Both legacy rows are now GCM-readable and recover their exact keys.
|
||||
expect(cipher.decryptDEKWithInstanceKey(byId(rawRow.id).value)).toBe(rawA);
|
||||
expect(cipher.decryptDEKWithInstanceKey(byId(cbcRow.id).value)).toBe(rawC);
|
||||
// The already-GCM row is untouched, byte for byte.
|
||||
expect(byId(gcmRow.id).value).toBe(gcmRow.value);
|
||||
});
|
||||
|
||||
it('leaves a properly GCM-wrapped key untouched', async () => {
|
||||
const cipher = Container.get(Cipher);
|
||||
const rawKey = randomBytes(32).toString('hex');
|
||||
await seed(cipher.encryptDEKWithInstanceKey(rawKey));
|
||||
const before = await onlyRow();
|
||||
|
||||
await Container.get(KeyManagerService).repairLegacyDataEncryptionKeys();
|
||||
|
||||
const after = await onlyRow();
|
||||
expect(after.value).toBe(before.value);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ describe('EncryptionBootstrapService', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
keyManager.repairLegacyDataEncryptionKeys.mockResolvedValue(undefined);
|
||||
keyManager.bootstrapLegacyCbcKey.mockResolvedValue(undefined);
|
||||
keyManager.bootstrapGcmKey.mockResolvedValue(undefined);
|
||||
});
|
||||
|
|
@ -66,6 +67,15 @@ describe('EncryptionBootstrapService', () => {
|
|||
expect(encryptionKeyProxy.setProvider).toHaveBeenCalledWith(keyManager);
|
||||
});
|
||||
|
||||
it('repairs legacy keys before seeding', async () => {
|
||||
await createService().run();
|
||||
|
||||
expect(keyManager.repairLegacyDataEncryptionKeys).toHaveBeenCalled();
|
||||
expect(keyManager.repairLegacyDataEncryptionKeys.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
keyManager.bootstrapGcmKey.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('bootstraps CBC before GCM', async () => {
|
||||
const order: string[] = [];
|
||||
keyManager.bootstrapLegacyCbcKey.mockImplementation(async () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,14 @@ import { mockInstance } from '@n8n/backend-test-utils';
|
|||
import type { DeploymentKey } from '@n8n/db';
|
||||
import { DeploymentKeyRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { Cipher, InstanceSettings } from 'n8n-core';
|
||||
import {
|
||||
Cipher,
|
||||
CipherAes256CBC,
|
||||
CipherAes256GCM,
|
||||
type EncryptionKeyProxy,
|
||||
InstanceSettings,
|
||||
} from 'n8n-core';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
|
@ -672,4 +679,74 @@ describe('KeyManagerService', () => {
|
|||
expect(repo.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairLegacyDataEncryptionKeys()', () => {
|
||||
// A real Cipher, so every legacy value is produced by real encryption and the
|
||||
// re-wrap round-trips for real. Only the repository is mocked.
|
||||
const realCipher = (encryptionKey: string) =>
|
||||
new Cipher(
|
||||
mock<InstanceSettings>({ encryptionKey }),
|
||||
new CipherAes256GCM(),
|
||||
new CipherAes256CBC(),
|
||||
mock<EncryptionKeyProxy>(),
|
||||
);
|
||||
|
||||
const makeRepairService = (encryptionKey = randomBytes(24).toString('base64')) => {
|
||||
const repo = mock<DeploymentKeyRepository>();
|
||||
const cipher = realCipher(encryptionKey);
|
||||
const service = new KeyManagerService(
|
||||
repo,
|
||||
cipher,
|
||||
mock<InstanceSettings>({ encryptionKey }),
|
||||
mock<Logger>(),
|
||||
);
|
||||
return { service, repo, cipher };
|
||||
};
|
||||
|
||||
// A real, freshly generated DEK — never a hand-built constant.
|
||||
const rawKey = randomBytes(32).toString('hex');
|
||||
|
||||
it.each<[string, (cipher: Cipher) => string]>([
|
||||
['raw 2.18.x', () => rawKey],
|
||||
['CBC 2.19.x', (cipher) => cipher.encryptWithInstanceKey(rawKey)],
|
||||
])('recovers a %s key and re-wraps it as GCM', async (_name, build) => {
|
||||
const { service, repo, cipher } = makeRepairService();
|
||||
repo.findDataEncryptionKeys.mockResolvedValue([makeKey({ id: 'k', value: build(cipher) })]);
|
||||
|
||||
await service.repairLegacyDataEncryptionKeys();
|
||||
|
||||
const [id, , wrapped] = repo.rewrapLegacyDataEncryptionValue.mock.calls[0];
|
||||
expect(id).toBe('k');
|
||||
// The re-wrapped value unwraps back to the exact same key: lossless.
|
||||
expect(cipher.decryptDEKWithInstanceKey(wrapped)).toBe(rawKey);
|
||||
});
|
||||
|
||||
it.each<[string, (cipher: Cipher, other: Cipher) => string]>([
|
||||
['already GCM-wrapped', (cipher) => cipher.encryptDEKWithInstanceKey(rawKey)],
|
||||
[
|
||||
'CBC under a different instance key',
|
||||
(_cipher, other) => other.encryptWithInstanceKey(rawKey),
|
||||
],
|
||||
['CBC that unwraps to a non-key', (cipher) => cipher.encryptWithInstanceKey('not-a-hex-key')],
|
||||
])('leaves a %s value untouched', async (_name, build) => {
|
||||
const { service, repo, cipher } = makeRepairService();
|
||||
const other = realCipher(randomBytes(24).toString('base64'));
|
||||
repo.findDataEncryptionKeys.mockResolvedValue([
|
||||
makeKey({ id: 'k', value: build(cipher, other) }),
|
||||
]);
|
||||
|
||||
await service.repairLegacyDataEncryptionKeys();
|
||||
|
||||
expect(repo.rewrapLegacyDataEncryptionValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when there are no data-encryption keys', async () => {
|
||||
const { service, repo } = makeRepairService();
|
||||
repo.findDataEncryptionKeys.mockResolvedValue([]);
|
||||
|
||||
await service.repairLegacyDataEncryptionKeys();
|
||||
|
||||
expect(repo.rewrapLegacyDataEncryptionValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export class EncryptionBootstrapService {
|
|||
// the provider is wired for commands too.
|
||||
if (this.instanceSettings.instanceType === 'main' && this.canSeed) {
|
||||
try {
|
||||
await this.keyManager.repairLegacyDataEncryptionKeys();
|
||||
await this.keyManager.bootstrapLegacyCbcKey(this.instanceSettings.encryptionKey);
|
||||
await this.keyManager.bootstrapGcmKey();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
|||
|
||||
import { isKeyRotationEnabled } from './key-rotation-flag';
|
||||
|
||||
/** Raw DEK format: 64 hex chars stored directly as key material (n8n 2.18.x). */
|
||||
const RAW_DEK_PATTERN = /^[0-9a-f]{64}$/i;
|
||||
|
||||
/** AES-256-CBC (OpenSSL Salted__) wrapped DEK, base64 prefix (n8n 2.19.x). */
|
||||
const CBC_WRAPPED_PREFIX = 'U2FsdGVk';
|
||||
|
||||
/**
|
||||
* How long a process trusts its database view of the active key. A rotation
|
||||
* done on another instance becomes visible within this window — the accepted
|
||||
|
|
@ -122,6 +128,64 @@ export class KeyManagerService implements IEncryptionKeyProvider {
|
|||
return keyInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw DEK for a legacy-format value, or null if the value is already
|
||||
* GCM-wrapped or cannot be recovered with this instance key.
|
||||
*/
|
||||
private recoverLegacyDek(keyInfo: DeploymentKey): string | null {
|
||||
const { value } = keyInfo;
|
||||
// 2.18.x: raw key material, used directly. Re-wrap as-is, no decrypt needed.
|
||||
if (RAW_DEK_PATTERN.test(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// 2.19.x: DEK wrapped with the instance key via AES-256-CBC.
|
||||
if (value.startsWith(CBC_WRAPPED_PREFIX)) {
|
||||
let recovered: string;
|
||||
try {
|
||||
recovered = this.cipher.decryptWithInstanceKey(value);
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`Failed to decrypt legacy CBC-wrapped DEK ${keyInfo.id} with the instance key, this was probably wrapped with a different instance key`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!RAW_DEK_PATTERN.test(recovered)) {
|
||||
this.logger.warn(`Recovered legacy DEK ${keyInfo.id} is not valid raw key material`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return recovered;
|
||||
}
|
||||
|
||||
// Already GCM-wrapped or an unknown format: leave untouched.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-wraps data-encryption keys stored in a pre-2.20 legacy format. Early key
|
||||
* managers stored the DEK either as raw 64-hex key material (2.18.x) or wrapped
|
||||
* with the instance key via AES-256-CBC (2.19.x). The current unwrap path expects
|
||||
* a GCM blob, so both fail to decrypt. Both wrap the same 64-hex DEK; recovering
|
||||
* it and re-wrapping with GCM restores the exact same key, so data stays readable.
|
||||
* Idempotent: a GCM value matches neither legacy detector and is left untouched.
|
||||
*/
|
||||
async repairLegacyDataEncryptionKeys(): Promise<void> {
|
||||
const rows = await this.deploymentKeyRepository.findDataEncryptionKeys();
|
||||
for (const row of rows) {
|
||||
const rawKey = this.recoverLegacyDek(row);
|
||||
if (rawKey === null) continue;
|
||||
const wrapped = this.cipher.encryptDEKWithInstanceKey(rawKey);
|
||||
await this.deploymentKeyRepository.rewrapLegacyDataEncryptionValue(
|
||||
row.id,
|
||||
row.value,
|
||||
wrapped,
|
||||
);
|
||||
this.logger.info(`Re-wrapped legacy DEK ${row.id} with the instance key`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the legacy CBC key used to decrypt old rows (no keyId prefix).
|
||||
*
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import './v3/in-memory-binary-data.rule';
|
|||
import './v3/offload-manual-executions.rule';
|
||||
import './v3/removed-nodes.rule';
|
||||
import './v3/removed-nodes-with-replacements.rule';
|
||||
import './v3/ssrf-default-blocked-ranges.rule';
|
||||
import './v3/task-runner-internal-mode.rule';
|
||||
import './v3/task-runner-task-timeout.rule';
|
||||
import './v3/unverified-packages.rule';
|
||||
import './v3/workflow-import-url-removed.rule';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { GlobalConfig } from '@n8n/config';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { SsrfDefaultBlockedRangesRule } from '../ssrf-default-blocked-ranges.rule';
|
||||
|
||||
describe('SsrfDefaultBlockedRangesRule', () => {
|
||||
const createRule = (enabled: boolean) =>
|
||||
new SsrfDefaultBlockedRangesRule(mock<GlobalConfig>({ ssrfProtection: { enabled } }));
|
||||
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.N8N_SSRF_BLOCKED_IP_RANGES;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should be affected when SSRF protection is enabled', async () => {
|
||||
const result = await createRule(true).detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should be affected when the block list keeps the default keyword', async () => {
|
||||
process.env.N8N_SSRF_BLOCKED_IP_RANGES = 'Default, 100.64.0.0/10';
|
||||
|
||||
const result = await createRule(true).detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
});
|
||||
|
||||
it('should not be affected when the block list is literal ranges only', async () => {
|
||||
process.env.N8N_SSRF_BLOCKED_IP_RANGES = '10.0.0.0/8,127.0.0.0/8';
|
||||
|
||||
const result = await createRule(true).detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be affected when SSRF protection is disabled', async () => {
|
||||
const result = await createRule(false).detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { TaskRunnerInternalModeRule } from '../task-runner-internal-mode.rule';
|
||||
|
||||
describe('TaskRunnerInternalModeRule', () => {
|
||||
const rule = new TaskRunnerInternalModeRule();
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.N8N_RUNNERS_MODE;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should be affected when the mode is explicitly internal', async () => {
|
||||
process.env.N8N_RUNNERS_MODE = 'internal';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not be affected when the mode is external', async () => {
|
||||
process.env.N8N_RUNNERS_MODE = 'external';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be affected when the mode is not set', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class SsrfDefaultBlockedRangesRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly globalConfig: GlobalConfig) {}
|
||||
|
||||
id: string = 'ssrf-default-blocked-ranges-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'SSRF protection blocks more IP ranges by default',
|
||||
description:
|
||||
'The built-in blocked IP ranges expand to include the shared address space (100.64.0.0/10) and IPv6 transition ranges. Requests to hosts in these ranges start failing on instances with SSRF protection enabled.',
|
||||
category: BreakingChangeCategory.environment,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/security',
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
// Instances that enumerate literal ranges do not pick up the expanded built-in list.
|
||||
const ranges = process.env.N8N_SSRF_BLOCKED_IP_RANGES;
|
||||
const usesDefaultList =
|
||||
ranges === undefined ||
|
||||
ranges
|
||||
.toLowerCase()
|
||||
.split(',')
|
||||
.some((r) => r.trim() === 'default');
|
||||
|
||||
if (!this.globalConfig.ssrfProtection.enabled || !usesDefaultList) {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'SSRF protection uses the built-in blocked IP ranges',
|
||||
description:
|
||||
'N8N_SSRF_PROTECTION_ENABLED is true and N8N_SSRF_BLOCKED_IP_RANGES relies on the built-in list, either because it is not set or because it contains the `default` keyword. After the update, that list also covers 100.64.0.0/10 and IPv6 transition ranges, so workflows calling hosts in these ranges fail.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Review internal hosts in the newly blocked ranges',
|
||||
description:
|
||||
"Check if any workflow calls hosts in 100.64.0.0/10 or IPv6 transition ranges. Add the ones you still need to N8N_SSRF_ALLOWED_IP_RANGES or N8N_SSRF_ALLOWED_HOSTNAMES. To keep the current list exactly, set N8N_SSRF_BLOCKED_IP_RANGES to the literal ranges; the `default` keyword always expands to the running version's built-in list.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class TaskRunnerInternalModeRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'task-runner-internal-mode-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Internal task runner mode is removed',
|
||||
description:
|
||||
'Task runners can no longer run as a child process of n8n. The new version only supports N8N_RUNNERS_MODE=external and fails to start Code node executions when no external task runner connects.',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'medium',
|
||||
documentationUrl: 'https://docs.n8n.io/deploy/host-n8n/configure-n8n/set-up-task-runners',
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
if (process.env.N8N_RUNNERS_MODE !== 'internal') {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'N8N_RUNNERS_MODE is set to "internal"',
|
||||
description:
|
||||
'This instance runs task runners inside the n8n process. The new version rejects this mode, so Code nodes stop executing until an external task runner is connected.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Switch to external task runners before upgrading',
|
||||
description:
|
||||
'Run the task runner launcher as a separate process or container, set N8N_RUNNERS_MODE=external and share N8N_RUNNERS_AUTH_TOKEN between n8n and the launcher.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -11,16 +11,29 @@ import { Container } from '@n8n/di';
|
|||
import type { INode } from 'n8n-workflow';
|
||||
import { CHAT_TRIGGER_NODE_TYPE, CHAT_TRIGGER_PATH_SUFFIX, WEBHOOK_NODE_TYPE } from 'n8n-workflow';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import request from 'supertest';
|
||||
|
||||
import { createMember, createOwner } from '@test-integration/db/users';
|
||||
import { setupTestServer } from '@test-integration/utils';
|
||||
|
||||
import { AuthService } from '@/auth/auth.service';
|
||||
import { AUTH_COOKIE_NAME } from '@/constants';
|
||||
import { OAuthTokenService } from '@/modules/oauth-server/oauth-token.service';
|
||||
import { CacheService } from '@/services/cache/cache.service';
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
||||
/** Root-level (no `/rest` prefix) agent authenticated as `user` — `authAgentFor` always
|
||||
* prefixes `/rest`, which 404s against root-level routes like `/oauth/authorize`. */
|
||||
const rootAgentFor = (user: User) => {
|
||||
const agent = request.agent(testServer.app);
|
||||
const token = Container.get(AuthService).issueJWT(user, user.mfaEnabled);
|
||||
agent.jar.setCookie(`${AUTH_COOKIE_NAME}=${token}`);
|
||||
return agent;
|
||||
};
|
||||
|
||||
import { OAuthClientRepository } from '../database/repositories/oauth-client.repository';
|
||||
import { UserConsentRepository } from '../database/repositories/oauth-user-consent.repository';
|
||||
|
||||
const testServer = setupTestServer({ modules: ['oauth-server', 'mcp'], endpointGroups: ['mcp'] });
|
||||
|
||||
|
|
@ -105,6 +118,7 @@ afterEach(async () => {
|
|||
'RefreshToken',
|
||||
'AuthorizationCode',
|
||||
'OAuthClient',
|
||||
'UserConsent',
|
||||
'WebhookEntity',
|
||||
'SharedWorkflow',
|
||||
'WorkflowEntity',
|
||||
|
|
@ -405,3 +419,121 @@ describe('runtime gate: verifyOAuthAccessToken enforces workflow:execute', () =>
|
|||
expect(result.user?.id).toBe(member.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consent reuse on a second visit', () => {
|
||||
const pkce = async () => {
|
||||
const { createHash, randomBytes } = await import('node:crypto');
|
||||
const codeVerifier = randomBytes(32).toString('base64url');
|
||||
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
return { codeVerifier, codeChallenge };
|
||||
};
|
||||
|
||||
const authorizeQuery = (resourceUrl: string, codeChallenge: string, state: string) => ({
|
||||
client_id: resourceUrl,
|
||||
redirect_uri: resourceUrl,
|
||||
response_type: 'code',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
resource: resourceUrl,
|
||||
state,
|
||||
});
|
||||
|
||||
test('a second visit reuses consent when the visitor is already logged in', async () => {
|
||||
// Control case: confirms tryAutoApproveConsent/tryReuseConsent themselves work when
|
||||
// the n8n-auth cookie is already present on the very first /oauth/authorize hit.
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
const resourceUrl = resourceUrlFor(path);
|
||||
|
||||
const authAgent = rootAgentFor(owner);
|
||||
|
||||
const { codeChallenge: cc1 } = await pkce();
|
||||
const first = await authAgent
|
||||
.get('/oauth/authorize')
|
||||
.query(authorizeQuery(resourceUrl, cc1, 'state-1'));
|
||||
expect(first.statusCode).toBe(302);
|
||||
expect(first.headers.location).toBe('/oauth/consent');
|
||||
|
||||
const rawSetCookie: string | string[] = first.headers['set-cookie'] ?? [];
|
||||
const setCookies = Array.isArray(rawSetCookie) ? rawSetCookie : [rawSetCookie];
|
||||
const sessionCookie = setCookies
|
||||
.map((cookie) => cookie.split(';')[0])
|
||||
.find((cookie) => cookie.startsWith('n8n-oauth-session='));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
|
||||
// `/consent/approve` lives under the `/rest` prefix, unlike the root-level
|
||||
// `/oauth/*` routes — a separate, `/rest`-prefixed agent for the same user,
|
||||
// carrying the session cookie the authorize step just set.
|
||||
const consentAgent = testServer.authAgentFor(owner);
|
||||
consentAgent.jar.setCookie(sessionCookie ?? '');
|
||||
|
||||
const approve = await consentAgent
|
||||
.post('/consent/approve')
|
||||
.send({ approved: true, scopes: [] });
|
||||
expect(approve.statusCode).toBe(200);
|
||||
|
||||
const { codeChallenge: cc2 } = await pkce();
|
||||
const second = await authAgent
|
||||
.get('/oauth/authorize')
|
||||
.query(authorizeQuery(resourceUrl, cc2, 'state-2'));
|
||||
|
||||
expect(second.statusCode).toBe(302);
|
||||
expect(second.headers.location).not.toBe('/oauth/consent');
|
||||
expect(second.headers.location).toContain(resourceUrl);
|
||||
});
|
||||
|
||||
test('a visitor who already consented is auto-approved after authenticating mid-flow', async () => {
|
||||
// The visitor already has a UserConsent row from a prior visit (e.g. the local grant
|
||||
// cookie was cleared/expired), but their n8n-auth cookie is gone too — so the very
|
||||
// first /oauth/authorize hit has no cookie to check
|
||||
// and tryAutoApproveConsent is skipped. They then log in as part of reaching the
|
||||
// (auth-gated) consent page. GET /consent/details now retries the reuse check once
|
||||
// the user is authenticated, instead of unconditionally returning the manual picker.
|
||||
const path = chatPath();
|
||||
await createPublishedChatWorkflow(path, chatTriggerNode());
|
||||
const resourceUrl = resourceUrlFor(path);
|
||||
|
||||
// Seed a prior consent for this exact (clientId, user) pair, as a previous visit
|
||||
// would have left behind.
|
||||
await Container.get(OAuthClientRepository).upsert(
|
||||
{
|
||||
id: resourceUrl,
|
||||
name: 'chat trigger',
|
||||
redirectUris: [resourceUrl],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
},
|
||||
['id'],
|
||||
);
|
||||
await Container.get(UserConsentRepository).upsert(
|
||||
{ userId: owner.id, clientId: resourceUrl, grantedAt: Date.now(), scope: [] },
|
||||
['userId', 'clientId'],
|
||||
);
|
||||
|
||||
// Not authenticated yet on this first hit — the browser has no n8n-auth cookie.
|
||||
const { codeChallenge } = await pkce();
|
||||
const first = await testServer.restlessAgent
|
||||
.get('/oauth/authorize')
|
||||
.query(authorizeQuery(resourceUrl, codeChallenge, 'state-1'));
|
||||
expect(first.statusCode).toBe(302);
|
||||
expect(first.headers.location).toBe('/oauth/consent');
|
||||
|
||||
const rawSetCookie: string | string[] = first.headers['set-cookie'] ?? [];
|
||||
const setCookies = Array.isArray(rawSetCookie) ? rawSetCookie : [rawSetCookie];
|
||||
const sessionCookie = setCookies
|
||||
.map((cookie) => cookie.split(';')[0])
|
||||
.find((cookie) => cookie.startsWith('n8n-oauth-session='));
|
||||
expect(sessionCookie).toBeDefined();
|
||||
|
||||
// The visitor now logs in (this is the "just logged in" step from the ticket),
|
||||
// carrying the same in-flight OAuth session cookie into the authenticated request.
|
||||
const consentAgent = testServer.authAgentFor(owner);
|
||||
consentAgent.jar.setCookie(sessionCookie ?? '');
|
||||
|
||||
const details = await consentAgent.get('/consent/details');
|
||||
|
||||
// Desired: already consented, so this should signal auto-approval instead of
|
||||
// requiring another manual click.
|
||||
expect(details.body.data?.autoApproved ?? false).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ describe('OAuthConsentService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
autoApproved: false,
|
||||
clientName: 'Test Client',
|
||||
clientId: 'client-123',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
|
|
@ -176,6 +177,7 @@ describe('OAuthConsentService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
autoApproved: false,
|
||||
clientName: 'Test Client',
|
||||
clientId: 'client-123',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
|
|
@ -284,6 +286,7 @@ describe('OAuthConsentService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
autoApproved: false,
|
||||
clientName: 'Test Client',
|
||||
clientId: 'client-123',
|
||||
resourceName: 'My Workflow',
|
||||
|
|
@ -325,6 +328,7 @@ describe('OAuthConsentService', () => {
|
|||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
autoApproved: false,
|
||||
clientName: 'Test Client',
|
||||
clientId: 'client-123',
|
||||
resourceName: undefined,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,19 @@ export class OAuthConsentController {
|
|||
return;
|
||||
}
|
||||
|
||||
if (consentDetails.autoApproved) {
|
||||
// The session's decision is already made — consume it, same as a manual approval.
|
||||
this.oauthSessionService.clearSession(res);
|
||||
res.json({
|
||||
data: {
|
||||
autoApproved: true,
|
||||
redirectUrl: consentDetails.redirectUrl,
|
||||
uiHints: consentDetails.uiHints,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
data: {
|
||||
clientName: consentDetails.clientName,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,14 @@ import { UrlService } from '@/services/url.service';
|
|||
type ConsentDetailsResult =
|
||||
| {
|
||||
ok: true;
|
||||
autoApproved: true;
|
||||
redirectUrl: string;
|
||||
/** Presentational hints for the header icon while the redirect happens. */
|
||||
uiHints?: ConsentUiHints;
|
||||
}
|
||||
| {
|
||||
ok: true;
|
||||
autoApproved: false;
|
||||
clientName: string;
|
||||
clientId: string;
|
||||
resourceName?: string;
|
||||
|
|
@ -82,6 +90,20 @@ export class OAuthConsentService {
|
|||
return { ok: false, reason: 'resource_unavailable' };
|
||||
}
|
||||
|
||||
// Resolved once above and threaded through, so a first-time consent (the
|
||||
// common case here — a prior consent already short-circuits via
|
||||
// tryAutoApproveConsent at /oauth/authorize) doesn't pay for the resource
|
||||
// resolver's DB-backed lookup twice.
|
||||
const reuse = await this.tryReuseConsent(user, sessionPayload, resource);
|
||||
if (reuse) {
|
||||
return {
|
||||
ok: true,
|
||||
autoApproved: true,
|
||||
redirectUrl: reuse.redirectUrl,
|
||||
uiHints: resource.uiHints,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(await resource.authorize(user)))
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -92,6 +114,7 @@ export class OAuthConsentService {
|
|||
|
||||
return {
|
||||
ok: true,
|
||||
autoApproved: false,
|
||||
clientName: client.name,
|
||||
clientId: client.id,
|
||||
resourceName: resource.displayName,
|
||||
|
|
@ -117,6 +140,7 @@ export class OAuthConsentService {
|
|||
|
||||
return {
|
||||
ok: true,
|
||||
autoApproved: false,
|
||||
clientName: client.name,
|
||||
clientId: client.id,
|
||||
redirectUri: sessionPayload.redirectUri,
|
||||
|
|
@ -312,13 +336,20 @@ export class OAuthConsentService {
|
|||
return scopes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param resolvedResource The caller's own resolution of `sessionPayload.resource`,
|
||||
* when it already has one — skips resolving it again here.
|
||||
*/
|
||||
async tryReuseConsent(
|
||||
user: User,
|
||||
sessionPayload: OAuthSessionPayload,
|
||||
resolvedResource?: ProtectedResource,
|
||||
): Promise<{ redirectUrl: string } | null> {
|
||||
if (!sessionPayload.resource) return null;
|
||||
|
||||
const resource = await this.protectedResourceRegistry.getByResourceUrl(sessionPayload.resource);
|
||||
const resource =
|
||||
resolvedResource ??
|
||||
(await this.protectedResourceRegistry.getByResourceUrl(sessionPayload.resource));
|
||||
if (!resource?.isFirstParty) return null;
|
||||
|
||||
const consent = await this.userConsentRepository.findOne({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-core",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"description": "Core functionality of n8n",
|
||||
"main": "dist/index",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/chat",
|
||||
"version": "1.38.0",
|
||||
"version": "1.38.2",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist .turbo",
|
||||
"dev": "pnpm run --dir=../storybook dev --initial-path=/docs/chat-chat--docs",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/composables",
|
||||
"type": "module",
|
||||
"version": "1.29.0",
|
||||
"version": "1.29.2",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "@n8n/design-system",
|
||||
"version": "2.38.0",
|
||||
"version": "2.38.2",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"type": "module",
|
||||
"name": "@n8n/frontend-module-sdk",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.2",
|
||||
"main": "src/index.ts",
|
||||
"import": "src/index.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/i18n",
|
||||
"type": "module",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.1",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/rest-api-client",
|
||||
"type": "module",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { ConsentUiHints } from '@n8n/api-types';
|
|||
import type { IRestApiContext } from '../types';
|
||||
import { makeRestApiRequest } from '../utils';
|
||||
|
||||
export interface ConsentDetails {
|
||||
export interface ConsentDetailsPicker {
|
||||
clientName: string;
|
||||
clientId: string;
|
||||
redirectUri?: string;
|
||||
|
|
@ -23,6 +23,20 @@ export interface ConsentDetails {
|
|||
isFirstParty?: boolean;
|
||||
}
|
||||
|
||||
export type ConsentDetails =
|
||||
| {
|
||||
/**
|
||||
* A prior consent already covers this request — e.g. the visitor only
|
||||
* authenticated as part of reaching this page. There is nothing left to
|
||||
* show a picker for.
|
||||
*/
|
||||
autoApproved: true;
|
||||
redirectUrl: string;
|
||||
/** Presentation hints from the resource, used for the header icon while redirecting. */
|
||||
uiHints?: ConsentUiHints;
|
||||
}
|
||||
| ({ autoApproved?: false } & ConsentDetailsPicker);
|
||||
|
||||
export interface ConsentApprovalResponse {
|
||||
status: string;
|
||||
redirectUrl: string;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@n8n/stores",
|
||||
"type": "module",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-editor-ui",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"description": "Workflow Editor UI for n8n",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ const resetFilters = () => {
|
|||
};
|
||||
|
||||
const shouldBeIconButton = computed(() => {
|
||||
return !hasFilters.value;
|
||||
return props.justIcon && !hasFilters.value;
|
||||
});
|
||||
|
||||
watch(filtersLength, (value) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { createPinia, setActivePinia } from 'pinia';
|
||||
|
||||
import { useConsentStore } from './consent.store';
|
||||
import { ResponseError } from '@n8n/rest-api-client/utils';
|
||||
|
||||
const { getConsentDetails, approveConsent } = vi.hoisted(() => ({
|
||||
getConsentDetails: vi.fn(),
|
||||
approveConsent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/rest-api-client/api/consent', () => ({
|
||||
getConsentDetails,
|
||||
approveConsent,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/useRootStore', () => ({
|
||||
useRootStore: vi.fn(() => ({
|
||||
restApiContext: { baseUrl: 'http://localhost:5678', pushRef: 'test' },
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('useConsentStore', () => {
|
||||
let consentStore: ReturnType<typeof useConsentStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePinia(createPinia());
|
||||
consentStore = useConsentStore();
|
||||
});
|
||||
|
||||
it('stores the resolved details and clears loading', async () => {
|
||||
const details = { clientName: 'Test Client', clientId: 'client-1', scopes: [] };
|
||||
getConsentDetails.mockResolvedValue(details);
|
||||
|
||||
const result = await consentStore.fetchConsentDetails();
|
||||
|
||||
expect(result).toEqual(details);
|
||||
expect(consentStore.consentDetails).toEqual(details);
|
||||
expect(consentStore.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('sets a resource_unavailable error code on a 422', async () => {
|
||||
getConsentDetails.mockRejectedValue(new ResponseError('gone', { httpStatusCode: 422 }));
|
||||
|
||||
await expect(consentStore.fetchConsentDetails()).rejects.toThrow('gone');
|
||||
|
||||
expect(consentStore.errorCode).toBe('resource_unavailable');
|
||||
expect(consentStore.error).toBe('gone');
|
||||
expect(consentStore.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// A component that abandons one fetch (e.g. it re-fetches, or a fresh mount starts
|
||||
// its own call) must not have its result overwritten by the earlier, now-stale one
|
||||
// resolving later — regardless of which settles first.
|
||||
it('ignores a stale call that resolves after a newer one', async () => {
|
||||
let resolveStale!: (value: unknown) => void;
|
||||
getConsentDetails.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveStale = resolve;
|
||||
}),
|
||||
);
|
||||
const staleCall = consentStore.fetchConsentDetails();
|
||||
|
||||
const freshDetails = { clientName: 'Fresh Client', clientId: 'client-2', scopes: [] };
|
||||
getConsentDetails.mockResolvedValueOnce(freshDetails);
|
||||
await consentStore.fetchConsentDetails();
|
||||
|
||||
expect(consentStore.consentDetails).toEqual(freshDetails);
|
||||
|
||||
resolveStale({ clientName: 'Stale Client', clientId: 'client-1', scopes: [] });
|
||||
await staleCall;
|
||||
|
||||
expect(consentStore.consentDetails).toEqual(freshDetails);
|
||||
});
|
||||
|
||||
it('ignores a stale call failing after a newer one succeeded', async () => {
|
||||
let rejectStale!: (reason: unknown) => void;
|
||||
getConsentDetails.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectStale = reject;
|
||||
}),
|
||||
);
|
||||
const staleCall = consentStore.fetchConsentDetails().catch(() => {});
|
||||
|
||||
const freshDetails = { clientName: 'Fresh Client', clientId: 'client-2', scopes: [] };
|
||||
getConsentDetails.mockResolvedValueOnce(freshDetails);
|
||||
await consentStore.fetchConsentDetails();
|
||||
|
||||
rejectStale(new Error('stale failure'));
|
||||
await staleCall;
|
||||
|
||||
expect(consentStore.consentDetails).toEqual(freshDetails);
|
||||
expect(consentStore.error).toBeNull();
|
||||
expect(consentStore.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -15,24 +15,36 @@ export const useConsentStore = defineStore(STORES.CONSENT, () => {
|
|||
|
||||
const rootStore = useRootStore();
|
||||
|
||||
// Guards against a stale call's response overwriting a newer call's, if two are
|
||||
// ever in flight at once — each call only writes shared state while it's still
|
||||
// the most recently issued one.
|
||||
let latestRequestId = 0;
|
||||
|
||||
const fetchConsentDetails = async () => {
|
||||
const requestId = ++latestRequestId;
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
errorCode.value = null;
|
||||
|
||||
try {
|
||||
consentDetails.value = await consentApi.getConsentDetails(rootStore.restApiContext);
|
||||
const response = await consentApi.getConsentDetails(rootStore.restApiContext);
|
||||
if (requestId !== latestRequestId) return consentDetails.value;
|
||||
consentDetails.value = response;
|
||||
return consentDetails.value;
|
||||
} catch (err) {
|
||||
if (err instanceof ResponseError && err.httpStatusCode === 422) {
|
||||
errorCode.value = 'resource_unavailable';
|
||||
} else if (err instanceof ResponseError && err.httpStatusCode === 403) {
|
||||
errorCode.value = 'forbidden';
|
||||
if (requestId === latestRequestId) {
|
||||
if (err instanceof ResponseError && err.httpStatusCode === 422) {
|
||||
errorCode.value = 'resource_unavailable';
|
||||
} else if (err instanceof ResponseError && err.httpStatusCode === 403) {
|
||||
errorCode.value = 'forbidden';
|
||||
}
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load consent details';
|
||||
}
|
||||
error.value = err instanceof Error ? err.message : 'Failed to load consent details';
|
||||
throw err;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (requestId === latestRequestId) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import OAuthConsentView from '@/app/views/OAuthConsentView.vue';
|
|||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { within } from '@testing-library/vue';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
vi.mock('@n8n/rest-api-client/api/consent');
|
||||
|
||||
|
|
@ -119,6 +120,64 @@ describe('OAuthConsentView', () => {
|
|||
expect(queryByText('Get a list of your workflows')).toBeNull();
|
||||
});
|
||||
|
||||
it('should redirect immediately without showing the picker or a success message when the server auto-approves', async () => {
|
||||
// The visitor never clicked anything here — a "success" message would be
|
||||
// confusing for something they didn't consciously trigger, so this must render
|
||||
// the same blank state as the initial fetch, not the manual-approve success screen.
|
||||
const redirectUrl = 'https://legitimate-client.com/callback?code=reused';
|
||||
consentStore.fetchConsentDetails.mockResolvedValue({
|
||||
autoApproved: true,
|
||||
redirectUrl,
|
||||
} as never);
|
||||
|
||||
const { queryByTestId, getByTestId } = renderComponent();
|
||||
await waitAllPromises();
|
||||
|
||||
expect(consentStore.approveConsent).not.toHaveBeenCalled();
|
||||
expect(queryByTestId('consent-allow-button')).toBeNull();
|
||||
expect(queryByTestId('consent-deny-button')).toBeNull();
|
||||
expect(queryByTestId('consent-success-screen')).toBeNull();
|
||||
expect(getByTestId('consent-loading')).toBeVisible();
|
||||
expect(window.location.href).toBe(redirectUrl);
|
||||
});
|
||||
|
||||
it('should not flash the generic instance-wide picker while the details fetch is pending', async () => {
|
||||
// Before the fetch resolves, `consentDetails` is still null (its initial state,
|
||||
// and its value on a fetch error too), so the template must not fall through to
|
||||
// the generic/default-resource copy — that's not known to be correct yet.
|
||||
consentStore.consentDetails = null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let resolveFetch!: (value: any) => void;
|
||||
consentStore.fetchConsentDetails.mockImplementation(
|
||||
async () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await new Promise<any>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { queryByTestId, getByTestId, queryByText } = renderComponent();
|
||||
await nextTick();
|
||||
|
||||
expect(queryByTestId('consent-content')).toBeNull();
|
||||
expect(queryByText('Test MCP Client wants access to your n8n instance')).toBeNull();
|
||||
expect(getByTestId('consent-loading')).toBeVisible();
|
||||
|
||||
// The real store assigns `consentDetails` as part of resolving the fetch;
|
||||
// mirror that so the template's store-bound `clientDetails` picks it up too.
|
||||
const resolvedDetails = {
|
||||
clientName: 'Test MCP Client',
|
||||
clientId: 'test-client-id',
|
||||
scopes: [],
|
||||
};
|
||||
consentStore.consentDetails = resolvedDetails;
|
||||
resolveFetch(resolvedDetails);
|
||||
await waitAllPromises();
|
||||
|
||||
expect(queryByTestId('consent-loading')).toBeNull();
|
||||
expect(getByTestId('consent-content')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should redirect to home page when deny is clicked', async () => {
|
||||
consentStore.approveConsent.mockResolvedValue({
|
||||
status: 'denied',
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useConsentStore } from '@/app/stores/consent.store';
|
|||
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import { onMounted, computed, ref, watch } from 'vue';
|
||||
import type { ConsentDetails } from '@n8n/rest-api-client/api/consent';
|
||||
import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
|
||||
import type { ConsentDetailsPicker } from '@n8n/rest-api-client/api/consent';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nCallout,
|
||||
|
|
@ -31,12 +31,21 @@ const telemetry = useTelemetry();
|
|||
|
||||
// Success state:
|
||||
const waitingForRedirect = ref(false);
|
||||
// Set instead of `waitingForRedirect` when the server silently reused a prior consent:
|
||||
// the visitor never clicked anything here, so a "success" message would be confusing —
|
||||
// this renders the same blank state as the initial fetch, not a message that then flashes.
|
||||
const autoApprovedRedirect = ref(false);
|
||||
const detailsResolved = ref(false);
|
||||
const redirectUriTrusted = ref(false);
|
||||
const selectedScopes = ref<string[]>([]);
|
||||
|
||||
let isActive = true;
|
||||
onUnmounted(() => {
|
||||
isActive = false;
|
||||
});
|
||||
|
||||
const error = computed(() => consentStore.error);
|
||||
const loading = computed(() => consentStore.isLoading);
|
||||
const resourceName = computed(() => consentStore.consentDetails?.resourceName);
|
||||
|
||||
const errorMessage = computed(() => {
|
||||
if (consentStore.errorCode === 'resource_unavailable') {
|
||||
|
|
@ -47,7 +56,14 @@ const errorMessage = computed(() => {
|
|||
return consentStore.error;
|
||||
});
|
||||
|
||||
const clientDetails = computed<ConsentDetails | null>(() => consentStore.consentDetails);
|
||||
// Narrows away the auto-approved redirect signal, which carries none of these fields
|
||||
// and is handled separately in `onMounted` before this is ever read.
|
||||
const clientDetails = computed<ConsentDetailsPicker | null>(() => {
|
||||
const details = consentStore.consentDetails;
|
||||
return details && !details.autoApproved ? details : null;
|
||||
});
|
||||
const resourceName = computed(() => clientDetails.value?.resourceName);
|
||||
const uiHints = computed(() => consentStore.consentDetails?.uiHints);
|
||||
// Known clients get their brand mark on the left tile; unknown ones fall back to the MCP glyph.
|
||||
const clientBrandIcon = computed(() => getClientBrand(clientDetails.value?.clientName ?? '').icon);
|
||||
// Localized noun for first-party copy, driven by the resource's consentType hint.
|
||||
|
|
@ -139,12 +155,20 @@ const handleClose = () => {
|
|||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('oauth.consentView.title'));
|
||||
try {
|
||||
await consentStore.fetchConsentDetails();
|
||||
const details = await consentStore.fetchConsentDetails();
|
||||
if (!isActive) return;
|
||||
detailsResolved.value = true;
|
||||
if (details?.autoApproved && details.redirectUrl) {
|
||||
autoApprovedRedirect.value = true;
|
||||
window.location.href = details.redirectUrl;
|
||||
return;
|
||||
}
|
||||
telemetry.track('User viewed MCP consent screen', {
|
||||
client_name: clientDetails.value?.clientName,
|
||||
available_scopes_count: availableScopes.value.length,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!isActive) return;
|
||||
toast.showError(err, i18n.baseText('oauth.consentView.error.fetchDetails'));
|
||||
}
|
||||
});
|
||||
|
|
@ -155,18 +179,13 @@ onMounted(async () => {
|
|||
<div :class="$style['consent-dialog']">
|
||||
<header :class="$style.header">
|
||||
<div :class="$style.logo">
|
||||
<N8nIcon
|
||||
v-if="clientDetails?.uiHints?.icon"
|
||||
:icon="clientDetails.uiHints.icon"
|
||||
size="large"
|
||||
color="text-dark"
|
||||
/>
|
||||
<N8nIcon v-if="uiHints?.icon" :icon="uiHints.icon" size="large" color="text-dark" />
|
||||
<component
|
||||
:is="clientBrandIcon"
|
||||
v-else-if="clientBrandIcon"
|
||||
:class="$style['brand-icon']"
|
||||
/>
|
||||
<N8nIcon v-else icon="mcp" size="large" color="text-dark" />
|
||||
<N8nIcon v-else-if="detailsResolved || error" icon="mcp" size="large" color="text-dark" />
|
||||
</div>
|
||||
<!-- Pending-connection connector: a dashed SVG line marching toward the n8n tile
|
||||
with a slow muted spinner badge. Decorative. -->
|
||||
|
|
@ -213,6 +232,15 @@ onMounted(async () => {
|
|||
:content="errorMessage ?? ''"
|
||||
></N8nNotice>
|
||||
</div>
|
||||
<!-- Nothing resolved yet, or the server just silently reused a prior consent:
|
||||
never guess at generic instance-wide copy, and never announce a "success"
|
||||
the visitor didn't ask for — the header's own connector spinner already
|
||||
signals activity while this redirects. -->
|
||||
<div
|
||||
v-else-if="autoApprovedRedirect || !detailsResolved"
|
||||
:class="$style.content"
|
||||
data-test-id="consent-loading"
|
||||
/>
|
||||
<!-- Default content -->
|
||||
<div v-else :class="$style.content" data-test-id="consent-content">
|
||||
<N8nHeading v-if="clientDetails?.isFirstParty" tag="h2" size="large" :bold="true">
|
||||
|
|
@ -285,7 +313,10 @@ onMounted(async () => {
|
|||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<footer v-if="!waitingForRedirect" :class="$style.footer">
|
||||
<footer
|
||||
v-if="!waitingForRedirect && !autoApprovedRedirect && (error || detailsResolved)"
|
||||
:class="$style.footer"
|
||||
>
|
||||
<!-- Third-party clients: the redirect destination, with the trust acknowledgment
|
||||
below it in the action row so it reads as a step rather than banner small
|
||||
print. Both are gated on the same `trustRequired` as the Allow button, so the
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
|
|||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.name'),
|
||||
key: 'name',
|
||||
width: 200,
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.status'),
|
||||
|
|
@ -76,26 +76,27 @@ const tableHeaders = computed<Array<TableHeader<AffectedWorkflow>>>(() => {
|
|||
row.active
|
||||
? i18n.baseText('settings.migrationReport.detail.table.active')
|
||||
: i18n.baseText('settings.migrationReport.detail.table.deactivated'),
|
||||
width: 40,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.nodesAffected'),
|
||||
key: 'issues',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.numberOfExecutions'),
|
||||
key: 'numberOfExecutions',
|
||||
width: 40,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastExecuted'),
|
||||
key: 'lastExecutedAt',
|
||||
width: 40,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: i18n.baseText('settings.migrationReport.detail.table.lastUpdated'),
|
||||
key: 'lastUpdatedAt',
|
||||
width: 40,
|
||||
width: 120,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -130,10 +130,9 @@ describe('MigrationRules', () => {
|
|||
});
|
||||
|
||||
// API called with correct context
|
||||
expect(breakingChangesApi.getReport).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
undefined,
|
||||
);
|
||||
expect(breakingChangesApi.getReport).toHaveBeenCalledWith(rootStore.restApiContext, {
|
||||
version: 'v3',
|
||||
});
|
||||
|
||||
// Loading skeletons are gone
|
||||
expect(document.querySelectorAll('.el-skeleton').length).toBe(0);
|
||||
|
|
@ -227,7 +226,7 @@ describe('MigrationRules', () => {
|
|||
expect(screen.getByText('No workflow issues detected')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Your workflows are fully compatible with version 2.0.0. You're good to go!",
|
||||
"Your workflows are fully compatible with version 3.0.0. You're good to go!",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -315,7 +314,7 @@ describe('MigrationRules', () => {
|
|||
expect(screen.getByText('No instance issues detected')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Your instance is fully compatible with version 2.0.0. You're good to go!",
|
||||
"Your instance is fully compatible with version 3.0.0. You're good to go!",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -403,10 +402,9 @@ describe('MigrationRules', () => {
|
|||
|
||||
// API called and data reloaded
|
||||
await waitFor(() => {
|
||||
expect(breakingChangesApi.refreshReport).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
undefined,
|
||||
);
|
||||
expect(breakingChangesApi.refreshReport).toHaveBeenCalledWith(rootStore.restApiContext, {
|
||||
version: 'v3',
|
||||
});
|
||||
expect(screen.getByText('Updated Rule')).toBeInTheDocument();
|
||||
expect(screen.getByText('10 Workflows')).toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/frontend-module-instance-registry",
|
||||
"version": "0.4.0",
|
||||
"version": "0.4.2",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/frontend-module-otel",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-nodes-base",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.2",
|
||||
"description": "Base nodes of n8n",
|
||||
"main": "index.js",
|
||||
"typesVersions": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* Paired item ancestry traversal benchmarks.
|
||||
*
|
||||
* `$('Node').item` / `.itemMatching()` walk an item's `pairedItem` chain back
|
||||
* through every ancestor, once per expression that uses them. Ancestry is a DAG,
|
||||
* so an item reachable through many paths must be expanded once, not once per path.
|
||||
*
|
||||
* Keep the chains short: `itemMatching` also runs a `getParentNodes` connection
|
||||
* check that is superlinear in the chain length and would otherwise dominate.
|
||||
*/
|
||||
import { bench, describe } from 'vitest';
|
||||
import { Workflow, WorkflowDataProxy } from 'n8n-workflow';
|
||||
import type {
|
||||
IConnections,
|
||||
IExecuteData,
|
||||
INode,
|
||||
INodeExecutionData,
|
||||
INodeTypes,
|
||||
IPairedItemData,
|
||||
IRunExecutionData,
|
||||
ITaskData,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { BENCH_OPTIONS } from '../bench-options';
|
||||
|
||||
const MAIN = 'main';
|
||||
|
||||
/** An unknown node type leaves the node as given; the traversal reads only run data and connections. */
|
||||
const nodeTypes: INodeTypes = {
|
||||
getByName: () => undefined as never,
|
||||
getByNameAndVersion: () => undefined as never,
|
||||
getKnownTypes: () => ({}),
|
||||
};
|
||||
|
||||
type ChainSpec = {
|
||||
/** Items in each node's output; its length is the chain length. */
|
||||
widths: number[];
|
||||
pairing: (d: number, i: number, widths: number[]) => IPairedItemData | IPairedItemData[];
|
||||
};
|
||||
|
||||
/** A -> B -> C -> ... with run data attached to every node. */
|
||||
function buildChain({ widths, pairing }: ChainSpec) {
|
||||
const nodes: INode[] = [];
|
||||
const connections: IConnections = {};
|
||||
const runData: IRunExecutionData['resultData']['runData'] = {};
|
||||
|
||||
for (let d = 0; d < widths.length; d++) {
|
||||
const name = `n${d}`;
|
||||
nodes.push({
|
||||
id: name,
|
||||
name,
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [d * 100, 0],
|
||||
parameters: {},
|
||||
});
|
||||
|
||||
if (d < widths.length - 1) {
|
||||
connections[name] = { [MAIN]: [[{ node: `n${d + 1}`, type: MAIN, index: 0 }]] };
|
||||
}
|
||||
|
||||
const items: INodeExecutionData[] = [];
|
||||
for (let i = 0; i < widths[d]!; i++) {
|
||||
const item: INodeExecutionData = { json: { d, i } };
|
||||
if (d > 0) item.pairedItem = pairing(d, i, widths);
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
runData[name] = [
|
||||
{
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: d,
|
||||
source: d === 0 ? [] : [{ previousNode: `n${d - 1}`, previousNodeRun: 0 }],
|
||||
data: { [MAIN]: [items] },
|
||||
} satisfies ITaskData,
|
||||
];
|
||||
}
|
||||
|
||||
return { nodes, connections, runData };
|
||||
}
|
||||
|
||||
/** A proxy on the last node of the chain, ready to trace back to the first. */
|
||||
function proxyForChain(spec: ChainSpec) {
|
||||
const { nodes, connections, runData } = buildChain(spec);
|
||||
const activeNode = `n${spec.widths.length - 1}`;
|
||||
const taskData = runData[activeNode]![0]!;
|
||||
const inputData = taskData.data![MAIN]![0]!;
|
||||
|
||||
const executeData: IExecuteData = {
|
||||
data: taskData.data!,
|
||||
node: nodes[nodes.length - 1]!,
|
||||
source: { [MAIN]: taskData.source },
|
||||
};
|
||||
|
||||
return new WorkflowDataProxy(
|
||||
new Workflow({ id: 'bench', name: 'bench', nodes, connections, active: false, nodeTypes }),
|
||||
{ resultData: { runData } } as IRunExecutionData,
|
||||
0,
|
||||
0,
|
||||
activeNode,
|
||||
inputData,
|
||||
{},
|
||||
'manual',
|
||||
{},
|
||||
executeData,
|
||||
).getDataProxy();
|
||||
}
|
||||
|
||||
/** The last node's items carry a single paired item, the proxy's entry point into the walk. */
|
||||
const isLastNode = (d: number, widths: number[]) => d === widths.length - 1;
|
||||
|
||||
/**
|
||||
* origin -> {bN, cN} -> mN -> {bN+1, cN+1} -> ... : 2^diamonds paths to the last
|
||||
* merge, where the chain shapes above reach a shared ancestor from one node.
|
||||
*/
|
||||
function diamondProxy(diamonds: number) {
|
||||
const nodes: INode[] = [];
|
||||
const connections: IConnections = {};
|
||||
const runData: IRunExecutionData['resultData']['runData'] = {};
|
||||
|
||||
const addNode = (name: string) => {
|
||||
nodes.push({
|
||||
id: name,
|
||||
name,
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [nodes.length * 100, 0],
|
||||
parameters: {},
|
||||
});
|
||||
};
|
||||
const link = (from: string, to: string, index: number) => {
|
||||
connections[from] ??= { [MAIN]: [[]] };
|
||||
connections[from][MAIN][0]!.push({ node: to, type: MAIN, index });
|
||||
};
|
||||
const task = (
|
||||
source: ITaskData['source'],
|
||||
pairedItem: IPairedItemData | IPairedItemData[] | undefined,
|
||||
) =>
|
||||
[
|
||||
{
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source,
|
||||
data: { [MAIN]: [[{ json: {}, ...(pairedItem ? { pairedItem } : {}) }]] },
|
||||
} satisfies ITaskData,
|
||||
] as ITaskData[];
|
||||
|
||||
addNode('origin');
|
||||
runData.origin = task([], undefined);
|
||||
|
||||
let cur = 'origin';
|
||||
for (let i = 0; i < diamonds; i++) {
|
||||
const [b, c, m] = [`b${i}`, `c${i}`, `m${i}`];
|
||||
for (const branch of [b, c]) {
|
||||
addNode(branch);
|
||||
link(cur, branch, 0);
|
||||
runData[branch] = task([{ previousNode: cur, previousNodeRun: 0 }], { item: 0, input: 0 });
|
||||
}
|
||||
addNode(m);
|
||||
link(b, m, 0);
|
||||
link(c, m, 1);
|
||||
runData[m] = task(
|
||||
[
|
||||
{ previousNode: b, previousNodeRun: 0 },
|
||||
{ previousNode: c, previousNodeRun: 0 },
|
||||
],
|
||||
[
|
||||
{ item: 0, input: 0 },
|
||||
{ item: 0, input: 1 },
|
||||
],
|
||||
);
|
||||
cur = m;
|
||||
}
|
||||
|
||||
addNode('end');
|
||||
link(cur, 'end', 0);
|
||||
runData.end = task([{ previousNode: cur, previousNodeRun: 0 }], { item: 0, input: 0 });
|
||||
|
||||
const taskData = runData.end![0]!;
|
||||
return new WorkflowDataProxy(
|
||||
new Workflow({ id: 'bench', name: 'bench', nodes, connections, active: false, nodeTypes }),
|
||||
{ resultData: { runData } } as IRunExecutionData,
|
||||
0,
|
||||
0,
|
||||
'end',
|
||||
taskData.data![MAIN]![0]!,
|
||||
{},
|
||||
'manual',
|
||||
{},
|
||||
{
|
||||
data: taskData.data!,
|
||||
node: nodes[nodes.length - 1]!,
|
||||
source: { [MAIN]: taskData.source },
|
||||
},
|
||||
).getDataProxy();
|
||||
}
|
||||
|
||||
describe('Paired item ancestry traversal', () => {
|
||||
// Every item pairs to the same ancestor twice: 2^depth paths over `depth`
|
||||
// distinct items. Exponential paths, must stay linear.
|
||||
const recombining = proxyForChain({
|
||||
widths: Array<number>(20).fill(1),
|
||||
pairing: (d, i, widths) =>
|
||||
isLastNode(d, widths)
|
||||
? { item: i, input: 0 }
|
||||
: [
|
||||
{ item: 0, input: 0 },
|
||||
{ item: 0, input: 0 },
|
||||
],
|
||||
});
|
||||
bench(
|
||||
'itemMatching: recombining chain (20 nodes, 2^20 paths)',
|
||||
() => {
|
||||
recombining.$('n0').itemMatching(0);
|
||||
},
|
||||
BENCH_OPTIONS,
|
||||
);
|
||||
|
||||
// One item paired to 10k inputs that all trace to the same origin: no path is
|
||||
// walked twice, so every visit is new work.
|
||||
const FAN = 10_000;
|
||||
const fanIn = proxyForChain({
|
||||
widths: [1, FAN, 1, 1],
|
||||
pairing: (d, i, widths) => {
|
||||
if (isLastNode(d, widths)) return { item: i, input: 0 };
|
||||
if (d === 2) return Array.from({ length: FAN }, (_, k) => ({ item: k, input: 0 }));
|
||||
return { item: 0, input: 0 };
|
||||
},
|
||||
});
|
||||
bench(
|
||||
`itemMatching: fan-in (one item paired to ${FAN} ancestors)`,
|
||||
() => {
|
||||
fanIn.$('n0').itemMatching(0);
|
||||
},
|
||||
BENCH_OPTIONS,
|
||||
);
|
||||
|
||||
// Branches converge from two separate parents, so a shared ancestor is reached
|
||||
// under one state from several routes. Exponential paths, must stay linear.
|
||||
const diamonds = diamondProxy(14);
|
||||
bench(
|
||||
'itemMatching: diamond chain (14 diamonds, 2^14 paths)',
|
||||
() => {
|
||||
diamonds.$('origin').itemMatching(0);
|
||||
},
|
||||
BENCH_OPTIONS,
|
||||
);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "n8n-workflow",
|
||||
"version": "2.39.0",
|
||||
"version": "2.39.1",
|
||||
"description": "Workflow base code of n8n",
|
||||
"types": "dist/esm/index.d.ts",
|
||||
"module": "dist/esm/index.js",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import type { INodeExecutionData, IPairedItemData, ISourceData } from './interfaces';
|
||||
|
||||
type PairedItemMemoResult =
|
||||
| { ok: true; result: INodeExecutionData }
|
||||
| { ok: false; error: unknown };
|
||||
|
||||
/**
|
||||
* Memoizes one paired item traversal per (node run output, item) state.
|
||||
* For a fixed destination, revisiting a state yields the same result or error.
|
||||
*/
|
||||
export class PairedItemMemo {
|
||||
private readonly entries = new Map<string, PairedItemMemoResult>();
|
||||
|
||||
/**
|
||||
* Resolves the memoized result for the given source data and paired item,
|
||||
* or computes and memoizes a new result if none exists.
|
||||
*/
|
||||
resolve(
|
||||
sourceData: ISourceData,
|
||||
pairedItem: IPairedItemData,
|
||||
compute: () => INodeExecutionData,
|
||||
): INodeExecutionData {
|
||||
const key = this.toKey(sourceData, pairedItem);
|
||||
|
||||
let entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
try {
|
||||
entry = { ok: true, result: compute() };
|
||||
} catch (error) {
|
||||
entry = { ok: false, error };
|
||||
}
|
||||
this.entries.set(key, entry);
|
||||
}
|
||||
|
||||
if (entry.ok) return entry.result;
|
||||
throw entry.error;
|
||||
}
|
||||
|
||||
private toKey(sourceData: ISourceData, pairedItem: IPairedItemData): string {
|
||||
return `${sourceData.previousNodeRun ?? 0}|${sourceData.previousNodeOutput ?? 0}|${pairedItem.item}|${sourceData.previousNode}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import type { Workflow } from './workflow';
|
|||
import type { EnvProviderState } from './workflow-data-proxy-env-provider';
|
||||
import { createEnvProvider, createEnvProviderState } from './workflow-data-proxy-env-provider';
|
||||
import { getPinDataIfManualExecution } from './workflow-data-proxy-helpers';
|
||||
import { PairedItemMemo } from './workflow-data-proxy-paired-item-memo';
|
||||
|
||||
const isScriptingNode = (nodeName: string, workflow: Workflow) => {
|
||||
const node = workflow.getNode(nodeName);
|
||||
|
|
@ -1034,7 +1035,26 @@ export class WorkflowDataProxy {
|
|||
incomingSourceData: ISourceData | null,
|
||||
initialPairedItem: IPairedItemData,
|
||||
usedMethodName: PairedItemMethod = PAIRED_ITEM_METHOD.$GET_PAIRED_ITEM,
|
||||
nodeBeforeLast?: string,
|
||||
): INodeExecutionData =>
|
||||
resolvePairedItem(
|
||||
destinationNodeName,
|
||||
incomingSourceData,
|
||||
initialPairedItem,
|
||||
usedMethodName,
|
||||
undefined,
|
||||
// Ancestry is a DAG: branches recombine on shared ancestors (e.g. an
|
||||
// Aggregate output pairing to all its inputs), so without memoization
|
||||
// the walk revisits the same item exponentially often.
|
||||
new PairedItemMemo(),
|
||||
);
|
||||
|
||||
const resolvePairedItem = (
|
||||
destinationNodeName: string,
|
||||
incomingSourceData: ISourceData | null,
|
||||
initialPairedItem: IPairedItemData,
|
||||
usedMethodName: PairedItemMethod,
|
||||
nodeBeforeLast: string | undefined,
|
||||
memo: PairedItemMemo,
|
||||
): INodeExecutionData => {
|
||||
// Normalize inputs
|
||||
const [pairedItem, sourceData] = normalizeInputs(initialPairedItem, incomingSourceData);
|
||||
|
|
@ -1043,6 +1063,26 @@ export class WorkflowDataProxy {
|
|||
throw createPairedItemNotFound(destinationNodeName, nodeBeforeLast);
|
||||
}
|
||||
|
||||
return memo.resolve(sourceData, pairedItem, () =>
|
||||
resolvePairedItemUncached(
|
||||
destinationNodeName,
|
||||
sourceData,
|
||||
pairedItem,
|
||||
usedMethodName,
|
||||
nodeBeforeLast,
|
||||
memo,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const resolvePairedItemUncached = (
|
||||
destinationNodeName: string,
|
||||
sourceData: ISourceData,
|
||||
pairedItem: IPairedItemData,
|
||||
usedMethodName: PairedItemMethod,
|
||||
nodeBeforeLast: string | undefined,
|
||||
memo: PairedItemMemo,
|
||||
): INodeExecutionData => {
|
||||
const taskData = getTaskData(sourceData);
|
||||
const outputData = getNodeOutput(taskData, sourceData, nodeBeforeLast);
|
||||
const item = outputData[pairedItem.item];
|
||||
|
|
@ -1074,12 +1114,13 @@ export class WorkflowDataProxy {
|
|||
|
||||
try {
|
||||
return createResultOk(
|
||||
getPairedItem(
|
||||
resolvePairedItem(
|
||||
destinationNodeName,
|
||||
nextSource,
|
||||
{ ...nextPairedItem, input: inputIndex },
|
||||
usedMethodName,
|
||||
sourceData.previousNode,
|
||||
memo,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import type { INodeExecutionData } from '../src/interfaces';
|
||||
import { PairedItemMemo } from '../src/workflow-data-proxy-paired-item-memo';
|
||||
|
||||
describe('PairedItemMemo', () => {
|
||||
const source = { previousNode: 'A', previousNodeRun: 1, previousNodeOutput: 2 };
|
||||
const item: INodeExecutionData = { json: {} };
|
||||
|
||||
test('computes once per state and replays the result', () => {
|
||||
const memo = new PairedItemMemo();
|
||||
const compute = vi.fn(() => item);
|
||||
|
||||
expect(memo.resolve(source, { item: 0 }, compute)).toBe(item);
|
||||
expect(memo.resolve(source, { item: 0 }, compute)).toBe(item);
|
||||
expect(compute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('replays the same error without recomputing', () => {
|
||||
const memo = new PairedItemMemo();
|
||||
const error = new Error('boom');
|
||||
const compute = vi.fn(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
expect(() => memo.resolve(source, { item: 0 }, compute)).toThrow(error);
|
||||
expect(() => memo.resolve(source, { item: 0 }, compute)).toThrow(error);
|
||||
expect(compute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('treats omitted run and output as 0', () => {
|
||||
const memo = new PairedItemMemo();
|
||||
const compute = vi.fn(() => item);
|
||||
|
||||
memo.resolve({ previousNode: 'A' }, { item: 0 }, compute);
|
||||
memo.resolve(
|
||||
{ previousNode: 'A', previousNodeRun: 0, previousNodeOutput: 0 },
|
||||
{ item: 0 },
|
||||
compute,
|
||||
);
|
||||
expect(compute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test.each([
|
||||
['node', { ...source, previousNode: 'B' }, { item: 0 }],
|
||||
['run', { ...source, previousNodeRun: 0 }, { item: 0 }],
|
||||
['output', { ...source, previousNodeOutput: 0 }, { item: 0 }],
|
||||
['item', source, { item: 1 }],
|
||||
])('keys a different %s as a distinct state', (_, otherSource, otherItem) => {
|
||||
const memo = new PairedItemMemo();
|
||||
const compute = vi.fn(() => item);
|
||||
|
||||
memo.resolve(source, { item: 0 }, compute);
|
||||
memo.resolve(otherSource, otherItem, compute);
|
||||
expect(compute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,8 @@ import {
|
|||
type IDataObject,
|
||||
type IExecuteData,
|
||||
type INode,
|
||||
type INodeExecutionData,
|
||||
type IPairedItemData,
|
||||
type IPinData,
|
||||
type IRun,
|
||||
type IWorkflowBase,
|
||||
|
|
@ -2264,3 +2266,360 @@ describe('WorkflowDataProxy', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkflowDataProxy → pairedItem traversal through recombining ancestry', () => {
|
||||
// Models a polling loop whose Aggregate-style node pairs its single output
|
||||
// item to all of its inputs: ancestry branches fan out at every pass and
|
||||
// recombine on the shared ancestor, forming a DAG. Traversal must resolve
|
||||
// this in polynomial time - a tree walk would take ROWS^PASSES steps.
|
||||
const ROWS = 8;
|
||||
const PASSES = 25;
|
||||
|
||||
const makeWorkflow = (): IWorkflowBase => ({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
nodes: (['Start', 'Rows', 'Agg', 'End'] as const).map((name, i) => ({
|
||||
id: `uuid-${i}`,
|
||||
name,
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
position: [i * 100, 0] as [number, number],
|
||||
parameters: {},
|
||||
})),
|
||||
connections: {
|
||||
Start: { main: [[{ node: 'Rows', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
Rows: { main: [[{ node: 'Agg', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
Agg: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
},
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const taskData = (source: Array<{ previousNode: string; previousNodeRun?: number } | null>) => ({
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source,
|
||||
data: { main: [[]] as INodeExecutionData[][] },
|
||||
});
|
||||
|
||||
const makeRun = (
|
||||
startItems: INodeExecutionData[],
|
||||
rowsPairing: (row: number) => number,
|
||||
): IRun => {
|
||||
const start = taskData([null]);
|
||||
start.data.main[0] = startItems;
|
||||
|
||||
const rowsRuns = [];
|
||||
const aggRuns = [];
|
||||
for (let pass = 0; pass < PASSES; pass++) {
|
||||
const rows = taskData([
|
||||
pass === 0 ? { previousNode: 'Start' } : { previousNode: 'Agg', previousNodeRun: pass - 1 },
|
||||
]);
|
||||
// only pass 0 is sourced from Start (multiple items); later passes are
|
||||
// sourced from Agg, which emits a single item
|
||||
rows.data.main[0] = Array.from({ length: ROWS }, (_, row) => ({
|
||||
json: { row },
|
||||
pairedItem: { item: pass === 0 ? rowsPairing(row) : 0 },
|
||||
}));
|
||||
rowsRuns.push(rows);
|
||||
|
||||
const agg = taskData([{ previousNode: 'Rows', previousNodeRun: pass }]);
|
||||
agg.data.main[0] = [
|
||||
{ json: { pass }, pairedItem: Array.from({ length: ROWS }, (_, item) => ({ item })) },
|
||||
];
|
||||
aggRuns.push(agg);
|
||||
}
|
||||
|
||||
const end = taskData([{ previousNode: 'Agg', previousNodeRun: PASSES - 1 }]);
|
||||
end.data.main[0] = [{ json: {}, pairedItem: { item: 0 } }];
|
||||
|
||||
return {
|
||||
data: createRunExecutionData({
|
||||
resultData: { runData: { Start: [start], Rows: rowsRuns, Agg: aggRuns, End: [end] } },
|
||||
}),
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'success',
|
||||
storedAt: 'db',
|
||||
};
|
||||
};
|
||||
|
||||
test('resolves when all branches recombine on one ancestor item', () => {
|
||||
const run = makeRun([{ json: { origin: true }, pairedItem: { item: 0 } }], () => 0);
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(proxy.$('Start').item.json).toEqual({ origin: true });
|
||||
});
|
||||
|
||||
test('still detects ambiguity when branches resolve to different ancestor items', () => {
|
||||
const startItems = Array.from({ length: ROWS }, (_, item) => ({
|
||||
json: { item },
|
||||
pairedItem: { item },
|
||||
}));
|
||||
// each row pairs back to its own Start item, so resolution is ambiguous
|
||||
const run = makeRun(startItems, (row) => row);
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkflowDataProxy → pairedItem traversal with sourceOverwrite routes', () => {
|
||||
// One item pairs to the same ancestor node run twice: once through its task's
|
||||
// default source and once through a `sourceOverwrite` redirect. The two routes
|
||||
// must resolve as distinct states.
|
||||
const makeWorkflow = (): IWorkflowBase => ({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
nodes: (['Start', 'Mid', 'Join', 'End'] as const).map((name, i) => ({
|
||||
id: `uuid-${i}`,
|
||||
name,
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
position: [i * 100, 0] as [number, number],
|
||||
parameters: {},
|
||||
})),
|
||||
connections: {
|
||||
Start: { main: [[{ node: 'Mid', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
Mid: { main: [[{ node: 'Join', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
Join: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
},
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const midRun = (outputs: INodeExecutionData[][]) => ({
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [{ previousNode: 'Start' }],
|
||||
data: { main: outputs },
|
||||
});
|
||||
|
||||
// Mid run N emits one item paired to Start item `midRunPairing[N]`
|
||||
const midRunsPairedTo = (midRunPairing: number[]) =>
|
||||
midRunPairing.map((startItem) => midRun([[{ json: {}, pairedItem: { item: startItem } }]]));
|
||||
|
||||
const defaultJoinPairing: IPairedItemData[] = [
|
||||
{ item: 0 },
|
||||
{ item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 1 } },
|
||||
];
|
||||
|
||||
const makeRun = (
|
||||
startItems: INodeExecutionData[],
|
||||
midRuns: ReturnType<typeof midRun>[],
|
||||
joinPairing: IPairedItemData[] = defaultJoinPairing,
|
||||
): IRun => {
|
||||
const start = {
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [null],
|
||||
data: { main: [startItems] },
|
||||
};
|
||||
|
||||
const join = {
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [{ previousNode: 'Mid', previousNodeRun: 0 }],
|
||||
data: { main: [[{ json: {}, pairedItem: joinPairing }]] },
|
||||
};
|
||||
|
||||
const end = {
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [{ previousNode: 'Join' }],
|
||||
data: { main: [[{ json: {}, pairedItem: { item: 0 } }]] },
|
||||
};
|
||||
|
||||
return {
|
||||
data: createRunExecutionData({
|
||||
resultData: { runData: { Start: [start], Mid: midRuns, Join: [join], End: [end] } },
|
||||
}),
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'success',
|
||||
storedAt: 'db',
|
||||
};
|
||||
};
|
||||
|
||||
test('resolves when the default and overwritten routes agree on the ancestor item', () => {
|
||||
const run = makeRun(
|
||||
[{ json: { origin: true }, pairedItem: { item: 0 } }],
|
||||
midRunsPairedTo([0, 0]),
|
||||
);
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(proxy.$('Start').item.json).toEqual({ origin: true });
|
||||
});
|
||||
|
||||
test('detects ambiguity when the overwritten route reaches a different ancestor item', () => {
|
||||
const startItems = [
|
||||
{ json: { item: 0 }, pairedItem: { item: 0 } },
|
||||
{ json: { item: 1 }, pairedItem: { item: 1 } },
|
||||
];
|
||||
const run = makeRun(startItems, midRunsPairedTo([0, 1]));
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
|
||||
});
|
||||
|
||||
test('returns the resolving route when another route fails, also on its memoized revisit', () => {
|
||||
const startItems = [{ json: { origin: true }, pairedItem: { item: 0 } }];
|
||||
// Mid run 1 has no pairedItem, so the overwritten route cannot resolve.
|
||||
// It is listed twice so the second visit replays the memoized error.
|
||||
const brokenRoute = { item: 0, sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 1 } };
|
||||
const midRuns = [...midRunsPairedTo([0]), midRun([[{ json: {} }]])];
|
||||
|
||||
const okFirst = getProxyFromFixture(
|
||||
makeWorkflow(),
|
||||
makeRun(startItems, midRuns, [{ item: 0 }, brokenRoute, brokenRoute]),
|
||||
'End',
|
||||
);
|
||||
expect(okFirst.$('Start').item.json).toEqual({ origin: true });
|
||||
|
||||
const okLast = getProxyFromFixture(
|
||||
makeWorkflow(),
|
||||
makeRun(startItems, midRuns, [brokenRoute, brokenRoute, { item: 0 }]),
|
||||
'End',
|
||||
);
|
||||
expect(okLast.$('Start').item.json).toEqual({ origin: true });
|
||||
|
||||
const allBroken = getProxyFromFixture(
|
||||
makeWorkflow(),
|
||||
makeRun(startItems, midRuns, [brokenRoute, brokenRoute]),
|
||||
'End',
|
||||
);
|
||||
expect(() => allBroken.$('Start').item).toThrowError(
|
||||
"Paired item data for item from node 'Mid' is unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
test('treats the same item index on different outputs of one run as distinct states', () => {
|
||||
const startItems = [
|
||||
{ json: { item: 0 }, pairedItem: { item: 0 } },
|
||||
{ json: { item: 1 }, pairedItem: { item: 1 } },
|
||||
];
|
||||
// Mid run 0 output 0 item 0 pairs to Start 0, output 1 item 0 pairs to Start 1
|
||||
const midRuns = [
|
||||
midRun([[{ json: {}, pairedItem: { item: 0 } }], [{ json: {}, pairedItem: { item: 1 } }]]),
|
||||
];
|
||||
const run = makeRun(startItems, midRuns, [
|
||||
{
|
||||
item: 0,
|
||||
sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 0, previousNodeOutput: 0 },
|
||||
},
|
||||
{
|
||||
item: 0,
|
||||
sourceOverwrite: { previousNode: 'Mid', previousNodeRun: 0, previousNodeOutput: 1 },
|
||||
},
|
||||
]);
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkflowDataProxy → pairedItem traversal through a diamond', () => {
|
||||
// Start fans out to Left and Right, which join again in Merge. A Start item is
|
||||
// reached under one state from two different parent nodes.
|
||||
const makeWorkflow = (): IWorkflowBase => ({
|
||||
id: '1',
|
||||
name: 'test',
|
||||
nodes: (['Start', 'Left', 'Right', 'Merge', 'End'] as const).map((name, i) => ({
|
||||
id: `uuid-${i}`,
|
||||
name,
|
||||
type: 'n8n-nodes-base.code',
|
||||
typeVersion: 1,
|
||||
position: [i * 100, 0] as [number, number],
|
||||
parameters: {},
|
||||
})),
|
||||
connections: {
|
||||
Start: {
|
||||
main: [
|
||||
[
|
||||
{ node: 'Left', type: NodeConnectionTypes.Main, index: 0 },
|
||||
{ node: 'Right', type: NodeConnectionTypes.Main, index: 0 },
|
||||
],
|
||||
],
|
||||
},
|
||||
Left: { main: [[{ node: 'Merge', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
Right: { main: [[{ node: 'Merge', type: NodeConnectionTypes.Main, index: 1 }]] },
|
||||
Merge: { main: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]] },
|
||||
},
|
||||
active: false,
|
||||
activeVersionId: null,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const task = (source: Array<{ previousNode: string } | null>, items: INodeExecutionData[]) => ({
|
||||
startTime: 0,
|
||||
executionTime: 0,
|
||||
executionIndex: 0,
|
||||
source,
|
||||
data: { main: [items] },
|
||||
});
|
||||
|
||||
const makeRun = (
|
||||
startItems: INodeExecutionData[],
|
||||
leftItem: number,
|
||||
rightItem: number,
|
||||
): IRun => ({
|
||||
data: createRunExecutionData({
|
||||
resultData: {
|
||||
runData: {
|
||||
Start: [task([null], startItems)],
|
||||
Left: [task([{ previousNode: 'Start' }], [{ json: {}, pairedItem: { item: leftItem } }])],
|
||||
Right: [
|
||||
task([{ previousNode: 'Start' }], [{ json: {}, pairedItem: { item: rightItem } }]),
|
||||
],
|
||||
Merge: [
|
||||
task(
|
||||
[{ previousNode: 'Left' }, { previousNode: 'Right' }],
|
||||
[
|
||||
{
|
||||
json: {},
|
||||
pairedItem: [
|
||||
{ item: 0, input: 0 },
|
||||
{ item: 0, input: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
End: [task([{ previousNode: 'Merge' }], [{ json: {}, pairedItem: { item: 0 } }])],
|
||||
},
|
||||
},
|
||||
}),
|
||||
mode: 'manual',
|
||||
startedAt: new Date(),
|
||||
status: 'success',
|
||||
storedAt: 'db',
|
||||
});
|
||||
|
||||
test('resolves when both routes reach one ancestor item', () => {
|
||||
const run = makeRun([{ json: { origin: true }, pairedItem: { item: 0 } }], 0, 0);
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(proxy.$('Start').item.json).toEqual({ origin: true });
|
||||
});
|
||||
|
||||
test('detects ambiguity when the routes reach different ancestor items', () => {
|
||||
const startItems = [0, 1].map((item) => ({ json: { item }, pairedItem: { item } }));
|
||||
const run = makeRun(startItems, 0, 1);
|
||||
const proxy = getProxyFromFixture(makeWorkflow(), run, 'End');
|
||||
|
||||
expect(() => proxy.$('Start').item).toThrowError('Multiple matches');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user