Mirrored from GitHub github.com/roostorg/coop
0

Configure Feed

Select the types of activity you want to include in your feed.

coop / server / services / moderationConfigService / modules / actionParametersValidation.test.ts
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});