Skip to content

Commit b68d0ec

Browse files
chore(core): Add DTOs for workflow create / update validation (n8n-io#23935)
1 parent 64498cd commit b68d0ec

9 files changed

Lines changed: 996 additions & 66 deletions

File tree

packages/@n8n/api-types/src/dto/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export { CredentialsGetOneRequestQuery } from './credentials/credentials-get-one
6161
export { CredentialsGetManyRequestQuery } from './credentials/credentials-get-many-request.dto';
6262
export { GenerateCredentialNameRequestQuery } from './credentials/generate-credential-name.dto';
6363

64+
export { CreateWorkflowDto } from './workflows/create-workflow.dto';
65+
export { UpdateWorkflowDto } from './workflows/update-workflow.dto';
6466
export { ImportWorkflowFromUrlDto } from './workflows/import-workflow-from-url.dto';
6567
export { TransferWorkflowBodyDto } from './workflows/transfer.dto';
6668
export { ActivateWorkflowDto } from './workflows/activate-workflow.dto';
Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
1+
import { CreateWorkflowDto } from '../create-workflow.dto';
2+
3+
describe('CreateWorkflowDto', () => {
4+
describe('Valid requests', () => {
5+
test.each([
6+
{
7+
name: 'minimal workflow',
8+
request: {
9+
name: 'My Workflow',
10+
nodes: [],
11+
connections: {},
12+
},
13+
},
14+
{
15+
name: 'with all optional fields',
16+
request: {
17+
name: 'Complete Workflow',
18+
nodes: [],
19+
connections: {},
20+
description: 'A test workflow',
21+
settings: { saveExecutionProgress: true },
22+
staticData: { key: 'value' },
23+
meta: { version: '1.0' },
24+
pinData: {},
25+
hash: 'abc123',
26+
tags: ['tag1', 'tag2'],
27+
projectId: 'proj123',
28+
parentFolderId: 'folder123',
29+
uiContext: 'workflow_list',
30+
aiBuilderAssisted: true,
31+
autosaved: false,
32+
},
33+
},
34+
{
35+
name: 'with tags as objects (backward compatibility)',
36+
request: {
37+
name: 'Tagged Workflow',
38+
nodes: [],
39+
connections: {},
40+
tags: [
41+
{ id: 'tag1', name: 'Tag 1' },
42+
{ id: 'tag2', name: 'Tag 2' },
43+
],
44+
},
45+
},
46+
])('should validate $name', ({ request }) => {
47+
const result = CreateWorkflowDto.safeParse(request);
48+
expect(result.success).toBe(true);
49+
});
50+
51+
test('should transform tags from objects to string array', () => {
52+
const result = CreateWorkflowDto.safeParse({
53+
name: 'Test',
54+
nodes: [],
55+
connections: {},
56+
tags: [
57+
{ id: 'tag1', name: 'Tag 1' },
58+
{ id: 'tag2', name: 'Tag 2' },
59+
],
60+
});
61+
62+
expect(result.success).toBe(true);
63+
expect(result.data?.tags).toEqual(['tag1', 'tag2']);
64+
});
65+
});
66+
67+
describe('Invalid requests', () => {
68+
test.each([
69+
{
70+
name: 'missing name',
71+
request: { nodes: [], connections: {} },
72+
expectedErrorPath: ['name'],
73+
},
74+
{
75+
name: 'empty name',
76+
request: { name: '', nodes: [], connections: {} },
77+
expectedErrorPath: ['name'],
78+
},
79+
{
80+
name: 'name too long',
81+
request: { name: 'a'.repeat(129), nodes: [], connections: {} },
82+
expectedErrorPath: ['name'],
83+
},
84+
{
85+
name: 'missing nodes',
86+
request: { name: 'Test', connections: {} },
87+
expectedErrorPath: ['nodes'],
88+
},
89+
{
90+
name: 'missing connections',
91+
request: { name: 'Test', nodes: [] },
92+
expectedErrorPath: ['connections'],
93+
},
94+
{
95+
name: 'connections as array',
96+
request: { name: 'Test', nodes: [], connections: [] },
97+
expectedErrorPath: ['connections'],
98+
},
99+
{
100+
name: 'settings as array',
101+
request: { name: 'Test', nodes: [], connections: {}, settings: [] },
102+
expectedErrorPath: ['settings'],
103+
},
104+
{
105+
name: 'staticData as array',
106+
request: { name: 'Test', nodes: [], connections: {}, staticData: [] },
107+
expectedErrorPath: ['staticData'],
108+
},
109+
{
110+
name: 'pinData as array',
111+
request: { name: 'Test', nodes: [], connections: {}, pinData: [] },
112+
expectedErrorPath: ['pinData'],
113+
},
114+
])('should fail validation for $name', ({ request, expectedErrorPath }) => {
115+
const result = CreateWorkflowDto.safeParse(request);
116+
expect(result.success).toBe(false);
117+
if (expectedErrorPath) {
118+
expect(result.error?.issues[0].path).toEqual(expectedErrorPath);
119+
}
120+
});
121+
});
122+
123+
describe('Security: Mass assignment protection', () => {
124+
describe('Activation fields', () => {
125+
test('should not accept active field', () => {
126+
const result = CreateWorkflowDto.safeParse({
127+
name: 'Test',
128+
nodes: [],
129+
connections: {},
130+
active: true,
131+
});
132+
133+
expect(result.success).toBe(true);
134+
expect(result.data).not.toHaveProperty('active');
135+
});
136+
137+
test('should not accept activeVersionId field', () => {
138+
const result = CreateWorkflowDto.safeParse({
139+
name: 'Test',
140+
nodes: [],
141+
connections: {},
142+
activeVersionId: 'version123',
143+
});
144+
145+
expect(result.success).toBe(true);
146+
expect(result.data).not.toHaveProperty('activeVersionId');
147+
});
148+
149+
test('should not accept activeVersion field', () => {
150+
const result = CreateWorkflowDto.safeParse({
151+
name: 'Test',
152+
nodes: [],
153+
connections: {},
154+
activeVersion: { versionId: 'v1', workflowId: 'w1' },
155+
});
156+
157+
expect(result.success).toBe(true);
158+
expect(result.data).not.toHaveProperty('activeVersion');
159+
});
160+
});
161+
162+
describe('Sharing and permissions', () => {
163+
test('should not accept shared field', () => {
164+
const result = CreateWorkflowDto.safeParse({
165+
name: 'Test',
166+
nodes: [],
167+
connections: {},
168+
shared: [{ role: 'owner' }],
169+
});
170+
171+
expect(result.success).toBe(true);
172+
expect(result.data).not.toHaveProperty('shared');
173+
});
174+
175+
test('should not accept sharedWithProjects field', () => {
176+
const result = CreateWorkflowDto.safeParse({
177+
name: 'Test',
178+
nodes: [],
179+
connections: {},
180+
sharedWithProjects: [{ id: 'proj1', name: 'Project' }],
181+
});
182+
183+
expect(result.success).toBe(true);
184+
expect(result.data).not.toHaveProperty('sharedWithProjects');
185+
});
186+
187+
test('should not accept homeProject field', () => {
188+
const result = CreateWorkflowDto.safeParse({
189+
name: 'Test',
190+
nodes: [],
191+
connections: {},
192+
homeProject: { id: 'proj1', name: 'Project' },
193+
});
194+
195+
expect(result.success).toBe(true);
196+
expect(result.data).not.toHaveProperty('homeProject');
197+
});
198+
});
199+
200+
describe('Internal counters and metadata', () => {
201+
test('should not accept triggerCount field (billing bypass)', () => {
202+
const result = CreateWorkflowDto.safeParse({
203+
name: 'Test',
204+
nodes: [],
205+
connections: {},
206+
triggerCount: 999,
207+
});
208+
209+
expect(result.success).toBe(true);
210+
expect(result.data).not.toHaveProperty('triggerCount');
211+
});
212+
213+
test('should not accept versionCounter field', () => {
214+
const result = CreateWorkflowDto.safeParse({
215+
name: 'Test',
216+
nodes: [],
217+
connections: {},
218+
versionCounter: 100,
219+
});
220+
221+
expect(result.success).toBe(true);
222+
expect(result.data).not.toHaveProperty('versionCounter');
223+
});
224+
225+
test('should not accept versionId field', () => {
226+
const result = CreateWorkflowDto.safeParse({
227+
name: 'Test',
228+
nodes: [],
229+
connections: {},
230+
versionId: 'custom-version-id',
231+
});
232+
233+
expect(result.success).toBe(true);
234+
expect(result.data).not.toHaveProperty('versionId');
235+
});
236+
237+
test('should not accept isArchived field', () => {
238+
const result = CreateWorkflowDto.safeParse({
239+
name: 'Test',
240+
nodes: [],
241+
connections: {},
242+
isArchived: true,
243+
});
244+
245+
expect(result.success).toBe(true);
246+
expect(result.data).not.toHaveProperty('isArchived');
247+
});
248+
});
249+
250+
describe('Timestamps', () => {
251+
test('should not accept createdAt field', () => {
252+
const result = CreateWorkflowDto.safeParse({
253+
name: 'Test',
254+
nodes: [],
255+
connections: {},
256+
createdAt: new Date().toISOString(),
257+
});
258+
259+
expect(result.success).toBe(true);
260+
expect(result.data).not.toHaveProperty('createdAt');
261+
});
262+
263+
test('should not accept updatedAt field', () => {
264+
const result = CreateWorkflowDto.safeParse({
265+
name: 'Test',
266+
nodes: [],
267+
connections: {},
268+
updatedAt: new Date().toISOString(),
269+
});
270+
271+
expect(result.success).toBe(true);
272+
expect(result.data).not.toHaveProperty('updatedAt');
273+
});
274+
});
275+
276+
describe('Multiple malicious fields at once', () => {
277+
test('should strip all internal fields when multiple are provided', () => {
278+
const result = CreateWorkflowDto.safeParse({
279+
name: 'Test',
280+
nodes: [],
281+
connections: {},
282+
active: true,
283+
activeVersionId: 'v1',
284+
triggerCount: 999,
285+
versionCounter: 100,
286+
isArchived: true,
287+
shared: [{ role: 'owner' }],
288+
createdAt: new Date().toISOString(),
289+
updatedAt: new Date().toISOString(),
290+
});
291+
292+
expect(result.success).toBe(true);
293+
expect(result.data).not.toHaveProperty('active');
294+
expect(result.data).not.toHaveProperty('activeVersionId');
295+
expect(result.data).not.toHaveProperty('triggerCount');
296+
expect(result.data).not.toHaveProperty('versionCounter');
297+
expect(result.data).not.toHaveProperty('isArchived');
298+
expect(result.data).not.toHaveProperty('shared');
299+
expect(result.data).not.toHaveProperty('createdAt');
300+
expect(result.data).not.toHaveProperty('updatedAt');
301+
// Valid fields should remain
302+
expect(result.data?.name).toBe('Test');
303+
expect(result.data?.nodes).toEqual([]);
304+
expect(result.data?.connections).toEqual({});
305+
});
306+
});
307+
});
308+
});

0 commit comments

Comments
 (0)