@safeaccess/inline 0.1.1

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.
Files changed (129) hide show
  1. package/.gitattributes +16 -0
  2. package/.gitkeep +0 -0
  3. package/CHANGELOG.md +38 -0
  4. package/LICENSE +21 -0
  5. package/README.md +454 -0
  6. package/benchmarks/get.bench.ts +26 -0
  7. package/benchmarks/parse.bench.ts +41 -0
  8. package/dist/accessors/abstract-accessor.d.ts +213 -0
  9. package/dist/accessors/abstract-accessor.js +294 -0
  10. package/dist/accessors/formats/any-accessor.d.ts +35 -0
  11. package/dist/accessors/formats/any-accessor.js +44 -0
  12. package/dist/accessors/formats/array-accessor.d.ts +26 -0
  13. package/dist/accessors/formats/array-accessor.js +39 -0
  14. package/dist/accessors/formats/env-accessor.d.ts +27 -0
  15. package/dist/accessors/formats/env-accessor.js +64 -0
  16. package/dist/accessors/formats/ini-accessor.d.ts +41 -0
  17. package/dist/accessors/formats/ini-accessor.js +109 -0
  18. package/dist/accessors/formats/json-accessor.d.ts +26 -0
  19. package/dist/accessors/formats/json-accessor.js +56 -0
  20. package/dist/accessors/formats/ndjson-accessor.d.ts +28 -0
  21. package/dist/accessors/formats/ndjson-accessor.js +71 -0
  22. package/dist/accessors/formats/object-accessor.d.ts +48 -0
  23. package/dist/accessors/formats/object-accessor.js +90 -0
  24. package/dist/accessors/formats/xml-accessor.d.ts +27 -0
  25. package/dist/accessors/formats/xml-accessor.js +52 -0
  26. package/dist/accessors/formats/yaml-accessor.d.ts +29 -0
  27. package/dist/accessors/formats/yaml-accessor.js +46 -0
  28. package/dist/contracts/accessors-interface.d.ts +11 -0
  29. package/dist/contracts/accessors-interface.js +1 -0
  30. package/dist/contracts/factory-accessors-interface.d.ts +16 -0
  31. package/dist/contracts/factory-accessors-interface.js +1 -0
  32. package/dist/contracts/parse-integration-interface.d.ts +31 -0
  33. package/dist/contracts/parse-integration-interface.js +1 -0
  34. package/dist/contracts/path-cache-interface.d.ts +40 -0
  35. package/dist/contracts/path-cache-interface.js +1 -0
  36. package/dist/contracts/readable-accessors-interface.d.ts +79 -0
  37. package/dist/contracts/readable-accessors-interface.js +1 -0
  38. package/dist/contracts/security-guard-interface.d.ts +40 -0
  39. package/dist/contracts/security-guard-interface.js +1 -0
  40. package/dist/contracts/security-parser-interface.d.ts +67 -0
  41. package/dist/contracts/security-parser-interface.js +1 -0
  42. package/dist/contracts/writable-accessors-interface.d.ts +65 -0
  43. package/dist/contracts/writable-accessors-interface.js +1 -0
  44. package/dist/core/dot-notation-parser.d.ts +204 -0
  45. package/dist/core/dot-notation-parser.js +343 -0
  46. package/dist/exceptions/accessor-exception.d.ts +13 -0
  47. package/dist/exceptions/accessor-exception.js +16 -0
  48. package/dist/exceptions/invalid-format-exception.d.ts +14 -0
  49. package/dist/exceptions/invalid-format-exception.js +17 -0
  50. package/dist/exceptions/parser-exception.d.ts +14 -0
  51. package/dist/exceptions/parser-exception.js +17 -0
  52. package/dist/exceptions/path-not-found-exception.d.ts +14 -0
  53. package/dist/exceptions/path-not-found-exception.js +17 -0
  54. package/dist/exceptions/readonly-violation-exception.d.ts +15 -0
  55. package/dist/exceptions/readonly-violation-exception.js +18 -0
  56. package/dist/exceptions/security-exception.d.ts +18 -0
  57. package/dist/exceptions/security-exception.js +21 -0
  58. package/dist/exceptions/unsupported-type-exception.d.ts +14 -0
  59. package/dist/exceptions/unsupported-type-exception.js +17 -0
  60. package/dist/exceptions/yaml-parse-exception.d.ts +17 -0
  61. package/dist/exceptions/yaml-parse-exception.js +20 -0
  62. package/dist/index.d.ts +30 -0
  63. package/dist/index.js +30 -0
  64. package/dist/inline.d.ts +402 -0
  65. package/dist/inline.js +512 -0
  66. package/dist/parser/xml-parser.d.ts +46 -0
  67. package/dist/parser/xml-parser.js +288 -0
  68. package/dist/parser/yaml-parser.d.ts +94 -0
  69. package/dist/parser/yaml-parser.js +286 -0
  70. package/dist/security/forbidden-keys.d.ts +34 -0
  71. package/dist/security/forbidden-keys.js +80 -0
  72. package/dist/security/security-guard.d.ts +94 -0
  73. package/dist/security/security-guard.js +172 -0
  74. package/dist/security/security-parser.d.ts +130 -0
  75. package/dist/security/security-parser.js +192 -0
  76. package/dist/type-format.d.ts +28 -0
  77. package/dist/type-format.js +29 -0
  78. package/eslint.config.js +1 -0
  79. package/package.json +39 -0
  80. package/src/accessors/abstract-accessor.ts +353 -0
  81. package/src/accessors/formats/any-accessor.ts +51 -0
  82. package/src/accessors/formats/array-accessor.ts +45 -0
  83. package/src/accessors/formats/env-accessor.ts +79 -0
  84. package/src/accessors/formats/ini-accessor.ts +124 -0
  85. package/src/accessors/formats/json-accessor.ts +66 -0
  86. package/src/accessors/formats/ndjson-accessor.ts +82 -0
  87. package/src/accessors/formats/object-accessor.ts +100 -0
  88. package/src/accessors/formats/xml-accessor.ts +58 -0
  89. package/src/accessors/formats/yaml-accessor.ts +52 -0
  90. package/src/contracts/accessors-interface.ts +12 -0
  91. package/src/contracts/factory-accessors-interface.ts +16 -0
  92. package/src/contracts/parse-integration-interface.ts +32 -0
  93. package/src/contracts/path-cache-interface.ts +43 -0
  94. package/src/contracts/readable-accessors-interface.ts +88 -0
  95. package/src/contracts/security-guard-interface.ts +43 -0
  96. package/src/contracts/security-parser-interface.ts +74 -0
  97. package/src/contracts/writable-accessors-interface.ts +70 -0
  98. package/src/core/dot-notation-parser.ts +419 -0
  99. package/src/exceptions/accessor-exception.ts +16 -0
  100. package/src/exceptions/invalid-format-exception.ts +18 -0
  101. package/src/exceptions/parser-exception.ts +18 -0
  102. package/src/exceptions/path-not-found-exception.ts +18 -0
  103. package/src/exceptions/readonly-violation-exception.ts +19 -0
  104. package/src/exceptions/security-exception.ts +22 -0
  105. package/src/exceptions/unsupported-type-exception.ts +18 -0
  106. package/src/exceptions/yaml-parse-exception.ts +21 -0
  107. package/src/index.ts +46 -0
  108. package/src/inline.ts +570 -0
  109. package/src/parser/xml-parser.ts +334 -0
  110. package/src/parser/yaml-parser.ts +368 -0
  111. package/src/security/forbidden-keys.ts +81 -0
  112. package/src/security/security-guard.ts +195 -0
  113. package/src/security/security-parser.ts +233 -0
  114. package/src/type-format.ts +28 -0
  115. package/stryker.config.json +24 -0
  116. package/tests/accessors/accessors.test.ts +1017 -0
  117. package/tests/accessors/json-accessor.test.ts +171 -0
  118. package/tests/core/dot-notation-parser.test.ts +587 -0
  119. package/tests/exceptions/parser-exception.test.ts +31 -0
  120. package/tests/inline.test.ts +445 -0
  121. package/tests/mocks/fake-parse-integration.ts +24 -0
  122. package/tests/mocks/fake-path-cache.ts +31 -0
  123. package/tests/parity.test.ts +164 -0
  124. package/tests/parser/xml-parser.test.ts +618 -0
  125. package/tests/parser/yaml-parser.test.ts +463 -0
  126. package/tests/security/security-guard.test.ts +646 -0
  127. package/tests/security/security-parser.test.ts +391 -0
  128. package/tsconfig.json +16 -0
  129. package/vitest.config.ts +19 -0
@@ -0,0 +1,587 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { DotNotationParser } from '../../src/core/dot-notation-parser.js';
3
+ import { SecurityGuard } from '../../src/security/security-guard.js';
4
+ import { SecurityParser } from '../../src/security/security-parser.js';
5
+ import { SecurityException } from '../../src/exceptions/security-exception.js';
6
+ import { FakePathCache } from '../mocks/fake-path-cache.js';
7
+
8
+ function makeParser(): DotNotationParser {
9
+ return new DotNotationParser();
10
+ }
11
+
12
+ describe(DotNotationParser.name, () => {
13
+ it('resolves a simple key', () => {
14
+ const parser = makeParser();
15
+ expect(parser.get({ name: 'Alice' }, 'name')).toBe('Alice');
16
+ });
17
+
18
+ it('returns null for empty path', () => {
19
+ const parser = makeParser();
20
+ expect(parser.get({ name: 'Alice' }, '')).toBeNull();
21
+ });
22
+
23
+ it('returns the default for a missing key', () => {
24
+ const parser = makeParser();
25
+ expect(parser.get({ name: 'Alice' }, 'missing', 'fallback')).toBe('fallback');
26
+ });
27
+
28
+ it('returns null (default) for a missing key when no default provided', () => {
29
+ const parser = makeParser();
30
+ expect(parser.get({ name: 'Alice' }, 'missing')).toBeNull();
31
+ });
32
+
33
+ it('resolves a nested 3-level path', () => {
34
+ const parser = makeParser();
35
+ expect(parser.get({ a: { b: { c: 42 } } }, 'a.b.c')).toBe(42);
36
+ });
37
+
38
+ it('returns default when intermediate key is missing', () => {
39
+ const parser = makeParser();
40
+ expect(parser.get({ a: 1 }, 'a.b.c', 'nope')).toBe('nope');
41
+ });
42
+
43
+ it('returns default when value is non-object and path continues', () => {
44
+ const parser = makeParser();
45
+ expect(parser.get({ a: 'string' }, 'a.b', 'default')).toBe('default');
46
+ });
47
+ });
48
+
49
+ describe(`${DotNotationParser.name} > has`, () => {
50
+ it('returns true when path exists', () => {
51
+ expect(makeParser().has({ a: 1 }, 'a')).toBe(true);
52
+ });
53
+
54
+ it('returns false for empty path', () => {
55
+ expect(makeParser().has({ a: 1 }, '')).toBe(false);
56
+ });
57
+
58
+ it('returns false for missing path', () => {
59
+ expect(makeParser().has({ a: 1 }, 'b')).toBe(false);
60
+ });
61
+
62
+ it('returns true for nested path', () => {
63
+ expect(makeParser().has({ a: { b: { c: 1 } } }, 'a.b.c')).toBe(true);
64
+ });
65
+
66
+ it('returns false when key exists but path continues into non-object', () => {
67
+ expect(makeParser().has({ a: 'string' }, 'a.b')).toBe(false);
68
+ });
69
+
70
+ it('returns true when value is null (key exists)', () => {
71
+ expect(makeParser().has({ a: null }, 'a')).toBe(true);
72
+ });
73
+ });
74
+
75
+ describe(`${DotNotationParser.name} > set`, () => {
76
+ it('sets a value at a simple key', () => {
77
+ const parser = makeParser();
78
+ const result = parser.set({}, 'name', 'Alice');
79
+ expect(result).toEqual({ name: 'Alice' });
80
+ });
81
+
82
+ it('returns a new object (immutability)', () => {
83
+ const parser = makeParser();
84
+ const original = { name: 'Alice' };
85
+ const result = parser.set(original, 'name', 'Bob');
86
+ expect(original.name).toBe('Alice');
87
+ expect(result.name).toBe('Bob');
88
+ });
89
+
90
+ it('creates nested intermediate objects', () => {
91
+ const parser = makeParser();
92
+ const result = parser.set({}, 'user.profile.name', 'Alice');
93
+ expect(result).toEqual({ user: { profile: { name: 'Alice' } } });
94
+ });
95
+
96
+ it('overwrites an existing nested key', () => {
97
+ const parser = makeParser();
98
+ const result = parser.set({ a: { b: 1 } }, 'a.b', 99);
99
+ expect(result).toEqual({ a: { b: 99 } });
100
+ });
101
+
102
+ it('replaces a non-object intermediate with an object', () => {
103
+ const parser = makeParser();
104
+ const result = parser.set({ a: 'string' }, 'a.b', 1);
105
+ expect(result).toEqual({ a: { b: 1 } });
106
+ });
107
+ });
108
+
109
+ describe(`${DotNotationParser.name} > remove`, () => {
110
+ it('removes a simple key', () => {
111
+ const parser = makeParser();
112
+ const result = parser.remove({ a: 1, b: 2 }, 'a');
113
+ expect(result).toEqual({ b: 2 });
114
+ });
115
+
116
+ it('returns the same data when key does not exist', () => {
117
+ const parser = makeParser();
118
+ const original = { a: 1 };
119
+ const result = parser.remove(original, 'missing');
120
+ expect(result).toEqual({ a: 1 });
121
+ });
122
+
123
+ it('removes a nested key', () => {
124
+ const parser = makeParser();
125
+ const result = parser.remove({ a: { b: 1, c: 2 } }, 'a.b');
126
+ expect(result).toEqual({ a: { c: 2 } });
127
+ });
128
+
129
+ it('returns original when intermediate path does not exist', () => {
130
+ const parser = makeParser();
131
+ const result = parser.remove({ a: 1 }, 'b.c');
132
+ expect(result).toEqual({ a: 1 });
133
+ });
134
+
135
+ it('returns original when intermediate is non-object', () => {
136
+ const parser = makeParser();
137
+ const result = parser.remove({ a: 'string' }, 'a.b');
138
+ expect(result).toEqual({ a: 'string' });
139
+ });
140
+
141
+ it('returns a new object (immutability)', () => {
142
+ const parser = makeParser();
143
+ const original = { a: 1, b: 2 };
144
+ const result = parser.remove(original, 'a');
145
+ expect(original).toHaveProperty('a');
146
+ expect(result).not.toHaveProperty('a');
147
+ });
148
+ });
149
+
150
+ describe(`${DotNotationParser.name} > getAt`, () => {
151
+ it('resolves using pre-parsed segments', () => {
152
+ const parser = makeParser();
153
+ expect(parser.getAt({ a: { b: 1 } }, ['a', 'b'])).toBe(1);
154
+ });
155
+
156
+ it('returns null for empty segments', () => {
157
+ const parser = makeParser();
158
+ expect(parser.getAt({ a: 1 }, [])).toEqual({ a: 1 });
159
+ });
160
+
161
+ it('returns default when segments lead to missing key', () => {
162
+ const parser = makeParser();
163
+ expect(parser.getAt({ a: 1 }, ['missing'], 'fallback')).toBe('fallback');
164
+ });
165
+
166
+ it('returns default when intermediate is non-object', () => {
167
+ const parser = makeParser();
168
+ expect(parser.getAt({ a: 'str' }, ['a', 'b'], 'default')).toBe('default');
169
+ });
170
+ });
171
+
172
+ describe(`${DotNotationParser.name} > setAt`, () => {
173
+ it('sets value using pre-parsed segments', () => {
174
+ const parser = makeParser();
175
+ const result = parser.setAt({}, ['a', 'b'], 42);
176
+ expect(result).toEqual({ a: { b: 42 } });
177
+ });
178
+
179
+ it('returns the same data for empty segments', () => {
180
+ const parser = makeParser();
181
+ const data = { x: 1 };
182
+ expect(parser.setAt(data, [], 'value')).toEqual({ x: 1 });
183
+ });
184
+ });
185
+
186
+ describe(`${DotNotationParser.name} > hasAt`, () => {
187
+ it('returns true when segments lead to a value', () => {
188
+ const parser = makeParser();
189
+ expect(parser.hasAt({ a: { b: 1 } }, ['a', 'b'])).toBe(true);
190
+ });
191
+
192
+ it('returns false when segments lead to missing key', () => {
193
+ const parser = makeParser();
194
+ expect(parser.hasAt({ a: 1 }, ['missing'])).toBe(false);
195
+ });
196
+ });
197
+
198
+ describe(`${DotNotationParser.name} > removeAt`, () => {
199
+ it('removes using pre-parsed segments', () => {
200
+ const parser = makeParser();
201
+ const result = parser.removeAt({ a: { b: 1 } }, ['a', 'b']);
202
+ expect(result).toEqual({ a: {} });
203
+ });
204
+
205
+ it('returns the same data for empty segments', () => {
206
+ const parser = makeParser();
207
+ const data = { x: 1 };
208
+ expect(parser.removeAt(data, [])).toEqual({ x: 1 });
209
+ });
210
+ });
211
+
212
+ describe(`${DotNotationParser.name} > merge`, () => {
213
+ it('merges at root level with empty path', () => {
214
+ const parser = makeParser();
215
+ const result = parser.merge({ a: 1 }, '', { b: 2 });
216
+ expect(result).toEqual({ a: 1, b: 2 });
217
+ });
218
+
219
+ it('merges at a nested path', () => {
220
+ const parser = makeParser();
221
+ const result = parser.merge({ a: { b: 1 } }, 'a', { c: 2 });
222
+ expect(result).toEqual({ a: { b: 1, c: 2 } });
223
+ });
224
+
225
+ it('creates a nested path when it does not exist', () => {
226
+ const parser = makeParser();
227
+ const result = parser.merge({}, 'a.b', { c: 1 });
228
+ expect(result).toEqual({ a: { b: { c: 1 } } });
229
+ });
230
+
231
+ it('overwrites non-object with merged object', () => {
232
+ const parser = makeParser();
233
+ const result = parser.merge({ a: 'string' }, 'a', { key: 'val' });
234
+ expect(result).toEqual({ a: { key: 'val' } });
235
+ });
236
+
237
+ it('throws SecurityException when deep merge exceeds maxResolveDepth', () => {
238
+ const parser = new DotNotationParser(
239
+ new SecurityGuard(),
240
+ new SecurityParser({ maxResolveDepth: 1 }),
241
+ );
242
+ // 3 levels of merging triggers depth 2 in deepMerge
243
+ expect(() => parser.merge({ a: { b: { c: 1 } } }, '', { a: { b: { c: 2 } } })).toThrow(
244
+ SecurityException,
245
+ );
246
+ });
247
+ });
248
+
249
+ describe(`${DotNotationParser.name} > validate`, () => {
250
+ it('does not throw for safe data', () => {
251
+ const parser = makeParser();
252
+ expect(() => parser.validate({ name: 'Alice', age: 30 })).not.toThrow();
253
+ });
254
+
255
+ it('throws SecurityException for forbidden keys', () => {
256
+ const parser = makeParser();
257
+ expect(() => parser.validate({ constructor: 'bad' })).toThrow(SecurityException);
258
+ });
259
+
260
+ it('throws SecurityException for too many keys', () => {
261
+ const parser = new DotNotationParser(
262
+ new SecurityGuard(),
263
+ new SecurityParser({ maxKeys: 2 }),
264
+ );
265
+ expect(() => parser.validate({ a: 1, b: 2, c: 3 })).toThrow(SecurityException);
266
+ });
267
+
268
+ it('throws SecurityException for data too deeply nested', () => {
269
+ const parser = new DotNotationParser(
270
+ new SecurityGuard(),
271
+ new SecurityParser({ maxDepth: 1 }),
272
+ );
273
+ expect(() => parser.validate({ a: { b: { c: 1 } } })).toThrow(SecurityException);
274
+ });
275
+ });
276
+
277
+ describe(`${DotNotationParser.name} > assertPayload`, () => {
278
+ it('does not throw for a small payload', () => {
279
+ const parser = makeParser();
280
+ expect(() => parser.assertPayload('hello')).not.toThrow();
281
+ });
282
+
283
+ it('throws SecurityException for oversized payload', () => {
284
+ const parser = new DotNotationParser(
285
+ new SecurityGuard(),
286
+ new SecurityParser({ maxPayloadBytes: 3 }),
287
+ );
288
+ expect(() => parser.assertPayload('1234')).toThrow(SecurityException);
289
+ });
290
+ });
291
+
292
+ describe(`${DotNotationParser.name} > getMaxDepth`, () => {
293
+ it('returns configured max depth from SecurityParser', () => {
294
+ const parser = new DotNotationParser(
295
+ new SecurityGuard(),
296
+ new SecurityParser({ maxDepth: 7 }),
297
+ );
298
+ expect(parser.getMaxDepth()).toBe(7);
299
+ });
300
+ });
301
+
302
+ describe(`${DotNotationParser.name} > pathCache integration`, () => {
303
+ it('stores parsed segments in the cache on first access', () => {
304
+ const cache = new FakePathCache();
305
+ const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser(), cache);
306
+ parser.get({ a: { b: 1 } }, 'a.b');
307
+ expect(cache.store.has('a.b')).toBe(true);
308
+ expect(cache.store.get('a.b')).toEqual(['a', 'b']);
309
+ });
310
+
311
+ it('reads from the cache on subsequent calls without re-parsing', () => {
312
+ const cache = new FakePathCache();
313
+ const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser(), cache);
314
+ parser.get({ a: { b: 1 } }, 'a.b');
315
+ const getCountAfterFirst = cache.getCallCount;
316
+ parser.get({ a: { b: 1 } }, 'a.b');
317
+ // Second call should hit the cache (getCallCount increases, setCallCount stays the same)
318
+ expect(cache.setCallCount).toBe(1);
319
+ expect(cache.getCallCount).toBeGreaterThan(getCountAfterFirst);
320
+ });
321
+
322
+ it('returns the correct value when cache is used', () => {
323
+ const cache = new FakePathCache();
324
+ const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser(), cache);
325
+ expect(parser.get({ a: { b: 42 } }, 'a.b')).toBe(42);
326
+ expect(parser.get({ a: { b: 42 } }, 'a.b')).toBe(42);
327
+ });
328
+
329
+ it('works without a cache (undefined)', () => {
330
+ const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser());
331
+ expect(parser.get({ a: { b: 1 } }, 'a.b')).toBe(1);
332
+ });
333
+ });
334
+
335
+ // Additional branch-coverage tests (targeting Stryker survivors)
336
+
337
+ describe(`${DotNotationParser.name} > get empty path branch`, () => {
338
+ // Kills lines 50:13/22/26 — `path === ''` condition in get()
339
+ it('returns defaultValue (not null) for empty path', () => {
340
+ const parser = makeParser();
341
+ expect(parser.get({ a: 1 }, '', 'custom_default')).toBe('custom_default');
342
+ });
343
+
344
+ it('returns null when empty path and no default', () => {
345
+ const parser = makeParser();
346
+ expect(parser.get({ a: 1 }, '')).toBeNull();
347
+ });
348
+ });
349
+
350
+ describe(`${DotNotationParser.name} > has empty path branch`, () => {
351
+ // Kills lines 86:13/22/26 — `path === ''` condition in has()
352
+ it('returns false for empty path (not "has everything")', () => {
353
+ const parser = makeParser();
354
+ // If the condition were removed, sentinel lookup would always find the data itself → true
355
+ expect(parser.has({ a: 1 }, '')).toBe(false);
356
+ });
357
+ });
358
+
359
+ describe(`${DotNotationParser.name} > getAt branch conditions`, () => {
360
+ // Kills lines 128/129 — conditions inside getAt loop
361
+ it('returns defaultValue when current is null mid-path', () => {
362
+ const parser = makeParser();
363
+ expect(parser.getAt({ a: null }, ['a', 'b'], 'default')).toBe('default');
364
+ });
365
+
366
+ it('returns defaultValue when key does not exist as own property', () => {
367
+ const parser = makeParser();
368
+ const data = Object.create({ inherited: true }) as Record<string, unknown>;
369
+ expect(parser.getAt(data, ['inherited'], 'fallback')).toBe('fallback');
370
+ });
371
+
372
+ it('returns value when key is a direct own property', () => {
373
+ const parser = makeParser();
374
+ expect(parser.getAt({ key: 'value' }, ['key'])).toBe('value');
375
+ });
376
+ });
377
+
378
+ describe(`${DotNotationParser.name} > removeAt empty segments`, () => {
379
+ // Kills line 189:36/13 — `segments.length === 0` early return
380
+ it('returns original data for empty segments', () => {
381
+ const parser = makeParser();
382
+ const data = { a: 1 };
383
+ expect(parser.removeAt(data, [])).toBe(data);
384
+ });
385
+
386
+ it('returns a different object when segments are non-empty', () => {
387
+ const parser = makeParser();
388
+ const data = { a: 1, b: 2 };
389
+ const result = parser.removeAt(data, ['a']);
390
+ expect(result).not.toBe(data);
391
+ expect(result).toEqual({ b: 2 });
392
+ });
393
+ });
394
+
395
+ describe(`${DotNotationParser.name} > merge existing is non-object`, () => {
396
+ // Kills line 214:45 — typeof existing === 'object' check in merge()
397
+ it('merges into empty object when existing path value is a primitive', () => {
398
+ const parser = makeParser();
399
+ // 'a' is a string (primitive), not an object — should merge into {}
400
+ const result = parser.merge({ a: 'string' }, 'a', { key: 'val' });
401
+ expect(result).toEqual({ a: { key: 'val' } });
402
+ });
403
+
404
+ it('merges into empty object when existing path value is null', () => {
405
+ const parser = makeParser();
406
+ const result = parser.merge({ a: null }, 'a', { key: 'val' });
407
+ expect(result).toEqual({ a: { key: 'val' } });
408
+ });
409
+ });
410
+
411
+ describe(`${DotNotationParser.name} > eraseAt hasOwnProperty check`, () => {
412
+ // Kills line 281:22 — hasOwnProperty check in eraseAt
413
+ it('does not remove inherited (non-own) properties via prototype chain', () => {
414
+ const parser = makeParser();
415
+ const proto = { inherited: 1 };
416
+ const data = Object.create(proto) as Record<string, unknown>;
417
+ data['own'] = 2;
418
+ const result = parser.removeAt(data, ['inherited']);
419
+ // 'inherited' is not an own property so eraseAt returns data unchanged
420
+ expect(Object.prototype.hasOwnProperty.call(result, 'inherited')).toBe(false);
421
+ expect(result['own']).toBe(2);
422
+ });
423
+ });
424
+
425
+ describe(`${DotNotationParser.name} > eraseAt child null/non-object`, () => {
426
+ // Kills lines 290:13/30/42/60 — `typeof child !== 'object' || child === null`
427
+ it('returns copy unchanged when intermediate child is null', () => {
428
+ const parser = makeParser();
429
+ const data = { a: null };
430
+ const result = parser.removeAt(data as Record<string, unknown>, ['a', 'b']);
431
+ expect(result).toEqual({ a: null });
432
+ });
433
+
434
+ it('returns copy unchanged when intermediate child is a number', () => {
435
+ const parser = makeParser();
436
+ const result = parser.removeAt({ a: 42 }, ['a', 'b']);
437
+ expect(result).toEqual({ a: 42 });
438
+ });
439
+
440
+ it('returns copy unchanged when intermediate child is a string', () => {
441
+ const parser = makeParser();
442
+ const result = parser.removeAt({ a: 'text' }, ['a', 'b']);
443
+ expect(result).toEqual({ a: 'text' });
444
+ });
445
+ });
446
+
447
+ describe(`${DotNotationParser.name} > writeAt single segment`, () => {
448
+ // Kills lines 305:63/13 — `index === segments.length - 1` check (terminal condition)
449
+ it('sets value at a single-segment path correctly', () => {
450
+ const parser = makeParser();
451
+ const result = parser.setAt({}, ['key'], 'value');
452
+ expect(result).toEqual({ key: 'value' });
453
+ });
454
+
455
+ it('sets value at a single-segment path, overwriting existing', () => {
456
+ const parser = makeParser();
457
+ const result = parser.setAt({ key: 'old' }, ['key'], 'new');
458
+ expect(result).toEqual({ key: 'new' });
459
+ });
460
+ });
461
+
462
+ describe(`${DotNotationParser.name} > writeAt child handling`, () => {
463
+ // Kills lines 317:13/42/58 — typeof child === 'object' check in writeAt
464
+ it('creates nested object when child is null', () => {
465
+ const parser = makeParser();
466
+ const result = parser.setAt({ a: null }, ['a', 'b'], 1);
467
+ expect(result).toEqual({ a: { b: 1 } });
468
+ });
469
+
470
+ it('creates nested object when child is a primitive', () => {
471
+ const parser = makeParser();
472
+ const result = parser.setAt({ a: 42 }, ['a', 'b'], 1);
473
+ expect(result).toEqual({ a: { b: 1 } });
474
+ });
475
+
476
+ it('creates nested object when child is an array', () => {
477
+ const parser = makeParser();
478
+ const result = parser.setAt({ a: [1, 2] }, ['a', 'b'], 1);
479
+ expect(result).toEqual({ a: { b: 1 } });
480
+ });
481
+
482
+ it('preserves existing nested object when overwriting a key', () => {
483
+ const parser = makeParser();
484
+ const result = parser.setAt({ a: { x: 1, y: 2 } }, ['a', 'z'], 3);
485
+ expect(result).toEqual({ a: { x: 1, y: 2, z: 3 } });
486
+ });
487
+ });
488
+
489
+ describe(`${DotNotationParser.name} > write-path forbidden key validation`, () => {
490
+ it('throws SecurityException when setting a forbidden key via set', () => {
491
+ const parser = makeParser();
492
+ expect(() => parser.set({}, 'constructor', 'bad')).toThrow(SecurityException);
493
+ });
494
+
495
+ it('throws SecurityException when setting a nested forbidden key via set', () => {
496
+ const parser = makeParser();
497
+ expect(() => parser.set({}, 'prototype.nested', 'bad')).toThrow(SecurityException);
498
+ });
499
+
500
+ it('throws SecurityException when removing a forbidden key via remove', () => {
501
+ const parser = makeParser();
502
+ expect(() => parser.remove({ safe: 1 }, 'constructor')).toThrow(SecurityException);
503
+ });
504
+
505
+ it('throws SecurityException when setting a forbidden key via setAt', () => {
506
+ const parser = makeParser();
507
+ expect(() => parser.setAt({}, ['__proto__'], 'bad')).toThrow(SecurityException);
508
+ });
509
+
510
+ it('throws SecurityException when removing a forbidden key via removeAt', () => {
511
+ const parser = makeParser();
512
+ expect(() => parser.removeAt({ safe: 1 }, ['constructor'])).toThrow(SecurityException);
513
+ });
514
+
515
+ it('throws SecurityException when merge source contains a forbidden key', () => {
516
+ const parser = makeParser();
517
+ expect(() => parser.merge({}, '', { hasOwnProperty: 'bad' })).toThrow(SecurityException);
518
+ });
519
+
520
+ it('throws SecurityException when merge source contains a nested forbidden key', () => {
521
+ const parser = makeParser();
522
+ expect(() => parser.merge({ user: { name: 'Alice' } }, '', { user: { prototype: 'bad' } as Record<string, unknown> })).toThrow(SecurityException);
523
+ });
524
+
525
+ it('allows safe keys through write-path operations', () => {
526
+ const parser = makeParser();
527
+ expect(parser.set({}, 'username', 'Alice')).toEqual({ username: 'Alice' });
528
+ expect(parser.remove({ username: 'Alice' }, 'username')).toEqual({});
529
+ expect(parser.merge({}, '', { name: 'Bob' })).toEqual({ name: 'Bob' });
530
+ });
531
+
532
+ it('write-path error message contains the forbidden key name', () => {
533
+ const parser = makeParser();
534
+ expect(() => parser.set({}, 'hasOwnProperty', 'bad')).toThrow("Forbidden key 'hasOwnProperty' detected.");
535
+ });
536
+
537
+ it('throws SecurityException for prototype pollution key via set', () => {
538
+ const parser = makeParser();
539
+ expect(() => parser.set({}, 'prototype', 'bad')).toThrow(SecurityException);
540
+ });
541
+ });
542
+
543
+ describe(`${DotNotationParser.name} > deepMerge branch conditions`, () => {
544
+ it('recursively merges when both target and source values are objects', () => {
545
+ const parser = makeParser();
546
+ const result = parser.merge({ a: { x: 1 } }, 'a', { y: 2 });
547
+ expect(result).toEqual({ a: { x: 1, y: 2 } });
548
+ });
549
+
550
+ it('overwrites when source value is null (not an object)', () => {
551
+ const parser = makeParser();
552
+ const result = parser.merge({ a: { x: 1 } }, 'a', { x: null as unknown as Record<string, unknown> });
553
+ expect((result['a'] as Record<string, unknown>)['x']).toBeNull();
554
+ });
555
+
556
+ it('overwrites when source value is an array (not a plain object)', () => {
557
+ const parser = makeParser();
558
+ const result = parser.merge({ a: { x: 1 } }, '', { a: [1, 2, 3] as unknown as Record<string, unknown> });
559
+ expect(result['a']).toEqual([1, 2, 3]);
560
+ });
561
+
562
+ it('overwrites when target value is null (not an object) and source is an object', () => {
563
+ const parser = makeParser();
564
+ const result = parser.merge({ a: null }, '', { a: { key: 'val' } as Record<string, unknown> });
565
+ expect(result['a']).toEqual({ key: 'val' });
566
+ });
567
+
568
+ it('overwrites when target value is an array and source is an object', () => {
569
+ const parser = makeParser();
570
+ const result = parser.merge({ a: [1, 2] }, '', { a: { key: 'val' } as unknown as Record<string, unknown> });
571
+ expect(result['a']).toEqual({ key: 'val' });
572
+ });
573
+ });
574
+
575
+ describe(`${DotNotationParser.name} > getMaxKeys`, () => {
576
+ it('returns the max key count from the configured SecurityParser', () => {
577
+ const parser = new DotNotationParser(
578
+ new SecurityGuard(),
579
+ new SecurityParser({ maxKeys: 42 }),
580
+ );
581
+ expect(parser.getMaxKeys()).toBe(42);
582
+ });
583
+
584
+ it('returns the default max key count when not overridden', () => {
585
+ expect(makeParser().getMaxKeys()).toBe(10_000);
586
+ });
587
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { ParserException } from '../../src/exceptions/parser-exception.js';
3
+ import { AccessorException } from '../../src/exceptions/accessor-exception.js';
4
+
5
+ describe(ParserException.name, () => {
6
+ it('stores the provided message', () => {
7
+ expect(new ParserException('Parser failed.').message).toBe('Parser failed.');
8
+ });
9
+
10
+ it('sets name to ParserException', () => {
11
+ expect(new ParserException('msg').name).toBe('ParserException');
12
+ });
13
+
14
+ it('is an instance of AccessorException', () => {
15
+ expect(new ParserException('msg')).toBeInstanceOf(AccessorException);
16
+ });
17
+
18
+ it('is an instance of Error', () => {
19
+ expect(new ParserException('msg')).toBeInstanceOf(Error);
20
+ });
21
+
22
+ it('supports cause chaining via ErrorOptions', () => {
23
+ const cause = new Error('root cause');
24
+ const err = new ParserException('outer', { cause });
25
+ expect(err.cause).toBe(cause);
26
+ });
27
+
28
+ it('can be constructed without options', () => {
29
+ expect(() => new ParserException('msg')).not.toThrow();
30
+ });
31
+ });