mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-28 15:27:03 +02:00
feat(core): Implement all breaking changes rules to v2 (#21217)
This commit is contained in:
parent
040dcdbfc9
commit
363a7773b8
|
|
@ -0,0 +1,34 @@
|
|||
import { mock } from 'jest-mock-extended';
|
||||
import type { BinaryDataConfig } from 'n8n-core';
|
||||
|
||||
import { BinaryDataStorageRule } from '../binary-data-storage.rule';
|
||||
|
||||
describe('BinaryDataStorageRule', () => {
|
||||
let rule: BinaryDataStorageRule;
|
||||
const config: BinaryDataConfig = mock<BinaryDataConfig>();
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new BinaryDataStorageRule(config);
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected if mode is not default', async () => {
|
||||
config.mode = 'filesystem';
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected if mode is default', async () => {
|
||||
config.mode = 'default';
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('Binary data storage mode changed');
|
||||
expect(result.recommendations).toHaveLength(3);
|
||||
expect(result.recommendations[0].action).toBe('Ensure adequate disk space');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { CliActivateAllWorkflowsRule } from '../cli-activate-all-workflows.rule';
|
||||
|
||||
describe('CliActivateAllWorkflowsRule', () => {
|
||||
let rule: CliActivateAllWorkflowsRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new CliActivateAllWorkflowsRule();
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata.version).toBe('v2');
|
||||
expect(metadata.title).toBe('Remove CLI command operation to activate all workflows');
|
||||
expect(metadata.severity).toBe('low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should always be affected (informational)', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('CLI command to activate all workflows removed');
|
||||
expect(result.instanceIssues[0].level).toBe('info');
|
||||
});
|
||||
|
||||
it('should include description about CLI command removal', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.instanceIssues[0].description).toContain('CLI command');
|
||||
expect(result.instanceIssues[0].description).toContain('removed');
|
||||
});
|
||||
|
||||
it('should have 2 recommendations', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.recommendations).toHaveLength(2);
|
||||
expect(result.recommendations[0].action).toContain('Use the API to activate workflows');
|
||||
expect(result.recommendations[1].action).toContain('Review deployment scripts');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
|
||||
import { DisabledNodesRule } from '../disabled-nodes.rule';
|
||||
|
||||
describe('DisabledNodesRule', () => {
|
||||
let rule: DisabledNodesRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new DisabledNodesRule();
|
||||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it('should not be affected when no disabled nodes are found', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect ExecuteCommand node', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Execute', 'n8n-nodes-base.executeCommand'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].title).toContain('Execute');
|
||||
expect(result.issues[0].title).toContain('will be disabled');
|
||||
});
|
||||
|
||||
it('should detect LocalFileTrigger node', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('FileTrigger', 'n8n-nodes-base.localFileTrigger'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { access } from 'node:fs/promises';
|
||||
|
||||
import { DotenvUpgradeRule } from '../dotenv-upgrade.rule';
|
||||
|
||||
jest.mock('node:fs/promises');
|
||||
|
||||
describe('DotenvUpgradeRule', () => {
|
||||
let rule: DotenvUpgradeRule;
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new DotenvUpgradeRule();
|
||||
process.env = { ...originalEnv };
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when no .env files exist', async () => {
|
||||
(access as jest.Mock).mockRejectedValue(new Error('File not found'));
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when .env files exist', async () => {
|
||||
delete process.env.DOTENV_CONFIG_PATH;
|
||||
(access as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('dotenv library upgrade detected');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ describe('FileAccessRule', () => {
|
|||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata).toEqual({
|
||||
expect(metadata).toMatchObject({
|
||||
version: 'v2',
|
||||
title: 'File Access Restrictions',
|
||||
description: 'File access is now restricted to a default directory for security purposes',
|
||||
|
|
@ -26,7 +26,7 @@ describe('FileAccessRule', () => {
|
|||
|
||||
describe('getRecommendations()', () => {
|
||||
it('should return recommendations', async () => {
|
||||
const recommendations = await rule.getRecommendations();
|
||||
const recommendations = await rule.getRecommendations([]);
|
||||
|
||||
expect(recommendations).toHaveLength(1);
|
||||
expect(recommendations[0]).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
|
||||
import { BreakingChangeCategory } from '../../../types';
|
||||
import { GitNodeBareReposRule } from '../git-node-bare-repos.rule';
|
||||
|
||||
describe('GitNodeBareReposRule', () => {
|
||||
let rule: GitNodeBareReposRule;
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
rule = new GitNodeBareReposRule();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
version: 'v2',
|
||||
title: 'Git node bare repositories disabled by default',
|
||||
description:
|
||||
'N8N_GIT_NODE_DISABLE_BARE_REPOS now defaults to true for security. Bare repositories are disabled to prevent RCE attacks via Git hooks',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecommendations()', () => {
|
||||
it('should return recommendations', async () => {
|
||||
const recommendations = await rule.getRecommendations([]);
|
||||
|
||||
expect(recommendations).toHaveLength(3);
|
||||
expect(recommendations).toEqual([
|
||||
{
|
||||
action: 'Review Git node usage',
|
||||
description:
|
||||
'Check if any Git nodes in your workflows use bare repositories. Bare repositories are now disabled by default for security reasons.',
|
||||
},
|
||||
{
|
||||
action: 'Migrate away from bare repositories',
|
||||
description:
|
||||
'If possible, update your workflows to use regular Git repositories instead of bare repositories.',
|
||||
},
|
||||
{
|
||||
action: 'Enable bare repositories if required (not recommended)',
|
||||
description:
|
||||
'If you absolutely need bare repository support and understand the security risks, set N8N_GIT_NODE_DISABLE_BARE_REPOS=false. This is not recommended as it exposes your instance to potential RCE attacks via Git hooks.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it('should return no issues when no Git nodes are found', async () => {
|
||||
delete process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS;
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return no issues when N8N_GIT_NODE_DISABLE_BARE_REPOS is explicitly set to false', async () => {
|
||||
process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS = 'false';
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Git', 'n8n-nodes-base.git'),
|
||||
]);
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect Git nodes when N8N_GIT_NODE_DISABLE_BARE_REPOS is not set', async () => {
|
||||
delete process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS;
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Git', 'n8n-nodes-base.git'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0]).toMatchObject({
|
||||
title: "Git node 'Git' may be affected by bare repository restrictions",
|
||||
description: expect.stringContaining('Bare repositories are now disabled by default'),
|
||||
level: 'warning',
|
||||
nodeId: expect.any(String),
|
||||
nodeName: 'Git',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect Git nodes when N8N_GIT_NODE_DISABLE_BARE_REPOS is set to true', async () => {
|
||||
process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS = 'true';
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Git', 'n8n-nodes-base.git'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should detect multiple Git nodes', async () => {
|
||||
delete process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS;
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Git1', 'n8n-nodes-base.git'),
|
||||
createNode('Git2', 'n8n-nodes-base.git'),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(2);
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
nodeName: 'Git1',
|
||||
title: "Git node 'Git1' may be affected by bare repository restrictions",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeName: 'Git2',
|
||||
title: "Git node 'Git2' may be affected by bare repository restrictions",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should include security explanation in description', async () => {
|
||||
delete process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS;
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Git', 'n8n-nodes-base.git'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.issues[0].description).toContain('security');
|
||||
expect(result.issues[0].description).toContain('N8N_GIT_NODE_DISABLE_BARE_REPOS=false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { OAuthCallbackAuthRule } from '../oauth-callback-auth.rule';
|
||||
|
||||
describe('OAuthCallbackAuthRule', () => {
|
||||
let rule: OAuthCallbackAuthRule;
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new OAuthCallbackAuthRule();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should be affected when N8N_SKIP_AUTH_ON_OAUTH_CALLBACK is not set', async () => {
|
||||
delete process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('OAuth callback authentication now required');
|
||||
});
|
||||
|
||||
it.each(['true', 'false', '1', '0'])(
|
||||
'should not be affected when N8N_SKIP_AUTH_ON_OAUTH_CALLBACK is set to %s',
|
||||
async (value) => {
|
||||
process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK = value;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
expect(result.recommendations).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ describe('ProcessEnvAccessRule', () => {
|
|||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata).toEqual({
|
||||
expect(metadata).toMatchObject({
|
||||
version: 'v2',
|
||||
title: 'Block process.env Access in Expressions and Code nodes',
|
||||
description: 'Direct access to process.env is blocked by default for security',
|
||||
|
|
@ -45,6 +45,32 @@ describe('ProcessEnvAccessRule', () => {
|
|||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it('should return no issues when N8N_BLOCK_ENV_ACCESS_IN_NODE is set to false', async () => {
|
||||
const originalValue = process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE;
|
||||
try {
|
||||
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = 'false';
|
||||
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Code', 'n8n-nodes-base.code', {
|
||||
code: 'const apiKey = process.env.API_KEY;\nreturn { apiKey };',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
} finally {
|
||||
if (originalValue === undefined) {
|
||||
delete process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE;
|
||||
} else {
|
||||
process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE = originalValue;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return no issues when no process.env usage is found', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Clean Workflow', [
|
||||
createNode('Code', 'n8n-nodes-base.code', {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
|
||||
import { BreakingChangeCategory } from '../../../types';
|
||||
import { PyodideRemovedRule } from '../pyodide-removed.rule';
|
||||
|
||||
describe('PyodideRemovedRule', () => {
|
||||
let rule: PyodideRemovedRule;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
rule = new PyodideRemovedRule();
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
version: 'v2',
|
||||
title: 'Remove Pyodide-based Python in Code node',
|
||||
description:
|
||||
'The Pyodide-based Python implementation in the Code node has been removed and replaced with a native Python task runner implementation',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecommendations()', () => {
|
||||
it('should return recommendations', async () => {
|
||||
const recommendations = await rule.getRecommendations([]);
|
||||
|
||||
expect(recommendations).toHaveLength(3);
|
||||
expect(recommendations).toEqual([
|
||||
{
|
||||
action: 'Update Code nodes to use native Python',
|
||||
description:
|
||||
'Manually update affected Code nodes from the legacy python parameter to the new pythonNative parameter',
|
||||
},
|
||||
{
|
||||
action: 'Review and adjust Python scripts',
|
||||
description:
|
||||
'Review Code node scripts relying on Pyodide syntax and adjust for breaking changes. See: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/#python-native-beta',
|
||||
},
|
||||
{
|
||||
action: 'Set up Python task runner',
|
||||
description:
|
||||
'Ensure a Python task runner is available and configured. Native Python task runners are enabled by default in v2',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it('should return no issues when no Code nodes are found', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return no issues when Code nodes use JavaScript', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Code', 'n8n-nodes-base.code', {
|
||||
language: 'javaScript',
|
||||
jsCode: 'return items;',
|
||||
}),
|
||||
]);
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return no issues when Code nodes use native Python (pythonNative)', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Python', 'n8n-nodes-base.code', {
|
||||
language: 'pythonNative',
|
||||
pythonCode: 'print("hello")',
|
||||
}),
|
||||
]);
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect Code node with Pyodide Python (language="python")', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Python', 'n8n-nodes-base.code', {
|
||||
language: 'python',
|
||||
pythonCode: 'print("hello")',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0]).toMatchObject({
|
||||
title: "Code node 'Python' uses removed Pyodide Python implementation",
|
||||
description:
|
||||
'The Pyodide-based Python implementation (language="python") is no longer supported. This node must be migrated to use the task runner-based implementation (language="pythonNative").',
|
||||
level: 'error',
|
||||
nodeId: expect.any(String),
|
||||
nodeName: 'Python',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect multiple Pyodide-based Code nodes', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Python1', 'n8n-nodes-base.code', {
|
||||
language: 'python',
|
||||
pythonCode: 'print("hello")',
|
||||
}),
|
||||
createNode('Python2', 'n8n-nodes-base.code', {
|
||||
language: 'python',
|
||||
pythonCode: 'print("world")',
|
||||
}),
|
||||
createNode('Python3', 'n8n-nodes-base.code', {
|
||||
language: 'pythonNative',
|
||||
pythonCode: 'print("ok")',
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(2);
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
nodeName: 'Python1',
|
||||
title: "Code node 'Python1' uses removed Pyodide Python implementation",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
nodeName: 'Python2',
|
||||
title: "Code node 'Python2' uses removed Pyodide Python implementation",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should detect mixed workflow with Pyodide and non-Pyodide nodes', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
createNode('Python', 'n8n-nodes-base.code', {
|
||||
language: 'python',
|
||||
pythonCode: 'print("hello")',
|
||||
}),
|
||||
createNode('JS', 'n8n-nodes-base.code', {
|
||||
language: 'javaScript',
|
||||
jsCode: 'return items;',
|
||||
}),
|
||||
createNode('Set', 'n8n-nodes-base.set'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].nodeName).toBe('Python');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { QueueWorkerMaxStalledCountRule } from '../queue-worker-max-stalled-count.rule';
|
||||
|
||||
describe('QueueWorkerMaxStalledCountRule', () => {
|
||||
let rule: QueueWorkerMaxStalledCountRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new QueueWorkerMaxStalledCountRule();
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when QUEUE_WORKER_MAX_STALLED_COUNT is not defined', async () => {
|
||||
delete process.env.QUEUE_WORKER_MAX_STALLED_COUNT;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when QUEUE_WORKER_MAX_STALLED_COUNT is set', async () => {
|
||||
process.env.QUEUE_WORKER_MAX_STALLED_COUNT = '10';
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('QUEUE_WORKER_MAX_STALLED_COUNT is deprecated');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
|
||||
import { RemovedDatabaseTypesRule } from '../removed-database-types.rule';
|
||||
|
||||
describe('RemovedDatabaseTypesRule', () => {
|
||||
let rule: RemovedDatabaseTypesRule;
|
||||
let globalConfig: GlobalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
globalConfig = mockInstance(GlobalConfig);
|
||||
rule = new RemovedDatabaseTypesRule(globalConfig);
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when using PostgreSQL', async () => {
|
||||
globalConfig.database.type = 'postgresdb';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when using MySQL', async () => {
|
||||
globalConfig.database.type = 'mysqldb';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('MySQL database type removed');
|
||||
});
|
||||
|
||||
it('should be affected when using MariaDB', async () => {
|
||||
globalConfig.database.type = 'mariadb';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('MariaDB database type removed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,7 @@ describe('RemovedNodesRule', () => {
|
|||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata).toEqual({
|
||||
expect(metadata).toMatchObject({
|
||||
version: 'v2',
|
||||
title: 'Removed Deprecated Nodes',
|
||||
description: 'Several deprecated nodes have been removed and will no longer work',
|
||||
|
|
@ -26,7 +26,7 @@ describe('RemovedNodesRule', () => {
|
|||
|
||||
describe('getRecommendations()', () => {
|
||||
it('should return recommendations', async () => {
|
||||
const recommendations = await rule.getRecommendations();
|
||||
const recommendations = await rule.getRecommendations([]);
|
||||
|
||||
expect(recommendations).toHaveLength(1);
|
||||
expect(recommendations[0]).toMatchObject({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import type { InstanceSettingsConfig } from '@n8n/config';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import { SettingsFilePermissionsRule } from '../settings-file-permissions.rule';
|
||||
|
||||
describe('SettingsFilePermissionsRule', () => {
|
||||
let rule: SettingsFilePermissionsRule;
|
||||
const instanceSettingsConfig = mock<InstanceSettingsConfig>({});
|
||||
let originalEnvValue: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new SettingsFilePermissionsRule(instanceSettingsConfig);
|
||||
originalEnvValue = process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvValue === undefined) {
|
||||
delete process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS;
|
||||
} else {
|
||||
process.env.N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS = originalEnvValue;
|
||||
}
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when enforceSettingsFilePermissions is set to false', async () => {
|
||||
instanceSettingsConfig.enforceSettingsFilePermissions = false;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
expect(result.recommendations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when enforceSettingsFilePermissions is not set to false', async () => {
|
||||
instanceSettingsConfig.enforceSettingsFilePermissions = true;
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('Settings file permissions will be enforced');
|
||||
expect(result.recommendations).toHaveLength(3);
|
||||
expect(result.recommendations[0].action).toBe('Configure volume permissions');
|
||||
expect(result.recommendations[1].action).toBe('Disable enforcement if needed');
|
||||
expect(result.recommendations[1].description).toContain(
|
||||
'N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false',
|
||||
);
|
||||
expect(result.recommendations[2].action).toBe('Separate configs for multi-instance setups');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
|
||||
import { SqliteLegacyDriverRule } from '../sqlite-legacy-driver.rule';
|
||||
|
||||
describe('SqliteLegacyDriverRule', () => {
|
||||
let rule: SqliteLegacyDriverRule;
|
||||
let globalConfig: GlobalConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
globalConfig = mockInstance(GlobalConfig, {
|
||||
database: {
|
||||
type: 'postgresdb',
|
||||
sqlite: {
|
||||
poolSize: 0,
|
||||
enableWAL: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
rule = new SqliteLegacyDriverRule(globalConfig);
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when using PostgreSQL', async () => {
|
||||
globalConfig.database.type = 'postgresdb';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not be affected when using SQLite with poolSize >= 1 and WAL enabled', async () => {
|
||||
globalConfig.database.type = 'sqlite';
|
||||
globalConfig.database.sqlite.poolSize = 3;
|
||||
globalConfig.database.sqlite.enableWAL = true;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when using SQLite with poolSize < 1', async () => {
|
||||
globalConfig.database.type = 'sqlite';
|
||||
globalConfig.database.sqlite.poolSize = 0;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(2);
|
||||
expect(result.instanceIssues[0].title).toBe('SQLite legacy driver removed');
|
||||
});
|
||||
|
||||
it('should be affected when using SQLite with WAL disabled', async () => {
|
||||
globalConfig.database.type = 'sqlite';
|
||||
globalConfig.database.sqlite.poolSize = 3;
|
||||
globalConfig.database.sqlite.enableWAL = false;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(2);
|
||||
expect(result.instanceIssues[0].title).toBe('SQLite legacy driver removed');
|
||||
});
|
||||
|
||||
it('should be affected when using SQLite with both poolSize < 1 and WAL disabled', async () => {
|
||||
globalConfig.database.type = 'sqlite';
|
||||
globalConfig.database.sqlite.poolSize = 0;
|
||||
globalConfig.database.sqlite.enableWAL = false;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(2);
|
||||
expect(result.instanceIssues[0].title).toBe('SQLite legacy driver removed');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { TaskRunnerDockerImageRule } from '../task-runner-docker-image.rule';
|
||||
|
||||
describe('TaskRunnerDockerImageRule', () => {
|
||||
let rule: TaskRunnerDockerImageRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new TaskRunnerDockerImageRule();
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata.version).toBe('v2');
|
||||
expect(metadata.title).toBe('Remove task runner from n8nio/n8n docker image');
|
||||
expect(metadata.severity).toBe('medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should always be affected (informational)', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('Task runner removed from main Docker image');
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
});
|
||||
|
||||
it('should include description about Docker image change', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.instanceIssues[0].description).toContain('n8nio/n8n');
|
||||
expect(result.instanceIssues[0].description).toContain('n8nio/runners');
|
||||
});
|
||||
|
||||
it('should have 3 recommendations', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.recommendations).toHaveLength(3);
|
||||
expect(result.recommendations[0].action).toContain('Update Docker configuration');
|
||||
expect(result.recommendations[1].action).toContain('Configure external task runners');
|
||||
expect(result.recommendations[2].action).toContain('Review task runner documentation');
|
||||
});
|
||||
|
||||
it('should mention N8N_RUNNERS_MODE=external in recommendations', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
const externalRunnerRec = result.recommendations.find((r) =>
|
||||
r.description.includes('N8N_RUNNERS_MODE=external'),
|
||||
);
|
||||
expect(externalRunnerRec).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import type { TaskRunnersConfig } from '@n8n/config';
|
||||
|
||||
import { TaskRunnersRule } from '../task-runners.rule';
|
||||
|
||||
describe('TaskRunnersRule', () => {
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when runners are already enabled', async () => {
|
||||
const mockConfig = { enabled: true } as TaskRunnersConfig;
|
||||
const rule = new TaskRunnersRule(mockConfig);
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when runners are not enabled', async () => {
|
||||
const mockConfig = { enabled: false } as TaskRunnersConfig;
|
||||
const rule = new TaskRunnersRule(mockConfig);
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('Task Runners will be enabled by default');
|
||||
});
|
||||
|
||||
it('should be affected when runners are explicitly disabled', async () => {
|
||||
const mockConfig = { enabled: false } as TaskRunnersConfig;
|
||||
const rule = new TaskRunnersRule(mockConfig);
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.recommendations).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { TunnelOptionRule } from '../tunnel-option.rule';
|
||||
|
||||
describe('TunnelOptionRule', () => {
|
||||
let rule: TunnelOptionRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new TunnelOptionRule();
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should always be affected (informational)', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toBe('--tunnel option removed');
|
||||
});
|
||||
|
||||
it('should have no recommendations', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.recommendations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,280 @@
|
|||
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
|
||||
import { BreakingChangeCategory } from '../../../types';
|
||||
import { WaitNodeSubworkflowRule } from '../wait-node-subworkflow.rule';
|
||||
|
||||
describe('WaitNodeSubworkflowRule', () => {
|
||||
let rule: WaitNodeSubworkflowRule;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
rule = new WaitNodeSubworkflowRule();
|
||||
});
|
||||
|
||||
describe('getMetadata()', () => {
|
||||
it('should return correct metadata', () => {
|
||||
const metadata = rule.getMetadata();
|
||||
|
||||
expect(metadata).toMatchObject({
|
||||
version: 'v2',
|
||||
title: 'Waiting node behavior change in sub-workflows',
|
||||
description:
|
||||
'Waiting nodes (Wait, Form, and HITL nodes) in sub-workflows now return data from the last node instead of the node before the waiting node',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRecommendations()', () => {
|
||||
it('should return recommendations', async () => {
|
||||
const recommendations = await rule.getRecommendations([]);
|
||||
|
||||
expect(recommendations).toHaveLength(3);
|
||||
expect(recommendations).toEqual([
|
||||
{
|
||||
action: 'Review sub-workflow output handling',
|
||||
description:
|
||||
'Check workflows that use Execute Workflow node to call sub-workflows containing waiting nodes (Wait, Form, or HITL nodes). The output data structure may have changed.',
|
||||
},
|
||||
{
|
||||
action: 'Update downstream logic',
|
||||
description:
|
||||
'Adjust any logic in parent workflows that depends on the data returned from sub-workflows with waiting nodes, as it now returns the last node data instead of the node before the waiting node.',
|
||||
},
|
||||
{
|
||||
action: 'Test affected workflows',
|
||||
description:
|
||||
'Test all workflows with Execute Workflow nodes calling sub-workflows that contain waiting nodes to ensure the new behavior works as expected.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it.each([
|
||||
{
|
||||
description: 'workflow has no waiting nodes',
|
||||
nodes: [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'workflow has waiting nodes but no Execute Workflow Trigger',
|
||||
nodes: [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
createNode('Wait', 'n8n-nodes-base.wait'),
|
||||
],
|
||||
},
|
||||
{
|
||||
description: 'workflow has Execute Workflow Trigger but no waiting nodes',
|
||||
nodes: [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
],
|
||||
},
|
||||
])('should return no issues when $description', async ({ nodes }) => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', nodes);
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result).toEqual({
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect sub-workflow with Wait nodes', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode('Wait', 'n8n-nodes-base.wait'),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe('Wait');
|
||||
});
|
||||
|
||||
it('should detect sub-workflow with multiple Wait nodes', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode('Wait1', 'n8n-nodes-base.wait'),
|
||||
createNode('Wait2', 'n8n-nodes-base.wait'),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(2);
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe('Wait1');
|
||||
expect(result.issues[1].nodeId).toBeDefined();
|
||||
expect(result.issues[1].nodeName).toBe('Wait2');
|
||||
});
|
||||
|
||||
it('should detect complex sub-workflow with Wait among other nodes', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
createNode('Wait', 'n8n-nodes-base.wait'),
|
||||
createNode('Code', 'n8n-nodes-base.code'),
|
||||
createNode('Set', 'n8n-nodes-base.set'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].level).toBe('warning');
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe('Wait');
|
||||
});
|
||||
|
||||
it('should not detect regular workflow with Wait and Execute Workflow nodes', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
createNode('Wait', 'n8n-nodes-base.wait'),
|
||||
createNode('ExecuteWorkflow', 'n8n-nodes-base.executeWorkflow'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect sub-workflow with Form node', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode('Form', 'n8n-nodes-base.form'),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].description).toContain('form');
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe('Form');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
nodeName: 'Slack',
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
operation: 'sendAndWait',
|
||||
expectedInDescription: 'slack',
|
||||
},
|
||||
])(
|
||||
'should detect sub-workflow with $nodeName HITL node using $operation operation',
|
||||
async ({ nodeName, nodeType, operation, expectedInDescription }) => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode(nodeName, nodeType, { operation }),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].description).toContain(expectedInDescription);
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe(nodeName);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
nodeName: 'Slack',
|
||||
nodeType: 'n8n-nodes-base.slack',
|
||||
nonWaitingOperation: 'sendMessage',
|
||||
},
|
||||
{
|
||||
nodeName: 'Telegram',
|
||||
nodeType: 'n8n-nodes-base.telegram',
|
||||
nonWaitingOperation: 'sendMessage',
|
||||
},
|
||||
{
|
||||
nodeName: 'GitHub',
|
||||
nodeType: 'n8n-nodes-base.github',
|
||||
nonWaitingOperation: 'getIssue',
|
||||
},
|
||||
])(
|
||||
'should NOT detect sub-workflow with $nodeName node without $waitingOperation operation',
|
||||
async ({ nodeName, nodeType, nonWaitingOperation }) => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode(nodeName, nodeType, { operation: nonWaitingOperation }),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('should detect sub-workflow with multiple HITL node types', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode('Wait', 'n8n-nodes-base.wait'),
|
||||
createNode('Form', 'n8n-nodes-base.form'),
|
||||
createNode('Slack', 'n8n-nodes-base.slack', { operation: 'sendAndWait' }),
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest'),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(3);
|
||||
expect(result.issues[0].description).toContain('wait');
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe('Wait');
|
||||
expect(result.issues[1].description).toContain('form');
|
||||
expect(result.issues[1].nodeId).toBeDefined();
|
||||
expect(result.issues[1].nodeName).toBe('Form');
|
||||
expect(result.issues[2].description).toContain('slack');
|
||||
expect(result.issues[2].nodeId).toBeDefined();
|
||||
expect(result.issues[2].nodeName).toBe('Slack');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ nodeName: 'Telegram', nodeType: 'n8n-nodes-base.telegram', operation: 'sendAndWait' },
|
||||
{ nodeName: 'EmailSend', nodeType: 'n8n-nodes-base.emailSend', operation: 'sendAndWait' },
|
||||
{
|
||||
nodeName: 'MicrosoftTeams',
|
||||
nodeType: 'n8n-nodes-base.microsoftTeams',
|
||||
operation: 'sendAndWait',
|
||||
},
|
||||
{
|
||||
nodeName: 'MicrosoftOutlook',
|
||||
nodeType: 'n8n-nodes-base.microsoftOutlook',
|
||||
operation: 'sendAndWait',
|
||||
},
|
||||
{ nodeName: 'Discord', nodeType: 'n8n-nodes-base.discord', operation: 'sendAndWait' },
|
||||
{ nodeName: 'GitHub', nodeType: 'n8n-nodes-base.github', operation: 'dispatchAndWait' },
|
||||
])(
|
||||
'should detect sub-workflow with $nodeName HITL node using $operation',
|
||||
async ({ nodeName, nodeType, operation }) => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('ExecuteWorkflowTrigger', 'n8n-nodes-base.executeWorkflowTrigger'),
|
||||
createNode(nodeName, nodeType, { operation }),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].nodeId).toBeDefined();
|
||||
expect(result.issues[0].nodeName).toBe(nodeName);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { BinaryDataConfig } from 'n8n-core';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class BinaryDataStorageRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly config: BinaryDataConfig) {}
|
||||
|
||||
id: string = 'binary-data-storage-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Disable binary data in-memory mode by default',
|
||||
description:
|
||||
'Binary files are now stored on disk by default instead of in memory, removing the 512MB file size limit',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'low',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#remove-in-memory-binary-data-mode',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
if (this.config.mode !== 'default') {
|
||||
return {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Binary data storage mode changed',
|
||||
description: `Binary files are now stored in ${this.config.localStoragePath} directory by default instead of in memory. This removes the previous 512MB file size limit but increases disk usage.`,
|
||||
level: 'info',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Ensure adequate disk space',
|
||||
description: `Verify sufficient disk space is available for binary file storage in the ${this.config.localStoragePath} directory`,
|
||||
},
|
||||
{
|
||||
action: 'Configure persistent storage',
|
||||
description:
|
||||
'If using containers, ensure the binary data directory is mounted on a persistent volume',
|
||||
},
|
||||
{
|
||||
action: 'Include in backups',
|
||||
description: 'Add the binary data folder to your backup procedures',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class CliActivateAllWorkflowsRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'cli-activate-all-workflows-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Remove CLI command operation to activate all workflows',
|
||||
description: 'The CLI command to activate all workflows has been removed for simplification',
|
||||
category: BreakingChangeCategory.instance,
|
||||
severity: 'low',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#remove-cli-command-operation-to-activate-all-workflows',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'CLI command to activate all workflows removed',
|
||||
description:
|
||||
'The CLI command to activate all workflows in bulk has been removed. If you were using this command in scripts or automation, you will need to update your approach.',
|
||||
level: 'info',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Use the API to activate workflows',
|
||||
description:
|
||||
'Update automation scripts to use the public API to activate workflows individually instead of the CLI command',
|
||||
},
|
||||
{
|
||||
action: 'Review deployment scripts',
|
||||
description:
|
||||
'Check any deployment or automation scripts that may have used the CLI command to activate all workflows and update them accordingly',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeWorkflowRule,
|
||||
WorkflowDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class DisabledNodesRule implements IBreakingChangeWorkflowRule {
|
||||
private readonly DISABLED_NODES = [
|
||||
'n8n-nodes-base.executeCommand',
|
||||
'n8n-nodes-base.localFileTrigger',
|
||||
];
|
||||
|
||||
id: string = 'disabled-nodes-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Disable ExecuteCommand and LocalFileTrigger nodes by default',
|
||||
description:
|
||||
'ExecuteCommand and LocalFileTrigger nodes are now disabled by default for security reasons',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#disable-executecommand-and-localfiletrigger-nodes-by-default',
|
||||
};
|
||||
}
|
||||
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Enable nodes if required',
|
||||
description:
|
||||
'Set the appropriate environment variables or settings to enable these nodes if they are required for your workflows',
|
||||
},
|
||||
{
|
||||
action: 'Replace with alternatives',
|
||||
description:
|
||||
'Consider replacing these nodes with safer alternatives that achieve the same functionality',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async detectWorkflow(
|
||||
_workflow: WorkflowEntity,
|
||||
nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
if (process.env.NODES_EXCLUDE) {
|
||||
return { isAffected: false, issues: [] };
|
||||
}
|
||||
|
||||
const disabledNodes = this.DISABLED_NODES.flatMap((type) => nodesGroupedByType.get(type) ?? []);
|
||||
if (disabledNodes.length === 0) return { isAffected: false, issues: [] };
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
issues: disabledNodes.map((node) => ({
|
||||
title: `Node '${node.type}' with name '${node.name}' will be disabled`,
|
||||
description: `This node is disabled by default in v2. If you want to keep using ${node.type} node, you can configure NODES_EXCLUDE=[].`,
|
||||
level: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { constants } from 'node:fs';
|
||||
import { access } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class DotenvUpgradeRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'dotenv-upgrade-v2';
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Upgrade dotenv',
|
||||
description:
|
||||
'The dotenv library has been upgraded, which may affect how .env files are parsed',
|
||||
category: BreakingChangeCategory.environment,
|
||||
severity: 'low',
|
||||
documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#upgrade-dotenv',
|
||||
};
|
||||
}
|
||||
|
||||
private async fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path, constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
// check if default .env files exist and are likely being used
|
||||
const possibleEnvPaths = [
|
||||
join(process.cwd(), '.env'),
|
||||
join(process.cwd(), '.env.local'),
|
||||
join(process.cwd(), '.env.development'),
|
||||
join(process.cwd(), '.env.production'),
|
||||
];
|
||||
|
||||
// Check files in parallel
|
||||
const existsChecks = await Promise.all(
|
||||
possibleEnvPaths.map(async (path) => ({
|
||||
path,
|
||||
exists: await this.fileExists(path),
|
||||
})),
|
||||
);
|
||||
|
||||
const existingEnvFiles = existsChecks.filter((check) => check.exists);
|
||||
|
||||
// Only flag as affected if .env files exist that could be loaded by dotenv
|
||||
if (existingEnvFiles.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isAffected = true;
|
||||
result.instanceIssues.push({
|
||||
title: 'dotenv library upgrade detected',
|
||||
description:
|
||||
'The dotenv library has been upgraded, which changes how values containing # or newlines are parsed. This may affect .env file parsing.',
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Review .env files',
|
||||
description:
|
||||
'Ensure any values containing # or newlines are quoted appropriately. Avoid ambiguous unquoted usages that might now be interpreted differently.',
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import { WorkflowEntity } from '@n8n/db';
|
||||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { INode } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
|
|
@ -23,10 +23,14 @@ export class FileAccessRule implements IBreakingChangeWorkflowRule {
|
|||
description: 'File access is now restricted to a default directory for security purposes',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#set-default-value-for-n8n_restrict_file_access_to',
|
||||
};
|
||||
}
|
||||
|
||||
async getRecommendations(): Promise<BreakingChangeRecommendation[]> {
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Configure file access paths',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeWorkflowRule,
|
||||
WorkflowDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class GitNodeBareReposRule implements IBreakingChangeWorkflowRule {
|
||||
id: string = 'git-node-bare-repos-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Git node bare repositories disabled by default',
|
||||
description:
|
||||
'N8N_GIT_NODE_DISABLE_BARE_REPOS now defaults to true for security. Bare repositories are disabled to prevent RCE attacks via Git hooks',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#change-the-default-value-of-n8n_git_node_disable_bare_repos-to-true',
|
||||
};
|
||||
}
|
||||
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Review Git node usage',
|
||||
description:
|
||||
'Check if any Git nodes in your workflows use bare repositories. Bare repositories are now disabled by default for security reasons.',
|
||||
},
|
||||
{
|
||||
action: 'Migrate away from bare repositories',
|
||||
description:
|
||||
'If possible, update your workflows to use regular Git repositories instead of bare repositories.',
|
||||
},
|
||||
{
|
||||
action: 'Enable bare repositories if required (not recommended)',
|
||||
description:
|
||||
'If you absolutely need bare repository support and understand the security risks, set N8N_GIT_NODE_DISABLE_BARE_REPOS=false. This is not recommended as it exposes your instance to potential RCE attacks via Git hooks.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async detectWorkflow(
|
||||
_workflow: WorkflowEntity,
|
||||
nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
// Check if N8N_GIT_NODE_DISABLE_BARE_REPOS is already set to false
|
||||
// If it's explicitly set to false, the user has opted in to allow bare repos
|
||||
const disableBareRepos = process.env.N8N_GIT_NODE_DISABLE_BARE_REPOS;
|
||||
if (disableBareRepos === 'false') {
|
||||
// User has explicitly enabled bare repos, so they're aware of the change
|
||||
return { isAffected: false, issues: [] };
|
||||
}
|
||||
|
||||
// Check if the workflow contains Git nodes
|
||||
const gitNodes = nodesGroupedByType.get('n8n-nodes-base.git') ?? [];
|
||||
|
||||
if (gitNodes.length === 0) {
|
||||
return { isAffected: false, issues: [] };
|
||||
}
|
||||
|
||||
// We can't easily detect if a Git node is using a bare repository from the parameters
|
||||
// So we flag all Git nodes as potentially affected for user review
|
||||
return {
|
||||
isAffected: true,
|
||||
issues: gitNodes.map((node) => ({
|
||||
title: `Git node '${node.name}' may be affected by bare repository restrictions`,
|
||||
description:
|
||||
'This workflow contains a Git node. Bare repositories are now disabled by default for security reasons. If this node uses bare repositories, it may fail. Review your Git node configuration and migrate to regular repositories if needed, or set N8N_GIT_NODE_DISABLE_BARE_REPOS=false (not recommended) to re-enable bare repository support.',
|
||||
level: 'warning',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,42 @@
|
|||
import { BinaryDataStorageRule } from './binary-data-storage.rule';
|
||||
import { CliActivateAllWorkflowsRule } from './cli-activate-all-workflows.rule';
|
||||
import { DisabledNodesRule } from './disabled-nodes.rule';
|
||||
import { DotenvUpgradeRule } from './dotenv-upgrade.rule';
|
||||
import { FileAccessRule } from './file-access.rule';
|
||||
import { GitNodeBareReposRule } from './git-node-bare-repos.rule';
|
||||
import { OAuthCallbackAuthRule } from './oauth-callback-auth.rule';
|
||||
import { ProcessEnvAccessRule } from './process-env-access.rule';
|
||||
import { PyodideRemovedRule } from './pyodide-removed.rule';
|
||||
import { QueueWorkerMaxStalledCountRule } from './queue-worker-max-stalled-count.rule';
|
||||
import { RemovedDatabaseTypesRule } from './removed-database-types.rule';
|
||||
import { RemovedNodesRule } from './removed-nodes.rule';
|
||||
import { SettingsFilePermissionsRule } from './settings-file-permissions.rule';
|
||||
import { SqliteLegacyDriverRule } from './sqlite-legacy-driver.rule';
|
||||
import { TaskRunnerDockerImageRule } from './task-runner-docker-image.rule';
|
||||
import { TaskRunnersRule } from './task-runners.rule';
|
||||
import { TunnelOptionRule } from './tunnel-option.rule';
|
||||
import { WaitNodeSubworkflowRule } from './wait-node-subworkflow.rule';
|
||||
|
||||
const v2Rules = [RemovedNodesRule, ProcessEnvAccessRule, FileAccessRule];
|
||||
const v2Rules = [
|
||||
// Workflow-level rules
|
||||
RemovedNodesRule,
|
||||
ProcessEnvAccessRule,
|
||||
PyodideRemovedRule,
|
||||
FileAccessRule,
|
||||
DisabledNodesRule,
|
||||
WaitNodeSubworkflowRule,
|
||||
GitNodeBareReposRule,
|
||||
// Instance-level rules
|
||||
DotenvUpgradeRule,
|
||||
OAuthCallbackAuthRule,
|
||||
CliActivateAllWorkflowsRule,
|
||||
QueueWorkerMaxStalledCountRule,
|
||||
TunnelOptionRule,
|
||||
RemovedDatabaseTypesRule,
|
||||
SettingsFilePermissionsRule,
|
||||
TaskRunnersRule,
|
||||
TaskRunnerDockerImageRule,
|
||||
SqliteLegacyDriverRule,
|
||||
BinaryDataStorageRule,
|
||||
];
|
||||
export { v2Rules };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class OAuthCallbackAuthRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'oauth-callback-auth-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Require auth on OAuth callback URLs by default',
|
||||
description:
|
||||
'OAuth callbacks now enforce n8n user authentication by default for improved security',
|
||||
category: BreakingChangeCategory.instance,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#require-authentication-on-oauth-callback-urls-by-default',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
// If the env var is set explicitly, then the instance is not affected
|
||||
// because the user has already made a choice
|
||||
if (process.env.N8N_SKIP_AUTH_ON_OAUTH_CALLBACK) {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'OAuth callback authentication now required',
|
||||
description:
|
||||
'OAuth callbacks will now enforce n8n user authentication by default unless N8N_SKIP_AUTH_ON_OAUTH_CALLBACK is explicitly set to true.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Review OAuth workflows',
|
||||
description:
|
||||
'If you need to skip authentication on OAuth callbacks (e.g., for embed mode), set N8N_SKIP_AUTH_ON_OAUTH_CALLBACK=true',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,8 @@ export class ProcessEnvAccessRule implements IBreakingChangeWorkflowRule {
|
|||
description: 'Direct access to process.env is blocked by default for security',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'low',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#block-environment-variable-access-from-code-node-by-default',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -27,6 +29,15 @@ export class ProcessEnvAccessRule implements IBreakingChangeWorkflowRule {
|
|||
workflow: WorkflowEntity,
|
||||
_nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
// If N8N_BLOCK_ENV_ACCESS_IN_NODE is explicitly set, then the instance is not affected
|
||||
// because the user has already made a choice
|
||||
if (process.env.N8N_BLOCK_ENV_ACCESS_IN_NODE) {
|
||||
return {
|
||||
isAffected: false,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Match process.env with optional whitespace, newlines, comments between 'process' and '.env'
|
||||
// This covers: process.env, process .env, process/* comment */.env, process\n.env, etc.
|
||||
// Also matches optional chaining: process?.env
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeWorkflowRule,
|
||||
WorkflowDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class PyodideRemovedRule implements IBreakingChangeWorkflowRule {
|
||||
id: string = 'pyodide-removed-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Remove Pyodide-based Python in Code node',
|
||||
description:
|
||||
'The Pyodide-based Python implementation in the Code node has been removed and replaced with a native Python task runner implementation',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#remove-pyodide-based-python-code-node',
|
||||
};
|
||||
}
|
||||
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Update Code nodes to use native Python',
|
||||
description:
|
||||
'Manually update affected Code nodes from the legacy python parameter to the new pythonNative parameter',
|
||||
},
|
||||
{
|
||||
action: 'Review and adjust Python scripts',
|
||||
description:
|
||||
'Review Code node scripts relying on Pyodide syntax and adjust for breaking changes. See: https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.code/#python-native-beta',
|
||||
},
|
||||
{
|
||||
action: 'Set up Python task runner',
|
||||
description:
|
||||
'Ensure a Python task runner is available and configured. Native Python task runners are enabled by default in v2',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async detectWorkflow(
|
||||
_workflow: WorkflowEntity,
|
||||
nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
// Get all Code nodes (the Code node supports both JavaScript and Python)
|
||||
const codeNodes = nodesGroupedByType.get('n8n-nodes-base.code') ?? [];
|
||||
|
||||
// Filter for Code nodes using the Pyodide-based Python implementation
|
||||
// The 'language' parameter determines which language/implementation is used:
|
||||
// - 'python' = Pyodide (being removed)
|
||||
// - 'pythonNative' = Task runner (new implementation)
|
||||
// - 'javaScript' = JavaScript (not affected)
|
||||
const affectedNodes = codeNodes.filter((node) => {
|
||||
const language = node.parameters?.language;
|
||||
// Nodes with language='python' use Pyodide and are affected
|
||||
return language === 'python';
|
||||
});
|
||||
|
||||
if (affectedNodes.length === 0) return { isAffected: false, issues: [] };
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
issues: affectedNodes.map((node) => ({
|
||||
title: `Code node '${node.name}' uses removed Pyodide Python implementation`,
|
||||
description:
|
||||
'The Pyodide-based Python implementation (language="python") is no longer supported. This node must be migrated to use the task runner-based implementation (language="pythonNative").',
|
||||
level: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class QueueWorkerMaxStalledCountRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'queue-worker-max-stalled-count-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Remove QUEUE_WORKER_MAX_STALLED_COUNT',
|
||||
description:
|
||||
'The QUEUE_WORKER_MAX_STALLED_COUNT environment variable has been removed and will be ignored',
|
||||
category: BreakingChangeCategory.environment,
|
||||
severity: 'low',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#remove-queue_worker_max_stalled_count',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
// If QUEUE_WORKER_MAX_STALLED_COUNT is not set, the instance is not affected
|
||||
// because the default behavior remains unchanged
|
||||
if (!process.env.QUEUE_WORKER_MAX_STALLED_COUNT) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.isAffected = true;
|
||||
result.instanceIssues.push({
|
||||
title: 'QUEUE_WORKER_MAX_STALLED_COUNT is deprecated',
|
||||
description:
|
||||
'The QUEUE_WORKER_MAX_STALLED_COUNT environment variable has been removed. Any customization will be ignored in v2.',
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Remove environment variable',
|
||||
description:
|
||||
'Remove QUEUE_WORKER_MAX_STALLED_COUNT from your environment configuration as it no longer has any effect',
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class RemovedDatabaseTypesRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly globalConfig: GlobalConfig) {}
|
||||
|
||||
id: string = 'removed-database-types-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'MySQL/MariaDB database types removed',
|
||||
description:
|
||||
'MySQL and MariaDB database types have been completely removed and will cause n8n to fail on startup',
|
||||
category: BreakingChangeCategory.database,
|
||||
severity: 'critical',
|
||||
documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#drop-mysqlmariadb-support',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const dbType = this.globalConfig.database.type;
|
||||
|
||||
if (dbType === 'mysqldb' || dbType === 'mariadb') {
|
||||
result.isAffected = true;
|
||||
result.instanceIssues.push({
|
||||
title: `${dbType === 'mysqldb' ? 'MySQL' : 'MariaDB'} database type removed`,
|
||||
description:
|
||||
'MySQL and MariaDB database types have been completely removed in v2. n8n will fail to start with this database configuration.',
|
||||
level: 'error',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Migrate to PostgreSQL or SQLite before upgrading',
|
||||
description:
|
||||
'You must migrate your database to PostgreSQL or SQLite before upgrading to v2. Use the database migration tool if available, or export/import your workflows and credentials.',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { INode } from 'n8n-workflow';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
|
|
@ -27,10 +27,14 @@ export class RemovedNodesRule implements IBreakingChangeWorkflowRule {
|
|||
description: 'Several deprecated nodes have been removed and will no longer work',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'low',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#removed-nodes-for-retired-services',
|
||||
};
|
||||
}
|
||||
|
||||
async getRecommendations(): Promise<BreakingChangeRecommendation[]> {
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Update affected workflows',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { InstanceSettingsConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class SettingsFilePermissionsRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly instanceSettingsConfig: InstanceSettingsConfig) {}
|
||||
|
||||
id: string = 'settings-file-permissions-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Enforce settings file permissions',
|
||||
description:
|
||||
'n8n now enforces stricter permissions on configuration files for improved security',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'low',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#enforce-settings-file-permissions',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
// If enforceSettingsFilePermissions is explicitly set to 'false', users are not affected
|
||||
// because they've configured the system to not enforce file permissions
|
||||
if (!this.instanceSettingsConfig.enforceSettingsFilePermissions) {
|
||||
return {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
}
|
||||
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Settings file permissions will be enforced',
|
||||
description:
|
||||
'n8n will now enforce chmod 600 permissions on configuration files. This may affect Docker/Kubernetes setups with volume mounts.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Configure volume permissions',
|
||||
description:
|
||||
'If using Docker or Kubernetes with volume mounts for .n8n directory, ensure the mounted volume has proper ownership and chmod 600 can be enforced on the config file',
|
||||
},
|
||||
{
|
||||
action: 'Disable enforcement if needed',
|
||||
description:
|
||||
'Set N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=false to disable permission enforcement',
|
||||
},
|
||||
{
|
||||
action: 'Separate configs for multi-instance setups',
|
||||
description:
|
||||
'In multi-main or queue setups, give each instance its own .n8n directory or use N8N_ENCRYPTION_KEY environment variable instead of relying on the config file',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class SqliteLegacyDriverRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly globalConfig: GlobalConfig) {}
|
||||
|
||||
id: string = 'sqlite-legacy-driver-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Remove SQLite legacy driver',
|
||||
description:
|
||||
'SQLite now uses WAL (Write-Ahead Logging) mode exclusively, with additional database files',
|
||||
category: BreakingChangeCategory.database,
|
||||
severity: 'low',
|
||||
documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-sqlite-legacy-driver',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
const dbType = this.globalConfig.database.type;
|
||||
// enableWAL is true if poolSize is > 1
|
||||
const enableWAL = this.globalConfig.database.sqlite.enableWAL;
|
||||
|
||||
// Only affected if using SQLite with WAL disabled
|
||||
if (dbType === 'sqlite' && !enableWAL) {
|
||||
result.isAffected = true;
|
||||
result.instanceIssues.push({
|
||||
title: 'SQLite legacy driver removed',
|
||||
description:
|
||||
'SQLite now uses WAL (Write-Ahead Logging) mode exclusively. The legacy driver (DB_SQLITE_POOL_SIZE=0) has been removed. Three database files will be created: database.sqlite (main), database.sqlite-wal (write-ahead log), and database.sqlite-shm (shared memory).',
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
result.instanceIssues.push({
|
||||
title: 'File system compatibility requirements',
|
||||
description:
|
||||
'Incompatible file systems include: NFS versions < 4, CIFS/SMB network shares, read-only file systems, and some container overlay filesystems.',
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Set DB_SQLITE_POOL_SIZE to enable WAL mode',
|
||||
description:
|
||||
'Set DB_SQLITE_POOL_SIZE to a value >= 1 (recommended: 3) to use the modern SQLite driver with WAL mode',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Update backup procedures',
|
||||
description:
|
||||
'Ensure backups include all three SQLite files (database.sqlite, database.sqlite-wal, database.sqlite-shm) or use the online backup API',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Verify file system compatibility',
|
||||
description:
|
||||
'Verify Docker volumes and file systems support shared memory operations required by WAL mode',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Rollback procedure if needed',
|
||||
description:
|
||||
'If rolling back to v1.x, convert back to rollback journal mode using: sqlite3 ~/.n8n/database.sqlite "PRAGMA journal_mode=DELETE;"',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class TaskRunnerDockerImageRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'task-runner-docker-image-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Remove task runner from n8nio/n8n docker image',
|
||||
description:
|
||||
'Task runners are no longer included in the n8nio/n8n docker image and must use the separate n8nio/runners image',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#remove-task-runner-from-n8nion8n-docker-image',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Task runner removed from main Docker image',
|
||||
description:
|
||||
'The task runner is no longer bundled with the n8nio/n8n Docker image. If you are using task runners in Docker, you must use the separate n8nio/runners image.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Update Docker configuration',
|
||||
description:
|
||||
'Change the task runner Docker image from n8nio/n8n to n8nio/runners in your docker-compose.yml or Kubernetes configuration',
|
||||
},
|
||||
{
|
||||
action: 'Configure external task runners',
|
||||
description:
|
||||
'Set up external task runners using the n8nio/runners image and configure n8n to connect to them using N8N_RUNNERS_MODE=external',
|
||||
},
|
||||
{
|
||||
action: 'Review task runner documentation',
|
||||
description:
|
||||
'Consult the updated task runner documentation for migration steps and configuration examples',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { TaskRunnersConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class TaskRunnersRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly taskRunnersConfig: TaskRunnersConfig) {}
|
||||
|
||||
id: string = 'task-runners-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Enable Task Runners by default',
|
||||
description:
|
||||
'Task Runners are now enabled by default, changing execution model and resource usage',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'medium',
|
||||
documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#enable-task-runners-by-default',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: false,
|
||||
instanceIssues: [],
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
// Check if runners are already enabled or explicitly disabled
|
||||
|
||||
if (!this.taskRunnersConfig.enabled) {
|
||||
result.isAffected = true;
|
||||
result.instanceIssues.push({
|
||||
title: 'Task Runners will be enabled by default',
|
||||
description:
|
||||
'Task Runners change the execution model to use separate processes for workflow execution. This may affect memory footprint and execution latency. N8N_RUNNERS_MAX_CONCURRENCY will default to 5 (previously 10).',
|
||||
level: 'warning',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Review concurrency settings',
|
||||
description:
|
||||
'Keep concurrency at 5 or raise back to 10 with N8N_RUNNERS_MAX_CONCURRENCY if your environment permits',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Configure runner memory limits',
|
||||
description:
|
||||
'Set N8N_RUNNERS_MAX_OLD_SPACE_SIZE to limit runner memory usage as needed for your infrastructure',
|
||||
});
|
||||
|
||||
result.recommendations.push({
|
||||
action: 'Consider external task runners',
|
||||
description: 'For better scalability, consider migrating to external task runner mode',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { Service } from '@n8n/di';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class TunnelOptionRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'tunnel-option-v2';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Remove n8n --tunnel option',
|
||||
description: 'The --tunnel CLI option has been removed and will be ignored',
|
||||
category: BreakingChangeCategory.instance,
|
||||
severity: 'low',
|
||||
documentationUrl: 'https://docs.n8n.io/2-0-breaking-changes/#remove-n8n-tunnel-option',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const result: InstanceDetectionReport = {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: '--tunnel option removed',
|
||||
description:
|
||||
'The --tunnel CLI option is no longer available. If you were using this feature, calls with the --tunnel flag will ignore the flag and not run the tunnel system.',
|
||||
level: 'info',
|
||||
},
|
||||
],
|
||||
recommendations: [],
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { SEND_AND_WAIT_OPERATION } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeWorkflowRule,
|
||||
WorkflowDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@Service()
|
||||
export class WaitNodeSubworkflowRule implements IBreakingChangeWorkflowRule {
|
||||
id: string = 'wait-node-subworkflow-v2';
|
||||
|
||||
// Configuration for node types and their waiting conditions
|
||||
private readonly waitingNodeConfig: Array<{ nodeTypes: string[]; operation?: string }> = [
|
||||
{
|
||||
// Node types that always wait (no operation check needed)
|
||||
nodeTypes: [
|
||||
'n8n-nodes-base.wait',
|
||||
'n8n-nodes-base.form',
|
||||
'@n8n/n8n-nodes-langchain.chat',
|
||||
'n8n-nodes-base.respondToWebhook',
|
||||
],
|
||||
},
|
||||
{
|
||||
// Node types that only wait when using sendAndWait operation
|
||||
nodeTypes: [
|
||||
'n8n-nodes-base.slack',
|
||||
'n8n-nodes-base.telegram',
|
||||
'n8n-nodes-base.googleChat',
|
||||
'n8n-nodes-base.gmail',
|
||||
'n8n-nodes-base.emailSend',
|
||||
'n8n-nodes-base.whatsApp',
|
||||
'n8n-nodes-base.microsoftTeams',
|
||||
'n8n-nodes-base.microsoftOutlook',
|
||||
'n8n-nodes-base.discord',
|
||||
],
|
||||
operation: SEND_AND_WAIT_OPERATION,
|
||||
},
|
||||
{
|
||||
// Node types that wait when using dispatchAndWait operation
|
||||
nodeTypes: ['n8n-nodes-base.github'],
|
||||
operation: 'dispatchAndWait',
|
||||
},
|
||||
];
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v2',
|
||||
title: 'Waiting node behavior change in sub-workflows',
|
||||
description:
|
||||
'Waiting nodes (Wait, Form, and HITL nodes) in sub-workflows now return data from the last node instead of the node before the waiting node',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
documentationUrl:
|
||||
'https://docs.n8n.io/2-0-breaking-changes/#return-expected-sub-workflow-data-when-it-contains-a-wait-node',
|
||||
};
|
||||
}
|
||||
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Review sub-workflow output handling',
|
||||
description:
|
||||
'Check workflows that use Execute Workflow node to call sub-workflows containing waiting nodes (Wait, Form, or HITL nodes). The output data structure may have changed.',
|
||||
},
|
||||
{
|
||||
action: 'Update downstream logic',
|
||||
description:
|
||||
'Adjust any logic in parent workflows that depends on the data returned from sub-workflows with waiting nodes, as it now returns the last node data instead of the node before the waiting node.',
|
||||
},
|
||||
{
|
||||
action: 'Test affected workflows',
|
||||
description:
|
||||
'Test all workflows with Execute Workflow nodes calling sub-workflows that contain waiting nodes to ensure the new behavior works as expected.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private hasWaitingOperation(node: INode, requiredOperation: string): boolean {
|
||||
const operation = node.parameters.operation;
|
||||
return operation === requiredOperation;
|
||||
}
|
||||
|
||||
async detectWorkflow(
|
||||
_workflow: WorkflowEntity,
|
||||
nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
// Check if the workflow contains any waiting nodes (Wait, Form, or HITL nodes)
|
||||
const foundWaitingNodes: Array<{ node: INode; nodeTypeName: string }> = [];
|
||||
|
||||
// Check all configured node types
|
||||
for (const { nodeTypes, operation } of this.waitingNodeConfig) {
|
||||
for (const nodeType of nodeTypes) {
|
||||
const nodes = nodesGroupedByType.get(nodeType) ?? [];
|
||||
|
||||
// If no operation is specified, all nodes of this type wait
|
||||
// Otherwise, filter for nodes with the specific operation
|
||||
const waitingNodes = operation
|
||||
? nodes.filter((node) => this.hasWaitingOperation(node, operation))
|
||||
: nodes;
|
||||
|
||||
for (const node of waitingNodes) {
|
||||
const nodeTypeName = nodeType.split('.').pop() ?? nodeType;
|
||||
foundWaitingNodes.push({ node, nodeTypeName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundWaitingNodes.length === 0) {
|
||||
return { isAffected: false, issues: [] };
|
||||
}
|
||||
|
||||
// Check if this workflow IS a subworkflow by looking for Execute Workflow Trigger
|
||||
const executeWorkflowTriggerNodes =
|
||||
nodesGroupedByType.get('n8n-nodes-base.executeWorkflowTrigger') ?? [];
|
||||
|
||||
if (executeWorkflowTriggerNodes.length === 0) {
|
||||
return { isAffected: false, issues: [] };
|
||||
}
|
||||
|
||||
// This workflow is a subworkflow (has Execute Workflow Trigger) and contains waiting nodes
|
||||
// The output behavior has changed
|
||||
// Create one issue per waiting node
|
||||
|
||||
const issues = foundWaitingNodes.map(({ node, nodeTypeName }) => ({
|
||||
title: 'Sub-workflow with waiting node has changed output behavior',
|
||||
description: `This workflow is a sub-workflow (contains Execute Workflow Trigger) with a waiting node (${nodeTypeName}). The data returned to the parent workflow from sub-workflows containing waiting nodes has changed. Previously, the child workflow returned data from the node before the waiting node. Now they return data from the last node in the workflow.`,
|
||||
level: 'warning' as const,
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
}));
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user