Mirrored from GitHub
github.com/roostorg/coop
13 kB
484 lines
1import {
2 parseStoredParameters,
3 validateActionParameters,
4} from './actionParametersValidation.js';
5import { validateActionParameterValues } from './actionParameterValueValidation.js';
6
7describe('validateActionParameters', () => {
8 it('returns an empty array for null/undefined/empty', () => {
9 expect(validateActionParameters(null)).toEqual([]);
10 expect(validateActionParameters(undefined)).toEqual([]);
11 expect(validateActionParameters([])).toEqual([]);
12 });
13
14 it('accepts a well-formed STRING parameter', () => {
15 const params = validateActionParameters([
16 {
17 name: 'reason',
18 displayName: 'Reason',
19 type: 'STRING',
20 required: true,
21 maxLength: 500,
22 },
23 ]);
24 expect(params).toHaveLength(1);
25 expect(params[0]?.type).toBe('STRING');
26 });
27
28 it('accepts NUMBER with min/max and default in range', () => {
29 expect(() =>
30 validateActionParameters([
31 {
32 name: 'days',
33 displayName: 'Days',
34 type: 'NUMBER',
35 required: false,
36 min: 1,
37 max: 30,
38 defaultValue: 7,
39 },
40 ]),
41 ).not.toThrow();
42 });
43
44 it('rejects NUMBER with default below min', () => {
45 expect(() =>
46 validateActionParameters([
47 {
48 name: 'days',
49 displayName: 'Days',
50 type: 'NUMBER',
51 required: false,
52 min: 5,
53 defaultValue: 1,
54 },
55 ]),
56 ).toThrow(/below min/);
57 });
58
59 it('rejects NUMBER with min > max', () => {
60 expect(() =>
61 validateActionParameters([
62 {
63 name: 'days',
64 displayName: 'Days',
65 type: 'NUMBER',
66 required: false,
67 min: 10,
68 max: 5,
69 },
70 ]),
71 ).toThrow(/min.*<=.*max/);
72 });
73
74 it('rejects names with whitespace, quotes, or brackets', () => {
75 for (const name of ['has spaces', 'q"uote', 'bracket[0]', 'paren(x)']) {
76 expect(() =>
77 validateActionParameters([
78 { name, displayName: 'Bad', type: 'STRING', required: false },
79 ]),
80 ).toThrow();
81 }
82 });
83
84 it('accepts snake_case, kebab-case, and dotted names', () => {
85 for (const name of [
86 'ban_duration',
87 'ban-duration',
88 'org.user.id',
89 'a.b-c_1',
90 ]) {
91 expect(() =>
92 validateActionParameters([
93 { name, displayName: 'OK', type: 'STRING', required: false },
94 ]),
95 ).not.toThrow();
96 }
97 });
98
99 it('rejects duplicate names', () => {
100 expect(() =>
101 validateActionParameters([
102 { name: 'a', displayName: 'A', type: 'STRING', required: false },
103 { name: 'a', displayName: 'A2', type: 'STRING', required: false },
104 ]),
105 ).toThrow(/duplicated/);
106 });
107
108 it('requires options for SELECT and MULTISELECT', () => {
109 expect(() =>
110 validateActionParameters([
111 { name: 'x', displayName: 'X', type: 'SELECT', required: false },
112 ]),
113 ).toThrow(/required for SELECT/);
114 expect(() =>
115 validateActionParameters([
116 {
117 name: 'x',
118 displayName: 'X',
119 type: 'MULTISELECT',
120 required: false,
121 },
122 ]),
123 ).toThrow(/required for MULTISELECT/);
124 });
125
126 it('rejects SELECT default that is not in options', () => {
127 expect(() =>
128 validateActionParameters([
129 {
130 name: 'reason',
131 displayName: 'Reason',
132 type: 'SELECT',
133 required: false,
134 options: [{ value: 'spam', label: 'Spam' }],
135 defaultValue: 'abuse',
136 },
137 ]),
138 ).toThrow(/option values/);
139 });
140
141 it('accepts MULTISELECT default as an array of option values', () => {
142 expect(() =>
143 validateActionParameters([
144 {
145 name: 'tags',
146 displayName: 'Tags',
147 type: 'MULTISELECT',
148 required: false,
149 options: [
150 { value: 'a', label: 'A' },
151 { value: 'b', label: 'B' },
152 ],
153 defaultValue: ['a', 'b'],
154 },
155 ]),
156 ).not.toThrow();
157 });
158
159 it('rejects type-incompatible defaultValue', () => {
160 expect(() =>
161 validateActionParameters([
162 {
163 name: 'x',
164 displayName: 'X',
165 type: 'STRING',
166 required: false,
167 defaultValue: 123,
168 },
169 ]),
170 ).toThrow(/string for STRING/);
171 expect(() =>
172 validateActionParameters([
173 {
174 name: 'x',
175 displayName: 'X',
176 type: 'BOOLEAN',
177 required: false,
178 defaultValue: 'true',
179 },
180 ]),
181 ).toThrow(/boolean for BOOLEAN/);
182 });
183
184 it('rejects unknown top-level fields (additionalProperties)', () => {
185 expect(() =>
186 validateActionParameters([
187 {
188 name: 'x',
189 displayName: 'X',
190 type: 'STRING',
191 required: false,
192 surprise: 'value',
193 },
194 ]),
195 ).toThrow();
196 });
197
198 describe('rejects "empty" defaultValue when the parameter is required', () => {
199 it('STRING with empty default', () => {
200 expect(() =>
201 validateActionParameters([
202 {
203 name: 'x',
204 displayName: 'X',
205 type: 'STRING',
206 required: true,
207 defaultValue: '',
208 },
209 ]),
210 ).toThrow(/cannot be empty when the parameter is required/);
211 });
212
213 it('STRING with whitespace-only default', () => {
214 expect(() =>
215 validateActionParameters([
216 {
217 name: 'x',
218 displayName: 'X',
219 type: 'STRING',
220 required: true,
221 defaultValue: ' ',
222 },
223 ]),
224 ).toThrow(/cannot be empty when the parameter is required/);
225 });
226
227 it('MULTISELECT with empty array default', () => {
228 expect(() =>
229 validateActionParameters([
230 {
231 name: 'tags',
232 displayName: 'Tags',
233 type: 'MULTISELECT',
234 required: true,
235 options: [{ value: 'a', label: 'A' }],
236 defaultValue: [],
237 },
238 ]),
239 ).toThrow(/cannot be empty when the parameter is required/);
240 });
241 });
242});
243
244describe('parseStoredParameters', () => {
245 it('returns [] for null/undefined/empty/non-arrays', () => {
246 expect(parseStoredParameters(null)).toEqual([]);
247 expect(parseStoredParameters(undefined)).toEqual([]);
248 expect(parseStoredParameters([])).toEqual([]);
249 expect(parseStoredParameters('not an array')).toEqual([]);
250 });
251
252 it('round-trips a well-formed list', () => {
253 const stored = [
254 {
255 name: 'reason',
256 displayName: 'Reason',
257 type: 'SELECT',
258 required: true,
259 options: [{ value: 'spam', label: 'Spam' }],
260 },
261 ];
262 const parsed = parseStoredParameters(stored);
263 expect(parsed).toHaveLength(1);
264 expect(parsed[0]?.type).toBe('SELECT');
265 expect(parsed[0]?.options).toEqual([{ value: 'spam', label: 'Spam' }]);
266 });
267
268 it('skips entries with unknown type or missing required keys (defensive)', () => {
269 const stored = [
270 // Valid
271 { name: 'a', displayName: 'A', type: 'STRING', required: false },
272 // Missing displayName
273 { name: 'b', type: 'STRING', required: false },
274 // Unknown type
275 { name: 'c', displayName: 'C', type: 'WHATEVER', required: false },
276 // Not an object
277 'garbage',
278 null,
279 ];
280 expect(parseStoredParameters(stored)).toHaveLength(1);
281 });
282});
283
284describe('validateActionParameterValues', () => {
285 const spec = [
286 {
287 name: 'days',
288 displayName: 'Days',
289 type: 'NUMBER' as const,
290 required: true,
291 min: 1,
292 max: 365,
293 },
294 {
295 name: 'reason',
296 displayName: 'Reason',
297 type: 'SELECT' as const,
298 required: true,
299 options: [
300 { value: 'spam', label: 'Spam' },
301 { value: 'abuse', label: 'Abuse' },
302 ],
303 },
304 {
305 name: 'silent',
306 displayName: 'Silent',
307 type: 'BOOLEAN' as const,
308 required: false,
309 defaultValue: false,
310 },
311 ];
312
313 it('accepts a complete, well-typed value map', () => {
314 const out = validateActionParameterValues(spec, {
315 days: 7,
316 reason: 'spam',
317 silent: true,
318 });
319 expect(out).toEqual({ days: 7, reason: 'spam', silent: true });
320 });
321
322 it('applies defaults for omitted optional parameters', () => {
323 const out = validateActionParameterValues(spec, {
324 days: 1,
325 reason: 'abuse',
326 });
327 expect(out.silent).toBe(false);
328 });
329
330 it('rejects missing required parameters', () => {
331 expect(() =>
332 validateActionParameterValues(spec, { reason: 'spam' }),
333 ).toThrow(/required/);
334 });
335
336 it('rejects values that violate per-type rules', () => {
337 expect(() =>
338 validateActionParameterValues(spec, { days: 9999, reason: 'spam' }),
339 ).toThrow(/above max/);
340 expect(() =>
341 validateActionParameterValues(spec, { days: 1, reason: 'unknown' }),
342 ).toThrow(/option values/);
343 });
344
345 it('rejects unknown keys not declared in the spec', () => {
346 expect(() =>
347 validateActionParameterValues(spec, {
348 days: 1,
349 reason: 'spam',
350 sneaky: 'value',
351 }),
352 ).toThrow(/Unknown parameter/);
353 });
354
355 it('returns an empty object for empty spec and empty values', () => {
356 expect(validateActionParameterValues([], {})).toEqual({});
357 expect(validateActionParameterValues([], null)).toEqual({});
358 });
359
360 it('makes a defensive copy of MULTISELECT arrays', () => {
361 const multi = [
362 {
363 name: 'tags',
364 displayName: 'Tags',
365 type: 'MULTISELECT' as const,
366 required: true,
367 options: [
368 { value: 'a', label: 'A' },
369 { value: 'b', label: 'B' },
370 ],
371 },
372 ];
373 const input = ['a', 'b'];
374 const out = validateActionParameterValues(multi, { tags: input });
375 expect(out.tags).toEqual(['a', 'b']);
376 expect(out.tags).not.toBe(input);
377 });
378
379 describe('strict required-field semantics', () => {
380 const stringSpec = [
381 {
382 name: 'reason',
383 displayName: 'Reason',
384 type: 'STRING' as const,
385 required: true,
386 },
387 ];
388 const selectSpec = [
389 {
390 name: 'reason',
391 displayName: 'Reason',
392 type: 'SELECT' as const,
393 required: true,
394 options: [{ value: 'spam', label: 'Spam' }],
395 },
396 ];
397 const multiSpec = [
398 {
399 name: 'tags',
400 displayName: 'Tags',
401 type: 'MULTISELECT' as const,
402 required: true,
403 options: [{ value: 'a', label: 'A' }],
404 },
405 ];
406
407 it('treats empty STRING as missing for required parameters', () => {
408 expect(() =>
409 validateActionParameterValues(stringSpec, { reason: '' }),
410 ).toThrow(/required/);
411 });
412
413 it('treats whitespace-only STRING as missing for required parameters', () => {
414 expect(() =>
415 validateActionParameterValues(stringSpec, { reason: ' ' }),
416 ).toThrow(/required/);
417 });
418
419 it('treats empty MULTISELECT array as missing for required parameters', () => {
420 expect(() =>
421 validateActionParameterValues(multiSpec, { tags: [] }),
422 ).toThrow(/required/);
423 });
424
425 it('rejects null for required parameters', () => {
426 expect(() =>
427 validateActionParameterValues(stringSpec, { reason: null }),
428 ).toThrow(/required/);
429 });
430
431 it('rejects empty SELECT string against the option allowlist', () => {
432 expect(() =>
433 validateActionParameterValues(selectSpec, { reason: '' }),
434 ).toThrow(/required/);
435 });
436
437 it('keeps NUMBER 0 valid for required parameters', () => {
438 const spec = [
439 {
440 name: 'count',
441 displayName: 'Count',
442 type: 'NUMBER' as const,
443 required: true,
444 },
445 ];
446 expect(validateActionParameterValues(spec, { count: 0 })).toEqual({
447 count: 0,
448 });
449 });
450
451 it('keeps BOOLEAN false valid for required parameters', () => {
452 const spec = [
453 {
454 name: 'flag',
455 displayName: 'Flag',
456 type: 'BOOLEAN' as const,
457 required: true,
458 },
459 ];
460 expect(validateActionParameterValues(spec, { flag: false })).toEqual({
461 flag: false,
462 });
463 });
464 });
465
466 describe('top-level shape validation', () => {
467 it('rejects an array as the parameter map', () => {
468 expect(() => validateActionParameterValues([], [])).toThrow(
469 /plain object/,
470 );
471 });
472
473 it('rejects a primitive as the parameter map', () => {
474 expect(() => validateActionParameterValues([], 'string')).toThrow(
475 /plain object/,
476 );
477 });
478
479 it('accepts null and undefined (treated as no values supplied)', () => {
480 expect(validateActionParameterValues([], null)).toEqual({});
481 expect(validateActionParameterValues([], undefined)).toEqual({});
482 });
483 });
484});