|
| 1 | +import * as fc from 'fast-check'; |
| 2 | +import { describe, expect, it } from 'vitest'; |
| 3 | +import { MAX_FILTER_DEPTH, validateMetadataFilterDetailed } from './metadata-filter.js'; |
| 4 | + |
| 5 | +const primitiveArb = fc.oneof(fc.string(), fc.integer(), fc.boolean()); |
| 6 | + |
| 7 | +const validLeafArb: fc.Arbitrary<Record<string, unknown>> = fc |
| 8 | + .record({ |
| 9 | + field: fc |
| 10 | + .string({ minLength: 1 }) |
| 11 | + .filter((s) => !s.startsWith('$')) |
| 12 | + .map((name) => name || 'f'), |
| 13 | + op: fc.constantFrom('$eq', '$ne', '$gt', '$gte', '$lt', '$lte'), |
| 14 | + value: primitiveArb, |
| 15 | + }) |
| 16 | + .map(({ field, op, value }) => ({ [field]: { [op]: value } })); |
| 17 | + |
| 18 | +const { filter: validFilterArb } = fc.letrec<{ filter: fc.Arbitrary<Record<string, unknown>> }>( |
| 19 | + (tie) => ({ |
| 20 | + filter: fc.oneof( |
| 21 | + validLeafArb, |
| 22 | + fc.array(tie('filter'), { minLength: 1, maxLength: 3 }).map((items) => ({ $and: items })), |
| 23 | + fc.array(tie('filter'), { minLength: 1, maxLength: 3 }).map((items) => ({ $or: items })) |
| 24 | + ), |
| 25 | + }) |
| 26 | +); |
| 27 | + |
| 28 | +function deepAnd(levels: number): Record<string, unknown> { |
| 29 | + if (levels === 0) return { leaf: { $eq: 1 } }; |
| 30 | + return { $and: [deepAnd(levels - 1)] }; |
| 31 | +} |
| 32 | + |
| 33 | +describe('validateMetadataFilterDetailed fuzz', () => { |
| 34 | + it('never throws on arbitrary JSON-like input', () => { |
| 35 | + fc.assert( |
| 36 | + fc.property(fc.jsonValue(), (value) => { |
| 37 | + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { |
| 38 | + expect(() => |
| 39 | + validateMetadataFilterDetailed(value as Record<string, unknown>) |
| 40 | + ).not.toThrow(); |
| 41 | + } |
| 42 | + }), |
| 43 | + { numRuns: 100 } |
| 44 | + ); |
| 45 | + }); |
| 46 | + |
| 47 | + it('returns structured errors for known malformed filters', () => { |
| 48 | + const malformed = [ |
| 49 | + { year: { $regex: '^202' } }, |
| 50 | + { tags: { $in: [] } }, |
| 51 | + { tags: { $nin: [] } }, |
| 52 | + { $and: [] }, |
| 53 | + { $or: [] }, |
| 54 | + { tags: { $in: [1, {} as unknown as number] } }, |
| 55 | + { tags: { $and: 'not-array' } }, |
| 56 | + { x: deepAnd(MAX_FILTER_DEPTH + 1) }, |
| 57 | + ]; |
| 58 | + |
| 59 | + for (const filter of malformed) { |
| 60 | + const result = validateMetadataFilterDetailed(filter); |
| 61 | + expect(result).not.toBeNull(); |
| 62 | + expect(result!.message.length).toBeGreaterThan(0); |
| 63 | + } |
| 64 | + }); |
| 65 | + |
| 66 | + it('accepts valid complex generated filters unchanged', () => { |
| 67 | + fc.assert( |
| 68 | + fc.property(validFilterArb, (filter) => { |
| 69 | + const result = validateMetadataFilterDetailed(filter); |
| 70 | + expect(result).toBeNull(); |
| 71 | + }), |
| 72 | + { numRuns: 100 } |
| 73 | + ); |
| 74 | + }); |
| 75 | + |
| 76 | + it('accepts hand-crafted deeply nested valid filters within depth limit', () => { |
| 77 | + const valid = { |
| 78 | + $and: [ |
| 79 | + { status: { $eq: 'published' } }, |
| 80 | + { |
| 81 | + $or: [ |
| 82 | + { year: { $gte: 2020 } }, |
| 83 | + { |
| 84 | + tags: { |
| 85 | + $in: ['cpp', 'contracts'], |
| 86 | + }, |
| 87 | + }, |
| 88 | + ], |
| 89 | + }, |
| 90 | + ], |
| 91 | + }; |
| 92 | + expect(validateMetadataFilterDetailed(valid)).toBeNull(); |
| 93 | + expect(validateMetadataFilterDetailed({ x: deepAnd(MAX_FILTER_DEPTH) })).toBeNull(); |
| 94 | + }); |
| 95 | + |
| 96 | + it('handles special field names without throwing', () => { |
| 97 | + fc.assert( |
| 98 | + fc.property(fc.string({ minLength: 0, maxLength: 20 }), primitiveArb, (fieldName, value) => { |
| 99 | + const filter = { [fieldName]: { $eq: value } }; |
| 100 | + expect(() => validateMetadataFilterDetailed(filter)).not.toThrow(); |
| 101 | + }), |
| 102 | + { numRuns: 100 } |
| 103 | + ); |
| 104 | + }); |
| 105 | + |
| 106 | + it('rejects empty $in and accepts large primitive $in arrays', () => { |
| 107 | + expect(validateMetadataFilterDetailed({ tags: { $in: [] } })!.field).toBe('tags.$in'); |
| 108 | + |
| 109 | + const largeIn = { |
| 110 | + tags: { $in: Array.from({ length: 1000 }, (_, i) => (i % 3 === 0 ? i : `v${i}`)) }, |
| 111 | + }; |
| 112 | + expect(validateMetadataFilterDetailed(largeIn)).toBeNull(); |
| 113 | + |
| 114 | + expect(validateMetadataFilterDetailed({ tags: { $in: ['only'] } })).toBeNull(); |
| 115 | + }); |
| 116 | +}); |
0 commit comments