@gblikas/querykit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Tests for Validation & Security features of parseWithContext:
3
+ * - Schema-aware field validation
4
+ * - Security pre-check
5
+ */
6
+
7
+ import { QueryParser } from './parser';
8
+ import { IFieldSchema } from './types';
9
+
10
+ describe('QueryParser.parseWithContext - Validation & Security', () => {
11
+ let parser: QueryParser;
12
+
13
+ beforeEach(() => {
14
+ parser = new QueryParser();
15
+ });
16
+
17
+ describe('Field Validation (with schema)', () => {
18
+ const testSchema: Record<string, IFieldSchema> = {
19
+ status: { type: 'string', allowedValues: ['todo', 'doing', 'done'] },
20
+ priority: { type: 'number' },
21
+ name: { type: 'string' },
22
+ createdAt: { type: 'date' },
23
+ isActive: { type: 'boolean' },
24
+ tags: { type: 'array' }
25
+ };
26
+
27
+ describe('valid fields', () => {
28
+ it('should validate fields that exist in schema', () => {
29
+ const result = parser.parseWithContext('status:done', {
30
+ schema: testSchema
31
+ });
32
+
33
+ expect(result.fieldValidation).toBeDefined();
34
+ expect(result.fieldValidation?.valid).toBe(true);
35
+ expect(result.fieldValidation?.unknownFields).toHaveLength(0);
36
+ });
37
+
38
+ it('should include field details for valid fields', () => {
39
+ const result = parser.parseWithContext(
40
+ 'status:done AND priority:high',
41
+ {
42
+ schema: testSchema
43
+ }
44
+ );
45
+
46
+ expect(result.fieldValidation?.fields).toHaveLength(2);
47
+
48
+ const statusField = result.fieldValidation?.fields.find(
49
+ f => f.field === 'status'
50
+ );
51
+ expect(statusField).toMatchObject({
52
+ field: 'status',
53
+ valid: true,
54
+ expectedType: 'string',
55
+ allowedValues: ['todo', 'doing', 'done']
56
+ });
57
+
58
+ const priorityField = result.fieldValidation?.fields.find(
59
+ f => f.field === 'priority'
60
+ );
61
+ expect(priorityField).toMatchObject({
62
+ field: 'priority',
63
+ valid: true,
64
+ expectedType: 'number'
65
+ });
66
+ });
67
+
68
+ it('should handle multiple references to same field', () => {
69
+ const result = parser.parseWithContext('status:todo OR status:done', {
70
+ schema: testSchema
71
+ });
72
+
73
+ expect(result.fieldValidation?.valid).toBe(true);
74
+ // referencedFields should deduplicate
75
+ expect(result.structure.referencedFields).toEqual(['status']);
76
+ });
77
+ });
78
+
79
+ describe('invalid fields', () => {
80
+ it('should detect unknown fields', () => {
81
+ const result = parser.parseWithContext('unknownField:value', {
82
+ schema: testSchema
83
+ });
84
+
85
+ expect(result.fieldValidation?.valid).toBe(false);
86
+ expect(result.fieldValidation?.unknownFields).toContain('unknownField');
87
+ });
88
+
89
+ it('should provide reason for invalid fields', () => {
90
+ const result = parser.parseWithContext('badField:value', {
91
+ schema: testSchema
92
+ });
93
+
94
+ const badField = result.fieldValidation?.fields.find(
95
+ f => f.field === 'badField'
96
+ );
97
+ expect(badField).toMatchObject({
98
+ field: 'badField',
99
+ valid: false,
100
+ reason: 'unknown_field'
101
+ });
102
+ });
103
+
104
+ it('should suggest similar field for typos', () => {
105
+ const result = parser.parseWithContext('statis:done', {
106
+ schema: testSchema
107
+ });
108
+
109
+ const field = result.fieldValidation?.fields.find(
110
+ f => f.field === 'statis'
111
+ );
112
+ expect(field?.suggestion).toBe('status');
113
+ });
114
+
115
+ it('should suggest case-corrected field', () => {
116
+ const result = parser.parseWithContext('STATUS:done', {
117
+ schema: testSchema
118
+ });
119
+
120
+ const field = result.fieldValidation?.fields.find(
121
+ f => f.field === 'STATUS'
122
+ );
123
+ expect(field?.suggestion).toBe('status');
124
+ });
125
+
126
+ it('should suggest field with similar prefix', () => {
127
+ const result = parser.parseWithContext('creat:value', {
128
+ schema: testSchema
129
+ });
130
+
131
+ const field = result.fieldValidation?.fields.find(
132
+ f => f.field === 'creat'
133
+ );
134
+ expect(field?.suggestion).toBe('createdAt');
135
+ });
136
+ });
137
+
138
+ describe('mixed valid and invalid fields', () => {
139
+ it('should report all fields with mixed validity', () => {
140
+ const result = parser.parseWithContext(
141
+ 'status:done AND badField:value',
142
+ { schema: testSchema }
143
+ );
144
+
145
+ expect(result.fieldValidation?.valid).toBe(false);
146
+ expect(result.fieldValidation?.fields).toHaveLength(2);
147
+
148
+ const validField = result.fieldValidation?.fields.find(
149
+ f => f.field === 'status'
150
+ );
151
+ expect(validField?.valid).toBe(true);
152
+
153
+ const invalidField = result.fieldValidation?.fields.find(
154
+ f => f.field === 'badField'
155
+ );
156
+ expect(invalidField?.valid).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe('no schema provided', () => {
161
+ it('should not include fieldValidation when no schema', () => {
162
+ const result = parser.parseWithContext('status:done');
163
+
164
+ expect(result.fieldValidation).toBeUndefined();
165
+ });
166
+ });
167
+ });
168
+
169
+ describe('Security Pre-check', () => {
170
+ describe('denied fields', () => {
171
+ it('should detect denied fields', () => {
172
+ const result = parser.parseWithContext('password:secret', {
173
+ securityOptions: {
174
+ denyFields: ['password', 'secret_key']
175
+ }
176
+ });
177
+
178
+ expect(result.security?.passed).toBe(false);
179
+ expect(result.security?.violations).toContainEqual(
180
+ expect.objectContaining({
181
+ type: 'denied_field',
182
+ field: 'password'
183
+ })
184
+ );
185
+ });
186
+
187
+ it('should pass when field is not denied', () => {
188
+ const result = parser.parseWithContext('status:done', {
189
+ securityOptions: {
190
+ denyFields: ['password', 'secret_key']
191
+ }
192
+ });
193
+
194
+ expect(result.security?.passed).toBe(true);
195
+ expect(result.security?.violations).toHaveLength(0);
196
+ });
197
+ });
198
+
199
+ describe('allowed fields', () => {
200
+ it('should detect fields not in allowed list', () => {
201
+ const result = parser.parseWithContext('status:done AND secret:value', {
202
+ securityOptions: {
203
+ allowedFields: ['status', 'priority', 'name']
204
+ }
205
+ });
206
+
207
+ expect(result.security?.passed).toBe(false);
208
+ expect(result.security?.violations).toContainEqual(
209
+ expect.objectContaining({
210
+ type: 'field_not_allowed',
211
+ field: 'secret'
212
+ })
213
+ );
214
+ });
215
+
216
+ it('should pass when all fields are allowed', () => {
217
+ const result = parser.parseWithContext(
218
+ 'status:done AND priority:high',
219
+ {
220
+ securityOptions: {
221
+ allowedFields: ['status', 'priority', 'name']
222
+ }
223
+ }
224
+ );
225
+
226
+ expect(result.security?.passed).toBe(true);
227
+ });
228
+ });
229
+
230
+ describe('dot notation', () => {
231
+ it('should detect dot notation when disabled', () => {
232
+ const result = parser.parseWithContext('user.email:test@example.com', {
233
+ securityOptions: {
234
+ allowDotNotation: false
235
+ }
236
+ });
237
+
238
+ expect(result.security?.passed).toBe(false);
239
+ expect(result.security?.violations).toContainEqual(
240
+ expect.objectContaining({
241
+ type: 'dot_notation',
242
+ field: 'user.email'
243
+ })
244
+ );
245
+ });
246
+
247
+ it('should allow dot notation by default', () => {
248
+ const result = parser.parseWithContext('user.email:test@example.com', {
249
+ securityOptions: {}
250
+ });
251
+
252
+ expect(result.security?.passed).toBe(true);
253
+ });
254
+ });
255
+
256
+ describe('query depth', () => {
257
+ it('should detect exceeded depth', () => {
258
+ const result = parser.parseWithContext('((a:1 AND b:2) OR c:3)', {
259
+ securityOptions: {
260
+ maxQueryDepth: 1
261
+ }
262
+ });
263
+
264
+ expect(result.security?.passed).toBe(false);
265
+ expect(result.security?.violations).toContainEqual(
266
+ expect.objectContaining({
267
+ type: 'depth_exceeded'
268
+ })
269
+ );
270
+ });
271
+
272
+ it('should warn when approaching depth limit', () => {
273
+ const result = parser.parseWithContext('(a:1 AND b:2)', {
274
+ securityOptions: {
275
+ maxQueryDepth: 2
276
+ }
277
+ });
278
+
279
+ // Depth is 1, limit is 2, 80% of 2 is 1.6
280
+ // So depth 1 doesn't trigger warning, but depth 2 would
281
+ expect(result.security?.passed).toBe(true);
282
+ });
283
+
284
+ it('should pass when within depth limit', () => {
285
+ const result = parser.parseWithContext('a:1 AND b:2', {
286
+ securityOptions: {
287
+ maxQueryDepth: 5
288
+ }
289
+ });
290
+
291
+ expect(result.security?.passed).toBe(true);
292
+ });
293
+ });
294
+
295
+ describe('clause count', () => {
296
+ it('should detect exceeded clause count', () => {
297
+ const result = parser.parseWithContext('a:1 AND b:2 AND c:3 AND d:4', {
298
+ securityOptions: {
299
+ maxClauseCount: 3
300
+ }
301
+ });
302
+
303
+ expect(result.security?.passed).toBe(false);
304
+ expect(result.security?.violations).toContainEqual(
305
+ expect.objectContaining({
306
+ type: 'clause_limit'
307
+ })
308
+ );
309
+ });
310
+
311
+ it('should warn when approaching clause limit', () => {
312
+ const result = parser.parseWithContext('a:1 AND b:2 AND c:3 AND d:4', {
313
+ securityOptions: {
314
+ maxClauseCount: 5
315
+ }
316
+ });
317
+
318
+ expect(result.security?.passed).toBe(true);
319
+ expect(result.security?.warnings).toContainEqual(
320
+ expect.objectContaining({
321
+ type: 'approaching_clause_limit',
322
+ current: 4,
323
+ limit: 5
324
+ })
325
+ );
326
+ });
327
+ });
328
+
329
+ describe('complexity warnings', () => {
330
+ it('should warn about complex queries', () => {
331
+ const clauses = Array.from(
332
+ { length: 10 },
333
+ (_, i) => `field${i}:value${i}`
334
+ );
335
+ const query = clauses.join(' AND ');
336
+
337
+ const result = parser.parseWithContext(query, {
338
+ securityOptions: {}
339
+ });
340
+
341
+ expect(result.security?.warnings).toContainEqual(
342
+ expect.objectContaining({
343
+ type: 'complex_query'
344
+ })
345
+ );
346
+ });
347
+ });
348
+
349
+ describe('multiple violations', () => {
350
+ it('should report all violations', () => {
351
+ const result = parser.parseWithContext(
352
+ 'password:secret AND user.role:admin',
353
+ {
354
+ securityOptions: {
355
+ denyFields: ['password'],
356
+ allowDotNotation: false
357
+ }
358
+ }
359
+ );
360
+
361
+ expect(result.security?.passed).toBe(false);
362
+ expect(result.security?.violations.length).toBeGreaterThanOrEqual(2);
363
+ });
364
+ });
365
+
366
+ describe('no security options provided', () => {
367
+ it('should not include security when no options', () => {
368
+ const result = parser.parseWithContext('status:done');
369
+
370
+ expect(result.security).toBeUndefined();
371
+ });
372
+ });
373
+ });
374
+
375
+ describe('Combined schema and security', () => {
376
+ it('should include both fieldValidation and security when both provided', () => {
377
+ const result = parser.parseWithContext('status:done', {
378
+ schema: {
379
+ status: { type: 'string' }
380
+ },
381
+ securityOptions: {
382
+ denyFields: ['password']
383
+ }
384
+ });
385
+
386
+ expect(result.fieldValidation).toBeDefined();
387
+ expect(result.security).toBeDefined();
388
+ expect(result.fieldValidation?.valid).toBe(true);
389
+ expect(result.security?.passed).toBe(true);
390
+ });
391
+
392
+ it('should report issues from both validations', () => {
393
+ const result = parser.parseWithContext(
394
+ 'unknownField:value AND password:secret',
395
+ {
396
+ schema: {
397
+ status: { type: 'string' }
398
+ },
399
+ securityOptions: {
400
+ denyFields: ['password']
401
+ }
402
+ }
403
+ );
404
+
405
+ expect(result.fieldValidation?.valid).toBe(false);
406
+ expect(result.security?.passed).toBe(false);
407
+ });
408
+ });
409
+
410
+ describe('Integration with Core Parsing', () => {
411
+ it('should include all core parsing features', () => {
412
+ const result = parser.parseWithContext('status:done AND priority:high', {
413
+ cursorPosition: 5,
414
+ schema: {
415
+ status: { type: 'string' },
416
+ priority: { type: 'number' }
417
+ },
418
+ securityOptions: {
419
+ maxClauseCount: 10
420
+ }
421
+ });
422
+
423
+ // Core parsing features
424
+ expect(result.success).toBe(true);
425
+ expect(result.tokens).toHaveLength(3);
426
+ expect(result.activeToken).toBeDefined();
427
+ expect(result.structure).toBeDefined();
428
+
429
+ // Validation & Security features
430
+ expect(result.fieldValidation).toBeDefined();
431
+ expect(result.security).toBeDefined();
432
+ });
433
+
434
+ it('should work with failed parsing', () => {
435
+ const result = parser.parseWithContext('status:', {
436
+ schema: {
437
+ status: { type: 'string' }
438
+ },
439
+ securityOptions: {}
440
+ });
441
+
442
+ expect(result.success).toBe(false);
443
+ expect(result.fieldValidation?.valid).toBe(true); // Field is valid
444
+ expect(result.security?.passed).toBe(true); // No security issues
445
+ });
446
+ });
447
+ });