Skip to content

Commit 696a8b2

Browse files
VladimirFilonovkibanamachine
authored andcommitted
[One Workflow] Fix name field duplication on workflow cloning (elastic#241916)
closes: elastic/security-team#14568 Workflow cloning failed because of name field duplication, appeared during migrating workflows from SO to elastic index --------- Co-authored-by: kibanamachine <[email protected]>
1 parent 07bd7f8 commit 696a8b2

File tree

3 files changed

+249
-7
lines changed

3 files changed

+249
-7
lines changed
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import type { KibanaRequest } from '@kbn/core/server';
11+
import { httpServerMock } from '@kbn/core-http-server-mocks';
12+
import type { WorkflowDetailDto } from '@kbn/workflows';
13+
import { z } from '@kbn/zod';
14+
import { WorkflowsManagementApi } from './workflows_management_api';
15+
import type { WorkflowsService } from './workflows_management_service';
16+
17+
describe('WorkflowsManagementApi', () => {
18+
let api: WorkflowsManagementApi;
19+
let mockWorkflowsService: jest.Mocked<WorkflowsService>;
20+
let mockGetWorkflowsExecutionEngine: jest.Mock;
21+
let mockRequest: KibanaRequest;
22+
23+
beforeEach(() => {
24+
mockWorkflowsService = {
25+
getWorkflowZodSchema: jest.fn(),
26+
createWorkflow: jest.fn(),
27+
} as any;
28+
29+
mockGetWorkflowsExecutionEngine = jest.fn();
30+
31+
api = new WorkflowsManagementApi(mockWorkflowsService, mockGetWorkflowsExecutionEngine);
32+
33+
mockRequest = httpServerMock.createKibanaRequest();
34+
});
35+
36+
describe('cloneWorkflow', () => {
37+
const createMockWorkflow = (overrides: Partial<WorkflowDetailDto> = {}): WorkflowDetailDto => ({
38+
id: 'workflow-123',
39+
name: 'Original Workflow',
40+
description: 'A workflow to be cloned',
41+
enabled: true,
42+
yaml: 'name: Original Workflow\ndescription: A workflow to be cloned\nenabled: true',
43+
valid: true,
44+
createdAt: '2024-01-15T10:00:00.000Z',
45+
createdBy: '[email protected]',
46+
lastUpdatedAt: '2024-01-15T10:30:00.000Z',
47+
lastUpdatedBy: '[email protected]',
48+
definition: null,
49+
...overrides,
50+
});
51+
52+
const createMockZodSchema = () => {
53+
return z.object({
54+
name: z.string(),
55+
description: z.string().optional(),
56+
enabled: z.boolean().optional(),
57+
steps: z.array(z.any()).optional(),
58+
});
59+
};
60+
61+
it('should clone workflow successfully with updated name', async () => {
62+
const originalWorkflow = createMockWorkflow();
63+
const mockZodSchema = createMockZodSchema();
64+
65+
mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema);
66+
67+
const clonedWorkflow: WorkflowDetailDto = {
68+
...originalWorkflow,
69+
id: 'workflow-clone-456',
70+
name: 'Original Workflow Copy',
71+
yaml: 'name: Original Workflow Copy\ndescription: A workflow to be cloned\nenabled: true',
72+
definition: null,
73+
};
74+
75+
mockWorkflowsService.createWorkflow.mockResolvedValue(clonedWorkflow);
76+
77+
const result = await api.cloneWorkflow(originalWorkflow, 'default', mockRequest);
78+
79+
expect(mockWorkflowsService.getWorkflowZodSchema).toHaveBeenCalledWith(
80+
{ loose: true },
81+
'default',
82+
mockRequest
83+
);
84+
expect(mockWorkflowsService.createWorkflow).toHaveBeenCalledWith(
85+
expect.objectContaining({
86+
yaml: expect.stringContaining('name: Original Workflow Copy'),
87+
}),
88+
'default',
89+
mockRequest
90+
);
91+
expect(result.name).toBe('Original Workflow Copy');
92+
expect(result.id).toBe('workflow-clone-456');
93+
});
94+
95+
it('should not create duplicate name keys in YAML', async () => {
96+
const originalWorkflow = createMockWorkflow({
97+
yaml: 'name: Original Workflow\ndescription: A workflow to be cloned\nenabled: true',
98+
});
99+
100+
const mockZodSchema = createMockZodSchema();
101+
mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema);
102+
103+
const clonedWorkflow: WorkflowDetailDto = {
104+
...originalWorkflow,
105+
id: 'workflow-clone-456',
106+
name: 'Original Workflow Copy',
107+
};
108+
109+
mockWorkflowsService.createWorkflow.mockImplementation((command) => {
110+
// Verify that the YAML doesn't have duplicate name keys
111+
const yamlString = command.yaml;
112+
const nameMatches = (yamlString.match(/^name:/gm) || []).length;
113+
expect(nameMatches).toBe(1);
114+
115+
return Promise.resolve({
116+
...clonedWorkflow,
117+
yaml: yamlString,
118+
});
119+
});
120+
121+
await api.cloneWorkflow(originalWorkflow, 'default', mockRequest);
122+
123+
expect(mockWorkflowsService.createWorkflow).toHaveBeenCalled();
124+
const createCall = mockWorkflowsService.createWorkflow.mock.calls[0];
125+
const yamlString = createCall[0].yaml;
126+
127+
// Verify no duplicate name keys
128+
const lines = yamlString.split('\n');
129+
const nameLines = lines.filter((line) => line.trim().startsWith('name:'));
130+
expect(nameLines.length).toBe(1);
131+
expect(nameLines[0]).toContain('Original Workflow Copy');
132+
});
133+
134+
it('should preserve all other workflow properties in cloned YAML', async () => {
135+
const originalWorkflow = createMockWorkflow({
136+
yaml: `name: Original Workflow
137+
description: A workflow to be cloned
138+
enabled: true
139+
tags:
140+
- tag1
141+
- tag2
142+
steps:
143+
- id: step1
144+
name: First Step
145+
type: action
146+
action: test-action`,
147+
});
148+
149+
const mockZodSchema = z.object({
150+
name: z.string(),
151+
description: z.string().optional(),
152+
enabled: z.boolean().optional(),
153+
tags: z.array(z.string()).optional(),
154+
steps: z.array(z.any()).optional(),
155+
});
156+
157+
mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema);
158+
159+
mockWorkflowsService.createWorkflow.mockImplementation((command) => {
160+
const yamlString = command.yaml;
161+
// Verify all properties are preserved
162+
expect(yamlString).toContain('description: A workflow to be cloned');
163+
expect(yamlString).toContain('enabled: true');
164+
expect(yamlString).toContain('tag1');
165+
expect(yamlString).toContain('tag2');
166+
expect(yamlString).toContain('step1');
167+
expect(yamlString).toContain('First Step');
168+
expect(yamlString).toContain('test-action');
169+
// Verify name is updated
170+
expect(yamlString).toContain('name: Original Workflow Copy');
171+
// Verify no duplicate name
172+
const nameMatches = (yamlString.match(/^name:/gm) || []).length;
173+
expect(nameMatches).toBe(1);
174+
175+
return Promise.resolve({
176+
...originalWorkflow,
177+
id: 'workflow-clone-456',
178+
name: 'Original Workflow Copy',
179+
yaml: yamlString,
180+
});
181+
});
182+
183+
await api.cloneWorkflow(originalWorkflow, 'default', mockRequest);
184+
185+
expect(mockWorkflowsService.createWorkflow).toHaveBeenCalled();
186+
});
187+
188+
it('should handle YAML parsing errors gracefully', async () => {
189+
const originalWorkflow = createMockWorkflow({
190+
yaml: 'invalid: yaml: content: with: multiple: colons',
191+
});
192+
193+
const mockZodSchema = createMockZodSchema();
194+
mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema);
195+
196+
await expect(api.cloneWorkflow(originalWorkflow, 'default', mockRequest)).rejects.toThrow();
197+
198+
expect(mockWorkflowsService.getWorkflowZodSchema).toHaveBeenCalled();
199+
expect(mockWorkflowsService.createWorkflow).not.toHaveBeenCalled();
200+
});
201+
202+
it('should handle workflow creation errors', async () => {
203+
const originalWorkflow = createMockWorkflow();
204+
const mockZodSchema = createMockZodSchema();
205+
const creationError = new Error('Failed to create workflow');
206+
207+
mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema);
208+
mockWorkflowsService.createWorkflow.mockRejectedValue(creationError);
209+
210+
await expect(api.cloneWorkflow(originalWorkflow, 'default', mockRequest)).rejects.toThrow(
211+
'Failed to create workflow'
212+
);
213+
214+
expect(mockWorkflowsService.getWorkflowZodSchema).toHaveBeenCalled();
215+
expect(mockWorkflowsService.createWorkflow).toHaveBeenCalled();
216+
});
217+
218+
it('should work with different space contexts', async () => {
219+
const originalWorkflow = createMockWorkflow();
220+
const mockZodSchema = createMockZodSchema();
221+
const spaceId = 'custom-space';
222+
223+
mockWorkflowsService.getWorkflowZodSchema.mockResolvedValue(mockZodSchema);
224+
mockWorkflowsService.createWorkflow.mockResolvedValue({
225+
...originalWorkflow,
226+
id: 'workflow-clone-789',
227+
name: 'Original Workflow Copy',
228+
});
229+
230+
await api.cloneWorkflow(originalWorkflow, spaceId, mockRequest);
231+
232+
expect(mockWorkflowsService.getWorkflowZodSchema).toHaveBeenCalledWith(
233+
{ loose: true },
234+
spaceId,
235+
mockRequest
236+
);
237+
expect(mockWorkflowsService.createWorkflow).toHaveBeenCalledWith(
238+
expect.any(Object),
239+
spaceId,
240+
mockRequest
241+
);
242+
});
243+
});
244+
});

src/platform/plugins/shared/workflows_management/server/workflows_management/workflows_management_api.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import type {
3434
} from './workflows_management_service';
3535
import { WorkflowValidationError } from '../../common/lib/errors';
3636
import { validateStepNameUniqueness } from '../../common/lib/validate_step_names';
37-
import { parseWorkflowYamlToJSON } from '../../common/lib/yaml_utils';
37+
import { parseWorkflowYamlToJSON, stringifyWorkflowDefinition } from '../../common/lib/yaml_utils';
3838

3939
export interface GetWorkflowsParams {
4040
triggerType?: 'schedule' | 'event' | 'manual';
@@ -150,11 +150,8 @@ export class WorkflowsManagementApi {
150150
})}`,
151151
};
152152

153-
// Convert back to YAML string
154-
const clonedYaml = `name: ${updatedYaml.name}\n${workflow.yaml
155-
.split('\n')
156-
.slice(1)
157-
.join('\n')}`;
153+
// Convert back to YAML string using proper YAML stringification
154+
const clonedYaml = stringifyWorkflowDefinition(updatedYaml as WorkflowYaml);
158155
return this.workflowsService.createWorkflow({ yaml: clonedYaml }, spaceId, request);
159156
}
160157

src/platform/plugins/shared/workflows_management/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
"@kbn/core-logging-server-mocks",
6464
"@kbn/core-http-router-server-mocks",
6565
"@kbn/react-query",
66-
"@kbn/core-http-server"
66+
"@kbn/core-http-server",
67+
"@kbn/core-http-server-mocks"
6768
],
6869
"exclude": ["target/**/*"]
6970
}

0 commit comments

Comments
 (0)