mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(core): Block create/update of workflows with deprecated nodes (#31360)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Danny Martini <danny@n8n.io> Co-authored-by: Emilia <100027345+sovietspaceship@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
5543023723
commit
ea8417e29a
|
|
@ -49,4 +49,14 @@ export class NodesConfig {
|
|||
/** Memory limit in MB for the Merge node's SQL sandbox. */
|
||||
@Env('NODES_MERGE_SQL_SANDBOX_MEMORY_LIMIT_MB', z.coerce.number().int().positive())
|
||||
mergeSqlSandboxMemoryLimitMb: number = 64;
|
||||
|
||||
/**
|
||||
* Refuse deprecated nodes. Saving or importing a workflow fails if it adds or
|
||||
* changes one. A sub-workflow from inline JSON, a URL or a file, or a single
|
||||
* node run by n8n Assistant, fails if it contains one. Saved workflows that
|
||||
* already contain deprecated nodes keep running. Set to `false` to turn this
|
||||
* off, for example to import older backups.
|
||||
*/
|
||||
@Env('N8N_DEPRECATED_NODES_BLOCK')
|
||||
blockDeprecated: boolean = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ describe('GlobalConfig', () => {
|
|||
exclude: ['n8n-nodes-base.executeCommand', 'n8n-nodes-base.localFileTrigger'],
|
||||
pythonEnabled: true,
|
||||
mergeSqlSandboxMemoryLimitMb: 64,
|
||||
blockDeprecated: true,
|
||||
},
|
||||
publicApi: {
|
||||
disabled: false,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { LOG_LEVELS, CONSOLE_OUTPUT_REDACTED_MESSAGE } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
Code,
|
||||
transformLegacyLangchainImport,
|
||||
createSandboxLogger,
|
||||
createProductionConsoleLog,
|
||||
} from './Code.node';
|
||||
|
||||
describe('Code.node', () => {
|
||||
it('is marked deprecated so the backend refuses new instances', () => {
|
||||
const node = new Code();
|
||||
expect(node.description.deprecated).toBe(true);
|
||||
});
|
||||
|
||||
describe('createSandboxLogger', () => {
|
||||
const logLevelKeys = LOG_LEVELS.filter((level) => level !== 'silent');
|
||||
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ export class Code implements INodeType {
|
|||
description: INodeTypeDescription = {
|
||||
displayName: 'LangChain Code',
|
||||
name: 'code',
|
||||
deprecated: true,
|
||||
icon: 'fa:code',
|
||||
iconColor: 'black',
|
||||
group: ['transform'],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ModuleRegistry } from '@n8n/backend-common';
|
||||
import { Logger, ModuleRegistry } from '@n8n/backend-common';
|
||||
import { EventService, UrlService } from '@n8n/backend-services';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import { ExecutionsConfig, GlobalConfig, WorkflowsConfig } from '@n8n/config';
|
||||
import { ExecutionsConfig, GlobalConfig, type NodesConfig, WorkflowsConfig } from '@n8n/config';
|
||||
import type { WorkflowEntity, Project, WorkflowHistory } from '@n8n/db';
|
||||
import {
|
||||
ExecutionRepository,
|
||||
|
|
@ -62,6 +62,8 @@ import {
|
|||
} from '@/workflow-execute-additional-data';
|
||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
|
||||
import { WorkflowStaticDataService } from '@/workflows/workflow-static-data.service';
|
||||
|
||||
|
|
@ -141,6 +143,20 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
mockInstance(DataTableProxyService);
|
||||
mockInstance(WorkflowHookContextService);
|
||||
const workflowPublishedDataService = mockInstance(WorkflowPublishedDataService);
|
||||
const deprecatedNodeTypes = mock<NodeTypes>();
|
||||
deprecatedNodeTypes.getByNameAndVersion.mockImplementation((type) =>
|
||||
type === 'n8n-nodes-base.function'
|
||||
? mock<INodeType>({ description: { deprecated: true } })
|
||||
: mock<INodeType>(),
|
||||
);
|
||||
Container.set(
|
||||
DeprecatedNodesValidationService,
|
||||
new DeprecatedNodesValidationService(
|
||||
mock<Logger>(),
|
||||
mock<NodesConfig>({ blockDeprecated: true }),
|
||||
deprecatedNodeTypes,
|
||||
),
|
||||
);
|
||||
const workflowsConfig = Container.get(WorkflowsConfig);
|
||||
afterEach(() => {
|
||||
// Keep the default (flag off) for every test; flag-on tests opt in.
|
||||
|
|
@ -1196,6 +1212,15 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
expect(result.id).toBe('parent-workflow-id');
|
||||
},
|
||||
);
|
||||
|
||||
it('refuses inline nodes of a deprecated type', async () => {
|
||||
const nodes = [mock<INode>({ type: 'n8n-nodes-base.function', typeVersion: 1 })];
|
||||
const workflowCode = mock<IWorkflowBase>({ nodes, connections: {} });
|
||||
|
||||
await expect(loadWorkflow({ code: workflowCode }, 'parent-workflow-id')).rejects.toThrow(
|
||||
DeprecatedNodesError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPublishedWorkflowData', () => {
|
||||
|
|
@ -1425,6 +1450,17 @@ describe('WorkflowExecuteAdditionalData', () => {
|
|||
workflowRepository.get.mockClear();
|
||||
});
|
||||
|
||||
it('loads a stored workflow that contains a deprecated node', async () => {
|
||||
const nodes = [mock<INode>({ type: 'n8n-nodes-base.function', typeVersion: 1 })];
|
||||
workflowRepository.get.mockResolvedValue(
|
||||
mock<WorkflowEntity>({ id: 'workflow-123', nodes, connections: {} }),
|
||||
);
|
||||
|
||||
const result = await getDraftWorkflowData({ id: 'workflow-123' }, 'parent-workflow-id');
|
||||
|
||||
expect(result.nodes).toEqual(nodes);
|
||||
});
|
||||
|
||||
it('should use draft version', async () => {
|
||||
const activeVersionNodes: INode[] = [
|
||||
mock<INode>({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { ResponseError } from '@n8n/errors';
|
||||
|
||||
export type DeprecatedNodeViolation = {
|
||||
kind: 'added' | 'edited';
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when a workflow adds or edits a node whose type is marked
|
||||
* `deprecated: true`, on save or before an unsaved workflow or node runs.
|
||||
* Lists the offending nodes in `meta.violations` so callers can act on them
|
||||
* without parsing the error message.
|
||||
*/
|
||||
export class DeprecatedNodesError extends ResponseError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly meta: { violations: DeprecatedNodeViolation[] },
|
||||
) {
|
||||
super(message, 400);
|
||||
this.name = 'DeprecatedNodesError';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { NodesConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import type { EntityManager } from '@n8n/typeorm';
|
||||
import type { INode, INodeType } from 'n8n-workflow';
|
||||
|
|
@ -6,7 +7,9 @@ import { v4 as uuid } from 'uuid';
|
|||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
|
||||
import type { ChatHubTool } from '../chat-hub-tool.entity';
|
||||
import type { ChatHubToolRepository } from '../chat-hub-tool.repository';
|
||||
|
|
@ -73,7 +76,16 @@ describe('ChatHubToolService', () => {
|
|||
description: { properties: [] },
|
||||
} as unknown as INodeType);
|
||||
|
||||
service = new ChatHubToolService(logger, chatToolRepository, nodeTypes);
|
||||
service = new ChatHubToolService(
|
||||
logger,
|
||||
chatToolRepository,
|
||||
nodeTypes,
|
||||
new DeprecatedNodesValidationService(
|
||||
logger,
|
||||
mock<NodesConfig>({ blockDeprecated: true }),
|
||||
nodeTypes,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('getToolsByUserId', () => {
|
||||
|
|
@ -124,7 +136,34 @@ describe('ChatHubToolService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
const deprecatedCodeTool: INode = {
|
||||
...mockDefinition,
|
||||
type: '@n8n/n8n-nodes-langchain.code',
|
||||
parameters: { code: { supplyData: { code: 'return 1;' } } },
|
||||
};
|
||||
|
||||
const markCodeNodeDeprecated = () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation(
|
||||
(type) =>
|
||||
({
|
||||
description: {
|
||||
properties: [],
|
||||
deprecated: type === deprecatedCodeTool.type ? true : undefined,
|
||||
},
|
||||
}) as unknown as INodeType,
|
||||
);
|
||||
};
|
||||
|
||||
describe('createTool', () => {
|
||||
it('rejects a tool of a deprecated node type', async () => {
|
||||
markCodeNodeDeprecated();
|
||||
|
||||
await expect(
|
||||
service.createTool(mockUser, { definition: deprecatedCodeTool }),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
expect(chatToolRepository.createTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a tool from the definition', async () => {
|
||||
const created = makeTool();
|
||||
chatToolRepository.createTool.mockResolvedValue(created);
|
||||
|
|
@ -288,6 +327,40 @@ describe('ChatHubToolService', () => {
|
|||
});
|
||||
|
||||
describe('updateTool', () => {
|
||||
it('rejects changing a tool of a deprecated node type', async () => {
|
||||
markCodeNodeDeprecated();
|
||||
chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool }));
|
||||
|
||||
await expect(
|
||||
service.updateTool('tool-1', mockUser, {
|
||||
definition: { ...deprecatedCodeTool, name: 'Renamed tool' },
|
||||
}),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
expect(chatToolRepository.updateTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects enabling a stored tool of a deprecated node type', async () => {
|
||||
markCodeNodeDeprecated();
|
||||
chatToolRepository.getOneById.mockResolvedValue(
|
||||
makeTool({ definition: deprecatedCodeTool, enabled: false }),
|
||||
);
|
||||
|
||||
await expect(service.updateTool('tool-1', mockUser, { enabled: true })).rejects.toThrow(
|
||||
DeprecatedNodesError,
|
||||
);
|
||||
expect(chatToolRepository.updateTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows disabling a stored tool of a deprecated node type', async () => {
|
||||
markCodeNodeDeprecated();
|
||||
chatToolRepository.getOneById.mockResolvedValue(makeTool({ definition: deprecatedCodeTool }));
|
||||
chatToolRepository.updateTool.mockResolvedValue(makeTool({ enabled: false }));
|
||||
|
||||
await service.updateTool('tool-1', mockUser, { enabled: false });
|
||||
|
||||
expect(chatToolRepository.updateTool).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update tool definition and denormalized fields', async () => {
|
||||
const existingTool = makeTool();
|
||||
const updatedDef: INode = { ...mockDefinition, name: 'Updated Tool' };
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { type IBinaryData, type INode, CHAT_TRIGGER_NODE_TYPE } from 'n8n-workfl
|
|||
import type { Mock } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import type { ChatHubAgent } from '../chat-hub-agent.entity';
|
||||
|
|
@ -38,6 +40,7 @@ describe('ChatHubWorkflowService', () => {
|
|||
|
||||
const mockCipher = mock<Cipher>();
|
||||
const policyEnforcementService = mock<PolicyEnforcementService>();
|
||||
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
|
||||
|
||||
let chatHubAttachmentService: ChatHubAttachmentService;
|
||||
let service: ChatHubWorkflowService;
|
||||
|
|
@ -72,6 +75,7 @@ describe('ChatHubWorkflowService', () => {
|
|||
workflowFinderService,
|
||||
mockCipher,
|
||||
policyEnforcementService,
|
||||
deprecatedNodesValidationService,
|
||||
);
|
||||
|
||||
// Mock repository methods
|
||||
|
|
@ -162,6 +166,32 @@ describe('ChatHubWorkflowService', () => {
|
|||
expect(workflowRepository.createContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes nothing when the workflow contains a deprecated node', async () => {
|
||||
deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => {
|
||||
throw new DeprecatedNodesError('deprecated', { violations: [] });
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.createChatWorkflow(
|
||||
'user-123',
|
||||
'session-456',
|
||||
'project-789',
|
||||
[],
|
||||
'Hello',
|
||||
[],
|
||||
{},
|
||||
{ provider: 'openai', model: 'gpt-4' },
|
||||
undefined,
|
||||
[],
|
||||
'UTC',
|
||||
null,
|
||||
defaultExecutionMetadata,
|
||||
),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
|
||||
expect(workflowRepository.createContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('enforces the save for the title-generation workflow', async () => {
|
||||
await service.createTitleGenerationWorkflow(
|
||||
'user-123',
|
||||
|
|
@ -1363,6 +1393,7 @@ describe('ChatHubWorkflowService', () => {
|
|||
workflowFinderService,
|
||||
mockCipher,
|
||||
policyEnforcementService,
|
||||
deprecatedNodesValidationService,
|
||||
);
|
||||
|
||||
const mockTrx = mock<EntityManager>();
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { collectExpressionDefaults, findDisallowedChatToolExpressions } from 'n8
|
|||
|
||||
import { BadRequestError, NotFoundError } from '@n8n/errors';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
|
||||
import type { ChatHubTool } from './chat-hub-tool.entity';
|
||||
import { ChatHubToolRepository } from './chat-hub-tool.repository';
|
||||
|
|
@ -21,6 +22,7 @@ export class ChatHubToolService {
|
|||
private readonly logger: Logger,
|
||||
private readonly chatToolRepository: ChatHubToolRepository,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {
|
||||
this.logger = this.logger.scoped('chat-hub');
|
||||
}
|
||||
|
|
@ -85,6 +87,7 @@ export class ChatHubToolService {
|
|||
async createTool(user: User, data: ChatHubCreateToolRequest): Promise<ChatHubTool> {
|
||||
const definition = data.definition;
|
||||
this.validateToolExpressions(definition);
|
||||
this.deprecatedNodesValidationService.validateOnCreate([definition]);
|
||||
|
||||
const tool = await this.chatToolRepository.createTool({
|
||||
id: definition.id,
|
||||
|
|
@ -117,12 +120,21 @@ export class ChatHubToolService {
|
|||
|
||||
if (updates.definition !== undefined) {
|
||||
this.validateToolExpressions(updates.definition);
|
||||
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||
[updates.definition],
|
||||
[existingTool.definition],
|
||||
);
|
||||
updateData.definition = updates.definition;
|
||||
updateData.name = updates.definition.name;
|
||||
updateData.type = updates.definition.type;
|
||||
updateData.typeVersion = updates.definition.typeVersion ?? 1;
|
||||
}
|
||||
if (updates.enabled !== undefined) {
|
||||
if (updates.enabled) {
|
||||
this.deprecatedNodesValidationService.validateOnCreate([
|
||||
updates.definition ?? existingTool.definition,
|
||||
]);
|
||||
}
|
||||
updateData.enabled = updates.enabled;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { BadRequestError } from '@n8n/errors';
|
||||
import type { UserLike } from '@/types/user-like.types';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { ChatHubAgentRepository } from './chat-hub-agent.repository';
|
||||
|
|
@ -96,6 +97,7 @@ export class ChatHubWorkflowService {
|
|||
private readonly workflowFinderService: WorkflowFinderService,
|
||||
private readonly cipher: Cipher,
|
||||
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {
|
||||
this.logger = this.logger.scoped('chat-hub');
|
||||
}
|
||||
|
|
@ -196,6 +198,8 @@ export class ChatHubWorkflowService {
|
|||
projectId: string,
|
||||
user: UserLike,
|
||||
) {
|
||||
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
|
||||
|
||||
return await this.policyEnforcementService.enforceWorkflowSave(
|
||||
{
|
||||
workflow: { id: workflow.id ?? null, name: workflow.name, nodes: workflow.nodes },
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import { mock } from 'vitest-mock-extended';
|
|||
import type { CredentialsService } from '@/credentials/credentials.service';
|
||||
import type { VariablesService } from '@/environments.ee/variables/variables.service.ee';
|
||||
import type { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error';
|
||||
import type { DataTableColumnRepository } from '@/modules/data-table/data-table-column.repository';
|
||||
import type { DataTableDDLService } from '@/modules/data-table/data-table-ddl.service';
|
||||
|
|
@ -44,6 +45,7 @@ import type { DataTableRepository } from '@/modules/data-table/data-table.reposi
|
|||
import type { RedactionEnforcementService } from '@/modules/redaction/redaction-enforcement.service';
|
||||
import type { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
import type { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import type { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
import type { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
||||
import type { WorkflowPublishGuardProxy } from '@/workflows/workflow-publish-guard-proxy.service';
|
||||
|
|
@ -86,6 +88,7 @@ describe('SourceControlImportService', () => {
|
|||
const dataTableColumnRepository = mock<DataTableColumnRepository>();
|
||||
const dataTableDDLService = mock<DataTableDDLService>();
|
||||
const redactionEnforcementService = mock<RedactionEnforcementService>();
|
||||
const deprecatedNodesValidationService = mock<DeprecatedNodesValidationService>();
|
||||
const policyEnforcementService = mock<PolicyEnforcementService>();
|
||||
policyEnforcementService.hasChecksFor.mockReturnValue(true);
|
||||
policyEnforcementService.enforceContentImport.mockResolvedValue(mock());
|
||||
|
|
@ -140,6 +143,7 @@ describe('SourceControlImportService', () => {
|
|||
workflowPublishGuard,
|
||||
workflowMutationHooks,
|
||||
workflowFinderService,
|
||||
deprecatedNodesValidationService,
|
||||
);
|
||||
|
||||
const globMock = fastGlob.default as unknown as Mock<(...args: string[]) => Promise<string[]>>;
|
||||
|
|
@ -1618,6 +1622,82 @@ describe('SourceControlImportService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deprecated node enforcement', () => {
|
||||
const mockUserId = 'user-id-123';
|
||||
const deprecatedNode = {
|
||||
id: 'node-1',
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
};
|
||||
const workflowFile = (id: string) =>
|
||||
JSON.stringify({
|
||||
id,
|
||||
name: `Workflow ${id}`,
|
||||
active: false,
|
||||
nodes: [deprecatedNode],
|
||||
connections: {},
|
||||
versionId: 'v1',
|
||||
parentFolderId: null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
projectRepository.getPersonalProjectForUserOrFail.mockResolvedValue(
|
||||
Object.assign(new Project(), {
|
||||
id: 'personal-project-id-123',
|
||||
name: 'Personal Project',
|
||||
type: 'personal',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
folderRepository.find.mockResolvedValue([]);
|
||||
sharedWorkflowRepository.findWithFields.mockResolvedValue([]);
|
||||
workflowRepository.upsertImportedContent.mockResolvedValue('1');
|
||||
});
|
||||
|
||||
it('rejects a new workflow that fails the deprecated-node check', async () => {
|
||||
workflowRepository.findByIds.mockResolvedValue([]);
|
||||
fsReadFile.mockResolvedValueOnce(workflowFile('2'));
|
||||
deprecatedNodesValidationService.validateOnCreate.mockImplementationOnce(() => {
|
||||
throw new DeprecatedNodesError('blocked', { violations: [] });
|
||||
});
|
||||
|
||||
const candidates = [mock<SourceControlledFile>({ file: '/mock/workflow2.json', id: '2' })];
|
||||
|
||||
await expect(service.importWorkflowFromWorkFolder(candidates, mockUserId)).rejects.toThrow(
|
||||
DeprecatedNodesError,
|
||||
);
|
||||
|
||||
expect(deprecatedNodesValidationService.validateOnCreate).toHaveBeenCalledWith(
|
||||
[deprecatedNode],
|
||||
'2',
|
||||
);
|
||||
expect(workflowRepository.upsertImportedContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks an existing workflow against its stored nodes', async () => {
|
||||
workflowRepository.findByIds.mockResolvedValue([
|
||||
Object.assign(new WorkflowEntity(), { id: '1', nodes: [deprecatedNode] }),
|
||||
]);
|
||||
fsReadFile.mockResolvedValueOnce(workflowFile('1'));
|
||||
|
||||
const candidates = [mock<SourceControlledFile>({ file: '/mock/workflow1.json', id: '1' })];
|
||||
|
||||
await service.importWorkflowFromWorkFolder(candidates, mockUserId);
|
||||
|
||||
expect(deprecatedNodesValidationService.validateOnUpdate).toHaveBeenCalledWith(
|
||||
[deprecatedNode],
|
||||
[deprecatedNode],
|
||||
'1',
|
||||
);
|
||||
expect(deprecatedNodesValidationService.validateOnCreate).not.toHaveBeenCalled();
|
||||
expect(workflowRepository.upsertImportedContent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content-import policy enforcement', () => {
|
||||
const mockUserId = 'user-id-123';
|
||||
const mockWorkflowFile = '/mock/workflow1.json';
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import { isUniqueConstraintError } from '@/response-helper';
|
|||
import { TagService } from '@/services/tag.service';
|
||||
import { assertNever } from '@/utils';
|
||||
import { validateWorkflowNodeGroups, sanitizeNodeGroupDescriptions } from '@/workflow-helpers';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
import { WorkflowMutationHooksProxy } from '@/workflows/workflow-mutation-hooks-proxy.service';
|
||||
|
|
@ -173,6 +174,7 @@ export class SourceControlImportService {
|
|||
private readonly workflowPublishGuard: WorkflowPublishGuardProxy,
|
||||
private readonly workflowMutationHooks: WorkflowMutationHooksProxy,
|
||||
private readonly workflowFinderService: WorkflowFinderService,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {
|
||||
this.gitFolder = path.join(instanceSettings.n8nFolder, SOURCE_CONTROL_GIT_FOLDER);
|
||||
this.workflowExportFolder = path.join(this.gitFolder, SOURCE_CONTROL_WORKFLOW_EXPORT_FOLDER);
|
||||
|
|
@ -783,7 +785,7 @@ export class SourceControlImportService {
|
|||
const personalProject = await this.projectRepository.getPersonalProjectForUserOrFail(userId);
|
||||
const candidateIds = candidates.map((c) => c.id);
|
||||
const existingWorkflows = await this.workflowRepository.findByIds(candidateIds, {
|
||||
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived'],
|
||||
fields: ['id', 'name', 'versionId', 'active', 'activeVersionId', 'isArchived', 'nodes'],
|
||||
});
|
||||
|
||||
const folders = await this.folderRepository.find({ select: ['id'] });
|
||||
|
|
@ -857,6 +859,19 @@ export class SourceControlImportService {
|
|||
}
|
||||
const existingWorkflow = existingWorkflows.find((e) => e.id === id);
|
||||
|
||||
// Apply the same deprecated-node enforcement as the REST/public API
|
||||
// endpoints. A re-sync that leaves the deprecated nodes untouched is
|
||||
// allowed; introducing or in-place editing them through git is not.
|
||||
if (existingWorkflow) {
|
||||
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||
nodes,
|
||||
existingWorkflow.nodes ?? [],
|
||||
id,
|
||||
);
|
||||
} else {
|
||||
this.deprecatedNodesValidationService.validateOnCreate(nodes, id);
|
||||
}
|
||||
|
||||
await this.redactionEnforcementService.assertPolicyChangeAllowed(
|
||||
existingWorkflow?.settings?.redactionPolicy,
|
||||
importedWorkflow.settings?.redactionPolicy,
|
||||
|
|
|
|||
|
|
@ -641,6 +641,7 @@ describe('auto-close on source-control pull', () => {
|
|||
mock(), // workflowPublishGuard
|
||||
Container.get(WorkflowMutationHooksProxy),
|
||||
Container.get(WorkflowFinderService),
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { mockInstance } from '@n8n/backend-test-utils';
|
||||
import type { NodesConfig } from '@n8n/config';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import type { User } from '@n8n/db';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
|
@ -11,7 +12,9 @@ import { CredentialsFinderService } from '@n8n/backend-services';
|
|||
import { BadRequestError, ForbiddenError } from '@n8n/errors';
|
||||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
|
||||
import { ExecuteNodeService } from '../execute-node.service';
|
||||
import type { ExecuteNodeRequest } from '../execute-node.service';
|
||||
|
|
@ -54,6 +57,11 @@ describe('ExecuteNodeService', () => {
|
|||
const activeExecutions = mockInstance(ActiveExecutions);
|
||||
const executionPersistence = mockInstance(ExecutionPersistence);
|
||||
const instanceSettings = mockInstance(InstanceSettings);
|
||||
const deprecatedNodesValidationService = new DeprecatedNodesValidationService(
|
||||
logger,
|
||||
mock<NodesConfig>({ blockDeprecated: true }),
|
||||
nodeTypes,
|
||||
);
|
||||
|
||||
const service = new ExecuteNodeService(
|
||||
nodeTypes,
|
||||
|
|
@ -64,6 +72,7 @@ describe('ExecuteNodeService', () => {
|
|||
activeExecutions,
|
||||
executionPersistence,
|
||||
instanceSettings,
|
||||
deprecatedNodesValidationService,
|
||||
);
|
||||
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
|
@ -86,6 +95,17 @@ describe('ExecuteNodeService', () => {
|
|||
});
|
||||
|
||||
describe('pre-flight validation', () => {
|
||||
it('rejects a deprecated node type before creating any workflow row', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockReturnValue(
|
||||
mockNodeType({ description: { ...testNodeDescription, deprecated: true } }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.run(user, baseRequest({ type: 'n8n-nodes-base.function', version: 1 })),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
expect(workflowRepository.createWorkflowWithOwner).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unknown node type before creating any workflow row', async () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||
throw new Error('unknown');
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { BadRequestError, ForbiddenError } from '@n8n/errors';
|
|||
import { ExecutionPersistence } from '@/executions/execution-persistence';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
import { WorkflowRunner } from '@/workflow-runner';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
|
||||
export const DEFAULT_EXECUTE_NODE_TIMEOUT_MS = 30_000;
|
||||
export const MAX_EXECUTE_NODE_TIMEOUT_MS = 60_000;
|
||||
|
|
@ -86,6 +87,7 @@ export class ExecuteNodeService {
|
|||
private readonly activeExecutions: ActiveExecutions,
|
||||
private readonly executionPersistence: ExecutionPersistence,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {}
|
||||
|
||||
async run(user: User, request: ExecuteNodeRequest): Promise<ExecuteNodeResult> {
|
||||
|
|
@ -98,6 +100,7 @@ export class ExecuteNodeService {
|
|||
await this.checkCredentialAccess(user, credentials);
|
||||
|
||||
const node = this.buildNode(request, credentials);
|
||||
this.deprecatedNodesValidationService.validateOnCreate([node]);
|
||||
const timeoutMs = Math.min(
|
||||
request.timeoutMs ?? DEFAULT_EXECUTE_NODE_TIMEOUT_MS,
|
||||
MAX_EXECUTE_NODE_TIMEOUT_MS,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ describe('ImportService', () => {
|
|||
mockPolicyEnforcementService,
|
||||
mockSharedWorkflowRepository,
|
||||
mockWorkflowRepository,
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
validateWorkflowStructure,
|
||||
sanitizeNodeGroupDescriptions,
|
||||
} from '@/workflow-helpers';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import { WorkflowService } from '@/workflows/workflow.service';
|
||||
|
||||
import { DATA_TABLE_ROWS_FILE_PREFIX } from './data-table-export.constants';
|
||||
|
|
@ -90,6 +91,7 @@ export class ImportService {
|
|||
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||
private readonly sharedWorkflowRepository: SharedWorkflowRepository,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {}
|
||||
|
||||
async initRecords() {
|
||||
|
|
@ -118,20 +120,22 @@ export class ImportService {
|
|||
const workflowIds = workflows.map((w) => w.id).filter((id) => !!id);
|
||||
const existingWorkflowIds = new Set<string>();
|
||||
const activeVersionIdByWorkflow = new Map<string, string>();
|
||||
const existingNodesByWorkflow = new Map<string, INode[]>();
|
||||
|
||||
let existingOwnerProjects = new Map<string, Project>();
|
||||
|
||||
if (workflowIds.length > 0) {
|
||||
const existingWorkflows = await dbManager.find(WorkflowEntity, {
|
||||
where: { id: In(workflowIds) },
|
||||
select: ['id', 'activeVersionId'],
|
||||
select: ['id', 'activeVersionId', 'nodes'],
|
||||
});
|
||||
|
||||
for (const { id, activeVersionId } of existingWorkflows) {
|
||||
for (const { id, activeVersionId, nodes } of existingWorkflows) {
|
||||
existingWorkflowIds.add(id);
|
||||
if (activeVersionId !== null) {
|
||||
activeVersionIdByWorkflow.set(id, activeVersionId);
|
||||
}
|
||||
existingNodesByWorkflow.set(id, nodes ?? []);
|
||||
}
|
||||
|
||||
// An existing workflow's policy scope is its own project, not `projectId` — that
|
||||
|
|
@ -162,6 +166,21 @@ export class ImportService {
|
|||
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
|
||||
validateWorkflowStructure(workflow);
|
||||
|
||||
// Apply the same deprecated-node enforcement that the REST/public API
|
||||
// endpoints use: treat unknown workflow IDs as creates, known IDs as
|
||||
// updates (so a re-import of an existing workflow with the same
|
||||
// deprecated nodes is allowed, but introducing new ones is not).
|
||||
const existingNodes = workflow.id ? existingNodesByWorkflow.get(workflow.id) : undefined;
|
||||
if (existingNodes) {
|
||||
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||
workflow.nodes,
|
||||
existingNodes,
|
||||
workflow.id,
|
||||
);
|
||||
} else {
|
||||
this.deprecatedNodesValidationService.validateOnCreate(workflow.nodes, workflow.id);
|
||||
}
|
||||
|
||||
for (const warning of sanitizeNodeGroupDescriptions(workflow)) {
|
||||
this.logger.warn(`Workflow "${workflow.name}": ${warning}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ import { TaskRequester } from '@/task-runners/task-managers/task-requester';
|
|||
import { findSubworkflowStart } from '@/utils';
|
||||
import { objectToError } from '@/utils/object-to-error';
|
||||
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import { getWorkflowProjectDetailsSafe } from '@/workflows/utils';
|
||||
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
|
||||
|
||||
|
|
@ -157,6 +158,11 @@ async function fetchWorkflowData(
|
|||
} else {
|
||||
const workflowData = workflowInfo.code;
|
||||
if (workflowData) {
|
||||
// The save-time check does not see inline JSON, so it runs here.
|
||||
Container.get(DeprecatedNodesValidationService).validateOnCreate(
|
||||
workflowData.nodes ?? [],
|
||||
parentWorkflowId,
|
||||
);
|
||||
// An inline sub-workflow is part of the parent that embeds it, not a
|
||||
// workflow of its own, so it runs under the parent workflow's id.
|
||||
workflowData.id = parentWorkflowId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,322 @@
|
|||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { NodesConfig } from '@n8n/config';
|
||||
import type { INode, INodeType } from 'n8n-workflow';
|
||||
import { fail } from 'node:assert';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import {
|
||||
DeprecatedNodesError,
|
||||
type DeprecatedNodeViolation,
|
||||
} from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
|
||||
describe('DeprecatedNodesValidationService', () => {
|
||||
let validator: DeprecatedNodesValidationService;
|
||||
let nodesConfig: NodesConfig;
|
||||
let nodeTypes: ReturnType<typeof mock<NodeTypes>>;
|
||||
let logger: ReturnType<typeof mock<Logger>>;
|
||||
|
||||
const nodeTypeFor = (type: string, deprecated?: boolean): INodeType =>
|
||||
Object.assign(mock<INodeType>(), {
|
||||
description: {
|
||||
name: type,
|
||||
deprecated: deprecated ? true : undefined,
|
||||
properties: [
|
||||
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '// default' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
nodesConfig = { blockDeprecated: true } as NodesConfig;
|
||||
nodeTypes = mock<NodeTypes>();
|
||||
logger = mock<Logger>();
|
||||
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type) => {
|
||||
if (type === 'n8n-nodes-base.function' || type === 'n8n-nodes-base.functionItem') {
|
||||
return nodeTypeFor(type, true);
|
||||
}
|
||||
return nodeTypeFor(type, false);
|
||||
});
|
||||
|
||||
validator = new DeprecatedNodesValidationService(logger, nodesConfig, nodeTypes);
|
||||
});
|
||||
|
||||
const expectViolations = (run: () => void, violations: DeprecatedNodeViolation[]) => {
|
||||
try {
|
||||
run();
|
||||
fail('expected to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DeprecatedNodesError);
|
||||
expect((error as DeprecatedNodesError).meta.violations).toEqual(violations);
|
||||
}
|
||||
};
|
||||
|
||||
const makeNode = (overrides: Partial<INode> & Pick<INode, 'id' | 'type'>): INode => ({
|
||||
name: overrides.id,
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('validateOnCreate', () => {
|
||||
it('passes when no deprecated nodes are present', () => {
|
||||
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
|
||||
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws DeprecatedNodesError when any node type is deprecated', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
|
||||
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
|
||||
];
|
||||
expect(() => validator.validateOnCreate(nodes)).toThrow(DeprecatedNodesError);
|
||||
expect(() => validator.validateOnCreate(nodes)).toThrow(/deprecated/);
|
||||
});
|
||||
|
||||
it('exposes every deprecated node in error meta and message', () => {
|
||||
const nodes = [
|
||||
makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func A' }),
|
||||
makeNode({ id: 'b', type: 'n8n-nodes-base.functionItem', name: 'Func B' }),
|
||||
];
|
||||
try {
|
||||
validator.validateOnCreate(nodes);
|
||||
fail('expected to throw');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(DeprecatedNodesError);
|
||||
const typed = error as DeprecatedNodesError;
|
||||
expect(typed.meta.violations).toEqual([
|
||||
{ kind: 'added', nodeName: 'Func A', nodeType: 'n8n-nodes-base.function' },
|
||||
{ kind: 'added', nodeName: 'Func B', nodeType: 'n8n-nodes-base.functionItem' },
|
||||
]);
|
||||
expect(typed.message).toContain('Func A');
|
||||
expect(typed.message).toContain('Func B');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat an unknown node type as deprecated', () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation(() => {
|
||||
throw new Error('Unrecognized node type');
|
||||
});
|
||||
const nodes = [makeNode({ id: 'a', type: 'community.unknown' })];
|
||||
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
|
||||
});
|
||||
|
||||
it('is a no-op when the config flag is off', () => {
|
||||
nodesConfig.blockDeprecated = false;
|
||||
const nodes = [makeNode({ id: 'a', type: 'n8n-nodes-base.function' })];
|
||||
expect(() => validator.validateOnCreate(nodes)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateOnUpdate', () => {
|
||||
it('allows an unchanged deprecated node to pass through', () => {
|
||||
const node = makeNode({
|
||||
id: 'a',
|
||||
type: 'n8n-nodes-base.function',
|
||||
parameters: { functionCode: 'return items;' },
|
||||
});
|
||||
expect(() => validator.validateOnUpdate([node], [node])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows position-only changes to a deprecated node', () => {
|
||||
const before = makeNode({
|
||||
id: 'a',
|
||||
type: 'n8n-nodes-base.function',
|
||||
name: 'Func',
|
||||
position: [0, 0],
|
||||
});
|
||||
const after = { ...before, position: [200, 100] as [number, number] };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows a structurally equal copy of a deprecated node', () => {
|
||||
const before = makeNode({
|
||||
id: 'a',
|
||||
type: 'n8n-nodes-base.function',
|
||||
parameters: { functionCode: 'return items;' },
|
||||
credentials: { someApi: { id: '1', name: 'Some API' } },
|
||||
});
|
||||
expect(() => validator.validateOnUpdate([structuredClone(before)], [before])).not.toThrow();
|
||||
});
|
||||
|
||||
it('treats absent and default disabled and credentials as unchanged', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
|
||||
const after = { ...before, disabled: false, credentials: {} };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows the editor-saved shape of a deprecated node stored with explicit defaults', () => {
|
||||
const before: INode = {
|
||||
...makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' }),
|
||||
parameters: { functionCode: '// default' },
|
||||
notes: '',
|
||||
onError: 'stopWorkflow',
|
||||
continueOnFail: false,
|
||||
};
|
||||
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||
});
|
||||
|
||||
it('blocks adding notes to an existing deprecated node', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = { ...before, notes: 'changed' };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||
});
|
||||
|
||||
it('blocks disabling an existing deprecated node', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = { ...before, disabled: true };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||
});
|
||||
|
||||
it('blocks changing the credentials of an existing deprecated node', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = { ...before, credentials: { someApi: { id: '2', name: 'Other API' } } };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||
});
|
||||
|
||||
it('blocks changing the error handling of an existing deprecated node', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after: INode = { ...before, onError: 'continueRegularOutput' };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||
});
|
||||
|
||||
it('treats a deprecated node sent with a new id as added', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = { ...before, id: 'b' };
|
||||
expectViolations(
|
||||
() => validator.validateOnUpdate([after], [before]),
|
||||
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a deprecated node sent without an id as added', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = { ...before, id: undefined as unknown as string };
|
||||
expectViolations(
|
||||
() => validator.validateOnUpdate([after], [before]),
|
||||
[{ kind: 'added', nodeName: 'Func', nodeType: 'n8n-nodes-base.function' }],
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a deprecated node reusing the id of a non-deprecated node as added', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.set', name: 'Set' });
|
||||
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Set' });
|
||||
expectViolations(
|
||||
() => validator.validateOnUpdate([after], [before]),
|
||||
[{ kind: 'added', nodeName: 'Set', nodeType: 'n8n-nodes-base.function' }],
|
||||
);
|
||||
});
|
||||
|
||||
it('logs a warning with the workflow id when it rejects a save', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = { ...before, parameters: { functionCode: 'return [];' } };
|
||||
expect(() => validator.validateOnUpdate([after], [before], 'wf-1')).toThrow(
|
||||
DeprecatedNodesError,
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.any(String), {
|
||||
workflowId: 'wf-1',
|
||||
violations: [{ kind: 'edited', nodeType: 'n8n-nodes-base.function' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks adding a deprecated node that did not exist before', () => {
|
||||
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
|
||||
const after = [
|
||||
...before,
|
||||
makeNode({ id: 'b', type: 'n8n-nodes-base.function', name: 'New Func' }),
|
||||
];
|
||||
expect(() => validator.validateOnUpdate(after, before)).toThrow(/Cannot use.*New Func/);
|
||||
});
|
||||
|
||||
it('blocks editing the parameters of an existing deprecated node', () => {
|
||||
const before = makeNode({
|
||||
id: 'a',
|
||||
type: 'n8n-nodes-base.function',
|
||||
name: 'Func',
|
||||
parameters: { functionCode: 'return items;' },
|
||||
});
|
||||
const after = { ...before, parameters: { functionCode: 'return [];' } };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify.*Func/);
|
||||
});
|
||||
|
||||
it('blocks editing the name of an existing deprecated node', () => {
|
||||
const before = makeNode({
|
||||
id: 'a',
|
||||
type: 'n8n-nodes-base.function',
|
||||
name: 'Func',
|
||||
});
|
||||
const after = { ...before, name: 'Renamed' };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot modify/);
|
||||
});
|
||||
|
||||
it('blocks bumping the typeVersion of a non-deprecated node down to a deprecated version', () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
|
||||
if (type === 'n8n-nodes-base.myNode' && version === 1) {
|
||||
return nodeTypeFor(type, true);
|
||||
}
|
||||
return nodeTypeFor(type, false);
|
||||
});
|
||||
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.myNode', typeVersion: 2 });
|
||||
const after = { ...before, typeVersion: 1 };
|
||||
expect(() => validator.validateOnUpdate([after], [before])).toThrow(/Cannot use/);
|
||||
});
|
||||
|
||||
it('allows migrating a deprecated typeVersion forward, including parameter changes', () => {
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type, version) => {
|
||||
if (type === 'n8n-nodes-base.myNode' && version === 1) {
|
||||
return nodeTypeFor(type, true);
|
||||
}
|
||||
return nodeTypeFor(type, false);
|
||||
});
|
||||
|
||||
const before = makeNode({
|
||||
id: 'a',
|
||||
type: 'n8n-nodes-base.myNode',
|
||||
typeVersion: 1,
|
||||
parameters: { mode: 'legacy' },
|
||||
});
|
||||
const after = {
|
||||
...before,
|
||||
typeVersion: 2,
|
||||
parameters: { mode: 'safe', extra: 'new-field' },
|
||||
};
|
||||
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows replacing a deprecated node with a different node type at the same id', () => {
|
||||
const before = makeNode({ id: 'a', type: 'n8n-nodes-base.function', name: 'Func' });
|
||||
const after = makeNode({ id: 'a', type: 'n8n-nodes-base.code', name: 'Func' });
|
||||
expect(() => validator.validateOnUpdate([after], [before])).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows deleting a deprecated node', () => {
|
||||
const before = [
|
||||
makeNode({ id: 'a', type: 'n8n-nodes-base.set' }),
|
||||
makeNode({ id: 'b', type: 'n8n-nodes-base.function' }),
|
||||
];
|
||||
const after = [before[0]];
|
||||
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
|
||||
});
|
||||
|
||||
it('allows edits to non-deprecated nodes alongside a frozen deprecated one', () => {
|
||||
const deprecated = makeNode({ id: 'a', type: 'n8n-nodes-base.function' });
|
||||
const otherBefore = makeNode({ id: 'b', type: 'n8n-nodes-base.set', parameters: { x: 1 } });
|
||||
const otherAfter = { ...otherBefore, parameters: { x: 2 } };
|
||||
expect(() =>
|
||||
validator.validateOnUpdate([deprecated, otherAfter], [deprecated, otherBefore]),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('is a no-op when the config flag is off', () => {
|
||||
nodesConfig.blockDeprecated = false;
|
||||
const before = [makeNode({ id: 'a', type: 'n8n-nodes-base.set' })];
|
||||
const after = [...before, makeNode({ id: 'b', type: 'n8n-nodes-base.function' })];
|
||||
expect(() => validator.validateOnUpdate(after, before)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -123,6 +123,7 @@ describe('WorkflowCreationService', () => {
|
|||
policyEnforcementServiceMock,
|
||||
workflowRepositoryMock,
|
||||
nodeGroupRulesFlagGateMock,
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
restrictedNodeTypesProviderMock, // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -562,6 +563,7 @@ describe('WorkflowService', () => {
|
|||
nodeGroupRulesFlagGateMock, // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
|
@ -1599,6 +1601,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
|
||||
// Bypass validation internals
|
||||
|
|
@ -2361,6 +2364,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2504,6 +2508,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2816,6 +2821,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -2992,6 +2998,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -3181,6 +3188,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -3286,6 +3294,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
mock(), // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -3394,6 +3403,7 @@ describe('WorkflowService', () => {
|
|||
mock(), // nodeGroupRulesFlagGate
|
||||
errorWorkflowValidationServiceMock, // errorWorkflowValidationService
|
||||
mock(), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
|
||||
vi.mocked(WorkflowHelpers.removeDefaultValues).mockImplementation((settings) => settings);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
import { Logger } from '@n8n/backend-common';
|
||||
import { NodesConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import type { INode, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeHelpers, normalizeNodeShape } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
DeprecatedNodesError,
|
||||
type DeprecatedNodeViolation,
|
||||
} from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import { NodeTypes } from '@/node-types';
|
||||
|
||||
/**
|
||||
* Refuses adding a deprecated node or changing one in place. Removing a
|
||||
* deprecated node, or moving it to a non-deprecated type or version, is allowed.
|
||||
* Does nothing when `N8N_DEPRECATED_NODES_BLOCK` is off.
|
||||
*/
|
||||
@Service()
|
||||
export class DeprecatedNodesValidationService {
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly nodesConfig: NodesConfig,
|
||||
private readonly nodeTypes: NodeTypes,
|
||||
) {}
|
||||
|
||||
/** Throws if any node is deprecated. Use where there is no stored version to compare with. */
|
||||
validateOnCreate(nodes: INode[], workflowId?: string): void {
|
||||
if (!this.nodesConfig.blockDeprecated) return;
|
||||
|
||||
const violations: DeprecatedNodeViolation[] = [];
|
||||
for (const node of nodes) {
|
||||
if (this.deprecatedDescription(node)) {
|
||||
violations.push({ kind: 'added', nodeName: node.name, nodeType: node.type });
|
||||
}
|
||||
}
|
||||
|
||||
this.throwIfViolations(violations, workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if a deprecated node is new, or differs from the stored node with the
|
||||
* same id other than in position.
|
||||
*/
|
||||
validateOnUpdate(incomingNodes: INode[], existingNodes: INode[], workflowId?: string): void {
|
||||
if (!this.nodesConfig.blockDeprecated) return;
|
||||
|
||||
const existingById = new Map(existingNodes.map((n) => [n.id, n]));
|
||||
const violations: DeprecatedNodeViolation[] = [];
|
||||
|
||||
for (const incoming of incomingNodes) {
|
||||
const description = this.deprecatedDescription(incoming);
|
||||
if (!description) continue;
|
||||
|
||||
const before = existingById.get(incoming.id);
|
||||
// A stored node that was not deprecated counts as adding one, so a node cannot be moved back onto a deprecated version.
|
||||
if (!before || !this.deprecatedDescription(before)) {
|
||||
violations.push({ kind: 'added', nodeName: incoming.name, nodeType: incoming.type });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
!isEqual(this.frozenFields(before, description), this.frozenFields(incoming, description))
|
||||
) {
|
||||
violations.push({ kind: 'edited', nodeName: incoming.name, nodeType: incoming.type });
|
||||
}
|
||||
}
|
||||
|
||||
this.throwIfViolations(violations, workflowId);
|
||||
}
|
||||
|
||||
/** Puts a node in the shape the editor saves, so a re-save of an untouched node compares equal. */
|
||||
private frozenFields(node: INode, description: INodeTypeDescription) {
|
||||
const {
|
||||
position: _position,
|
||||
notes,
|
||||
onError,
|
||||
continueOnFail,
|
||||
disabled,
|
||||
credentials,
|
||||
parameters,
|
||||
...rest
|
||||
} = normalizeNodeShape(node);
|
||||
|
||||
return {
|
||||
...rest,
|
||||
parameters:
|
||||
NodeHelpers.getNodeParameters(
|
||||
description.properties,
|
||||
parameters,
|
||||
false,
|
||||
false,
|
||||
node,
|
||||
description,
|
||||
) ?? {},
|
||||
credentials: credentials ?? {},
|
||||
disabled: disabled === true,
|
||||
continueOnFail: continueOnFail === true,
|
||||
onError: onError ?? 'stopWorkflow',
|
||||
notes: notes ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
private throwIfViolations(violations: DeprecatedNodeViolation[], workflowId?: string) {
|
||||
if (violations.length === 0) return;
|
||||
|
||||
this.logger.warn('Rejected deprecated nodes', {
|
||||
workflowId,
|
||||
violations: violations.map(({ kind, nodeType }) => ({ kind, nodeType })),
|
||||
});
|
||||
|
||||
throw new DeprecatedNodesError(this.formatMessage(violations), { violations });
|
||||
}
|
||||
|
||||
private deprecatedDescription(node: INode): INodeTypeDescription | undefined {
|
||||
try {
|
||||
const { description } = this.nodeTypes.getByNameAndVersion(node.type, node.typeVersion);
|
||||
return description.deprecated === true ? description : undefined;
|
||||
} catch {
|
||||
// Unknown node types can't be deprecated by us; they're handled elsewhere.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private formatMessage(violations: DeprecatedNodeViolation[]): string {
|
||||
const lines = violations.map((v) => {
|
||||
const verb = v.kind === 'added' ? 'use a' : 'modify a';
|
||||
return `Cannot ${verb} "${v.nodeType}" node ("${v.nodeName}"): this node type is deprecated.`;
|
||||
});
|
||||
|
||||
const suffix = ' Replace the node with a supported alternative or remove it from the workflow.';
|
||||
|
||||
return lines.join(' ') + suffix;
|
||||
}
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import { TagService } from '@/services/tag.service';
|
|||
import * as WorkflowHelpers from '@/workflow-helpers';
|
||||
import { WorkflowHookContextService } from '@/workflow-hook-context.service';
|
||||
|
||||
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
|
||||
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
|
||||
import { dropRedactionPolicy } from './utils';
|
||||
import { WorkflowFinderService } from './workflow-finder.service';
|
||||
|
|
@ -74,6 +75,7 @@ export class WorkflowCreationService {
|
|||
private readonly policyEnforcementService: PolicyEnforcementService,
|
||||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {}
|
||||
|
||||
async prepareBatchContext(
|
||||
|
|
@ -222,6 +224,7 @@ export class WorkflowCreationService {
|
|||
WorkflowHelpers.makeGetNodeTypeForGrouping(this.nodeTypes),
|
||||
rules,
|
||||
);
|
||||
this.deprecatedNodesValidationService.validateOnCreate(newWorkflow.nodes, newWorkflow.id);
|
||||
|
||||
if (parentFolderId && parentFolderId !== PROJECT_ROOT) {
|
||||
if (!batchContext) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
staticErrorWorkflowId,
|
||||
type ErrorWorkflowProblem,
|
||||
} from './error-workflow-validation.service';
|
||||
import { DeprecatedNodesValidationService } from './deprecated-nodes-validation.service';
|
||||
import { WorkflowPublicationNotifier } from './publication/workflow-publication-notifier';
|
||||
import { WorkflowPublicationStatusService } from './publication/workflow-publication-status.service';
|
||||
import { NodeGroupRulesFlagGate } from './node-group-rules-flag-gate';
|
||||
|
|
@ -185,6 +186,7 @@ export class WorkflowService {
|
|||
private readonly nodeGroupRulesFlagGate: NodeGroupRulesFlagGate,
|
||||
private readonly errorWorkflowValidationService: ErrorWorkflowValidationService,
|
||||
private readonly restrictedNodeTypesProvider: RestrictedNodeTypesProviderProxy,
|
||||
private readonly deprecatedNodesValidationService: DeprecatedNodesValidationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
|
|
@ -668,6 +670,14 @@ export class WorkflowService {
|
|||
);
|
||||
}
|
||||
|
||||
if (hasNodesKey && nodesChanged) {
|
||||
this.deprecatedNodesValidationService.validateOnUpdate(
|
||||
workflowUpdateData.nodes,
|
||||
workflow.nodes,
|
||||
workflow.id,
|
||||
);
|
||||
}
|
||||
|
||||
// Strip redactionPolicy if instance lacks data-redaction license
|
||||
if (
|
||||
workflowUpdateData.settings?.redactionPolicy !== undefined &&
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
import { testDb, createWorkflow } from '@n8n/backend-test-utils';
|
||||
import { NodesConfig } from '@n8n/config';
|
||||
import { ExecutionRepository, type IWorkflowDb } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { NodeConnectionTypes } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { WorkflowExecutionService } from '@/workflows/workflow-execution.service';
|
||||
|
||||
import { createOwner } from './shared/db/users';
|
||||
import * as utils from './shared/utils';
|
||||
import { loadNodesFromDist } from './shared/utils/node-types-data';
|
||||
|
||||
describe('inline sub-workflow with a deprecated node', () => {
|
||||
let owner: Awaited<ReturnType<typeof createOwner>>;
|
||||
let workflowExecutionService: WorkflowExecutionService;
|
||||
let executionRepository: ExecutionRepository;
|
||||
let nodesConfig: NodesConfig;
|
||||
let previousBlockDeprecated: boolean;
|
||||
|
||||
beforeAll(async () => {
|
||||
await testDb.init();
|
||||
|
||||
owner = await createOwner();
|
||||
|
||||
const nodeTypes = loadNodesFromDist([
|
||||
'n8n-nodes-base.manualTrigger',
|
||||
'n8n-nodes-base.executeWorkflow',
|
||||
'n8n-nodes-base.executeWorkflowTrigger',
|
||||
'n8n-nodes-base.function',
|
||||
]);
|
||||
|
||||
await utils.initNodeTypes(nodeTypes);
|
||||
await utils.initBinaryDataService();
|
||||
|
||||
workflowExecutionService = Container.get(WorkflowExecutionService);
|
||||
executionRepository = Container.get(ExecutionRepository);
|
||||
nodesConfig = Container.get(NodesConfig);
|
||||
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||
await testDb.truncate(['ExecutionEntity', 'WorkflowEntity']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
function inlineSubWorkflowWithFunctionNode() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
parameters: { inputSource: 'passthrough' },
|
||||
type: 'n8n-nodes-base.executeWorkflowTrigger',
|
||||
typeVersion: 1.1,
|
||||
id: uuid(),
|
||||
name: 'Sub Trigger',
|
||||
position: [0, 0],
|
||||
},
|
||||
{
|
||||
parameters: { functionCode: "return [{ json: { ran: 'function' } }];" },
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
id: uuid(),
|
||||
name: 'Function',
|
||||
position: [200, 0],
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
'Sub Trigger': {
|
||||
main: [[{ node: 'Function', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parentWithInlineSubWorkflow() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
id: uuid(),
|
||||
name: 'Trigger',
|
||||
position: [0, 0],
|
||||
},
|
||||
{
|
||||
parameters: {
|
||||
source: 'parameter',
|
||||
workflowJson: JSON.stringify(inlineSubWorkflowWithFunctionNode()),
|
||||
options: { waitForSubWorkflow: true },
|
||||
},
|
||||
type: 'n8n-nodes-base.executeWorkflow',
|
||||
typeVersion: 1.2,
|
||||
id: uuid(),
|
||||
name: 'Execute Sub-workflow',
|
||||
position: [200, 0],
|
||||
},
|
||||
],
|
||||
connections: {
|
||||
Trigger: {
|
||||
main: [[{ node: 'Execute Sub-workflow', type: NodeConnectionTypes.Main, index: 0 }]],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runParent() {
|
||||
const parent = await createWorkflow(
|
||||
{ name: 'Parent', ...parentWithInlineSubWorkflow() } as unknown as IWorkflowDb,
|
||||
owner,
|
||||
);
|
||||
|
||||
const result = await workflowExecutionService.executeManually(
|
||||
parent,
|
||||
{ triggerToStartFrom: { name: 'Trigger' } },
|
||||
owner,
|
||||
);
|
||||
if (!('executionId' in result)) {
|
||||
throw new Error(`Expected an executionId, got ${JSON.stringify(result)}`);
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 10000) {
|
||||
const execution = await executionRepository.findSingleExecution(result.executionId, {
|
||||
includeData: true,
|
||||
unflattenData: true,
|
||||
});
|
||||
if (execution && ['error', 'success'].includes(execution.status)) return execution;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Execution ${result.executionId} did not finish within 10000ms`);
|
||||
}
|
||||
|
||||
it('fails the parent node before the deprecated node runs when the block is on', async () => {
|
||||
nodesConfig.blockDeprecated = true;
|
||||
|
||||
const execution = await runParent();
|
||||
|
||||
expect(execution.status).toBe('error');
|
||||
const nodeRun = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1);
|
||||
expect(nodeRun?.executionStatus).toBe('error');
|
||||
expect(JSON.stringify(nodeRun?.error)).toContain('n8n-nodes-base.function');
|
||||
expect(await executionRepository.count({ where: { workflowId: execution.workflowId } })).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('runs the deprecated node when the block is off', async () => {
|
||||
nodesConfig.blockDeprecated = false;
|
||||
|
||||
const execution = await runParent();
|
||||
|
||||
expect(execution.status).toBe('success');
|
||||
const output = execution.data.resultData.runData['Execute Sub-workflow']?.at(-1)?.data?.main;
|
||||
expect(output?.[0]?.[0]?.json).toEqual({ ran: 'function' });
|
||||
});
|
||||
});
|
||||
|
|
@ -32,23 +32,28 @@ import {
|
|||
UserRepository,
|
||||
WorkflowPublishedVersionRepository,
|
||||
} from '@n8n/db';
|
||||
import type { NodesConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import * as fastGlob from 'fast-glob';
|
||||
import { Cipher } from 'n8n-core';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import * as utils from 'n8n-workflow';
|
||||
import type { INodeType } from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { Mock, Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import type { IWorkflowToImport } from '@/interfaces';
|
||||
import { SourceControlContextFactory } from '@/modules/source-control.ee/source-control-context.factory';
|
||||
import { SourceControlImportService } from '@/modules/source-control.ee/source-control-import.service.ee';
|
||||
import { SourceControlScopedService } from '@/modules/source-control.ee/source-control-scoped.service';
|
||||
import type { ExportableCredential } from '@/modules/source-control.ee/types/exportable-credential';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
import { WorkflowHistoryService } from '@/workflows/workflow-history/workflow-history.service';
|
||||
import { createFolder } from '@test-integration/db/folders';
|
||||
|
|
@ -115,6 +120,15 @@ describe('SourceControlImportService', () => {
|
|||
async (context, actor) =>
|
||||
await Container.get(PolicyEnforcementService).enforceContentImport(context, actor),
|
||||
);
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
|
||||
Object.assign(mock<INodeType>(), {
|
||||
description: {
|
||||
deprecated: type === 'n8n-nodes-base.function' ? true : undefined,
|
||||
properties: [{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
service = new SourceControlImportService(
|
||||
mock(),
|
||||
mock(),
|
||||
|
|
@ -148,6 +162,11 @@ describe('SourceControlImportService', () => {
|
|||
mock(), // workflowPublishGuard
|
||||
mock(), // workflowMutationHooks
|
||||
Container.get(WorkflowFinderService),
|
||||
new DeprecatedNodesValidationService(
|
||||
mock(),
|
||||
mock<NodesConfig>({ blockDeprecated: true }),
|
||||
nodeTypes,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1853,6 +1872,89 @@ describe('SourceControlImportService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('deprecated nodes', () => {
|
||||
it('rejects pulling a new workflow that contains a deprecated node', async () => {
|
||||
const importingUser = await getGlobalOwner();
|
||||
const workflow = makeWorkflowImport({
|
||||
nodes: [
|
||||
{
|
||||
id: 'node-1',
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [250, 300],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
},
|
||||
] as IWorkflowToImport['nodes'],
|
||||
});
|
||||
const file = putWorkflowFile(workflow.id, workflow);
|
||||
|
||||
await expect(
|
||||
service.importWorkflowFromWorkFolder(
|
||||
[mock<SourceControlledFile>({ id: workflow.id, file })],
|
||||
importingUser.id,
|
||||
),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
|
||||
await expect(workflowRepository.findOneBy({ id: workflow.id })).resolves.toBeNull();
|
||||
});
|
||||
|
||||
const functionNode = {
|
||||
id: 'node-1',
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [250, 300] as [number, number],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
};
|
||||
|
||||
it('allows pulling an existing workflow whose deprecated node is unchanged', async () => {
|
||||
const importingUser = await getGlobalOwner();
|
||||
const workflowId = nanoid();
|
||||
await createWorkflowWithHistory(
|
||||
{ id: workflowId, name: 'Old name', nodes: [functionNode], connections: {} },
|
||||
importingUser,
|
||||
);
|
||||
const workflow = makeWorkflowImport({
|
||||
id: workflowId,
|
||||
name: 'New name',
|
||||
nodes: [functionNode] as IWorkflowToImport['nodes'],
|
||||
});
|
||||
const file = putWorkflowFile(workflowId, workflow);
|
||||
|
||||
await service.importWorkflowFromWorkFolder(
|
||||
[mock<SourceControlledFile>({ id: workflowId, file })],
|
||||
importingUser.id,
|
||||
);
|
||||
|
||||
const stored = await workflowRepository.findOneBy({ id: workflowId });
|
||||
expect(stored?.name).toBe('New name');
|
||||
});
|
||||
|
||||
it('rejects pulling an existing workflow whose deprecated node was edited', async () => {
|
||||
const importingUser = await getGlobalOwner();
|
||||
const workflowId = nanoid();
|
||||
await createWorkflowWithHistory(
|
||||
{ id: workflowId, name: 'Test Workflow', nodes: [functionNode], connections: {} },
|
||||
importingUser,
|
||||
);
|
||||
const workflow = makeWorkflowImport({
|
||||
id: workflowId,
|
||||
nodes: [
|
||||
{ ...functionNode, parameters: { functionCode: 'return [];' } },
|
||||
] as IWorkflowToImport['nodes'],
|
||||
});
|
||||
const file = putWorkflowFile(workflowId, workflow);
|
||||
|
||||
await expect(
|
||||
service.importWorkflowFromWorkFolder(
|
||||
[mock<SourceControlledFile>({ id: workflowId, file })],
|
||||
importingUser.id,
|
||||
),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow history', () => {
|
||||
it('should create workflow history for new workflow on import', async () => {
|
||||
const importingUser = await getGlobalOwner();
|
||||
|
|
|
|||
|
|
@ -19,16 +19,20 @@ import {
|
|||
UserRepository,
|
||||
} from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { NodesConfig } from '@n8n/config';
|
||||
import type { ContentImportContext, PolicyViolation } from '@n8n/decorators';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import type { INode, INodeType } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { Mocked } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { DeprecatedNodesError } from '@/errors/response-errors/deprecated-nodes.error';
|
||||
import type { WorkflowIndexService } from '@/modules/workflow-index/workflow-index.service';
|
||||
import type { NodeTypes } from '@/node-types';
|
||||
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
|
||||
import { PolicyViolationError } from '@/policy/policy-violation.error';
|
||||
import { ImportService } from '@/services/import.service';
|
||||
import { DeprecatedNodesValidationService } from '@/workflows/deprecated-nodes-validation.service';
|
||||
import type { WorkflowService } from '@/workflows/workflow.service';
|
||||
|
||||
import { createMember, createOwner } from './shared/db/users';
|
||||
|
|
@ -85,6 +89,7 @@ describe('ImportService', () => {
|
|||
mockPolicyEnforcementService,
|
||||
sharedWorkflowRepository,
|
||||
Container.get(WorkflowRepository),
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -653,4 +658,98 @@ describe('ImportService', () => {
|
|||
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deprecated nodes', () => {
|
||||
let blockingImportService: ImportService;
|
||||
|
||||
const deprecatedNode: INode = {
|
||||
id: uuid(),
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
const nodeTypes = mock<NodeTypes>();
|
||||
nodeTypes.getByNameAndVersion.mockImplementation((type) =>
|
||||
Object.assign(mock<INodeType>(), {
|
||||
description: {
|
||||
deprecated: type === deprecatedNode.type ? true : undefined,
|
||||
properties: [
|
||||
{ displayName: 'Code', name: 'functionCode', type: 'string', default: '' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
blockingImportService = new ImportService(
|
||||
mock(),
|
||||
Container.get(CredentialsRepository),
|
||||
tagRepository,
|
||||
mock(),
|
||||
mock(),
|
||||
mockWorkflowIndexService,
|
||||
mock(),
|
||||
Container.get(UserRepository),
|
||||
mockWorkflowService,
|
||||
mockPolicyEnforcementService,
|
||||
sharedWorkflowRepository,
|
||||
workflowRepository,
|
||||
new DeprecatedNodesValidationService(
|
||||
mock(),
|
||||
mock<NodesConfig>({ blockDeprecated: true }),
|
||||
nodeTypes,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a new workflow with a deprecated node and leaves active workflows running', async () => {
|
||||
const active = await createActiveWorkflow({ name: 'Active' });
|
||||
const withDeprecated = newWorkflow({ id: uuid(), nodes: [deprecatedNode] });
|
||||
|
||||
await expect(
|
||||
blockingImportService.importWorkflows(
|
||||
[active, withDeprecated],
|
||||
ownerPersonalProject.id,
|
||||
owner.id,
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
|
||||
await expect(getWorkflowById(withDeprecated.id)).resolves.toBeNull();
|
||||
expect(mockWorkflowService.deactivateWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('rejects re-importing a workflow whose deprecated node was edited', async () => {
|
||||
const existing = await createWorkflow({ nodes: [deprecatedNode] });
|
||||
const edited = {
|
||||
...existing,
|
||||
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
|
||||
};
|
||||
|
||||
await expect(
|
||||
blockingImportService.importWorkflows([edited], ownerPersonalProject.id, owner.id, {}),
|
||||
).rejects.toThrow(DeprecatedNodesError);
|
||||
|
||||
const dbWorkflow = await getWorkflowById(existing.id);
|
||||
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
|
||||
});
|
||||
|
||||
test('allows re-importing a workflow whose deprecated node is unchanged', async () => {
|
||||
const existing = await createWorkflow({ nodes: [deprecatedNode] });
|
||||
|
||||
await blockingImportService.importWorkflows(
|
||||
[{ ...existing, name: 'Re-imported' }],
|
||||
ownerPersonalProject.id,
|
||||
owner.id,
|
||||
{},
|
||||
);
|
||||
|
||||
const dbWorkflow = await getWorkflowById(existing.id);
|
||||
expect(dbWorkflow?.name).toBe('Re-imported');
|
||||
expect(dbWorkflow?.nodes).toEqual([deprecatedNode]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
linkUserToProject,
|
||||
shareWorkflowWithUsers,
|
||||
} from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { GlobalConfig, NodesConfig } from '@n8n/config';
|
||||
import type { Project, TagEntity, User, WorkflowHistory } from '@n8n/db';
|
||||
import {
|
||||
WorkflowRepository,
|
||||
|
|
@ -4394,3 +4394,92 @@ return [{ json: result }];
|
|||
expect(retrievedUpdatedNode.parameters.language).toBe('javaScript');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deprecated nodes', () => {
|
||||
const nodesConfig = Container.get(NodesConfig);
|
||||
let previousBlockDeprecated: boolean;
|
||||
beforeAll(() => {
|
||||
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||
nodesConfig.blockDeprecated = true;
|
||||
});
|
||||
afterAll(() => {
|
||||
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||
});
|
||||
|
||||
const deprecatedNode: INode = {
|
||||
id: uuid(),
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
};
|
||||
|
||||
test('POST /workflows rejects a workflow with a deprecated node', async () => {
|
||||
const response = await authOwnerAgent.post('/workflows').send({
|
||||
name: 'has deprecated',
|
||||
nodes: [deprecatedNode],
|
||||
connections: {},
|
||||
settings: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.message).toMatch(/deprecated/i);
|
||||
});
|
||||
|
||||
test('PUT /workflows/:id rejects editing a deprecated node', async () => {
|
||||
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
|
||||
name: workflow.name,
|
||||
nodes: [{ ...deprecatedNode, parameters: { functionCode: 'return [];' } }],
|
||||
connections: workflow.connections,
|
||||
settings: workflow.settings,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.message).toMatch(/deprecated/i);
|
||||
});
|
||||
|
||||
test('PUT /workflows/:id allows editing other nodes while a deprecated node stays unchanged', async () => {
|
||||
const otherNode: INode = {
|
||||
id: uuid(),
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
};
|
||||
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
|
||||
name: workflow.name,
|
||||
nodes: [deprecatedNode, { ...otherNode, notes: 'edited' }],
|
||||
connections: workflow.connections,
|
||||
settings: workflow.settings,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('PUT /workflows/:id allows removing a deprecated node', async () => {
|
||||
const otherNode: INode = {
|
||||
id: uuid(),
|
||||
name: 'Start',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
};
|
||||
const workflow = await createWorkflowWithHistory({ nodes: [deprecatedNode, otherNode] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.put(`/workflows/${workflow.id}`).send({
|
||||
name: workflow.name,
|
||||
nodes: [otherNode],
|
||||
connections: {},
|
||||
settings: workflow.settings,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import { HttpHeaderAuth } from 'n8n-nodes-base/credentials/HttpHeaderAuth.creden
|
|||
import { OpenAiApi } from 'n8n-nodes-base/credentials/OpenAiApi.credentials';
|
||||
import { Cron } from 'n8n-nodes-base/nodes/Cron/Cron.node';
|
||||
import { FormTrigger } from 'n8n-nodes-base/nodes/Form/FormTrigger.node';
|
||||
import { Function as FunctionNode } from 'n8n-nodes-base/nodes/Function/Function.node';
|
||||
import { FunctionItem } from 'n8n-nodes-base/nodes/FunctionItem/FunctionItem.node';
|
||||
import { ManualTrigger } from 'n8n-nodes-base/nodes/ManualTrigger/ManualTrigger.node';
|
||||
import { ScheduleTrigger } from 'n8n-nodes-base/nodes/Schedule/ScheduleTrigger.node';
|
||||
import { Set } from 'n8n-nodes-base/nodes/Set/Set.node';
|
||||
|
|
@ -139,6 +141,14 @@ function buildDefaultNodes(): INodeTypeData {
|
|||
type: new WebhookNode() as unknown as INodeType,
|
||||
sourcePath: '',
|
||||
},
|
||||
'n8n-nodes-base.function': {
|
||||
type: new FunctionNode(),
|
||||
sourcePath: '',
|
||||
},
|
||||
'n8n-nodes-base.functionItem': {
|
||||
type: new FunctionItem(),
|
||||
sourcePath: '',
|
||||
},
|
||||
// Minimal mocks for node types the package-import fixtures reference at typeVersion 1.
|
||||
'n8n-nodes-base.httpRequest': minimalNodeType('n8n-nodes-base.httpRequest'),
|
||||
'n8n-nodes-base.dataTable': minimalNodeType('n8n-nodes-base.dataTable'),
|
||||
|
|
|
|||
|
|
@ -137,6 +137,7 @@ beforeAll(async () => {
|
|||
Container.get(NodeGroupRulesFlagGate), // nodeGroupRulesFlagGate
|
||||
Container.get(ErrorWorkflowValidationService), // errorWorkflowValidationService
|
||||
Container.get(RestrictedNodeTypesProviderProxy), // restrictedNodeTypesProvider
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
WorkflowRepository,
|
||||
WorkflowPublishHistoryRepository,
|
||||
} from '@n8n/db';
|
||||
import { NodesConfig } from '@n8n/config';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { Scope } from '@n8n/permissions';
|
||||
import { DateTime } from 'luxon';
|
||||
|
|
@ -880,6 +881,43 @@ describe('POST /workflows', () => {
|
|||
expect(workflowsInDb).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('deprecated nodes', () => {
|
||||
const nodesConfig = Container.get(NodesConfig);
|
||||
let previousBlockDeprecated: boolean;
|
||||
beforeAll(() => {
|
||||
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||
nodesConfig.blockDeprecated = true;
|
||||
});
|
||||
afterAll(() => {
|
||||
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||
});
|
||||
|
||||
const deprecatedNode: INode = {
|
||||
id: uuid(),
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
};
|
||||
|
||||
test('blocks creating a workflow that contains a deprecated node', async () => {
|
||||
const response = await authOwnerAgent.post('/workflows').send({
|
||||
name: 'has deprecated',
|
||||
nodes: [deprecatedNode],
|
||||
connections: {},
|
||||
settings: {},
|
||||
active: false,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.message).toMatch(/deprecated/i);
|
||||
expect(response.body.meta?.violations).toEqual([
|
||||
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security: Mass Assignment Protection', () => {
|
||||
test.each([
|
||||
{
|
||||
|
|
@ -4009,6 +4047,132 @@ describe('PATCH /workflows/:workflowId', () => {
|
|||
expect(response.body.data.name).toBe('Updated name');
|
||||
});
|
||||
|
||||
describe('deprecated nodes', () => {
|
||||
const nodesConfig = Container.get(NodesConfig);
|
||||
let previousBlockDeprecated: boolean;
|
||||
beforeAll(() => {
|
||||
previousBlockDeprecated = nodesConfig.blockDeprecated;
|
||||
nodesConfig.blockDeprecated = true;
|
||||
});
|
||||
afterAll(() => {
|
||||
nodesConfig.blockDeprecated = previousBlockDeprecated;
|
||||
});
|
||||
|
||||
const buildDeprecatedNode = (overrides: Partial<INode> = {}): INode => ({
|
||||
id: 'deprecated-node-id',
|
||||
name: 'Function',
|
||||
type: 'n8n-nodes-base.function',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: { functionCode: 'return items;' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const buildCleanNode = (overrides: Partial<INode> = {}): INode => ({
|
||||
id: 'clean-node-id',
|
||||
name: 'Manual Trigger',
|
||||
type: 'n8n-nodes-base.manualTrigger',
|
||||
typeVersion: 1,
|
||||
position: [200, 0],
|
||||
parameters: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('blocks adding a deprecated node to an existing workflow', async () => {
|
||||
const workflow = await createWorkflow({ nodes: [buildCleanNode()] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||
versionId: workflow.versionId,
|
||||
nodes: [buildCleanNode(), buildDeprecatedNode()],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.meta?.violations).toEqual([
|
||||
{ kind: 'added', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('blocks editing a deprecated node that already exists in the workflow', async () => {
|
||||
const deprecated = buildDeprecatedNode();
|
||||
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||
versionId: workflow.versionId,
|
||||
nodes: [{ ...deprecated, parameters: { functionCode: 'return [];' } }],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.body.meta?.violations).toEqual([
|
||||
{ kind: 'edited', nodeName: 'Function', nodeType: 'n8n-nodes-base.function' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('allows editing other nodes while a deprecated node stays unchanged', async () => {
|
||||
const deprecated = buildDeprecatedNode();
|
||||
const cleanNode = buildCleanNode();
|
||||
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||
versionId: workflow.versionId,
|
||||
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
|
||||
connections: workflow.connections,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('allows the editor-saved shape of a deprecated node stored with explicit defaults', async () => {
|
||||
const deprecated = buildDeprecatedNode();
|
||||
const cleanNode = buildCleanNode();
|
||||
const workflow = await createWorkflow(
|
||||
{
|
||||
nodes: [
|
||||
{ ...deprecated, notes: '', onError: 'stopWorkflow', continueOnFail: false },
|
||||
cleanNode,
|
||||
],
|
||||
},
|
||||
owner,
|
||||
);
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||
versionId: workflow.versionId,
|
||||
nodes: [deprecated, { ...cleanNode, notes: 'edited' }],
|
||||
connections: workflow.connections,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('allows moving a deprecated node on the canvas', async () => {
|
||||
const deprecated = buildDeprecatedNode();
|
||||
const workflow = await createWorkflow({ nodes: [deprecated] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||
versionId: workflow.versionId,
|
||||
nodes: [{ ...deprecated, position: [400, 200] }],
|
||||
connections: workflow.connections,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
test('allows removing a deprecated node from an existing workflow', async () => {
|
||||
const deprecated = buildDeprecatedNode();
|
||||
const cleanNode = buildCleanNode();
|
||||
const workflow = await createWorkflow({ nodes: [deprecated, cleanNode] }, owner);
|
||||
|
||||
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send({
|
||||
versionId: workflow.versionId,
|
||||
nodes: [cleanNode],
|
||||
connections: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security: Mass Assignment Protection on Update', () => {
|
||||
test.each([
|
||||
{
|
||||
|
|
|
|||
8
packages/nodes-base/nodes/Function/Function.node.test.ts
Normal file
8
packages/nodes-base/nodes/Function/Function.node.test.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { Function } from './Function.node';
|
||||
|
||||
describe('Function Node', () => {
|
||||
it('is marked deprecated so the backend refuses new instances', () => {
|
||||
const node = new Function();
|
||||
expect(node.description.deprecated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ export class Function implements INodeType {
|
|||
displayName: 'Function',
|
||||
name: 'function',
|
||||
hidden: true,
|
||||
deprecated: true,
|
||||
icon: 'fa:code',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { FunctionItem } from './FunctionItem.node';
|
||||
|
||||
describe('FunctionItem Node', () => {
|
||||
it('is marked deprecated so the backend refuses new instances', () => {
|
||||
const node = new FunctionItem();
|
||||
expect(node.description.deprecated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ export class FunctionItem implements INodeType {
|
|||
displayName: 'Function Item',
|
||||
name: 'functionItem',
|
||||
hidden: true,
|
||||
deprecated: true,
|
||||
icon: 'fa:code',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ const TMP_PDF_DEST_DIR = path.join(BASE_TMP_DIR, 'testData', 'pdfs');
|
|||
* @param options Options for `child_process.execFile`. `cwd` is typically required.
|
||||
* @returns A promise that resolves with the stdout of the command, or rejects on error.
|
||||
*/
|
||||
async function runN8nCliCommand(command: string, args: string[], options: { cwd: string }) {
|
||||
async function runN8nCliCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd: string; env?: NodeJS.ProcessEnv },
|
||||
) {
|
||||
const packagesRoot = findPackagesRoot('cli');
|
||||
const n8nExecutablePath = path.join(packagesRoot, 'cli/bin/n8n');
|
||||
console.log(`Executing n8n command: n8n ${command} ${args.join(' ')}`);
|
||||
|
|
@ -85,6 +89,7 @@ export async function globalWorkflowSetup(): Promise<void> {
|
|||
console.log('📥 Importing test workflows...');
|
||||
await runN8nCliCommand('import:workflow', ['--separate', '--input', WORKFLOWS_DIR_NAME], {
|
||||
cwd: __dirname,
|
||||
env: { ...process.env, N8N_DEPRECATED_NODES_BLOCK: 'false' },
|
||||
});
|
||||
|
||||
console.log('📁 Copying test assets...');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { test } from '../../../fixtures/base';
|
||||
|
||||
test.use({ capability: { env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } } });
|
||||
|
||||
/**
|
||||
* Regression guard for AI-2505 — PDF embedding via Default Data Loader →
|
||||
* In-Memory Vector Store insert was throwing
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.use({ capability: 'proxy' });
|
||||
test.use({
|
||||
capability: { services: ['proxy'], env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } },
|
||||
});
|
||||
test.describe(
|
||||
'Langchain Integration',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { nanoid } from 'nanoid';
|
|||
|
||||
import { test, expect } from '../../../fixtures/base';
|
||||
|
||||
test.use({ capability: { env: { N8N_DEPRECATED_NODES_BLOCK: 'false' } } });
|
||||
|
||||
const AGENT = '@n8n/n8n-nodes-langchain.agent';
|
||||
const PARSER = '@n8n/n8n-nodes-langchain.outputParserAutofixing';
|
||||
const LANGCHAIN_CODE = '@n8n/n8n-nodes-langchain.code';
|
||||
|
|
|
|||
|
|
@ -164,9 +164,9 @@ test.describe(
|
|||
await expect(n8n.ndv.getNodeVersion()).toContainText('Latest');
|
||||
await n8n.ndv.close();
|
||||
|
||||
await n8n.canvas.openNode('Function');
|
||||
await n8n.canvas.openNode('Cron');
|
||||
await n8n.ndv.openSettings();
|
||||
await expect(n8n.ndv.getNodeVersion()).toContainText('Function node version 1');
|
||||
await expect(n8n.ndv.getNodeVersion()).toContainText('Cron node version 1');
|
||||
await expect(n8n.ndv.getNodeVersion()).toContainText('Deprecated');
|
||||
await n8n.ndv.close();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,10 +35,10 @@
|
|||
},
|
||||
{
|
||||
"name": "Map each attachment",
|
||||
"type": "n8n-nodes-base.function",
|
||||
"type": "n8n-nodes-base.code",
|
||||
"position": [620, 420],
|
||||
"parameters": {
|
||||
"functionCode": "const _ = require('lodash')\n\nconst sanitize = str => _.chain(str)\n .replace(/[^A-Za-z0-9&.-]/g, '-') // sanitise via whitelist of characters\n .replace(/-(?=-)/g, '') // remove repeated dashes - https://regexr.com/6ag8h\n .trim('-') // trim any leading/trailing dashes\n .truncate({\n length: 60,\n omission: '-' // when the string ends with '-', you'll know it was truncated\n })\n .value()\n\nconst result = _.flatMap(items.map(item => {\n //console.log({item})\n\n // Maps each attachment to a separate item\n return _.values(item.binary).map(file => {\n console.log(\"Saving attachement:\", file.fileName, 'from:', ...item.json.from.value)\n \n // sanitize filename but exclude extension\n const filename_parts = file.fileName.split('.')\n const ext = _.slice(filename_parts, filename_parts.length-1)\n const filename_main = _.join(_.dropRight(filename_parts), '.')\n file.fileName = sanitize(filename_main) + '.' + ext\n \n return {\n json: {\n from: sanitize(item.json.from.value[0].name),\n date: sanitize(new Date(item.json.date).toISOString().split(\"T\")[0]) // get date part \"2020-01-01\"\n }, \n binary: { file }\n }\n })\n}))\n\n//console.log(result)\nreturn result"
|
||||
"jsCode": "const _ = require('lodash')\n\nconst sanitize = str => _.chain(str)\n .replace(/[^A-Za-z0-9&.-]/g, '-') // sanitise via whitelist of characters\n .replace(/-(?=-)/g, '') // remove repeated dashes - https://regexr.com/6ag8h\n .trim('-') // trim any leading/trailing dashes\n .truncate({\n length: 60,\n omission: '-' // when the string ends with '-', you'll know it was truncated\n })\n .value()\n\nconst result = _.flatMap(items.map(item => {\n //console.log({item})\n\n // Maps each attachment to a separate item\n return _.values(item.binary).map(file => {\n console.log(\"Saving attachement:\", file.fileName, 'from:', ...item.json.from.value)\n \n // sanitize filename but exclude extension\n const filename_parts = file.fileName.split('.')\n const ext = _.slice(filename_parts, filename_parts.length-1)\n const filename_main = _.join(_.dropRight(filename_parts), '.')\n file.fileName = sanitize(filename_main) + '.' + ext\n \n return {\n json: {\n from: sanitize(item.json.from.value[0].name),\n date: sanitize(new Date(item.json.date).toISOString().split(\"T\")[0]) // get date part \"2020-01-01\"\n }, \n binary: { file }\n }\n })\n}))\n\n//console.log(result)\nreturn result"
|
||||
},
|
||||
"typeVersion": 1
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
"workflowInfo": {
|
||||
"nodeCount": 3,
|
||||
"nodeTypes": {
|
||||
"n8n-nodes-base.function": {
|
||||
"n8n-nodes-base.code": {
|
||||
"count": 1
|
||||
},
|
||||
"n8n-nodes-base.nextCloud": {
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
{
|
||||
"id": 14,
|
||||
"icon": "fa:code",
|
||||
"name": "n8n-nodes-base.function",
|
||||
"name": "n8n-nodes-base.code",
|
||||
"defaults": {
|
||||
"name": "Function",
|
||||
"color": "#FF9922"
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
},
|
||||
{
|
||||
"id": "1ea0a87c-3395-4bd9-84fb-cf8b0f769cc4",
|
||||
"name": "Function",
|
||||
"type": "n8n-nodes-base.function",
|
||||
"name": "Cron",
|
||||
"type": "n8n-nodes-base.cron",
|
||||
"typeVersion": 1,
|
||||
"position": [960, 460]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2849,6 +2849,19 @@ export interface INodeTypeBaseDescription {
|
|||
*/
|
||||
hidden?: true;
|
||||
|
||||
/**
|
||||
* Marks the node, or one version of it, as deprecated. When `N8N_DEPRECATED_NODES_BLOCK` is enabled, workflows cannot add or
|
||||
* change a deprecated node, and workflow definitions that run without being
|
||||
* saved, such as inline sub-workflows, cannot contain one. Saved workflows that
|
||||
* already contain it keep running. Set this only for nodes that pose an active
|
||||
* risk, such as legacy nodes with insecure sandboxing; use `hidden` for nodes
|
||||
* that are only superseded.
|
||||
*
|
||||
* For versioned nodes, set this on the per-version description to mark only
|
||||
* that version as deprecated.
|
||||
*/
|
||||
deprecated?: true;
|
||||
|
||||
/**
|
||||
* Whether the node will be wrapped for tool-use by AI Agents,
|
||||
* optionally replacing provided parts of the description
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user