fix(editor): Sanitize workflow created during sub-workflow conversion (#30208)

This commit is contained in:
Charlie Kolb 2026-05-12 12:27:52 +02:00 committed by GitHub
parent fb78047d9a
commit d5d51731d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 184 additions and 1 deletions

View File

@ -0,0 +1,177 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import type { IConnections, INode } from 'n8n-workflow';
import { NodeConnectionTypes } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
import type { WorkflowDataCreate } from '@n8n/rest-api-client/api/workflows';
const { mockWorkflowsStore, mockWorkflowDocumentStore, mockNodeTypesStore, mockCanvasOperations } =
vi.hoisted(() => ({
mockWorkflowsStore: {
workflowId: 'parent-workflow-id',
createNewWorkflow: vi.fn(),
publishWorkflow: vi.fn(),
},
mockWorkflowDocumentStore: {
allNodes: [] as INodeUi[],
connectionsBySourceNode: {} as IConnections,
homeProject: { id: 'home-project' },
parentFolder: null as { id: string } | null,
getNodeById: vi.fn(),
getNodeByName: vi.fn(),
getChildNodes: vi.fn().mockReturnValue([]),
getExpressionHandler: vi.fn().mockReturnValue({}),
},
mockNodeTypesStore: {
getNodeType: vi.fn().mockReturnValue({
inputs: ['main'],
outputs: ['main'],
}),
},
mockCanvasOperations: {
addNodes: vi.fn().mockResolvedValue([{ id: 'execute-node-id' }]),
replaceNodeConnections: vi.fn(),
deleteNodes: vi.fn(),
replaceNodeParameters: vi.fn(),
},
}));
vi.mock('@/app/stores/workflows.store', () => ({
useWorkflowsStore: vi.fn().mockReturnValue(mockWorkflowsStore),
}));
vi.mock('@/app/stores/workflowDocument.store', () => ({
injectWorkflowDocumentStore: vi.fn().mockReturnValue({ value: mockWorkflowDocumentStore }),
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn().mockReturnValue(mockNodeTypesStore),
}));
vi.mock('@/app/composables/useCanvasOperations', () => ({
useCanvasOperations: vi.fn().mockReturnValue(mockCanvasOperations),
}));
vi.mock('@/app/stores/ui.store', () => ({
useUIStore: vi.fn().mockReturnValue({
resetLastInteractedWith: vi.fn(),
markStateDirty: vi.fn(),
openModalWithData: vi.fn(),
}),
}));
vi.mock('@/app/stores/history.store', () => ({
useHistoryStore: vi.fn().mockReturnValue({
startRecordingUndo: vi.fn(),
stopRecordingUndo: vi.fn(),
}),
}));
vi.mock('@/app/composables/useToast', () => ({
useToast: vi.fn().mockReturnValue({
showMessage: vi.fn(),
showError: vi.fn(),
}),
}));
vi.mock('@/app/composables/useTelemetry', () => ({
useTelemetry: vi.fn().mockReturnValue({ track: vi.fn() }),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: vi.fn().mockReturnValue({
baseText: vi.fn().mockImplementation((key: string) => key),
}),
}));
vi.mock('vue-router', () => ({
useRouter: vi.fn().mockReturnValue({
resolve: vi.fn().mockReturnValue({ href: '/workflow/new-id' }),
}),
}));
import { useWorkflowExtraction } from '@/app/composables/useWorkflowExtraction';
function makeNode(name: string, position: [number, number] = [0, 0]): INodeUi {
return {
id: `id-${name}`,
name,
type: 'n8n-nodes-base.set',
typeVersion: 1,
position,
parameters: {},
disabled: false,
issues: undefined,
typeUnknown: false,
} as INodeUi;
}
describe('useWorkflowExtraction', () => {
beforeEach(() => {
const pinia = createTestingPinia({ stubActions: false });
setActivePinia(pinia);
mockWorkflowsStore.createNewWorkflow.mockReset();
mockWorkflowsStore.publishWorkflow.mockReset();
mockCanvasOperations.addNodes.mockClear();
mockCanvasOperations.replaceNodeConnections.mockClear();
mockCanvasOperations.deleteNodes.mockClear();
mockCanvasOperations.replaceNodeParameters.mockClear();
mockWorkflowDocumentStore.getChildNodes.mockReturnValue([]);
mockWorkflowDocumentStore.allNodes = [];
mockWorkflowDocumentStore.connectionsBySourceNode = {};
});
describe('extractNodesIntoSubworkflow', () => {
it('strips connections referencing nodes outside the extracted selection', async () => {
const nodeA = makeNode('A', [0, 0]);
const nodeB = makeNode('B', [200, 0]);
const nodeC = makeNode('C', [400, 0]); // outside the extracted selection
mockWorkflowDocumentStore.allNodes = [nodeA, nodeB, nodeC];
mockWorkflowDocumentStore.connectionsBySourceNode = {
A: {
[NodeConnectionTypes.Main]: [
[
{ node: 'B', type: NodeConnectionTypes.Main, index: 0 },
{ node: 'C', type: NodeConnectionTypes.Main, index: 0 },
],
],
},
};
mockWorkflowsStore.createNewWorkflow.mockResolvedValue({
id: 'new-id',
versionId: 'v1',
});
mockWorkflowsStore.publishWorkflow.mockResolvedValue(undefined);
const { extractNodesIntoSubworkflow } = useWorkflowExtraction();
await extractNodesIntoSubworkflow(
{ start: 'A', end: undefined },
[nodeA, nodeB],
'Sub-workflow',
);
expect(mockWorkflowsStore.createNewWorkflow).toHaveBeenCalledTimes(1);
const created = mockWorkflowsStore.createNewWorkflow.mock.calls[0][0] as WorkflowDataCreate;
const aMainBuckets = created.connections?.A?.[NodeConnectionTypes.Main];
expect(aMainBuckets).toBeDefined();
const targetsFromA = (aMainBuckets?.[0] ?? []).map((c) => c?.node);
expect(targetsFromA).toContain('B');
expect(targetsFromA).not.toContain('C');
const allTargets = Object.values(created.connections ?? {}).flatMap((byType) =>
Object.values(byType).flatMap((buckets) =>
(buckets ?? []).flatMap((bucket) => (bucket ?? []).map((c) => c?.node)),
),
);
expect(allTargets).not.toContain('C');
const createdNodeNames = (created.nodes ?? []).map((n: INode) => n.name);
expect(createdNodeNames).not.toContain('C');
});
});
});

View File

@ -29,6 +29,7 @@ import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useTelemetry } from './useTelemetry';
import isEqual from 'lodash/isEqual';
import { v4 as uuidv4 } from 'uuid';
import { sanitizeConnections } from '../utils/workflowUtils';
const CANVAS_HISTORY_OPTIONS = {
trackBulk: false,
@ -246,7 +247,7 @@ export function useWorkflowExtraction() {
parameters: triggerParameters,
};
return {
const result: WorkflowDataCreate = {
name: newWorkflowName,
nodes: [...nodes, ...returnNode, triggerNode],
connections: {
@ -258,6 +259,11 @@ export function useWorkflowExtraction() {
projectId: workflowDocumentStore.value.homeProject?.id,
parentFolderId: workflowDocumentStore.value.parentFolder?.id ?? undefined,
};
result.connections = sanitizeConnections(
result.connections,
result.nodes?.map((x) => x.name),
);
return result;
}
function computeAveragePosition(nodes: INode[]): [number, number] {