mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 19:32:15 +00:00
fix(editor): Prevent execution data from leaking into workflow diffs UI (#18605)
Co-authored-by: Csaba Tuncsik <csaba@n8n.io>
This commit is contained in:
committed by
GitHub
parent
cae2b01288
commit
4bbf7cb749
@@ -27,9 +27,15 @@ vi.mock('vue-router', () => ({
|
|||||||
|
|
||||||
vi.mock('@/features/workflow-diff/useViewportSync', () => ({
|
vi.mock('@/features/workflow-diff/useViewportSync', () => ({
|
||||||
useProvideViewportSync: () => ({
|
useProvideViewportSync: () => ({
|
||||||
selectedDetailId: vi.fn(),
|
selectedDetailId: ref(undefined),
|
||||||
onNodeClick: vi.fn(),
|
onNodeClick: vi.fn(),
|
||||||
}),
|
}),
|
||||||
|
useInjectViewportSync: () => ({
|
||||||
|
triggerViewportChange: vi.fn(),
|
||||||
|
onViewportChange: vi.fn(),
|
||||||
|
selectedDetailId: ref(undefined),
|
||||||
|
triggerNodeClick: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/features/workflow-diff/useWorkflowDiff', () => ({
|
vi.mock('@/features/workflow-diff/useWorkflowDiff', () => ({
|
||||||
@@ -89,27 +95,6 @@ const renderModal = createComponentRenderer(WorkflowDiffModal, {
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
},
|
},
|
||||||
SyncedWorkflowCanvas: {
|
|
||||||
template: '<div><slot name="node" /><slot name="edge" /></div>',
|
|
||||||
},
|
|
||||||
WorkflowDiffAside: {
|
|
||||||
template: '<div><slot name="default" v-bind="{ outputFormat: \'unified\' }" /></div>',
|
|
||||||
},
|
|
||||||
Node: {
|
|
||||||
template: '<div class="canvas-node" />',
|
|
||||||
},
|
|
||||||
HighlightedEdge: {
|
|
||||||
template: '<div class="canvas-edge" />',
|
|
||||||
},
|
|
||||||
NodeDiff: {
|
|
||||||
template: '<div class="node-diff" />',
|
|
||||||
},
|
|
||||||
DiffBadge: {
|
|
||||||
template: '<span class="diff-badge" />',
|
|
||||||
},
|
|
||||||
NodeIcon: {
|
|
||||||
template: '<span class="node-icon" />',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -137,7 +122,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should mount successfully', async () => {
|
it('should mount successfully', async () => {
|
||||||
const { container } = renderModal({
|
const { container } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -154,7 +138,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should initialize with correct props', () => {
|
it('should initialize with correct props', () => {
|
||||||
const { container } = renderModal({
|
const { container } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -173,7 +156,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should display changes button', async () => {
|
it('should display changes button', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -190,7 +172,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should open changes dropdown when clicking Changes button', async () => {
|
it('should open changes dropdown when clicking Changes button', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -212,7 +193,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should render workflow panels', () => {
|
it('should render workflow panels', () => {
|
||||||
const { container } = renderModal({
|
const { container } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -229,7 +209,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should render navigation buttons', () => {
|
it('should render navigation buttons', () => {
|
||||||
const { container } = renderModal({
|
const { container } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -258,7 +237,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
workflowsStore.fetchWorkflow.mockResolvedValue(localWorkflow);
|
workflowsStore.fetchWorkflow.mockResolvedValue(localWorkflow);
|
||||||
|
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -273,7 +251,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should render back button', () => {
|
it('should render back button', () => {
|
||||||
const { container } = renderModal({
|
const { container } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -289,7 +266,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should handle different workflow directions', () => {
|
it('should handle different workflow directions', () => {
|
||||||
const pullComponent = renderModal({
|
const pullComponent = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -300,7 +276,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const pushComponent = renderModal({
|
const pushComponent = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -317,7 +292,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should show empty state when no changes exist in tabs', async () => {
|
it('should show empty state when no changes exist in tabs', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -347,7 +321,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should show empty state for connectors tab when no connector changes', async () => {
|
it('should show empty state for connectors tab when no connector changes', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -376,7 +349,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should show empty state for settings tab when no settings changes', async () => {
|
it('should show empty state for settings tab when no settings changes', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -412,7 +384,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
workflowsStore.fetchWorkflow.mockRejectedValue(new Error('Workflow not found'));
|
workflowsStore.fetchWorkflow.mockRejectedValue(new Error('Workflow not found'));
|
||||||
|
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -433,7 +404,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
workflowsStore.fetchWorkflow.mockResolvedValue(mockWorkflow);
|
workflowsStore.fetchWorkflow.mockResolvedValue(mockWorkflow);
|
||||||
|
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -451,7 +421,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should handle push direction without crashing', async () => {
|
it('should handle push direction without crashing', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
@@ -468,7 +437,6 @@ describe('WorkflowDiffModal', () => {
|
|||||||
|
|
||||||
it('should handle pull direction without crashing', async () => {
|
it('should handle pull direction without crashing', async () => {
|
||||||
const { getByText } = renderModal({
|
const { getByText } = renderModal({
|
||||||
pinia: createTestingPinia(),
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
eventBus,
|
eventBus,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { IWorkflowDb } from '@/Interface';
|
|||||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||||
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
import { useSourceControlStore } from '@/stores/sourceControl.store';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
|
import { removeWorkflowExecutionData } from '@/utils/workflowUtils';
|
||||||
import { N8nButton, N8nHeading, N8nIconButton, N8nRadioButtons, N8nText } from '@n8n/design-system';
|
import { N8nButton, N8nHeading, N8nIconButton, N8nRadioButtons, N8nText } from '@n8n/design-system';
|
||||||
import type { BaseTextKey } from '@n8n/i18n';
|
import type { BaseTextKey } from '@n8n/i18n';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useI18n } from '@n8n/i18n';
|
||||||
@@ -86,8 +87,8 @@ const sourceWorkFlow = computed(() => (props.data.direction === 'push' ? remote
|
|||||||
const targetWorkFlow = computed(() => (props.data.direction === 'push' ? local : remote));
|
const targetWorkFlow = computed(() => (props.data.direction === 'push' ? local : remote));
|
||||||
|
|
||||||
const { source, target, nodesDiff, connectionsDiff } = useWorkflowDiff(
|
const { source, target, nodesDiff, connectionsDiff } = useWorkflowDiff(
|
||||||
computed(() => sourceWorkFlow.value.state.value?.workflow),
|
computed(() => removeWorkflowExecutionData(sourceWorkFlow.value.state.value?.workflow)),
|
||||||
computed(() => targetWorkFlow.value.state.value?.workflow),
|
computed(() => removeWorkflowExecutionData(targetWorkFlow.value.state.value?.workflow)),
|
||||||
);
|
);
|
||||||
|
|
||||||
type SettingsChange = {
|
type SettingsChange = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import _pick from 'lodash-es/pick';
|
import _pick from 'lodash-es/pick';
|
||||||
import _isEqual from 'lodash-es/isEqual';
|
import _isEqual from 'lodash-es/isEqual';
|
||||||
import type { CanvasConnection } from '@/types';
|
import type { CanvasConnection, CanvasNode } from '@/types';
|
||||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||||
import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue';
|
import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue';
|
||||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||||
@@ -134,15 +134,17 @@ function createWorkflowDiff(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
workflow: workflowRef,
|
workflow: workflowRef,
|
||||||
nodes: nodes.value.map((node) => {
|
nodes: nodes.value.map((node: CanvasNode) => {
|
||||||
node.draggable = false;
|
node.draggable = false;
|
||||||
node.selectable = false;
|
node.selectable = false;
|
||||||
node.focusable = false;
|
node.focusable = false;
|
||||||
return node;
|
return node;
|
||||||
}),
|
}),
|
||||||
connections: connections.value.map((connection) => {
|
connections: connections.value.map((connection: CanvasConnection) => {
|
||||||
connection.selectable = false;
|
connection.selectable = false;
|
||||||
connection.focusable = false;
|
connection.focusable = false;
|
||||||
|
// Remove execution data from connection labels in diff context
|
||||||
|
connection.label = '';
|
||||||
return connection;
|
return connection;
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
124
packages/frontend/editor-ui/src/utils/workflowUtils.test.ts
Normal file
124
packages/frontend/editor-ui/src/utils/workflowUtils.test.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { removeWorkflowExecutionData } from './workflowUtils';
|
||||||
|
import type { IWorkflowDb } from '@/Interface';
|
||||||
|
import type { INodeIssues } from 'n8n-workflow';
|
||||||
|
|
||||||
|
describe('workflowUtils', () => {
|
||||||
|
describe('removeWorkflowExecutionData', () => {
|
||||||
|
it('should return undefined if workflow is undefined', () => {
|
||||||
|
expect(removeWorkflowExecutionData(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove execution-related data from nodes and workflow-level pinData', () => {
|
||||||
|
const mockWorkflow: IWorkflowDb = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
name: 'Test Workflow',
|
||||||
|
active: false,
|
||||||
|
isArchived: false,
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00Z',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Test Node',
|
||||||
|
type: 'test-type',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {},
|
||||||
|
// Execution-related data that should be removed
|
||||||
|
issues: {} as INodeIssues,
|
||||||
|
pinData: { someData: 'test' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'node2',
|
||||||
|
name: 'Clean Node',
|
||||||
|
type: 'another-type',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [200, 200],
|
||||||
|
parameters: {},
|
||||||
|
// No execution data
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
// Workflow-level execution data that should be removed
|
||||||
|
pinData: { node1: [{ json: { data: 'execution-result' } }] },
|
||||||
|
versionId: '1.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = removeWorkflowExecutionData(mockWorkflow);
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.nodes).toHaveLength(2);
|
||||||
|
|
||||||
|
// First node should have execution data removed
|
||||||
|
expect(result!.nodes[0]).toEqual({
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Test Node',
|
||||||
|
type: 'test-type',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [100, 100],
|
||||||
|
parameters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second node should remain unchanged (no execution data to remove)
|
||||||
|
expect(result!.nodes[1]).toEqual({
|
||||||
|
id: 'node2',
|
||||||
|
name: 'Clean Node',
|
||||||
|
type: 'another-type',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [200, 200],
|
||||||
|
parameters: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Workflow-level pinData should be removed
|
||||||
|
expect(result!.pinData).toBeUndefined();
|
||||||
|
|
||||||
|
// Workflow metadata should be preserved
|
||||||
|
expect(result!.id).toBe('test-workflow');
|
||||||
|
expect(result!.name).toBe('Test Workflow');
|
||||||
|
expect(result!.connections).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve all other node properties', () => {
|
||||||
|
const mockWorkflow: IWorkflowDb = {
|
||||||
|
id: 'test-workflow',
|
||||||
|
name: 'Test Workflow',
|
||||||
|
active: false,
|
||||||
|
isArchived: false,
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-01-01T00:00:00Z',
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Complex Node',
|
||||||
|
type: 'complex-type',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [150, 250],
|
||||||
|
parameters: { param1: 'value1', param2: { nested: true } },
|
||||||
|
color: '#ff0000',
|
||||||
|
notes: 'Some notes',
|
||||||
|
disabled: true,
|
||||||
|
// Execution data to be removed
|
||||||
|
issues: {} as INodeIssues,
|
||||||
|
pinData: { result: [{ json: { test: 'data' } }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
connections: {},
|
||||||
|
versionId: '2.0',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = removeWorkflowExecutionData(mockWorkflow);
|
||||||
|
|
||||||
|
expect(result!.nodes[0]).toEqual({
|
||||||
|
id: 'node1',
|
||||||
|
name: 'Complex Node',
|
||||||
|
type: 'complex-type',
|
||||||
|
typeVersion: 2,
|
||||||
|
position: [150, 250],
|
||||||
|
parameters: { param1: 'value1', param2: { nested: true } },
|
||||||
|
color: '#ff0000',
|
||||||
|
notes: 'Some notes',
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
27
packages/frontend/editor-ui/src/utils/workflowUtils.ts
Normal file
27
packages/frontend/editor-ui/src/utils/workflowUtils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { IWorkflowDb, INodeUi } from '@/Interface';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes execution data from workflow nodes and workflow-level execution data
|
||||||
|
* to ensure clean comparisons in diffs. This prevents execution status, run data,
|
||||||
|
* pinned data, and other runtime information from appearing in workflow difference
|
||||||
|
* comparisons.
|
||||||
|
*/
|
||||||
|
export function removeWorkflowExecutionData(
|
||||||
|
workflow: IWorkflowDb | undefined,
|
||||||
|
): IWorkflowDb | undefined {
|
||||||
|
if (!workflow) return workflow;
|
||||||
|
|
||||||
|
// Remove workflow-level execution data and clean up nodes
|
||||||
|
const { pinData, ...cleanWorkflow } = workflow;
|
||||||
|
|
||||||
|
const sanitizedWorkflow: IWorkflowDb = {
|
||||||
|
...cleanWorkflow,
|
||||||
|
nodes: workflow.nodes.map((node: INodeUi) => {
|
||||||
|
// Create a clean copy without execution-related data
|
||||||
|
const { issues, pinData, ...cleanNode } = node;
|
||||||
|
return cleanNode;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
return sanitizedWorkflow;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user