fix(core): Add workflow structure validation (#29699)

This commit is contained in:
Daria
2026-05-06 14:42:12 +03:00
committed by GitHub
parent d6bae35e8f
commit bec74aeb4f
17 changed files with 1021 additions and 26 deletions
+2 -1
View File
@@ -24,7 +24,7 @@ import {
import { v4 as uuid } from 'uuid';
import { readdir, readFile } from 'fs/promises';
import { replaceInvalidCredentials } from '@/workflow-helpers';
import { replaceInvalidCredentials, validateWorkflowStructure } from '@/workflow-helpers';
import { validateDbTypeForImportEntities } from '@/utils/validate-database-type';
import { Cipher } from 'n8n-core';
import { decompressFolder } from '@/utils/compression.util';
@@ -114,6 +114,7 @@ export class ImportService {
const hasInvalidCreds = workflow.nodes.some((node) => !node.credentials?.id);
if (hasInvalidCreds) await this.replaceInvalidCreds(workflow, projectId);
validateWorkflowStructure(workflow);
// Remove workflows from ActiveWorkflowManager BEFORE transaction to prevent orphaned trigger listeners
// Only remove if the workflow already exists in the database and is active
+23 -1
View File
@@ -12,7 +12,11 @@ import type {
IWorkflowSettings,
RelatedExecution,
} from 'n8n-workflow';
import { resolveNodeWebhookId } from 'n8n-workflow';
import {
formatWorkflowStructureIssuePath,
resolveNodeWebhookId,
safeParseWorkflowStructure,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
@@ -121,6 +125,24 @@ export function resolveNodeWebhookIds(workflow: IWorkflowBase, nodeTypes: INodeT
}
}
export function validateWorkflowStructure(workflow: Pick<IWorkflowBase, 'nodes' | 'connections'>) {
const result = safeParseWorkflowStructure(workflow);
if (result.success) return;
const details = result.issues
.map(({ path, message, code }) => {
const formattedPath = Array.isArray(path)
? formatWorkflowStructureIssuePath(path)
: 'workflow';
return `${formattedPath} (${code}): ${message}`;
})
.join('; ');
throw new BadRequestError(`Workflow structure is invalid. ${details}`);
}
/**
* Removes default values from workflow settings to avoid storing them in the database.
* Returns a new settings object without mutating the original.
@@ -10,6 +10,7 @@ import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { ProjectService } from '@/services/project.service.ee';
import * as WorkflowHelpers from '@/workflow-helpers';
import { WorkflowCreationService } from '@/workflows/workflow-creation.service';
import type { NodeTypes } from '@/node-types';
import type { EnterpriseWorkflowService } from '@/workflows/workflow.service.ee';
@@ -85,6 +86,24 @@ describe('WorkflowCreationService', () => {
}
describe('createWorkflow()', () => {
it('should throw BadRequestError for invalid workflow structure', async () => {
projectServiceMock.getProjectWithScope.mockResolvedValue({ id: 'project-1' } as never);
licenseStateMock.isSharingLicensed.mockReturnValue(false);
jest.mocked(WorkflowHelpers.validateWorkflowStructure).mockImplementationOnce(() => {
throw new BadRequestError('Workflow structure is invalid. nodes[0].type: Required');
});
const user = mock<User>();
const newWorkflow = new WorkflowEntity();
newWorkflow.name = 'Test';
newWorkflow.nodes = [{ name: 'Start', position: [0, 0], parameters: {} }] as never;
newWorkflow.connections = {};
await expect(
workflowCreationService.createWorkflow(user, newWorkflow, { projectId: 'project-1' }),
).rejects.toThrow('Workflow structure is invalid.');
});
describe('credential retrieval', () => {
it('should include global credentials when checking credential permissions', async () => {
/**
@@ -4,6 +4,7 @@ import type { Scope } from '@n8n/permissions';
import type { MockProxy } from 'jest-mock-extended';
import { mock } from 'jest-mock-extended';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { OwnershipService } from '@/services/ownership.service';
import type { RoleService } from '@/services/role.service';
@@ -236,6 +237,26 @@ describe('WorkflowService', () => {
return { settings } as unknown as WorkflowEntity;
}
test('should throw BadRequestError for invalid workflow structure', async () => {
setupExistingWorkflow();
jest.mocked(WorkflowHelpers.validateWorkflowStructure).mockImplementationOnce(() => {
throw new BadRequestError('Workflow structure is invalid. nodes[0].position: Required');
});
const user = mock<User>();
await expect(
workflowService.update(
user,
{
nodes: [{ name: 'Start', type: 'n8n-nodes-base.manualTrigger', parameters: {} }],
} as unknown as WorkflowEntity,
'workflow-1',
{ forceSave: true },
),
).rejects.toThrow('Workflow structure is invalid.');
});
test('should strip redactionPolicy when user lacks scope and value is changing', async () => {
setupExistingWorkflow({ redactionPolicy: 'none' });
userHasScopesMock.mockResolvedValue(false);
@@ -109,6 +109,7 @@ export class WorkflowCreationService {
WorkflowHelpers.addNodeIds(newWorkflow);
WorkflowHelpers.resolveNodeWebhookIds(newWorkflow, this.nodeTypes);
WorkflowHelpers.validateWorkflowStructure(newWorkflow);
if ('pinData' in newWorkflow) {
WorkflowHelpers.validatePinDataSize(newWorkflow);
@@ -374,6 +374,10 @@ export class WorkflowService {
WorkflowHelpers.addNodeIds(workflowUpdateData);
WorkflowHelpers.resolveNodeWebhookIds(workflowUpdateData, this.nodeTypes);
WorkflowHelpers.validateWorkflowStructure({
nodes: workflowUpdateData.nodes ?? workflow.nodes,
connections: workflowUpdateData.connections ?? workflow.connections,
});
// Strip redactionPolicy if instance lacks data-redaction license
if (
@@ -148,7 +148,29 @@ describe('update()', () => {
test('should save workflow history version with backfilled data when connection change', async () => {
const owner = await createOwner();
const workflow = await createWorkflowWithHistory({}, owner);
const workflow = await createWorkflowWithHistory(
{
nodes: [
{
id: 'uuid-1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [240, 300],
parameters: {},
},
{
id: 'uuid-2',
name: 'Code Node',
type: 'n8n-nodes-base.code',
typeVersion: 1,
position: [500, 300],
parameters: {},
},
],
},
owner,
);
const addRecordSpy = jest.spyOn(workflowPublishHistoryRepository, 'addRecord');
const saveVersionSpy = jest.spyOn(workflowHistoryService, 'saveVersion');
@@ -281,7 +303,7 @@ describe('activateWorkflow()', () => {
webhookId: 'version1',
name: 'test',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -290,7 +312,7 @@ describe('activateWorkflow()', () => {
webhookId: 'version1-2',
name: 'test2',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -308,9 +330,9 @@ describe('activateWorkflow()', () => {
{
id: '123',
webhookId: 'version2',
name: '',
name: 'updatedNode',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -330,7 +352,7 @@ describe('activateWorkflow()', () => {
webhookId: 'version2',
name: 'newNode',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -354,7 +376,7 @@ describe('activateWorkflow()', () => {
webhookId: 'version1',
name: 'test',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -363,7 +385,7 @@ describe('activateWorkflow()', () => {
webhookId: 'version1-2',
name: 'test2',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -382,7 +404,7 @@ describe('activateWorkflow()', () => {
webhookId: 'version2',
name: 'newNode',
typeVersion: 0,
type: '',
type: 'n8n-nodes-base.webhook',
position: [1, 2],
parameters: {},
},
@@ -3147,6 +3147,7 @@ describe('PATCH /workflows/:workflowId', () => {
const payload = {
nodes: [],
connections: {},
};
const response = await authOwnerAgent.patch(`/workflows/${workflow.id}`).send(payload);
@@ -2157,6 +2157,7 @@
"nodeView.showError.workflowError": "Workflow execution had an error",
"nodeView.showError.getWorkflowDataFromUrl.title": "Problem loading workflow",
"nodeView.showError.importWorkflowData.title": "Problem importing workflow",
"nodeView.showError.importWorkflowData.invalidNodes": "Skipped {count} node(s) with missing type",
"nodeView.showError.mounted1.message": "There was a problem loading init data",
"nodeView.showError.mounted1.title": "Init Problem",
"nodeView.showError.mounted2.message": "There was a problem initializing the workflow",
@@ -2258,6 +2259,7 @@
"nodeWebhooks.webhookUrls.chatTrigger": "Chat URL",
"nodeWebhooks.webhookUrls.mcpTrigger": "MCP URL",
"openWorkflow.workflowImportError": "Could not import workflow",
"openWorkflow.workflowDataInvalidError": "Workflow data is invalid",
"openWorkflow.workflowNotFoundError": "Could not find workflow",
"oauth.consentView.title": "OAuth access consent",
"oauth.consentView.heading": "{clientName} wants access to your n8n instance",
@@ -3752,7 +3752,7 @@ describe('useCanvasOperations', () => {
const newWorkflowId = 'new-workflow-id';
const workflow = createTestWorkflow({
id: newWorkflowId,
nodes: [createTestNode()],
nodes: [createTestNode({ name: 'Node 1' }), createTestNode({ name: 'Node 2' })],
connections: testConnections,
});
@@ -3768,6 +3768,56 @@ describe('useCanvasOperations', () => {
expect(workflowDocumentStore.setConnections).toHaveBeenCalledWith(testConnections);
});
it('should default position to [0, 0] for nodes with missing position', async () => {
const { position: _, ...nodeWithoutPosition } = createTestNode({ name: 'Start' });
const workflow = createTestWorkflow({
id: workflowId,
nodes: [nodeWithoutPosition as INodeUi],
connections: {},
});
const setNodesSpy = vi.spyOn(workflowDocumentStoreInstance, 'setNodes');
const { initializeWorkspace } = useCanvasOperations();
await initializeWorkspace(workflow);
expect(setNodesSpy).toHaveBeenCalledWith([
expect.objectContaining({ name: 'Start', position: [0, 0] }),
]);
});
it('should remove connections for nodes filtered out during initialization', async () => {
const validNode = createTestNode({ name: 'Start' });
const invalidNode = createTestNode({ name: 'Missing', type: '' });
const targetNode = createTestNode({ name: 'End' });
const workflow = createTestWorkflow({
id: workflowId,
nodes: [validNode, invalidNode, targetNode],
connections: {
Start: {
main: [
[
{ node: 'End', type: 'main', index: 0 },
{ node: 'Missing', type: 'main', index: 0 },
],
],
},
Missing: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
},
});
const { initializeWorkspace } = useCanvasOperations();
await initializeWorkspace(workflow);
expect(workflowDocumentStoreInstance.setConnections).toHaveBeenCalledWith({
Start: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
});
});
it('should initialize node data from node type description', async () => {
const nodeTypesStore = mockedStore(useNodeTypesStore);
const type = SET_NODE_TYPE;
@@ -3788,14 +3838,18 @@ describe('useCanvasOperations', () => {
nodeTypesStore.nodeTypes = { [type]: { [version]: expectedDescription } };
const workflow = createTestWorkflow({
id: workflowId,
nodes: [createTestNode()],
connections: {},
});
const setNodesSpy = vi.spyOn(workflowDocumentStoreInstance, 'setNodes');
const { initializeWorkspace } = useCanvasOperations();
await initializeWorkspace(workflow);
expect(workflow.nodes[0].parameters).toEqual({ value: true });
expect(setNodesSpy).toHaveBeenCalledWith([
expect.objectContaining({ parameters: { value: true } }),
]);
});
});
@@ -4616,6 +4670,46 @@ describe('useCanvasOperations', () => {
expect(toast.showError).not.toHaveBeenCalled();
});
it('should remove connections for nodes filtered out during import', async () => {
vi.mocked(workflowDocumentStoreInstance.createWorkflowObject).mockImplementation(
(nodes, connections) =>
createTestWorkflowObject({
nodes: nodes as INodeUi[],
connections,
}),
);
const workflowDataToImport = {
nodes: [
createTestNode({ id: 'start-id', name: 'Start' }),
createTestNode({ id: 'missing-id', name: 'Missing', type: '' }),
createTestNode({ id: 'end-id', name: 'End' }),
],
connections: {
Start: {
main: [
[
{ node: 'End', type: 'main' as const, index: 0 },
{ node: 'Missing', type: 'main' as const, index: 0 },
],
],
},
Missing: {
main: [[{ node: 'End', type: 'main' as const, index: 0 }]],
},
},
};
const canvasOperations = useCanvasOperations();
const workflow = await canvasOperations.importWorkflowData(workflowDataToImport, 'paste');
expect(workflow.connections).toEqual({
Start: {
main: [[{ node: 'End', type: 'main', index: 0 }]],
},
});
});
it.each(UPDATE_WEBHOOK_ID_NODE_TYPES)(
'should regenerate webhook ids for node type "%s" on pasting into canvas',
async (type) => {
@@ -118,6 +118,7 @@ import { computed, nextTick, ref, type DeepReadonly } from 'vue';
import { useUniqueNodeName } from '@/app/composables/useUniqueNodeName';
import { injectWorkflowState } from '@/app/composables/useWorkflowState';
import { isPresent, tryToParseNumber } from '@/app/utils/typesUtils';
import { ensureNodePosition, sanitizeConnections } from '@/app/utils/workflowUtils';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { CanvasLayoutEvent } from '@/features/workflows/canvas/composables/useCanvasLayout';
import { chatEventBus } from '@n8n/chat/event-buses';
@@ -2334,7 +2335,14 @@ export function useCanvasOperations() {
async function initializeWorkspace(data: IWorkflowDb) {
const { workflowDocumentStore: initializedDocumentStore } =
await workflowHelpers.initState(data);
data.nodes.forEach((node) => {
// Filter out nodes with missing type to prevent canvas rendering crashes
const validNodes = data.nodes
.filter((node) => !!node.type)
.map((node) => ({ ...node, position: ensureNodePosition(node.position) }));
const validNodeNames = validNodes.map((node) => node.name);
validNodes.forEach((node) => {
const nodeTypeDescription = requireNodeTypeDescription(node.type, node.typeVersion);
const isInstalledNode = nodeTypesStore.getIsNodeInstalled(node.type);
nodeHelpers.matchCredentials(node);
@@ -2345,8 +2353,8 @@ export function useCanvasOperations() {
}
});
initializedDocumentStore.setNodes(data.nodes);
initializedDocumentStore.setConnections(data.connections);
initializedDocumentStore.setNodes(validNodes);
initializedDocumentStore.setConnections(sanitizeConnections(data.connections, validNodeNames));
return { workflowDocumentStore: initializedDocumentStore };
}
@@ -2620,6 +2628,34 @@ export function useCanvasOperations() {
return {};
}
// Filter out nodes with missing type to prevent crashes
if (workflowData.nodes) {
const invalidNodes = workflowData.nodes.filter((node) => !node.type);
if (invalidNodes.length > 0) {
toast.showError(
new Error(
i18n.baseText('nodeView.showError.importWorkflowData.invalidNodes', {
interpolate: { count: String(invalidNodes.length) },
}),
),
i18n.baseText('nodeView.showError.importWorkflowData.title'),
);
workflowData.nodes = workflowData.nodes.filter((node) => !!node.type);
}
}
if (workflowData.nodes) {
workflowData.nodes = workflowData.nodes.map((node) => ({
...node,
position: ensureNodePosition(node.position),
}));
}
if (workflowData.connections) {
const validNodeNames = workflowData.nodes?.map((node) => node.name);
workflowData.connections = sanitizeConnections(workflowData.connections, validNodeNames);
}
try {
const nodeIdMap: { [prev: string]: string } = {};
if (workflowData.nodes) {
@@ -1,6 +1,7 @@
import { ref, computed, shallowRef } from 'vue';
import { type RouteRecordNameGeneric, useRoute, useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { safeParseWorkflowStructure, WorkflowStructureValidationError } from 'n8n-workflow';
import { useToast } from '@/app/composables/useToast';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useExternalHooks } from '@/app/composables/useExternalHooks';
@@ -154,6 +155,20 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
return true;
}
const templateValidation = safeParseWorkflowStructure({
nodes: workflow.nodes,
connections: workflow.connections,
});
if (!templateValidation.success) {
toast.showError(
new WorkflowStructureValidationError(templateValidation.issues),
i18n.baseText('nodeView.showError.openWorkflow.title'),
{ message: i18n.baseText('openWorkflow.workflowDataInvalidError') },
);
return true;
}
await openWorkflowTemplateFromJSON(workflow);
} else {
await openWorkflowTemplate(templateId.toString());
@@ -228,6 +243,19 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
}
async function openWorkflow(data: IWorkflowDb) {
const validationResult = safeParseWorkflowStructure({
nodes: data.nodes,
connections: data.connections,
});
if (!validationResult.success) {
toast.showError(
new WorkflowStructureValidationError(validationResult.issues),
i18n.baseText('nodeView.showError.openWorkflow.title'),
{ message: i18n.baseText('openWorkflow.workflowDataInvalidError') },
);
}
disposeCurrentWorkflowDocumentStore();
resetWorkspace();
@@ -237,14 +265,33 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
documentTitle.setDocumentTitle(data.name, 'IDLE');
}
const { workflowDocumentStore } = await initializeWorkspace(data);
currentWorkflowDocumentStore.value = workflowDocumentStore;
currentNDVStore.value = useNDVStore(
createWorkflowDocumentId(
workflowDocumentStore.workflowId,
workflowDocumentStore.workflowVersion,
),
);
try {
const { workflowDocumentStore } = await initializeWorkspace(data);
currentWorkflowDocumentStore.value = workflowDocumentStore;
currentNDVStore.value = useNDVStore(
createWorkflowDocumentId(
workflowDocumentStore.workflowId,
workflowDocumentStore.workflowVersion,
),
);
} catch (error) {
// Using error instead of warn so that unexpected errors are captured by Sentry
console.error('Failed to initialize workspace for workflow', {
workflowId: data.id,
error,
});
toast.showError(error, i18n.baseText('nodeView.showError.openWorkflow.title'));
// Set up a minimal document store so the UI stays functional
workflowsStore.setWorkflowId(data.id);
const workflowDocumentId = createWorkflowDocumentId(data.id);
currentWorkflowDocumentStore.value = useWorkflowDocumentStore(workflowDocumentId);
currentWorkflowDocumentStore.value.setName(data.name);
currentWorkflowDocumentStore.value.setHomeProject(data.homeProject ?? null);
currentWorkflowDocumentStore.value.setScopes(data.scopes ?? []);
currentNDVStore.value = useNDVStore(workflowDocumentId);
return;
}
void externalHooks.run('workflow.open', {
workflowId: data.id,
@@ -2,9 +2,12 @@ import {
removeWorkflowExecutionData,
convertWorkflowTagsToIds,
sortNodesByExecutionOrder,
sanitizeConnections,
ensureNodePosition,
} from './workflowUtils';
import type { IWorkflowDb } from '@/Interface';
import type { INodeIssues } from 'n8n-workflow';
import type { IConnection, IConnections, INodeIssues } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
describe('workflowUtils', () => {
describe('convertWorkflowTagsToIds', () => {
@@ -577,4 +580,187 @@ describe('workflowUtils', () => {
expect(result.map((n) => n.node.name)).toEqual(['Trigger', 'A']);
});
});
describe('ensureNodePosition', () => {
it('should return valid position as-is', () => {
expect(ensureNodePosition([100, 200])).toEqual([100, 200]);
});
it('should return [0, 0] for undefined', () => {
expect(ensureNodePosition(undefined)).toEqual([0, 0]);
});
it('should return [0, 0] for a string', () => {
expect(ensureNodePosition('bad')).toEqual([0, 0]);
});
it('should return [0, 0] for an array with fewer than 2 elements', () => {
expect(ensureNodePosition([100])).toEqual([0, 0]);
});
it('should return [0, 0] for an array with non-numeric strings', () => {
expect(ensureNodePosition(['a', 'b'])).toEqual([0, 0]);
});
it('should coerce numeric strings to numbers', () => {
expect(ensureNodePosition(['100', '200'])).toEqual([100, 200]);
});
});
describe('sanitizeConnections', () => {
it('should return empty object for empty connections', () => {
expect(sanitizeConnections({})).toEqual({});
});
it('should return empty object for non-object input', () => {
expect(sanitizeConnections('broken')).toEqual({});
});
it('should pass through valid connections unchanged', () => {
const connections: IConnections = {
Start: {
[NodeConnectionTypes.Main]: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
expect(sanitizeConnections(connections)).toEqual(connections);
});
it('should strip connection type when buckets is a string', () => {
const connections = {
Start: { main: 'not-an-array' },
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({ Start: {} });
});
it('should strip connection type when buckets is a number', () => {
const connections = {
Start: { main: 42 },
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({ Start: {} });
});
it('should skip node entry when value is not an object', () => {
const connections = {
Start: 'broken',
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({});
});
it('should skip node entry when value is null', () => {
const connections = {
Start: null,
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({});
});
it('should nullify bucket when it is an object instead of an array', () => {
const connections = {
Start: {
main: [{ node: 'Foo', type: 'main', index: 0 }],
},
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({
Start: { main: [null] },
});
});
it('should preserve null buckets in sparse connections', () => {
const connections: IConnections = {
Start: {
[NodeConnectionTypes.Main]: [
null as unknown as IConnection[],
[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }],
],
},
};
expect(sanitizeConnections(connections)).toEqual(connections);
});
it('should keep valid types and strip malformed ones on the same node', () => {
const connections = {
Start: {
[NodeConnectionTypes.Main]: 'not-an-array',
[NodeConnectionTypes.AiAgent]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiAgent, index: 0 }],
],
},
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({
Start: {
[NodeConnectionTypes.AiAgent]: [
[{ node: 'Agent', type: NodeConnectionTypes.AiAgent, index: 0 }],
],
},
});
});
it('should drop connections whose source or target nodes are missing', () => {
const connections: IConnections = {
Start: {
[NodeConnectionTypes.Main]: [
[
{ node: 'End', type: NodeConnectionTypes.Main, index: 0 },
{ node: 'Missing', type: NodeConnectionTypes.Main, index: 0 },
],
],
},
Missing: {
[NodeConnectionTypes.Main]: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]],
},
};
expect(sanitizeConnections(connections, ['Start', 'End'])).toEqual({
Start: {
[NodeConnectionTypes.Main]: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]],
},
});
});
it('should drop malformed entries inside a valid bucket', () => {
const connections = {
Start: {
[NodeConnectionTypes.Main]: [
[
{ node: 'End', type: NodeConnectionTypes.Main, index: 0 },
null,
'broken',
{ node: 'Other', type: NodeConnectionTypes.Main },
],
],
},
} as unknown as IConnections;
expect(sanitizeConnections(connections)).toEqual({
Start: {
[NodeConnectionTypes.Main]: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]],
},
});
});
it('should drop malformed entries before checking valid node names', () => {
const connections = {
Start: {
[NodeConnectionTypes.Main]: [
[
null,
{ node: 'End', type: NodeConnectionTypes.Main, index: 0 },
{ node: 'Missing', type: NodeConnectionTypes.Main, index: 0 },
],
],
},
} as unknown as IConnections;
expect(sanitizeConnections(connections, ['Start', 'End'])).toEqual({
Start: {
[NodeConnectionTypes.Main]: [[{ node: 'End', type: NodeConnectionTypes.Main, index: 0 }]],
},
});
});
});
});
@@ -1,7 +1,8 @@
import type { IWorkflowDb, INodeUi } from '@/Interface';
import type { ITag } from '@n8n/rest-api-client/api/tags';
import type { IConnections } from 'n8n-workflow';
import type { IConnection, IConnections, INodeConnections } from 'n8n-workflow';
import { SPLIT_IN_BATCHES_NODE_TYPE } from '@/app/constants';
import { isObject } from '@/app/utils/objectUtils';
/**
* Converts workflow tags from ITag[] (API response format) to string[] (store format)
@@ -42,6 +43,77 @@ export function removeWorkflowExecutionData(
return sanitizedWorkflow;
}
/**
* Return a valid position tuple, defaulting to [0, 0] if missing or malformed.
*/
export function ensureNodePosition(position: unknown): [number, number] {
if (Array.isArray(position) && position.length >= 2) {
const x = Number(position[0]);
const y = Number(position[1]);
if (!Number.isNaN(x) && !Number.isNaN(y)) {
return [x, y];
}
}
return [0, 0];
}
function isValidConnectionEntry(connection: unknown): connection is IConnection {
if (!isObject(connection)) return false;
if (!('node' in connection) || !('type' in connection) || !('index' in connection)) {
return false;
}
return (
typeof connection.node === 'string' &&
typeof connection.type === 'string' &&
typeof connection.index === 'number'
);
}
/**
* Strip out malformed connection entries that would crash the canvas.
* Keeps only entries where each connection type maps to an array of buckets,
* each bucket is either an array or null, and each bucket entry matches the
* expected connection shape. When validNodeNames is provided, it also removes
* connections whose source or target node is missing.
*/
export function sanitizeConnections(
connections: unknown,
validNodeNames?: Iterable<string>,
): IConnections {
if (!isObject(connections)) return {};
const sanitized: IConnections = {};
const validNodeNameSet = validNodeNames ? new Set(validNodeNames) : undefined;
for (const nodeName of Object.keys(connections)) {
if (validNodeNameSet && !validNodeNameSet.has(nodeName)) continue;
const nodeConnections = connections[nodeName];
if (!isObject(nodeConnections)) continue;
const sanitizedNodeConnections: INodeConnections = {};
for (const type of Object.keys(nodeConnections)) {
const buckets = nodeConnections[type];
if (!Array.isArray(buckets)) continue;
sanitizedNodeConnections[type] = buckets.map((bucket) => {
if (!Array.isArray(bucket)) return null;
return bucket.filter(
(connection): connection is IConnection =>
isValidConnectionEntry(connection) &&
(!validNodeNameSet || validNodeNameSet.has(connection.node)),
);
});
}
sanitized[nodeName] = sanitizedNodeConnections;
}
return sanitized;
}
interface ExecutionOrderItem {
node: { name: string; position: [number, number] };
isTrigger: boolean;
+1
View File
@@ -30,6 +30,7 @@ export * from './workflow-checksum';
export * from './workflow-data-proxy';
export * from './workflow-data-proxy-env-provider';
export * from './workflow-validation';
export * from './workflow-structure-validation';
export * from './versioned-node-type';
export * from './type-validation';
export * from './result';
@@ -0,0 +1,198 @@
import { z } from 'zod';
/**
* Workflow Structure Validation
*
* Single source of truth for validating workflow **structure** — the minimum
* shape that the editor and runtime assume is always present and correctly
* formed. Intentionally separate from activation/publish validation
* (WorkflowValidationService) which checks semantic correctness (trigger
* presence, known node types, credential issues, etc.).
*
* Lives in n8n-workflow so it can be shared by:
* - Backend: create/update/import reject malformed payloads (400)
* - Frontend: open path warns but still renders; import path blocks
*
* Validates:
* - Required node fields (name, type, position)
* - Position is a 2-number tuple
* - Connection entries have valid node/type/index
* - No duplicate node names
* - Connection source/target keys reference existing nodes
*
* Does NOT validate:
* - Whether a node type is installed
* - Node parameter correctness
* - Credential validity
* - Activation readiness
*
*/
const workflowNodeStructureSchema = z
.object({
name: z.string().min(1),
type: z.string().min(1),
position: z.tuple([z.number(), z.number()]),
parameters: z.record(z.string(), z.unknown()).optional(),
id: z.string().optional(),
typeVersion: z.number().optional(),
disabled: z.boolean().optional(),
})
.passthrough();
const connectionEntrySchema = z
.object({
node: z.string().min(1),
type: z.string().min(1),
index: z.number().int().min(0),
})
.passthrough();
// Buckets can be null when a multi-output node has unused output slots.
// Matches NodeInputConnections type.
const connectionBucketSchema = z.array(connectionEntrySchema).nullable().optional();
const workflowConnectionsStructureSchema = z.record(
z.string(),
z.record(z.string(), z.array(connectionBucketSchema)),
);
const workflowStructureSchema = z.object({
nodes: z.array(workflowNodeStructureSchema),
connections: workflowConnectionsStructureSchema,
});
type WorkflowStructureData = z.infer<typeof workflowStructureSchema>;
type WorkflowStructureGraphIssueCode =
| 'duplicate_node_name'
| 'unknown_connection_source'
| 'unknown_connection_target';
type WorkflowStructureGraphIssue = {
code: WorkflowStructureGraphIssueCode;
path: Array<string | number>;
message: string;
};
export type WorkflowStructureIssue = z.ZodIssue | WorkflowStructureGraphIssue;
type WorkflowStructureValidationSuccess = {
success: true;
data: WorkflowStructureData;
};
type WorkflowStructureValidationFailure = {
success: false;
issues: WorkflowStructureIssue[];
};
export type WorkflowStructureValidationResult =
| WorkflowStructureValidationSuccess
| WorkflowStructureValidationFailure;
export const formatWorkflowStructureIssuePath = (path: Array<string | number>): string => {
if (path.length === 0) return 'workflow';
return path.reduce<string>((acc, segment) => {
if (typeof segment === 'number') return `${acc}[${segment}]`;
return acc ? `${acc}.${segment}` : segment;
}, '');
};
const formatIssuesDescription = (issues: WorkflowStructureIssue[]): string =>
issues
.map(({ path, message }) => `${formatWorkflowStructureIssuePath(path)}: ${message}`)
.join('\n');
export class WorkflowStructureValidationError extends Error {
override name = 'WorkflowStructureValidationError';
readonly description: string;
constructor(readonly issues: WorkflowStructureIssue[]) {
super('Invalid workflow structure');
this.description = formatIssuesDescription(issues);
}
}
export function safeParseWorkflowStructure(input: unknown): WorkflowStructureValidationResult {
const parsed = workflowStructureSchema.safeParse(input);
if (!parsed.success) {
return {
success: false,
issues: parsed.error.issues,
};
}
const { nodes, connections } = parsed.data;
const issues: WorkflowStructureIssue[] = [];
const nodeNames = new Set<string>();
for (const [index, node] of nodes.entries()) {
if (nodeNames.has(node.name)) {
issues.push({
path: ['nodes', index, 'name'],
message: `Duplicate node name "${node.name}"`,
code: 'duplicate_node_name',
});
continue;
}
nodeNames.add(node.name);
}
for (const sourceNodeName of Object.keys(connections)) {
if (!nodeNames.has(sourceNodeName)) {
issues.push({
path: ['connections', sourceNodeName],
message: `Connection source "${sourceNodeName}" does not reference an existing node`,
code: 'unknown_connection_source',
});
}
const connectionTypes = connections[sourceNodeName];
for (const connectionType of Object.keys(connectionTypes)) {
const buckets = connectionTypes[connectionType];
for (const [sourceIndex, bucket] of buckets.entries()) {
for (const [targetIndex, connection] of (bucket ?? []).entries()) {
if (!nodeNames.has(connection.node)) {
issues.push({
path: [
'connections',
sourceNodeName,
connectionType,
sourceIndex,
targetIndex,
'node',
],
message: `Connection target "${connection.node}" does not reference an existing node`,
code: 'unknown_connection_target',
});
}
}
}
}
}
if (issues.length > 0) {
return { success: false, issues };
}
return {
success: true,
data: parsed.data,
};
}
export function parseWorkflowStructure(input: unknown): WorkflowStructureData {
const result = safeParseWorkflowStructure(input);
if (!result.success) {
throw new WorkflowStructureValidationError(result.issues);
}
return result.data;
}
@@ -0,0 +1,268 @@
import {
safeParseWorkflowStructure,
parseWorkflowStructure,
WorkflowStructureValidationError,
} from '../src/workflow-structure-validation';
describe('workflow-structure-validation', () => {
const validWorkflow = {
nodes: [
{
id: 'node-1',
name: 'Start',
type: 'n8n-nodes-base.manualTrigger',
position: [0, 0] as [number, number],
parameters: {},
},
{
id: 'node-2',
name: 'Set',
type: 'n8n-nodes-base.set',
position: [200, 0] as [number, number],
parameters: {},
},
],
connections: {
Start: {
main: [[{ node: 'Set', type: 'main', index: 0 }]],
},
},
};
test('accepts a structurally valid workflow', () => {
expect(safeParseWorkflowStructure(validWorkflow)).toEqual({
success: true,
data: validWorkflow,
});
});
test('accepts a valid workflow with empty connections', () => {
const result = safeParseWorkflowStructure({
nodes: [validWorkflow.nodes[0]],
connections: {},
});
expect(result.success).toBe(true);
});
test('accepts a valid workflow with empty nodes array', () => {
const result = safeParseWorkflowStructure({
nodes: [],
connections: {},
});
expect(result.success).toBe(true);
});
test('accepts null connection buckets (unused output slots)', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
connections: {
Start: {
main: [null, [{ node: 'Set', type: 'main', index: 0 }]],
},
},
});
expect(result.success).toBe(true);
});
test('rejects nodes missing a required field', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
nodes: [{ ...validWorkflow.nodes[0], type: undefined }],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'invalid_type',
path: ['nodes', 0, 'type'],
}),
);
});
test('rejects empty string node name', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
nodes: [{ ...validWorkflow.nodes[0], name: '' }],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'too_small',
path: ['nodes', 0, 'name'],
}),
);
});
test('rejects positions with fewer than two coordinates', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
nodes: [{ ...validWorkflow.nodes[0], position: [0] }],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'too_small',
path: ['nodes', 0, 'position'],
}),
);
});
test('rejects positions with more than two coordinates', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
nodes: [{ ...validWorkflow.nodes[0], position: [0, 0, 50] }],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'too_big',
path: ['nodes', 0, 'position'],
}),
);
});
test('rejects connection with negative index', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
connections: {
Start: {
main: [[{ node: 'Set', type: 'main', index: -1 }]],
},
},
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'too_small',
path: ['connections', 'Start', 'main', 0, 0, 'index'],
}),
);
});
test('rejects duplicate node names', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
nodes: [
validWorkflow.nodes[0],
{
...validWorkflow.nodes[1],
name: 'Start',
},
],
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'duplicate_node_name',
path: ['nodes', 1, 'name'],
}),
);
});
test('rejects unknown connection sources', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
connections: {
Missing: {
main: [[{ node: 'Set', type: 'main', index: 0 }]],
},
},
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'unknown_connection_source',
path: ['connections', 'Missing'],
}),
);
});
test('rejects unknown connection targets', () => {
const result = safeParseWorkflowStructure({
...validWorkflow,
connections: {
Start: {
main: [[{ node: 'Missing', type: 'main', index: 0 }]],
},
},
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'unknown_connection_target',
path: ['connections', 'Start', 'main', 0, 0, 'node'],
}),
);
});
test('rejects empty nodes with non-empty connections', () => {
const result = safeParseWorkflowStructure({
nodes: [],
connections: {
Start: {
main: [[{ node: 'Set', type: 'main', index: 0 }]],
},
},
});
expect(result.success).toBe(false);
if (result.success) return;
expect(result.issues).toContainEqual(
expect.objectContaining({
code: 'unknown_connection_source',
}),
);
});
test('throws a typed error for invalid workflows', () => {
expect(() =>
parseWorkflowStructure({
nodes: [{ name: 'Start', position: [0, 0], parameters: {} }],
connections: {},
}),
).toThrow(WorkflowStructureValidationError);
});
test('error description formats issue paths', () => {
let thrown: WorkflowStructureValidationError | undefined;
try {
parseWorkflowStructure({
nodes: [{ name: 'Start', position: [0, 0], parameters: {} }],
connections: {},
});
} catch (error) {
thrown = error as WorkflowStructureValidationError;
}
expect(thrown).toBeInstanceOf(WorkflowStructureValidationError);
expect(thrown?.description).toContain('nodes[0].type');
});
});