@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.
- package/.gitattributes +16 -0
- package/.gitkeep +0 -0
- package/CHANGELOG.md +38 -0
- package/LICENSE +21 -0
- package/README.md +454 -0
- package/benchmarks/get.bench.ts +26 -0
- package/benchmarks/parse.bench.ts +41 -0
- package/dist/accessors/abstract-accessor.d.ts +213 -0
- package/dist/accessors/abstract-accessor.js +294 -0
- package/dist/accessors/formats/any-accessor.d.ts +35 -0
- package/dist/accessors/formats/any-accessor.js +44 -0
- package/dist/accessors/formats/array-accessor.d.ts +26 -0
- package/dist/accessors/formats/array-accessor.js +39 -0
- package/dist/accessors/formats/env-accessor.d.ts +27 -0
- package/dist/accessors/formats/env-accessor.js +64 -0
- package/dist/accessors/formats/ini-accessor.d.ts +41 -0
- package/dist/accessors/formats/ini-accessor.js +109 -0
- package/dist/accessors/formats/json-accessor.d.ts +26 -0
- package/dist/accessors/formats/json-accessor.js +56 -0
- package/dist/accessors/formats/ndjson-accessor.d.ts +28 -0
- package/dist/accessors/formats/ndjson-accessor.js +71 -0
- package/dist/accessors/formats/object-accessor.d.ts +48 -0
- package/dist/accessors/formats/object-accessor.js +90 -0
- package/dist/accessors/formats/xml-accessor.d.ts +27 -0
- package/dist/accessors/formats/xml-accessor.js +52 -0
- package/dist/accessors/formats/yaml-accessor.d.ts +29 -0
- package/dist/accessors/formats/yaml-accessor.js +46 -0
- package/dist/contracts/accessors-interface.d.ts +11 -0
- package/dist/contracts/accessors-interface.js +1 -0
- package/dist/contracts/factory-accessors-interface.d.ts +16 -0
- package/dist/contracts/factory-accessors-interface.js +1 -0
- package/dist/contracts/parse-integration-interface.d.ts +31 -0
- package/dist/contracts/parse-integration-interface.js +1 -0
- package/dist/contracts/path-cache-interface.d.ts +40 -0
- package/dist/contracts/path-cache-interface.js +1 -0
- package/dist/contracts/readable-accessors-interface.d.ts +79 -0
- package/dist/contracts/readable-accessors-interface.js +1 -0
- package/dist/contracts/security-guard-interface.d.ts +40 -0
- package/dist/contracts/security-guard-interface.js +1 -0
- package/dist/contracts/security-parser-interface.d.ts +67 -0
- package/dist/contracts/security-parser-interface.js +1 -0
- package/dist/contracts/writable-accessors-interface.d.ts +65 -0
- package/dist/contracts/writable-accessors-interface.js +1 -0
- package/dist/core/dot-notation-parser.d.ts +204 -0
- package/dist/core/dot-notation-parser.js +343 -0
- package/dist/exceptions/accessor-exception.d.ts +13 -0
- package/dist/exceptions/accessor-exception.js +16 -0
- package/dist/exceptions/invalid-format-exception.d.ts +14 -0
- package/dist/exceptions/invalid-format-exception.js +17 -0
- package/dist/exceptions/parser-exception.d.ts +14 -0
- package/dist/exceptions/parser-exception.js +17 -0
- package/dist/exceptions/path-not-found-exception.d.ts +14 -0
- package/dist/exceptions/path-not-found-exception.js +17 -0
- package/dist/exceptions/readonly-violation-exception.d.ts +15 -0
- package/dist/exceptions/readonly-violation-exception.js +18 -0
- package/dist/exceptions/security-exception.d.ts +18 -0
- package/dist/exceptions/security-exception.js +21 -0
- package/dist/exceptions/unsupported-type-exception.d.ts +14 -0
- package/dist/exceptions/unsupported-type-exception.js +17 -0
- package/dist/exceptions/yaml-parse-exception.d.ts +17 -0
- package/dist/exceptions/yaml-parse-exception.js +20 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +30 -0
- package/dist/inline.d.ts +402 -0
- package/dist/inline.js +512 -0
- package/dist/parser/xml-parser.d.ts +46 -0
- package/dist/parser/xml-parser.js +288 -0
- package/dist/parser/yaml-parser.d.ts +94 -0
- package/dist/parser/yaml-parser.js +286 -0
- package/dist/security/forbidden-keys.d.ts +34 -0
- package/dist/security/forbidden-keys.js +80 -0
- package/dist/security/security-guard.d.ts +94 -0
- package/dist/security/security-guard.js +172 -0
- package/dist/security/security-parser.d.ts +130 -0
- package/dist/security/security-parser.js +192 -0
- package/dist/type-format.d.ts +28 -0
- package/dist/type-format.js +29 -0
- package/eslint.config.js +1 -0
- package/package.json +39 -0
- package/src/accessors/abstract-accessor.ts +353 -0
- package/src/accessors/formats/any-accessor.ts +51 -0
- package/src/accessors/formats/array-accessor.ts +45 -0
- package/src/accessors/formats/env-accessor.ts +79 -0
- package/src/accessors/formats/ini-accessor.ts +124 -0
- package/src/accessors/formats/json-accessor.ts +66 -0
- package/src/accessors/formats/ndjson-accessor.ts +82 -0
- package/src/accessors/formats/object-accessor.ts +100 -0
- package/src/accessors/formats/xml-accessor.ts +58 -0
- package/src/accessors/formats/yaml-accessor.ts +52 -0
- package/src/contracts/accessors-interface.ts +12 -0
- package/src/contracts/factory-accessors-interface.ts +16 -0
- package/src/contracts/parse-integration-interface.ts +32 -0
- package/src/contracts/path-cache-interface.ts +43 -0
- package/src/contracts/readable-accessors-interface.ts +88 -0
- package/src/contracts/security-guard-interface.ts +43 -0
- package/src/contracts/security-parser-interface.ts +74 -0
- package/src/contracts/writable-accessors-interface.ts +70 -0
- package/src/core/dot-notation-parser.ts +419 -0
- package/src/exceptions/accessor-exception.ts +16 -0
- package/src/exceptions/invalid-format-exception.ts +18 -0
- package/src/exceptions/parser-exception.ts +18 -0
- package/src/exceptions/path-not-found-exception.ts +18 -0
- package/src/exceptions/readonly-violation-exception.ts +19 -0
- package/src/exceptions/security-exception.ts +22 -0
- package/src/exceptions/unsupported-type-exception.ts +18 -0
- package/src/exceptions/yaml-parse-exception.ts +21 -0
- package/src/index.ts +46 -0
- package/src/inline.ts +570 -0
- package/src/parser/xml-parser.ts +334 -0
- package/src/parser/yaml-parser.ts +368 -0
- package/src/security/forbidden-keys.ts +81 -0
- package/src/security/security-guard.ts +195 -0
- package/src/security/security-parser.ts +233 -0
- package/src/type-format.ts +28 -0
- package/stryker.config.json +24 -0
- package/tests/accessors/accessors.test.ts +1017 -0
- package/tests/accessors/json-accessor.test.ts +171 -0
- package/tests/core/dot-notation-parser.test.ts +587 -0
- package/tests/exceptions/parser-exception.test.ts +31 -0
- package/tests/inline.test.ts +445 -0
- package/tests/mocks/fake-parse-integration.ts +24 -0
- package/tests/mocks/fake-path-cache.ts +31 -0
- package/tests/parity.test.ts +164 -0
- package/tests/parser/xml-parser.test.ts +618 -0
- package/tests/parser/yaml-parser.test.ts +463 -0
- package/tests/security/security-guard.test.ts +646 -0
- package/tests/security/security-parser.test.ts +391 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Inline } from '../src/inline.js';
|
|
3
|
+
import { TypeFormat } from '../src/type-format.js';
|
|
4
|
+
import { ArrayAccessor } from '../src/accessors/formats/array-accessor.js';
|
|
5
|
+
import { JsonAccessor } from '../src/accessors/formats/json-accessor.js';
|
|
6
|
+
import { AnyAccessor } from '../src/accessors/formats/any-accessor.js';
|
|
7
|
+
import { XmlAccessor } from '../src/accessors/formats/xml-accessor.js';
|
|
8
|
+
import { InvalidFormatException } from '../src/exceptions/invalid-format-exception.js';
|
|
9
|
+
import { SecurityException } from '../src/exceptions/security-exception.js';
|
|
10
|
+
import { UnsupportedTypeException } from '../src/exceptions/unsupported-type-exception.js';
|
|
11
|
+
import { SecurityGuard } from '../src/security/security-guard.js';
|
|
12
|
+
import { SecurityParser } from '../src/security/security-parser.js';
|
|
13
|
+
import { FakeParseIntegration } from './mocks/fake-parse-integration.js';
|
|
14
|
+
import { FakePathCache } from './mocks/fake-path-cache.js';
|
|
15
|
+
|
|
16
|
+
describe(Inline.name, () => {
|
|
17
|
+
it('creates a JsonAccessor via fromJson', () => {
|
|
18
|
+
const accessor = Inline.fromJson('{"name":"Alice"}');
|
|
19
|
+
expect(accessor).toBeInstanceOf(JsonAccessor);
|
|
20
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('creates an ArrayAccessor via fromArray', () => {
|
|
24
|
+
const accessor = Inline.fromArray({ key: 'value' });
|
|
25
|
+
expect(accessor).toBeInstanceOf(ArrayAccessor);
|
|
26
|
+
expect(accessor.get('key')).toBe('value');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('creates an ArrayAccessor from a JS array', () => {
|
|
30
|
+
const accessor = Inline.fromArray(['a', 'b', 'c']);
|
|
31
|
+
expect(accessor.get('0')).toBe('a');
|
|
32
|
+
expect(accessor.get('2')).toBe('c');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe(`${Inline.name} > from`, () => {
|
|
37
|
+
it('routes TypeFormat.Json to JsonAccessor', () => {
|
|
38
|
+
const accessor = Inline.from(TypeFormat.Json, '{"key":"value"}');
|
|
39
|
+
expect(accessor.get('key')).toBe('value');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('routes TypeFormat.Array to ArrayAccessor (using JS array)', () => {
|
|
43
|
+
// Use an actual array so that only ArrayAccessor can handle it;
|
|
44
|
+
// ObjectAccessor rejects arrays, killing the falls-through mutant.
|
|
45
|
+
const accessor = Inline.from(TypeFormat.Array, ['a', 'b']);
|
|
46
|
+
expect(accessor.get('0')).toBe('a');
|
|
47
|
+
expect(accessor.get('1')).toBe('b');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('routes TypeFormat.Yaml to YamlAccessor', () => {
|
|
51
|
+
const accessor = Inline.from(TypeFormat.Yaml, 'name: Alice');
|
|
52
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('routes TypeFormat.Ini to IniAccessor (INI syntax specific)', () => {
|
|
56
|
+
// Use INI-specific syntax (section); a non-INI accessor would not parse this
|
|
57
|
+
const accessor = Inline.from(TypeFormat.Ini, '[db]\nhost=localhost');
|
|
58
|
+
expect(accessor.get('db.host')).toBe('localhost');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('routes TypeFormat.Env to EnvAccessor', () => {
|
|
62
|
+
const accessor = Inline.from(TypeFormat.Env, 'APP=test');
|
|
63
|
+
expect(accessor.get('APP')).toBe('test');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('routes TypeFormat.Ndjson to NdjsonAccessor', () => {
|
|
67
|
+
const accessor = Inline.from(TypeFormat.Ndjson, '{"id":1}\n{"id":2}');
|
|
68
|
+
expect(accessor.get('0.id')).toBe(1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('routes TypeFormat.Object to ObjectAccessor', () => {
|
|
72
|
+
const accessor = Inline.from(TypeFormat.Object, { name: 'Alice' });
|
|
73
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('routes TypeFormat.Xml to XmlAccessor', () => {
|
|
77
|
+
const accessor = Inline.from(TypeFormat.Xml, '<root><key>value</key></root>');
|
|
78
|
+
expect(accessor.get('key')).toBe('value');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('resolves nested path through facade', () => {
|
|
82
|
+
const accessor = Inline.fromJson('{"user":{"name":"Alice"}}');
|
|
83
|
+
expect(accessor.get('user.name')).toBe('Alice');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe(`${Inline.name} > security`, () => {
|
|
88
|
+
it('throws SecurityException for forbidden key __proto__ in JSON', () => {
|
|
89
|
+
expect(() => Inline.fromJson('{"__proto__":"bad"}')).toThrow(SecurityException);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('throws SecurityException for stream wrapper key in JSON', () => {
|
|
93
|
+
expect(() => Inline.fromJson('{"javascript:alert":"bad"}')).toThrow(SecurityException);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe(`${Inline.name} > withSecurityGuard`, () => {
|
|
98
|
+
it('applies custom SecurityGuard', () => {
|
|
99
|
+
const guard = new SecurityGuard(512, ['custom_blocked']);
|
|
100
|
+
const accessor = Inline.withSecurityGuard(guard).fromJson('{"safe":"value"}');
|
|
101
|
+
expect(accessor.get('safe')).toBe('value');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('enforces extra forbidden keys from custom guard', () => {
|
|
105
|
+
const guard = new SecurityGuard(512, ['custom_blocked']);
|
|
106
|
+
expect(() => Inline.withSecurityGuard(guard).fromJson('{"custom_blocked":"bad"}')).toThrow(
|
|
107
|
+
SecurityException,
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe(`${Inline.name} > withSecurityParser`, () => {
|
|
113
|
+
it('applies custom SecurityParser with tighter limits', () => {
|
|
114
|
+
const secParser = new SecurityParser({ maxKeys: 2 });
|
|
115
|
+
expect(() => Inline.withSecurityParser(secParser).fromJson('{"a":1,"b":2,"c":3}')).toThrow(
|
|
116
|
+
SecurityException,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('passes when within custom limits', () => {
|
|
121
|
+
const secParser = new SecurityParser({ maxKeys: 10 });
|
|
122
|
+
const accessor = Inline.withSecurityParser(secParser).fromJson('{"a":1}');
|
|
123
|
+
expect(accessor.get('a')).toBe(1);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe(`${Inline.name} > format factories`, () => {
|
|
128
|
+
it('creates XmlAccessor via fromXml', () => {
|
|
129
|
+
const accessor = Inline.fromXml('<root><item>hello</item></root>');
|
|
130
|
+
expect(accessor).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('creates YamlAccessor via fromYaml', () => {
|
|
134
|
+
const accessor = Inline.fromYaml('name: Alice\nage: 30');
|
|
135
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('creates IniAccessor via fromIni', () => {
|
|
139
|
+
const accessor = Inline.fromIni('[section]\nkey=value');
|
|
140
|
+
expect(accessor.get('section.key')).toBe('value');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('creates EnvAccessor via fromEnv', () => {
|
|
144
|
+
const accessor = Inline.fromEnv('KEY=val');
|
|
145
|
+
expect(accessor.get('KEY')).toBe('val');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('creates NdjsonAccessor via fromNdjson', () => {
|
|
149
|
+
const accessor = Inline.fromNdjson('{"id":1}');
|
|
150
|
+
expect(accessor.get('0.id')).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('creates ObjectAccessor via fromObject', () => {
|
|
154
|
+
const accessor = Inline.fromObject({ foo: 'bar' });
|
|
155
|
+
expect(accessor.get('foo')).toBe('bar');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('throws InvalidFormatException for invalid format input', () => {
|
|
159
|
+
expect(() => Inline.fromJson(42 as unknown as string)).toThrow(InvalidFormatException);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('throws UnsupportedTypeException for unknown TypeFormat', () => {
|
|
163
|
+
expect(() => Inline.from('unsupported' as TypeFormat, '{}')).toThrow(
|
|
164
|
+
UnsupportedTypeException,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('UnsupportedTypeException message contains the unsupported format value', () => {
|
|
169
|
+
expect(() => Inline.from('bad_format' as TypeFormat, '{}')).toThrow(
|
|
170
|
+
/TypeFormat 'bad_format' is not supported/,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe(`${Inline.name} > fromAny`, () => {
|
|
176
|
+
it('delegates to AnyAccessor when an integration is provided', () => {
|
|
177
|
+
const integration = new FakeParseIntegration(true, { result: 42 });
|
|
178
|
+
const accessor = Inline.fromAny('raw-input', integration);
|
|
179
|
+
expect(accessor).toBeInstanceOf(AnyAccessor);
|
|
180
|
+
expect(accessor.get('result')).toBe(42);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('uses integration from withParserIntegration builder', () => {
|
|
184
|
+
const integration = new FakeParseIntegration(true, { x: 1 });
|
|
185
|
+
const accessor = Inline.withParserIntegration(integration).fromAny('raw');
|
|
186
|
+
expect(accessor.get('x')).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('throws InvalidFormatException when integration rejects the input', () => {
|
|
190
|
+
const integration = new FakeParseIntegration(false, {});
|
|
191
|
+
expect(() => Inline.fromAny('bad-input', integration)).toThrow(InvalidFormatException);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('inline integration override takes precedence over builder integration', () => {
|
|
195
|
+
const builderIntegration = new FakeParseIntegration(true, { from: 'builder' });
|
|
196
|
+
const overrideIntegration = new FakeParseIntegration(true, { from: 'override' });
|
|
197
|
+
const accessor = Inline.withParserIntegration(builderIntegration).fromAny(
|
|
198
|
+
'raw',
|
|
199
|
+
overrideIntegration,
|
|
200
|
+
);
|
|
201
|
+
expect(accessor.get('from')).toBe('override');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('resolves nested path through AnyAccessor', () => {
|
|
205
|
+
const integration = new FakeParseIntegration(true, { user: { name: 'Alice' } });
|
|
206
|
+
const accessor = Inline.fromAny('raw', integration);
|
|
207
|
+
expect(accessor.get('user.name')).toBe('Alice');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('throws InvalidFormatException when no integration is available', () => {
|
|
211
|
+
expect(() =>
|
|
212
|
+
(Inline as unknown as { fromAny(d: unknown): AnyAccessor }).fromAny('data'),
|
|
213
|
+
).toThrow(InvalidFormatException);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('throws InvalidFormatException with guidance message when no integration is set', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
(Inline as unknown as { fromAny(d: unknown): AnyAccessor }).fromAny('data'),
|
|
219
|
+
).toThrow('AnyAccessor requires a ParseIntegrationInterface');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('TypeFormat.Any with withParserIntegration resolves via from()', () => {
|
|
223
|
+
const integration = new FakeParseIntegration(true, { key: 'value' });
|
|
224
|
+
const accessor = Inline.withParserIntegration(integration).from(TypeFormat.Any, 'raw');
|
|
225
|
+
expect(accessor.get('key')).toBe('value');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe(`${Inline.name} > make`, () => {
|
|
230
|
+
it('creates an ArrayAccessor by constructor reference', () => {
|
|
231
|
+
const accessor = Inline.make(ArrayAccessor, { n: 1 });
|
|
232
|
+
expect(accessor).toBeInstanceOf(ArrayAccessor);
|
|
233
|
+
expect(accessor.get('n')).toBe(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('creates a JsonAccessor by constructor reference', () => {
|
|
237
|
+
const accessor = Inline.make(JsonAccessor, '{"k":"v"}');
|
|
238
|
+
expect(accessor).toBeInstanceOf(JsonAccessor);
|
|
239
|
+
expect(accessor.get('k')).toBe('v');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('creates an XmlAccessor by constructor reference', () => {
|
|
243
|
+
const accessor = Inline.make(XmlAccessor, '<root><item>hello</item></root>');
|
|
244
|
+
expect(accessor).toBeInstanceOf(XmlAccessor);
|
|
245
|
+
expect(accessor.get('item')).toBe('hello');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('propagates custom SecurityParser through make()', () => {
|
|
249
|
+
const secParser = new SecurityParser({ maxKeys: 1 });
|
|
250
|
+
expect(() =>
|
|
251
|
+
Inline.withSecurityParser(secParser).make(JsonAccessor, '{"a":1,"b":2}'),
|
|
252
|
+
).toThrow(SecurityException);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('resolves nested path through make()', () => {
|
|
256
|
+
const accessor = Inline.make(JsonAccessor, '{"user":{"name":"Alice"}}');
|
|
257
|
+
expect(accessor.get('user.name')).toBe('Alice');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('instance make() uses configured SecurityGuard for extra forbidden keys', () => {
|
|
261
|
+
const guard = new SecurityGuard(512, ['blocked_key']);
|
|
262
|
+
expect(() =>
|
|
263
|
+
Inline.withSecurityGuard(guard).make(JsonAccessor, '{"blocked_key":"x"}'),
|
|
264
|
+
).toThrow(SecurityException);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('throws InvalidFormatException when making AnyAccessor without integration', () => {
|
|
268
|
+
expect(() => Inline.make(AnyAccessor, 'data')).toThrow(TypeError);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe(`${Inline.name} > withPathCache`, () => {
|
|
273
|
+
it('uses the custom cache when resolving paths', () => {
|
|
274
|
+
const cache = new FakePathCache();
|
|
275
|
+
Inline.withPathCache(cache).fromJson('{"name":"Alice"}').get('name');
|
|
276
|
+
expect(cache.setCallCount).toBeGreaterThanOrEqual(1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('serves path segments from cache on repeated access', () => {
|
|
280
|
+
const cache = new FakePathCache();
|
|
281
|
+
const instance = Inline.withPathCache(cache);
|
|
282
|
+
instance.fromJson('{"name":"Alice"}').get('name');
|
|
283
|
+
const getCountAfterFirst = cache.getCallCount;
|
|
284
|
+
instance.fromJson('{"name":"Bob"}').get('name');
|
|
285
|
+
expect(cache.getCallCount).toBeGreaterThan(getCountAfterFirst);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('returns a new Inline instance (immutability)', () => {
|
|
289
|
+
const cache = new FakePathCache();
|
|
290
|
+
const a = Inline.withSecurityGuard(new SecurityGuard());
|
|
291
|
+
const b = a.withPathCache(cache);
|
|
292
|
+
expect(b).not.toBe(a);
|
|
293
|
+
// The returned instance must be functional (uses the cache)
|
|
294
|
+
b.fromJson('{"k":1}').get('k');
|
|
295
|
+
expect(cache.setCallCount).toBeGreaterThanOrEqual(1);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('resolves correct value after cache warmup', () => {
|
|
299
|
+
const cache = new FakePathCache();
|
|
300
|
+
const accessor = Inline.withPathCache(cache).fromJson('{"user":{"age":30}}');
|
|
301
|
+
expect(accessor.get('user.age')).toBe(30);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('caches nested dot-notation paths', () => {
|
|
305
|
+
const cache = new FakePathCache();
|
|
306
|
+
Inline.withPathCache(cache).fromJson('{"a":{"b":{"c":1}}}').get('a.b.c');
|
|
307
|
+
expect(cache.has('a.b.c')).toBe(true);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('clear() empties the cache store', () => {
|
|
311
|
+
const cache = new FakePathCache();
|
|
312
|
+
Inline.withPathCache(cache).fromJson('{"k":"v"}').get('k');
|
|
313
|
+
cache.clear();
|
|
314
|
+
expect(cache.store.size).toBe(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('withPathCache combines correctly with withSecurityParser', () => {
|
|
318
|
+
const cache = new FakePathCache();
|
|
319
|
+
const secParser = new SecurityParser({ maxKeys: 10 });
|
|
320
|
+
const accessor = Inline.withSecurityParser(secParser).fromJson('{"a":1}');
|
|
321
|
+
expect(accessor.get('a')).toBe(1);
|
|
322
|
+
// Wire cache via instance builder
|
|
323
|
+
const withCache = Inline.withPathCache(cache)
|
|
324
|
+
.withSecurityParser(secParser)
|
|
325
|
+
.fromJson('{"a":1}');
|
|
326
|
+
expect(withCache.get('a')).toBe(1);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe(`${Inline.name} > withParserIntegration`, () => {
|
|
331
|
+
it('returns a new Inline instance (immutability)', () => {
|
|
332
|
+
const integration = new FakeParseIntegration(true, { v: 1 });
|
|
333
|
+
const a = Inline.withSecurityGuard(new SecurityGuard());
|
|
334
|
+
const b = a.withParserIntegration(integration);
|
|
335
|
+
expect(b).not.toBe(a);
|
|
336
|
+
// The returned instance must be functional
|
|
337
|
+
expect(b.fromAny('raw').get('v')).toBe(1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('wires the AnyAccessor factory when integration is set', () => {
|
|
341
|
+
const integration = new FakeParseIntegration(true, { result: 42 });
|
|
342
|
+
const accessor = Inline.withParserIntegration(integration).fromAny('raw-input');
|
|
343
|
+
expect(accessor.get('result')).toBe(42);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('throws InvalidFormatException when integration rejects the format', () => {
|
|
347
|
+
const integration = new FakeParseIntegration(false, {});
|
|
348
|
+
expect(() => Inline.withParserIntegration(integration).fromAny('bad')).toThrow(
|
|
349
|
+
InvalidFormatException,
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('returns an accessor with no data when integration returns empty object', () => {
|
|
354
|
+
const integration = new FakeParseIntegration(true, {});
|
|
355
|
+
const accessor = Inline.withParserIntegration(integration).fromAny('raw');
|
|
356
|
+
expect(accessor.get('missing')).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('resolves nested path via integration', () => {
|
|
360
|
+
const integration = new FakeParseIntegration(true, { a: { b: 99 } });
|
|
361
|
+
const accessor = Inline.withParserIntegration(integration).fromAny('raw');
|
|
362
|
+
expect(accessor.get('a.b')).toBe(99);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('combines withParserIntegration and withSecurityGuard', () => {
|
|
366
|
+
const integration = new FakeParseIntegration(true, { safe: 1 });
|
|
367
|
+
const guard = new SecurityGuard(512, ['danger']);
|
|
368
|
+
const accessor = Inline.withParserIntegration(integration)
|
|
369
|
+
.withSecurityGuard(guard)
|
|
370
|
+
.fromAny('raw');
|
|
371
|
+
expect(accessor.get('safe')).toBe(1);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('TypeFormat.Any routes to fromAny() when integration is configured via withParserIntegration', () => {
|
|
375
|
+
const integration = new FakeParseIntegration(true, { routed: true });
|
|
376
|
+
const accessor = Inline.withParserIntegration(integration).from(TypeFormat.Any, 'raw');
|
|
377
|
+
expect(accessor.get('routed')).toBe(true);
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe(`${Inline.name} > withStrictMode`, () => {
|
|
382
|
+
it('withStrictMode(false) bypasses payload size validation', () => {
|
|
383
|
+
const secParser = new SecurityParser({ maxPayloadBytes: 5 });
|
|
384
|
+
const accessor = Inline.withSecurityParser(secParser)
|
|
385
|
+
.withStrictMode(false)
|
|
386
|
+
.fromJson('{"name":"Alice"}');
|
|
387
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('withStrictMode(true) enforces payload size validation', () => {
|
|
391
|
+
const secParser = new SecurityParser({ maxPayloadBytes: 5 });
|
|
392
|
+
expect(() =>
|
|
393
|
+
Inline.withSecurityParser(secParser).withStrictMode(true).fromJson('{"name":"Alice"}'),
|
|
394
|
+
).toThrow(SecurityException);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('withStrictMode(false) bypasses forbidden key validation', () => {
|
|
398
|
+
const accessor = Inline.withStrictMode(false).fromJson('{"__proto__":"injected"}');
|
|
399
|
+
expect(accessor.get('__proto__')).toBe('injected');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('withStrictMode(true) enforces forbidden key validation', () => {
|
|
403
|
+
expect(() =>
|
|
404
|
+
Inline.withStrictMode(true).fromJson('{"__proto__":"injected"}'),
|
|
405
|
+
).toThrow(SecurityException);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('default strict mode enforces security', () => {
|
|
409
|
+
expect(() => Inline.fromJson('{"__proto__":"injected"}')).toThrow(SecurityException);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('withStrictMode(false) works with fromArray', () => {
|
|
413
|
+
const accessor = Inline.withStrictMode(false).fromArray({
|
|
414
|
+
constructor: 'ok',
|
|
415
|
+
});
|
|
416
|
+
expect(accessor.get('constructor')).toBe('ok');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('withStrictMode(false) works with fromYaml', () => {
|
|
420
|
+
const secParser = new SecurityParser({ maxPayloadBytes: 2 });
|
|
421
|
+
const accessor = Inline.withSecurityParser(secParser)
|
|
422
|
+
.withStrictMode(false)
|
|
423
|
+
.fromYaml('name: Alice');
|
|
424
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it('withStrictMode chains with other builder methods', () => {
|
|
428
|
+
const cache = new FakePathCache();
|
|
429
|
+
const accessor = Inline.withStrictMode(false)
|
|
430
|
+
.withPathCache(cache)
|
|
431
|
+
.fromJson('{"__proto__":"ok"}');
|
|
432
|
+
expect(accessor.get('__proto__')).toBe('ok');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('withStrictMode(false) propagates through make()', () => {
|
|
436
|
+
const accessor = Inline.withStrictMode(false).make(JsonAccessor, '{"__proto__":"ok"}');
|
|
437
|
+
expect(accessor.get('__proto__')).toBe('ok');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('withStrictMode(true) propagates through make()', () => {
|
|
441
|
+
expect(() =>
|
|
442
|
+
Inline.withStrictMode(true).make(JsonAccessor, '{"__proto__":"injected"}'),
|
|
443
|
+
).toThrow(SecurityException);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ParseIntegrationInterface } from '../../src/contracts/parse-integration-interface.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fake ParseIntegrationInterface for use in tests.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export class FakeParseIntegration implements ParseIntegrationInterface {
|
|
9
|
+
private readonly accepts: boolean;
|
|
10
|
+
private readonly parsed: Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
constructor(accepts: boolean = true, parsed: Record<string, unknown> = {}) {
|
|
13
|
+
this.accepts = accepts;
|
|
14
|
+
this.parsed = parsed;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
assertFormat(_raw: unknown): boolean {
|
|
18
|
+
return this.accepts;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
parse(_raw: unknown): Record<string, unknown> {
|
|
22
|
+
return this.parsed;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PathCacheInterface } from '../../src/contracts/path-cache-interface.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fake PathCacheInterface for use in tests.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
export class FakePathCache implements PathCacheInterface {
|
|
9
|
+
public readonly store: Map<string, string[]> = new Map();
|
|
10
|
+
public getCallCount: number = 0;
|
|
11
|
+
public setCallCount: number = 0;
|
|
12
|
+
|
|
13
|
+
get(path: string): string[] | null {
|
|
14
|
+
this.getCallCount++;
|
|
15
|
+
return this.store.get(path) ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
set(path: string, segments: string[]): void {
|
|
19
|
+
this.setCallCount++;
|
|
20
|
+
this.store.set(path, segments);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
has(path: string): boolean {
|
|
24
|
+
return this.store.has(path);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
clear(): this {
|
|
28
|
+
this.store.clear();
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Inline } from '../src/inline.js';
|
|
3
|
+
import { ObjectAccessor } from '../src/accessors/formats/object-accessor.js';
|
|
4
|
+
import { IniAccessor } from '../src/accessors/formats/ini-accessor.js';
|
|
5
|
+
import { EnvAccessor } from '../src/accessors/formats/env-accessor.js';
|
|
6
|
+
import { NdjsonAccessor } from '../src/accessors/formats/ndjson-accessor.js';
|
|
7
|
+
import { JsonAccessor } from '../src/accessors/formats/json-accessor.js';
|
|
8
|
+
import { DotNotationParser } from '../src/core/dot-notation-parser.js';
|
|
9
|
+
import { SecurityGuard } from '../src/security/security-guard.js';
|
|
10
|
+
import { SecurityParser } from '../src/security/security-parser.js';
|
|
11
|
+
import { SecurityException } from '../src/exceptions/security-exception.js';
|
|
12
|
+
|
|
13
|
+
describe('Inline.fromObject (static)', () => {
|
|
14
|
+
it('returns correct accessor and resolves property', () => {
|
|
15
|
+
const accessor = Inline.fromObject({ user: { name: 'Alice' } });
|
|
16
|
+
expect(accessor.get('user.name')).toBe('Alice');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('Inline.make (parity)', () => {
|
|
21
|
+
it('creates IniAccessor by constructor', () => {
|
|
22
|
+
const accessor = Inline.make(IniAccessor, '[section]\nkey=value');
|
|
23
|
+
expect(accessor.get('section.key')).toBe('value');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('creates EnvAccessor by constructor', () => {
|
|
27
|
+
const accessor = Inline.make(EnvAccessor, 'APP_NAME=MyApp');
|
|
28
|
+
expect(accessor.get('APP_NAME')).toBe('MyApp');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('creates NdjsonAccessor by constructor', () => {
|
|
32
|
+
const accessor = Inline.make(NdjsonAccessor, '{"id":1}\n{"id":2}');
|
|
33
|
+
expect(accessor.get('0.id')).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('creates ObjectAccessor by constructor', () => {
|
|
37
|
+
const accessor = Inline.make(ObjectAccessor, { name: 'Alice' });
|
|
38
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('AbstractAccessor.getMany (parity)', () => {
|
|
43
|
+
it('returns multiple values keyed by path', () => {
|
|
44
|
+
const accessor = Inline.fromArray({ a: 1, b: { c: 2 } });
|
|
45
|
+
const result = accessor.getMany({ a: null, 'b.c': null });
|
|
46
|
+
expect(result).toEqual({ a: 1, 'b.c': 2 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('uses provided default for missing paths', () => {
|
|
50
|
+
const accessor = Inline.fromArray({ a: 1 });
|
|
51
|
+
const result = accessor.getMany({ a: null, missing: 'fallback' });
|
|
52
|
+
expect(result).toEqual({ a: 1, missing: 'fallback' });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('AbstractAccessor.getRaw (parity)', () => {
|
|
57
|
+
it('stores raw input for ArrayAccessor', () => {
|
|
58
|
+
const raw = { name: 'Alice', age: 30 };
|
|
59
|
+
const accessor = Inline.fromArray(raw);
|
|
60
|
+
expect(accessor.getRaw()).toEqual(raw);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('stores raw input for JsonAccessor', () => {
|
|
64
|
+
const raw = '{"name":"Alice"}';
|
|
65
|
+
const accessor = Inline.fromJson(raw);
|
|
66
|
+
expect(accessor.getRaw()).toBe(raw);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('stores raw input for YamlAccessor', () => {
|
|
70
|
+
const raw = 'name: Alice';
|
|
71
|
+
const accessor = Inline.fromYaml(raw);
|
|
72
|
+
expect(accessor.getRaw()).toBe(raw);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('stores raw input for IniAccessor', () => {
|
|
76
|
+
const raw = '[section]\nkey=value';
|
|
77
|
+
const accessor = Inline.fromIni(raw);
|
|
78
|
+
expect(accessor.getRaw()).toBe(raw);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('stores raw input for EnvAccessor', () => {
|
|
82
|
+
const raw = 'APP_NAME=MyApp';
|
|
83
|
+
const accessor = Inline.fromEnv(raw);
|
|
84
|
+
expect(accessor.getRaw()).toBe(raw);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe(`${Inline.name} > withStrictMode (parity)`, () => {
|
|
89
|
+
it('withStrictMode(false) bypasses payload size validation for JSON', () => {
|
|
90
|
+
const secParser = new SecurityParser({ maxPayloadBytes: 5 });
|
|
91
|
+
const accessor = Inline.withSecurityParser(secParser)
|
|
92
|
+
.withStrictMode(false)
|
|
93
|
+
.fromJson('{"name":"Alice"}');
|
|
94
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('withStrictMode(false) bypasses forbidden key validation for JSON', () => {
|
|
98
|
+
const accessor = Inline.withStrictMode(false).fromJson('{"__proto__":"injected"}');
|
|
99
|
+
expect(accessor.get('__proto__')).toBe('injected');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('withStrictMode(true) enforces forbidden key validation for JSON', () => {
|
|
103
|
+
expect(() =>
|
|
104
|
+
Inline.withStrictMode(true).fromJson('{"__proto__":"injected"}'),
|
|
105
|
+
).toThrow(SecurityException);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('strict(false) bypasses payload size validation for JSON', () => {
|
|
109
|
+
const tinyParser = new SecurityParser({ maxPayloadBytes: 5 });
|
|
110
|
+
const parser = new DotNotationParser(new SecurityGuard(), tinyParser);
|
|
111
|
+
const accessor = new JsonAccessor(parser).strict(false).from('{"name":"Alice"}');
|
|
112
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('strict(false) bypasses forbidden key validation for JSON', () => {
|
|
116
|
+
const parser = new DotNotationParser(new SecurityGuard(), new SecurityParser());
|
|
117
|
+
const accessor = new JsonAccessor(parser).strict(false).from('{"__proto__":"injected"}');
|
|
118
|
+
expect(accessor.get('__proto__')).toBe('injected');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe(`${Inline.name} > withStrictMode + make (parity)`, () => {
|
|
123
|
+
it('withStrictMode(false) bypasses forbidden key validation through make()', () => {
|
|
124
|
+
const accessor = Inline.withStrictMode(false).make(JsonAccessor, '{"__proto__":"ok"}');
|
|
125
|
+
expect(accessor.get('__proto__')).toBe('ok');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('withStrictMode(true) enforces forbidden key validation through make()', () => {
|
|
129
|
+
expect(() =>
|
|
130
|
+
Inline.withStrictMode(true).make(JsonAccessor, '{"__proto__":"injected"}'),
|
|
131
|
+
).toThrow(SecurityException);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('withStrictMode(false) bypasses payload size validation through make()', () => {
|
|
135
|
+
const secParser = new SecurityParser({ maxPayloadBytes: 5 });
|
|
136
|
+
const accessor = Inline.withSecurityParser(secParser)
|
|
137
|
+
.withStrictMode(false)
|
|
138
|
+
.make(JsonAccessor, '{"name":"Alice"}');
|
|
139
|
+
expect(accessor.get('name')).toBe('Alice');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('withStrictMode(true) enforces payload size validation through make()', () => {
|
|
143
|
+
const secParser = new SecurityParser({ maxPayloadBytes: 5 });
|
|
144
|
+
expect(() =>
|
|
145
|
+
Inline.withSecurityParser(secParser)
|
|
146
|
+
.withStrictMode(true)
|
|
147
|
+
.make(JsonAccessor, '{"name":"Alice"}'),
|
|
148
|
+
).toThrow(SecurityException);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('AbstractAccessor.keys (parity)', () => {
|
|
153
|
+
it('returns string keys for object-keyed data (JS and PHP both return string[])', () => {
|
|
154
|
+
const accessor = Inline.fromJson('{"name":"Alice","age":30}');
|
|
155
|
+
expect(accessor.keys()).toEqual(['name', 'age']);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('returns numeric indices as strings for NDJSON (parity with PHP array_map strval fix)', () => {
|
|
159
|
+
// PHP: array_keys(['Alice', 'Bob']) = [0, 1] → cast → ['0', '1']
|
|
160
|
+
// JS: Object.keys({'0': {...}, '1': {...}}) = ['0', '1'] (already strings)
|
|
161
|
+
const accessor = Inline.fromNdjson('{"name":"Alice"}\n{"name":"Bob"}');
|
|
162
|
+
expect(accessor.keys()).toEqual(['0', '1']);
|
|
163
|
+
});
|
|
164
|
+
});
|