apcore-js 0.3.0 → 0.5.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.
- package/.github/workflows/ci.yml +39 -0
- package/CHANGELOG.md +70 -0
- package/package.json +4 -2
- package/src/acl.ts +21 -8
- package/src/async-task.ts +267 -0
- package/src/bindings.ts +6 -0
- package/src/cancel.ts +32 -0
- package/src/context.ts +87 -2
- package/src/errors.ts +7 -2
- package/src/executor.ts +35 -9
- package/src/extensions.ts +265 -0
- package/src/index.ts +18 -4
- package/src/middleware/manager.ts +1 -1
- package/src/observability/context-logger.ts +4 -2
- package/src/observability/metrics.ts +4 -2
- package/src/observability/tracing.ts +73 -8
- package/src/registry/index.ts +1 -0
- package/src/registry/registry.ts +229 -5
- package/src/registry/scanner.ts +28 -10
- package/src/registry/schema-export.ts +10 -3
- package/src/schema/loader.ts +29 -15
- package/src/schema/ref-resolver.ts +14 -2
- package/src/schema/strict.ts +11 -1
- package/src/trace-context.ts +102 -0
- package/tests/async-task.test.ts +335 -0
- package/tests/integration/test-acl-safety.test.ts +2 -1
- package/tests/observability/test-metrics.test.ts +98 -1
- package/tests/observability/test-tracing.test.ts +173 -1
- package/tests/registry/test-registry.test.ts +1258 -1
- package/tests/registry/test-schema-export.test.ts +131 -1
- package/tests/schema/test-loader.test.ts +366 -2
- package/tests/schema/test-ref-resolver.test.ts +427 -2
- package/tests/schema/test-strict.test.ts +209 -0
- package/tests/test-acl.test.ts +218 -1
- package/tests/test-cancel.test.ts +71 -0
- package/tests/test-context.test.ts +115 -0
- package/tests/test-errors.test.ts +448 -5
- package/tests/test-extensions.test.ts +310 -0
- package/tests/test-trace-context.test.ts +251 -0
- package/tests/utils/test-pattern.test.ts +109 -0
package/tests/test-acl.test.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
2
5
|
import { ACL } from '../src/acl.js';
|
|
6
|
+
import { ACLRuleError, ConfigNotFoundError } from '../src/errors.js';
|
|
3
7
|
import { Context, createIdentity } from '../src/context.js';
|
|
4
8
|
|
|
5
9
|
function makeContext(opts: {
|
|
@@ -204,3 +208,216 @@ describe('ACL', () => {
|
|
|
204
208
|
expect(acl.removeRule(['x'], ['y'])).toBe(false);
|
|
205
209
|
});
|
|
206
210
|
});
|
|
211
|
+
|
|
212
|
+
describe('ACL.load', () => {
|
|
213
|
+
let tmpDir: string;
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'acl-test-'));
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
afterEach(() => {
|
|
220
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('loads valid ACL from a YAML file', () => {
|
|
224
|
+
const yamlContent = `
|
|
225
|
+
rules:
|
|
226
|
+
- callers: ["module.a"]
|
|
227
|
+
targets: ["module.b"]
|
|
228
|
+
effect: allow
|
|
229
|
+
description: "allow a to b"
|
|
230
|
+
`;
|
|
231
|
+
const filePath = join(tmpDir, 'acl.yaml');
|
|
232
|
+
writeFileSync(filePath, yamlContent, 'utf-8');
|
|
233
|
+
|
|
234
|
+
const acl = ACL.load(filePath);
|
|
235
|
+
expect(acl.check('module.a', 'module.b')).toBe(true);
|
|
236
|
+
expect(acl.check('module.x', 'module.y')).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('loads ACL with custom default_effect from YAML', () => {
|
|
240
|
+
const yamlContent = `
|
|
241
|
+
default_effect: allow
|
|
242
|
+
rules: []
|
|
243
|
+
`;
|
|
244
|
+
const filePath = join(tmpDir, 'acl.yaml');
|
|
245
|
+
writeFileSync(filePath, yamlContent, 'utf-8');
|
|
246
|
+
|
|
247
|
+
const acl = ACL.load(filePath);
|
|
248
|
+
expect(acl.check('any.caller', 'any.target')).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('throws ConfigNotFoundError for missing file', () => {
|
|
252
|
+
const missingPath = join(tmpDir, 'nonexistent.yaml');
|
|
253
|
+
expect(() => ACL.load(missingPath)).toThrow(ConfigNotFoundError);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('throws ACLRuleError for invalid YAML syntax', () => {
|
|
257
|
+
const filePath = join(tmpDir, 'bad.yaml');
|
|
258
|
+
writeFileSync(filePath, ':\n :\n - [invalid', 'utf-8');
|
|
259
|
+
|
|
260
|
+
expect(() => ACL.load(filePath)).toThrow(ACLRuleError);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('throws ACLRuleError when YAML is not a mapping', () => {
|
|
264
|
+
const filePath = join(tmpDir, 'array.yaml');
|
|
265
|
+
writeFileSync(filePath, '- item1\n- item2\n', 'utf-8');
|
|
266
|
+
|
|
267
|
+
expect(() => ACL.load(filePath)).toThrow(ACLRuleError);
|
|
268
|
+
expect(() => ACL.load(filePath)).toThrow(/must be a mapping/);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('throws ACLRuleError when YAML is a scalar', () => {
|
|
272
|
+
const filePath = join(tmpDir, 'scalar.yaml');
|
|
273
|
+
writeFileSync(filePath, 'just a string\n', 'utf-8');
|
|
274
|
+
|
|
275
|
+
expect(() => ACL.load(filePath)).toThrow(ACLRuleError);
|
|
276
|
+
expect(() => ACL.load(filePath)).toThrow(/must be a mapping/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('throws ACLRuleError when rules key is missing', () => {
|
|
280
|
+
const filePath = join(tmpDir, 'norules.yaml');
|
|
281
|
+
writeFileSync(filePath, 'default_effect: allow\n', 'utf-8');
|
|
282
|
+
|
|
283
|
+
expect(() => ACL.load(filePath)).toThrow(ACLRuleError);
|
|
284
|
+
expect(() => ACL.load(filePath)).toThrow(/missing required 'rules' key/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('throws ACLRuleError when rules is not an array', () => {
|
|
288
|
+
const filePath = join(tmpDir, 'badrules.yaml');
|
|
289
|
+
writeFileSync(filePath, 'rules: "not-a-list"\n', 'utf-8');
|
|
290
|
+
|
|
291
|
+
expect(() => ACL.load(filePath)).toThrow(ACLRuleError);
|
|
292
|
+
expect(() => ACL.load(filePath)).toThrow(/'rules' must be a list/);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('loads ACL with multiple rules and conditions', () => {
|
|
296
|
+
const yamlContent = `
|
|
297
|
+
rules:
|
|
298
|
+
- callers: ["*"]
|
|
299
|
+
targets: ["admin.panel"]
|
|
300
|
+
effect: allow
|
|
301
|
+
description: "admin access"
|
|
302
|
+
conditions:
|
|
303
|
+
roles: ["admin"]
|
|
304
|
+
- callers: ["*"]
|
|
305
|
+
targets: ["*"]
|
|
306
|
+
effect: deny
|
|
307
|
+
description: "deny all"
|
|
308
|
+
`;
|
|
309
|
+
const filePath = join(tmpDir, 'multi.yaml');
|
|
310
|
+
writeFileSync(filePath, yamlContent, 'utf-8');
|
|
311
|
+
|
|
312
|
+
const acl = ACL.load(filePath);
|
|
313
|
+
const adminCtx = makeContext({ identityType: 'user', roles: ['admin'] });
|
|
314
|
+
const userCtx = makeContext({ identityType: 'user', roles: ['viewer'] });
|
|
315
|
+
|
|
316
|
+
expect(acl.check('mod.a', 'admin.panel', adminCtx)).toBe(true);
|
|
317
|
+
expect(acl.check('mod.a', 'admin.panel', userCtx)).toBe(false);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
describe('ACL.reload', () => {
|
|
322
|
+
let tmpDir: string;
|
|
323
|
+
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'acl-reload-'));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
afterEach(() => {
|
|
329
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('reloads updated rules from the same YAML file', () => {
|
|
333
|
+
const filePath = join(tmpDir, 'acl.yaml');
|
|
334
|
+
writeFileSync(filePath, `
|
|
335
|
+
rules:
|
|
336
|
+
- callers: ["module.a"]
|
|
337
|
+
targets: ["module.b"]
|
|
338
|
+
effect: deny
|
|
339
|
+
description: "initial deny"
|
|
340
|
+
`, 'utf-8');
|
|
341
|
+
|
|
342
|
+
const acl = ACL.load(filePath);
|
|
343
|
+
expect(acl.check('module.a', 'module.b')).toBe(false);
|
|
344
|
+
|
|
345
|
+
writeFileSync(filePath, `
|
|
346
|
+
rules:
|
|
347
|
+
- callers: ["module.a"]
|
|
348
|
+
targets: ["module.b"]
|
|
349
|
+
effect: allow
|
|
350
|
+
description: "updated allow"
|
|
351
|
+
`, 'utf-8');
|
|
352
|
+
|
|
353
|
+
acl.reload();
|
|
354
|
+
expect(acl.check('module.a', 'module.b')).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('throws ACLRuleError when ACL was not loaded from a file', () => {
|
|
358
|
+
const acl = new ACL([
|
|
359
|
+
{ callers: ['*'], targets: ['*'], effect: 'allow', description: '' },
|
|
360
|
+
]);
|
|
361
|
+
|
|
362
|
+
expect(() => acl.reload()).toThrow(ACLRuleError);
|
|
363
|
+
expect(() => acl.reload()).toThrow(/Cannot reload/);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe('ACL constructor validation', () => {
|
|
368
|
+
it('throws ACLRuleError for invalid defaultEffect', () => {
|
|
369
|
+
expect(() => new ACL([], 'block')).toThrow(ACLRuleError);
|
|
370
|
+
expect(() => new ACL([], 'block')).toThrow(/Invalid default_effect/);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('throws ACLRuleError for empty string defaultEffect', () => {
|
|
374
|
+
expect(() => new ACL([], '')).toThrow(ACLRuleError);
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('ACL condition validation', () => {
|
|
379
|
+
it('returns false when identity_types condition is not an array', () => {
|
|
380
|
+
const acl = new ACL([{
|
|
381
|
+
callers: ['*'], targets: ['target'], effect: 'allow', description: '',
|
|
382
|
+
conditions: { identity_types: 'admin' },
|
|
383
|
+
}]);
|
|
384
|
+
const ctx = makeContext({ identityType: 'admin' });
|
|
385
|
+
expect(acl.check('mod.a', 'target', ctx)).toBe(false);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('returns false when roles condition is not an array', () => {
|
|
389
|
+
const acl = new ACL([{
|
|
390
|
+
callers: ['*'], targets: ['target'], effect: 'allow', description: '',
|
|
391
|
+
conditions: { roles: 'admin' },
|
|
392
|
+
}]);
|
|
393
|
+
const ctx = makeContext({ identityType: 'user', roles: ['admin'] });
|
|
394
|
+
expect(acl.check('mod.a', 'target', ctx)).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('returns false when max_call_depth condition is not a number', () => {
|
|
398
|
+
const acl = new ACL([{
|
|
399
|
+
callers: ['*'], targets: ['target'], effect: 'allow', description: '',
|
|
400
|
+
conditions: { max_call_depth: '5' },
|
|
401
|
+
}]);
|
|
402
|
+
const ctx = makeContext({ callChain: ['a'] });
|
|
403
|
+
expect(acl.check('mod.a', 'target', ctx)).toBe(false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('returns false for roles condition when identity is null', () => {
|
|
407
|
+
const acl = new ACL([{
|
|
408
|
+
callers: ['*'], targets: ['target'], effect: 'allow', description: '',
|
|
409
|
+
conditions: { roles: ['admin'] },
|
|
410
|
+
}]);
|
|
411
|
+
const ctx = makeContext({});
|
|
412
|
+
expect(acl.check('mod.a', 'target', ctx)).toBe(false);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('returns false for identity_types condition when identity is null', () => {
|
|
416
|
+
const acl = new ACL([{
|
|
417
|
+
callers: ['*'], targets: ['target'], effect: 'allow', description: '',
|
|
418
|
+
conditions: { identity_types: ['admin'] },
|
|
419
|
+
}]);
|
|
420
|
+
const ctx = makeContext({});
|
|
421
|
+
expect(acl.check('mod.a', 'target', ctx)).toBe(false);
|
|
422
|
+
});
|
|
423
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { CancelToken, ExecutionCancelledError } from '../src/cancel.js';
|
|
4
|
+
import { Context } from '../src/context.js';
|
|
5
|
+
import { Executor } from '../src/executor.js';
|
|
6
|
+
import { FunctionModule } from '../src/decorator.js';
|
|
7
|
+
import { Registry } from '../src/registry/registry.js';
|
|
8
|
+
|
|
9
|
+
describe('CancelToken', () => {
|
|
10
|
+
it('is initially not cancelled', () => {
|
|
11
|
+
const token = new CancelToken();
|
|
12
|
+
expect(token.isCancelled).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('sets flag after cancel()', () => {
|
|
16
|
+
const token = new CancelToken();
|
|
17
|
+
token.cancel();
|
|
18
|
+
expect(token.isCancelled).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('check() does nothing when not cancelled', () => {
|
|
22
|
+
const token = new CancelToken();
|
|
23
|
+
expect(() => token.check()).not.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('check() throws ExecutionCancelledError when cancelled', () => {
|
|
27
|
+
const token = new CancelToken();
|
|
28
|
+
token.cancel();
|
|
29
|
+
expect(() => token.check()).toThrow(ExecutionCancelledError);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('reset() clears cancellation', () => {
|
|
33
|
+
const token = new CancelToken();
|
|
34
|
+
token.cancel();
|
|
35
|
+
expect(token.isCancelled).toBe(true);
|
|
36
|
+
token.reset();
|
|
37
|
+
expect(token.isCancelled).toBe(false);
|
|
38
|
+
expect(() => token.check()).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('Executor cancellation', () => {
|
|
43
|
+
it('respects cancelled token before execution', async () => {
|
|
44
|
+
const registry = new Registry();
|
|
45
|
+
const mod = new FunctionModule({
|
|
46
|
+
execute: () => ({ result: 'ok' }),
|
|
47
|
+
moduleId: 'test.module',
|
|
48
|
+
inputSchema: Type.Object({}),
|
|
49
|
+
outputSchema: Type.Object({ result: Type.String() }),
|
|
50
|
+
description: 'Simple module',
|
|
51
|
+
});
|
|
52
|
+
registry.register('test.module', mod);
|
|
53
|
+
|
|
54
|
+
const executor = new Executor({ registry });
|
|
55
|
+
const token = new CancelToken();
|
|
56
|
+
token.cancel();
|
|
57
|
+
|
|
58
|
+
const ctx = new Context(
|
|
59
|
+
'trace-1',
|
|
60
|
+
null,
|
|
61
|
+
[],
|
|
62
|
+
executor,
|
|
63
|
+
null,
|
|
64
|
+
null,
|
|
65
|
+
{},
|
|
66
|
+
token,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
await expect(executor.call('test.module', {}, ctx)).rejects.toThrow(ExecutionCancelledError);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -149,3 +149,118 @@ describe('Context.child()', () => {
|
|
|
149
149
|
expect(parent.callChain).toEqual(chainBefore);
|
|
150
150
|
});
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('Context.toJSON() / Context.fromJSON()', () => {
|
|
154
|
+
it('round-trips context with identity', () => {
|
|
155
|
+
const identity = createIdentity('user-42', 'admin', ['superuser', 'editor'], { org: 'acme' });
|
|
156
|
+
const executor = { name: 'test-executor' };
|
|
157
|
+
const original = new Context(
|
|
158
|
+
'trace-abc',
|
|
159
|
+
'caller-1',
|
|
160
|
+
['mod.a', 'mod.b'],
|
|
161
|
+
executor,
|
|
162
|
+
identity,
|
|
163
|
+
{ password: '***' },
|
|
164
|
+
{ transient: 'value' },
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const serialized = original.toJSON();
|
|
168
|
+
const restored = Context.fromJSON(serialized);
|
|
169
|
+
|
|
170
|
+
expect(restored.traceId).toBe(original.traceId);
|
|
171
|
+
expect(restored.callerId).toBe(original.callerId);
|
|
172
|
+
expect(restored.callChain).toEqual(['mod.a', 'mod.b']);
|
|
173
|
+
expect(restored.identity).not.toBeNull();
|
|
174
|
+
expect(restored.identity!.id).toBe('user-42');
|
|
175
|
+
expect(restored.identity!.type).toBe('admin');
|
|
176
|
+
expect([...restored.identity!.roles]).toEqual(['superuser', 'editor']);
|
|
177
|
+
expect({ ...restored.identity!.attrs }).toEqual({ org: 'acme' });
|
|
178
|
+
expect(restored.redactedInputs).toEqual({ password: '***' });
|
|
179
|
+
expect(restored.data).toEqual({ transient: 'value' });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('round-trips context without identity', () => {
|
|
183
|
+
const original = new Context('trace-xyz', null, [], null, null, null);
|
|
184
|
+
const serialized = original.toJSON();
|
|
185
|
+
const restored = Context.fromJSON(serialized);
|
|
186
|
+
|
|
187
|
+
expect(restored.traceId).toBe('trace-xyz');
|
|
188
|
+
expect(restored.callerId).toBeNull();
|
|
189
|
+
expect(restored.callChain).toEqual([]);
|
|
190
|
+
expect(restored.identity).toBeNull();
|
|
191
|
+
expect(restored.redactedInputs).toBeNull();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('excludes executor from toJSON output but includes data', () => {
|
|
195
|
+
const ctx = new Context('trace-1', null, [], 'my-executor', null, null, { key: 'included' });
|
|
196
|
+
const serialized = ctx.toJSON();
|
|
197
|
+
|
|
198
|
+
expect(serialized).not.toHaveProperty('executor');
|
|
199
|
+
expect(serialized).toHaveProperty('data');
|
|
200
|
+
expect(serialized.data).toEqual({ key: 'included' });
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('re-injects executor via fromJSON', () => {
|
|
204
|
+
const ctx = new Context('trace-2', null, [], 'original-exec');
|
|
205
|
+
const serialized = ctx.toJSON();
|
|
206
|
+
const newExecutor = { name: 'new-executor' };
|
|
207
|
+
const restored = Context.fromJSON(serialized, newExecutor);
|
|
208
|
+
|
|
209
|
+
expect(restored.executor).toBe(newExecutor);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('defaults executor to null when not provided to fromJSON', () => {
|
|
213
|
+
const serialized = { traceId: 't1', callerId: null, callChain: [], identity: null, redactedInputs: null };
|
|
214
|
+
const restored = Context.fromJSON(serialized);
|
|
215
|
+
expect(restored.executor).toBeNull();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('toJSON returns copies, not references', () => {
|
|
219
|
+
const identity = createIdentity('u1', 'user', ['r1'], { k: 'v' });
|
|
220
|
+
const ctx = new Context('t1', null, ['a', 'b'], null, identity, { field: 'val' }, { shared: true });
|
|
221
|
+
const serialized = ctx.toJSON();
|
|
222
|
+
|
|
223
|
+
// Mutate serialized copies
|
|
224
|
+
(serialized.callChain as string[]).push('mutated');
|
|
225
|
+
(serialized.identity as Record<string, unknown>).id = 'mutated';
|
|
226
|
+
(serialized.redactedInputs as Record<string, unknown>).extra = true;
|
|
227
|
+
(serialized.data as Record<string, unknown>).extra = true;
|
|
228
|
+
|
|
229
|
+
// Originals unchanged
|
|
230
|
+
expect(ctx.callChain).toEqual(['a', 'b']);
|
|
231
|
+
expect(ctx.identity!.id).toBe('u1');
|
|
232
|
+
expect(ctx.redactedInputs).toEqual({ field: 'val' });
|
|
233
|
+
expect(ctx.data).toEqual({ shared: true });
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('toJSON excludes internal keys starting with _', () => {
|
|
237
|
+
const ctx = Context.create();
|
|
238
|
+
ctx.data['visible'] = 'yes';
|
|
239
|
+
ctx.data['_tracing_spans'] = [1, 2, 3];
|
|
240
|
+
ctx.data['_internal'] = 42;
|
|
241
|
+
const serialized = ctx.toJSON();
|
|
242
|
+
const data = serialized.data as Record<string, unknown>;
|
|
243
|
+
expect(data['visible']).toBe('yes');
|
|
244
|
+
expect(data['_tracing_spans']).toBeUndefined();
|
|
245
|
+
expect(data['_internal']).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('fromJSON handles null roles and attrs gracefully', () => {
|
|
249
|
+
const json = {
|
|
250
|
+
traceId: 'test-id',
|
|
251
|
+
callerId: null,
|
|
252
|
+
callChain: [],
|
|
253
|
+
identity: {
|
|
254
|
+
id: 'user-1',
|
|
255
|
+
type: 'user',
|
|
256
|
+
roles: null,
|
|
257
|
+
attrs: null,
|
|
258
|
+
},
|
|
259
|
+
data: {},
|
|
260
|
+
};
|
|
261
|
+
const ctx = Context.fromJSON(json);
|
|
262
|
+
expect(ctx.identity).toBeDefined();
|
|
263
|
+
expect(ctx.identity!.roles).toEqual([]);
|
|
264
|
+
expect(ctx.identity!.attrs).toEqual({});
|
|
265
|
+
});
|
|
266
|
+
});
|