@webex/internal-plugin-metrics 3.12.0-webex-services-ready.4 → 3.13.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.
Files changed (77) hide show
  1. package/.repo-context.json +466 -0
  2. package/.sdd/manifest.json +626 -0
  3. package/AGENTS.md +208 -0
  4. package/README.md +274 -0
  5. package/dist/call-diagnostic/call-diagnostic-metrics.js +155 -94
  6. package/dist/call-diagnostic/call-diagnostic-metrics.js.map +1 -1
  7. package/dist/call-diagnostic/config.js +3 -1
  8. package/dist/call-diagnostic/config.js.map +1 -1
  9. package/dist/config.js +7 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/index.js +10 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/metrics.js +118 -5
  14. package/dist/metrics.js.map +1 -1
  15. package/dist/metrics.types.js.map +1 -1
  16. package/dist/network-telemetry.js +610 -0
  17. package/dist/network-telemetry.js.map +1 -0
  18. package/dist/new-metrics.js +47 -11
  19. package/dist/new-metrics.js.map +1 -1
  20. package/dist/privacy-and-security-permission-enricher/constants.js +18 -0
  21. package/dist/privacy-and-security-permission-enricher/constants.js.map +1 -0
  22. package/dist/privacy-and-security-permission-enricher/index.js +143 -0
  23. package/dist/privacy-and-security-permission-enricher/index.js.map +1 -0
  24. package/dist/privacy-and-security-permission-enricher/types.js +7 -0
  25. package/dist/privacy-and-security-permission-enricher/types.js.map +1 -0
  26. package/dist/privacy-and-security-permission-enricher/utils.js +71 -0
  27. package/dist/privacy-and-security-permission-enricher/utils.js.map +1 -0
  28. package/dist/types/call-diagnostic/call-diagnostic-metrics.d.ts +12 -3
  29. package/dist/types/call-diagnostic/config.d.ts +2 -0
  30. package/dist/types/config.d.ts +8 -0
  31. package/dist/types/index.d.ts +2 -2
  32. package/dist/types/metrics.types.d.ts +7 -3
  33. package/dist/types/network-telemetry.d.ts +108 -0
  34. package/dist/types/new-metrics.d.ts +13 -1
  35. package/dist/types/privacy-and-security-permission-enricher/constants.d.ts +6 -0
  36. package/dist/types/privacy-and-security-permission-enricher/index.d.ts +40 -0
  37. package/dist/types/privacy-and-security-permission-enricher/types.d.ts +14 -0
  38. package/dist/types/privacy-and-security-permission-enricher/utils.d.ts +5 -0
  39. package/dist/types/unhandled-exception-telemetry/index.d.ts +60 -0
  40. package/dist/types/unhandled-exception-telemetry/utils.d.ts +6 -0
  41. package/dist/unhandled-exception-telemetry/index.js +331 -0
  42. package/dist/unhandled-exception-telemetry/index.js.map +1 -0
  43. package/dist/unhandled-exception-telemetry/utils.js +105 -0
  44. package/dist/unhandled-exception-telemetry/utils.js.map +1 -0
  45. package/docs/adr/index.md +27 -0
  46. package/docs/architecture.md +437 -0
  47. package/docs/getting-started.md +131 -0
  48. package/docs/index.md +52 -0
  49. package/docs/specs/README.md +88 -0
  50. package/package.json +11 -11
  51. package/schemas/repo-context.schema.json +796 -0
  52. package/src/call-diagnostic/call-diagnostic-metrics.ts +100 -30
  53. package/src/call-diagnostic/config.ts +2 -0
  54. package/src/call-diagnostic/docs/README.md +535 -0
  55. package/src/config.js +7 -0
  56. package/src/docs/README.md +703 -0
  57. package/src/index.ts +11 -0
  58. package/src/metrics.js +123 -5
  59. package/src/metrics.types.ts +13 -3
  60. package/src/network-telemetry.ts +757 -0
  61. package/src/new-metrics.ts +44 -2
  62. package/src/privacy-and-security-permission-enricher/constants.ts +33 -0
  63. package/src/privacy-and-security-permission-enricher/docs/README.md +356 -0
  64. package/src/privacy-and-security-permission-enricher/index.ts +142 -0
  65. package/src/privacy-and-security-permission-enricher/types.ts +21 -0
  66. package/src/privacy-and-security-permission-enricher/utils.ts +82 -0
  67. package/src/rtcMetrics/docs/README.md +447 -0
  68. package/src/unhandled-exception-telemetry/docs/README.md +536 -0
  69. package/src/unhandled-exception-telemetry/index.ts +404 -0
  70. package/src/unhandled-exception-telemetry/utils.ts +101 -0
  71. package/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts +485 -21
  72. package/test/unit/spec/metrics.js +14 -5
  73. package/test/unit/spec/network-telemetry.ts +610 -0
  74. package/test/unit/spec/new-metrics.ts +190 -48
  75. package/test/unit/spec/privacy-and-security-permission-enricher.ts +318 -0
  76. package/test/unit/spec/unhandled-exception-telemetry/utils.ts +109 -0
  77. package/test/unit/spec/unhandled-exception-telemetry.ts +689 -0
@@ -8,20 +8,27 @@ import {
8
8
  import MockWebex from '@webex/test-helper-mock-webex';
9
9
  import sinon from 'sinon';
10
10
 
11
- describe('internal-plugin-metrics', () => {
11
+ import SourceNewMetrics from '@webex/internal-plugin-metrics/src/new-metrics';
12
+ import * as Telemetry from '@webex/internal-plugin-metrics/src/unhandled-exception-telemetry';
12
13
 
13
- const mockWebex = () => new MockWebex({
14
- children: {
15
- newMetrics: NewMetrics,
16
- },
17
- meetings: {
18
- },
19
- request: sinon.stub().resolves({}),
20
- logger: {
21
- log: sinon.stub(),
22
- error: sinon.stub(),
23
- }
24
- });
14
+ describe('internal-plugin-metrics', () => {
15
+ const mockWebex = (NewMetricsPlugin = NewMetrics) =>
16
+ new MockWebex({
17
+ children: {
18
+ newMetrics: NewMetricsPlugin,
19
+ },
20
+ meetings: {
21
+ getBasicMeetingInformation: sinon.stub().callsFake((meetingId) => ({
22
+ id: meetingId,
23
+ correlationId: `correlation-${meetingId}`,
24
+ })),
25
+ },
26
+ request: sinon.stub().resolves({}),
27
+ logger: {
28
+ log: sinon.stub(),
29
+ error: sinon.stub(),
30
+ },
31
+ });
25
32
 
26
33
  describe('check submitClientEvent, submitFeatureEvent when webex is not ready', () => {
27
34
  let webex;
@@ -50,12 +57,14 @@ describe('internal-plugin-metrics', () => {
50
57
  payload: {
51
58
  meetingSummaryInfo: {
52
59
  featureName: 'syncSystemMuteStatus',
53
- featureActions: [{
54
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
55
- actionId: '14200',
56
- isInitialValue: false,
57
- clickCount: '1'
58
- }]
60
+ featureActions: [
61
+ {
62
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
63
+ actionId: '14200',
64
+ isInitialValue: false,
65
+ clickCount: '1',
66
+ },
67
+ ],
59
68
  },
60
69
  },
61
70
  });
@@ -67,12 +76,54 @@ describe('internal-plugin-metrics', () => {
67
76
  });
68
77
 
69
78
  describe('new-metrics contstructor', () => {
70
- it('checks callDiagnosticLatencies is defined before ready emit', () => {
79
+ afterEach(() => {
80
+ sinon.restore();
81
+ });
71
82
 
83
+ it('checks callDiagnosticLatencies is defined before ready emit', () => {
72
84
  const webex = mockWebex();
73
85
 
74
86
  assert.instanceOf(webex.internal.newMetrics.callDiagnosticLatencies, CallDiagnosticLatencies);
75
87
  });
88
+
89
+ it('checks callDiagnosticMetrics is defined before ready emit', () => {
90
+ const webex = mockWebex();
91
+
92
+ assert.isDefined(webex.internal.newMetrics.callDiagnosticMetrics);
93
+ });
94
+
95
+ it('can call buildClientEventFetchRequestOptions before ready', async () => {
96
+ const webex = mockWebex();
97
+ const stub = sinon
98
+ .stub(
99
+ webex.internal.newMetrics.callDiagnosticMetrics,
100
+ 'buildClientEventFetchRequestOptions'
101
+ )
102
+ .resolves({url: 'https://metrics.example.com'});
103
+
104
+ const result = await webex.internal.newMetrics.buildClientEventFetchRequestOptions({
105
+ name: 'client.alert.displayed',
106
+ options: {correlationId: 'test-id'},
107
+ });
108
+
109
+ assert.calledOnceWithExactly(stub, {
110
+ name: 'client.alert.displayed',
111
+ payload: undefined,
112
+ options: {correlationId: 'test-id'},
113
+ });
114
+ assert.deepEqual(result, {url: 'https://metrics.example.com'});
115
+ });
116
+
117
+ it('starts unhandled exception telemetry when webex is ready', () => {
118
+ const start = sinon.stub(Telemetry, 'startUnhandledExceptionTelemetry');
119
+ const webex = mockWebex(SourceNewMetrics);
120
+
121
+ assert.notCalled(start);
122
+
123
+ webex.emit('ready');
124
+
125
+ assert.calledOnceWithExactly(start, webex);
126
+ });
76
127
  });
77
128
 
78
129
  describe('new-metrics', () => {
@@ -98,7 +149,7 @@ describe('internal-plugin-metrics', () => {
98
149
 
99
150
  afterEach(() => {
100
151
  sinon.restore();
101
- })
152
+ });
102
153
 
103
154
  it('lazy metrics backend initialization when checking if backend ready', () => {
104
155
  assert.isUndefined(webex.internal.newMetrics.behavioralMetrics);
@@ -109,10 +160,10 @@ describe('internal-plugin-metrics', () => {
109
160
  webex.internal.newMetrics.isReadyToSubmitOperationalEvents();
110
161
  assert.isDefined(webex.internal.newMetrics.operationalMetrics);
111
162
 
112
- assert.isUndefined(webex.internal.newMetrics.businessMetrics)
163
+ assert.isUndefined(webex.internal.newMetrics.businessMetrics);
113
164
  webex.internal.newMetrics.isReadyToSubmitBusinessEvents();
114
165
  assert.isDefined(webex.internal.newMetrics.businessMetrics);
115
- })
166
+ });
116
167
 
117
168
  it('returns the automated user classification', () => {
118
169
  assert.strictEqual(
@@ -122,7 +173,7 @@ describe('internal-plugin-metrics', () => {
122
173
  });
123
174
 
124
175
  it('passes the table through to the business metrics', () => {
125
- assert.isUndefined(webex.internal.newMetrics.businessMetrics)
176
+ assert.isUndefined(webex.internal.newMetrics.businessMetrics);
126
177
  webex.internal.newMetrics.isReadyToSubmitBusinessEvents();
127
178
  assert.isDefined(webex.internal.newMetrics.businessMetrics);
128
179
  webex.internal.newMetrics.businessMetrics.submitBusinessEvent = sinon.stub();
@@ -130,14 +181,14 @@ describe('internal-plugin-metrics', () => {
130
181
  name: 'foobar',
131
182
  payload: {},
132
183
  table: 'test',
133
- metadata: { foo: 'bar' },
184
+ metadata: {foo: 'bar'},
134
185
  });
135
186
 
136
187
  assert.calledWith(webex.internal.newMetrics.businessMetrics.submitBusinessEvent, {
137
188
  name: 'foobar',
138
189
  payload: {},
139
190
  table: 'test',
140
- metadata: { foo: 'bar' },
191
+ metadata: {foo: 'bar'},
141
192
  });
142
193
  });
143
194
 
@@ -161,6 +212,87 @@ describe('internal-plugin-metrics', () => {
161
212
  });
162
213
  });
163
214
 
215
+ describe('privacy and security permission enrichment', () => {
216
+ const permission = {
217
+ camera: {status: 'GRANTED' as const},
218
+ microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const},
219
+ contentShare: {status: 'REQUESTING' as const},
220
+ };
221
+
222
+ it('enriches an eligible client event through the public metrics API', () => {
223
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
224
+ webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'});
225
+
226
+ const submittedPayload =
227
+ webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0]
228
+ .payload;
229
+
230
+ assert.deepEqual(submittedPayload.privacyAndSecurityPermission, {
231
+ camera: permission.camera,
232
+ microphone: permission.microphone,
233
+ });
234
+ });
235
+
236
+ it('uses the meeting correlation id to preserve history across identifier transitions', () => {
237
+ webex.meetings.getBasicMeetingInformation
238
+ .withArgs('meeting-1')
239
+ .returns({id: 'meeting-1', correlationId: 'correlation-1'});
240
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
241
+
242
+ webex.internal.newMetrics.submitClientEvent({
243
+ name: 'client.call.initiated',
244
+ options: {correlationId: 'correlation-1'},
245
+ });
246
+ webex.internal.newMetrics.submitClientEvent({
247
+ name: 'client.ice.end',
248
+ payload: {},
249
+ options: {meetingId: 'meeting-1'},
250
+ });
251
+
252
+ const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args;
253
+
254
+ assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission');
255
+ assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission');
256
+ });
257
+
258
+ it('uses the default scope when no correlation id can be resolved', () => {
259
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
260
+
261
+ webex.internal.newMetrics.submitClientEvent({
262
+ name: 'client.call.initiated',
263
+ options: {sessionCorrelationId: 'session-1'},
264
+ });
265
+ webex.internal.newMetrics.submitClientEvent({
266
+ name: 'client.ice.end',
267
+ payload: {},
268
+ options: {sessionCorrelationId: 'session-2'},
269
+ });
270
+
271
+ const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args;
272
+
273
+ assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission');
274
+ assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission');
275
+ });
276
+
277
+ it('captures the permission snapshot before a delayed event is queued', () => {
278
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission);
279
+ webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true});
280
+ webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'});
281
+ webex.internal.newMetrics.setPrivacyAndSecurityPermission({
282
+ camera: {status: 'DENIED' as const},
283
+ });
284
+
285
+ const submission =
286
+ webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0];
287
+
288
+ assert.isTrue(submission.delaySubmitEvent);
289
+ assert.deepEqual(submission.payload.privacyAndSecurityPermission, {
290
+ camera: permission.camera,
291
+ microphone: permission.microphone,
292
+ });
293
+ });
294
+ });
295
+
164
296
  it('submits feature Event successfully', () => {
165
297
  webex.internal.newMetrics.submitFeatureEvent({
166
298
  name: 'client.feature.meeting.summary',
@@ -170,12 +302,14 @@ describe('internal-plugin-metrics', () => {
170
302
  payload: {
171
303
  meetingSummaryInfo: {
172
304
  featureName: 'syncSystemMuteStatus',
173
- featureActions: [{
174
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
175
- actionId: '14200',
176
- isInitialValue: false,
177
- clickCount: '1'
178
- }]
305
+ featureActions: [
306
+ {
307
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
308
+ actionId: '14200',
309
+ isInitialValue: false,
310
+ clickCount: '1',
311
+ },
312
+ ],
179
313
  },
180
314
  },
181
315
  });
@@ -189,12 +323,14 @@ describe('internal-plugin-metrics', () => {
189
323
  payload: {
190
324
  meetingSummaryInfo: {
191
325
  featureName: 'syncSystemMuteStatus',
192
- featureActions: [{
193
- actionName: 'syncMeetingMicUnmuteStatusToSystem',
194
- actionId: '14200',
195
- isInitialValue: false,
196
- clickCount: '1'
197
- }]
326
+ featureActions: [
327
+ {
328
+ actionName: 'syncMeetingMicUnmuteStatusToSystem',
329
+ actionId: '14200',
330
+ isInitialValue: false,
331
+ clickCount: '1',
332
+ },
333
+ ],
198
334
  },
199
335
  },
200
336
  options: {meetingId: '123'},
@@ -255,9 +391,9 @@ describe('internal-plugin-metrics', () => {
255
391
  method: 'POST',
256
392
  api: 'metrics',
257
393
  resource: 'clientmetrics',
258
- headers: { 'x-prelogin-userid': 'my-id' },
394
+ headers: {'x-prelogin-userid': 'my-id'},
259
395
  body: {},
260
- qs: { alias: true },
396
+ qs: {alias: true},
261
397
  });
262
398
  assert.calledWith(
263
399
  webex.logger.log,
@@ -266,8 +402,8 @@ describe('internal-plugin-metrics', () => {
266
402
  });
267
403
 
268
404
  it('handles failed request correctly', async () => {
269
- webex.request.rejects(new Error("test error"));
270
- sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError')
405
+ webex.request.rejects(new Error('test error'));
406
+ sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError');
271
407
  try {
272
408
  await webex.internal.newMetrics.clientMetricsAliasUser({event: 'test'}, 'my-id');
273
409
  } catch (err) {
@@ -349,19 +485,26 @@ describe('internal-plugin-metrics', () => {
349
485
  sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, true);
350
486
  sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {});
351
487
 
352
- webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false, overrides: {foo: 'bar'}});
488
+ webex.internal.newMetrics.setDelaySubmitClientEvents({
489
+ shouldDelay: false,
490
+ overrides: {foo: 'bar'},
491
+ });
353
492
 
354
- assert.calledOnce(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents);
355
- assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, {foo: 'bar'});
493
+ assert.calledOnce(
494
+ webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents
495
+ );
496
+ assert.calledWith(
497
+ webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents,
498
+ {foo: 'bar'}
499
+ );
356
500
 
357
501
  sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false);
358
502
  sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {foo: 'bar'});
359
503
  });
360
504
 
361
505
  it('should not fail when called before webex is ready', () => {
362
-
363
506
  // Create mock
364
- webex = mockWebex()
507
+ webex = mockWebex();
365
508
 
366
509
  webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub();
367
510
  webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub();
@@ -376,7 +519,6 @@ describe('internal-plugin-metrics', () => {
376
519
  webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false});
377
520
  // Webex is ready
378
521
  webex.emit('ready');
379
-
380
522
  });
381
523
  });
382
524
  });
@@ -0,0 +1,318 @@
1
+ import {assert} from '@webex/test-helper-chai';
2
+ import sinon from 'sinon';
3
+
4
+ import type {ClientEvent, PrivacyAndSecurityPermission} from '../../../src/metrics.types';
5
+ import PrivacyAndSecurityPermissionEnricher, {
6
+ PERMISSION_ENRICHMENT_RULES,
7
+ } from '../../../src/privacy-and-security-permission-enricher';
8
+
9
+ describe('PrivacyAndSecurityPermissionEnricher', () => {
10
+ const permission: PrivacyAndSecurityPermission = {
11
+ camera: {status: 'GRANTED'},
12
+ microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'},
13
+ contentShare: {status: 'REQUESTING'},
14
+ };
15
+ let onEnrichmentError: sinon.SinonStub;
16
+ let enricher: PrivacyAndSecurityPermissionEnricher;
17
+
18
+ beforeEach(() => {
19
+ onEnrichmentError = sinon.stub();
20
+ enricher = new PrivacyAndSecurityPermissionEnricher(onEnrichmentError);
21
+ });
22
+
23
+ afterEach(() => {
24
+ sinon.restore();
25
+ });
26
+
27
+ describe('permission enrichment rules', () => {
28
+ it('registers each production event in only one rule', () => {
29
+ const registeredEvents = PERMISSION_ENRICHMENT_RULES.flatMap(({events}) => [...events]);
30
+
31
+ assert.lengthOf(new Set(registeredEvents), registeredEvents.length);
32
+ });
33
+
34
+ [
35
+ 'client.call.initiated',
36
+ 'client.media.capabilities',
37
+ 'client.ice.end',
38
+ 'client.locus.join.request',
39
+ 'client.locus.join.response',
40
+ 'client.media-engine.ready',
41
+ ].forEach((name) => {
42
+ it(`projects camera and microphone for ${name}`, () => {
43
+ enricher.setPermission(permission);
44
+
45
+ const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'});
46
+
47
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
48
+ camera: permission.camera,
49
+ microphone: permission.microphone,
50
+ });
51
+ });
52
+ });
53
+
54
+ ['client.media.tx.start', 'client.media.tx.stop'].forEach((name) => {
55
+ [
56
+ {mediaType: 'audio' as const, expected: {microphone: permission.microphone}},
57
+ {mediaType: 'video' as const, expected: {camera: permission.camera}},
58
+ {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}},
59
+ ].forEach(({mediaType, expected}) => {
60
+ it(`projects ${mediaType} permission for ${name}`, () => {
61
+ enricher.setPermission(permission);
62
+
63
+ const payload = enricher.enrich({
64
+ name: name as ClientEvent['name'],
65
+ payload: {mediaType},
66
+ scope: 'meeting',
67
+ });
68
+
69
+ assert.deepEqual(payload?.privacyAndSecurityPermission, expected);
70
+ });
71
+ });
72
+ });
73
+
74
+ [
75
+ 'client.share.initiated',
76
+ 'client.share.floor-grant.request',
77
+ 'client.share.floor-granted.local',
78
+ ].forEach((name) => {
79
+ it(`projects content-share permission for ${name}`, () => {
80
+ enricher.setPermission(permission);
81
+
82
+ const payload = enricher.enrich({
83
+ name: name as ClientEvent['name'],
84
+ payload: {mediaType: 'share'},
85
+ scope: 'meeting',
86
+ });
87
+
88
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
89
+ contentShare: permission.contentShare,
90
+ });
91
+ });
92
+ });
93
+
94
+ ['client.call.leave', 'client.call.remote-ended', 'client.call.aborted'].forEach((name) => {
95
+ it(`projects every available permission for ${name}`, () => {
96
+ enricher.setPermission(permission);
97
+
98
+ const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'});
99
+
100
+ assert.deepEqual(payload?.privacyAndSecurityPermission, permission);
101
+ });
102
+ });
103
+ });
104
+
105
+ describe('setPermission', () => {
106
+ it('stores a defensive copy of the permission snapshot', () => {
107
+ const mutablePermission: PrivacyAndSecurityPermission = {
108
+ camera: {status: 'GRANTED'},
109
+ };
110
+
111
+ enricher.setPermission(mutablePermission);
112
+ mutablePermission.camera = {status: 'DENIED', reason: 'DENIED_BY_USER'};
113
+
114
+ const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
115
+
116
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
117
+ camera: {status: 'GRANTED'},
118
+ });
119
+ });
120
+ });
121
+
122
+ describe('enrich', () => {
123
+ it('does not mutate the input payload or stored permission state', () => {
124
+ const inputPayload = {mediaType: 'audio' as const};
125
+
126
+ enricher.setPermission(permission);
127
+ const payload = enricher.enrich({
128
+ name: 'client.call.initiated',
129
+ payload: inputPayload,
130
+ scope: 'meeting',
131
+ });
132
+
133
+ assert.notStrictEqual(payload, inputPayload);
134
+ assert.notProperty(inputPayload, 'privacyAndSecurityPermission');
135
+ assert.notStrictEqual(payload?.privacyAndSecurityPermission?.camera, permission.camera);
136
+ });
137
+
138
+ it('returns the original payload when the requested permission is unavailable', () => {
139
+ const inputPayload = {mediaType: 'audio' as const};
140
+
141
+ enricher.setPermission({camera: permission.camera});
142
+ const payload = enricher.enrich({
143
+ name: 'client.media.tx.start',
144
+ payload: inputPayload,
145
+ scope: 'meeting',
146
+ });
147
+
148
+ assert.strictEqual(payload, inputPayload);
149
+ });
150
+
151
+ it('returns the original payload for unsupported share media types', () => {
152
+ const inputPayload = {mediaType: 'whiteboard' as const};
153
+
154
+ enricher.setPermission(permission);
155
+ const payload = enricher.enrich({
156
+ name: 'client.share.initiated',
157
+ payload: inputPayload,
158
+ scope: 'meeting',
159
+ });
160
+
161
+ assert.strictEqual(payload, inputPayload);
162
+ });
163
+
164
+ it('reports initial permission once and suppresses an unchanged later event', () => {
165
+ enricher.setPermission(permission);
166
+
167
+ const initialPayload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
168
+ const unchangedPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'});
169
+
170
+ assert.property(initialPayload, 'privacyAndSecurityPermission');
171
+ assert.isUndefined(unchangedPayload);
172
+ });
173
+
174
+ it('reports only permission resources that changed', () => {
175
+ enricher.setPermission(permission);
176
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
177
+ enricher.setPermission({
178
+ ...permission,
179
+ camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'},
180
+ });
181
+
182
+ const payload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'});
183
+
184
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
185
+ camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'},
186
+ });
187
+ });
188
+
189
+ it('reports a changed reason when status is unchanged', () => {
190
+ enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}});
191
+ enricher.enrich({
192
+ name: 'client.media.tx.start',
193
+ payload: {mediaType: 'audio'},
194
+ scope: 'meeting',
195
+ });
196
+ enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}});
197
+
198
+ const payload = enricher.enrich({
199
+ name: 'client.media.tx.stop',
200
+ payload: {mediaType: 'audio'},
201
+ scope: 'meeting',
202
+ });
203
+
204
+ assert.deepEqual(payload?.privacyAndSecurityPermission, {
205
+ microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'},
206
+ });
207
+ });
208
+
209
+ it('shares resource history across enrichment rules', () => {
210
+ enricher.setPermission(permission);
211
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
212
+
213
+ const payload = enricher.enrich({
214
+ name: 'client.media.tx.start',
215
+ payload: {mediaType: 'audio'},
216
+ scope: 'meeting',
217
+ });
218
+
219
+ assert.notProperty(payload, 'privacyAndSecurityPermission');
220
+ });
221
+
222
+ it('tracks permission history independently by scope', () => {
223
+ enricher.setPermission(permission);
224
+
225
+ const firstMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'});
226
+ const secondMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'});
227
+
228
+ assert.property(firstMeeting, 'privacyAndSecurityPermission');
229
+ assert.property(secondMeeting, 'privacyAndSecurityPermission');
230
+ });
231
+
232
+ it('clears only the terminal event scope', () => {
233
+ enricher.setPermission(permission);
234
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'});
235
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'});
236
+ enricher.enrich({name: 'client.call.leave', scope: 'meeting-1'});
237
+
238
+ const reusedScope = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'});
239
+ const activeScope = enricher.enrich({name: 'client.ice.end', scope: 'meeting-2'});
240
+
241
+ assert.property(reusedScope, 'privacyAndSecurityPermission');
242
+ assert.isUndefined(activeScope);
243
+ });
244
+
245
+ it('preserves an explicit permission payload and uses it as the baseline', () => {
246
+ const explicitPayload = {
247
+ privacyAndSecurityPermission: {
248
+ camera: permission.camera,
249
+ microphone: permission.microphone,
250
+ },
251
+ };
252
+
253
+ enricher.setPermission(permission);
254
+ const returnedPayload = enricher.enrich({
255
+ name: 'client.call.initiated',
256
+ payload: explicitPayload,
257
+ scope: 'meeting',
258
+ });
259
+ const laterPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'});
260
+
261
+ assert.strictEqual(returnedPayload, explicitPayload);
262
+ assert.isUndefined(laterPayload);
263
+ });
264
+
265
+ it('clears history after an explicit terminal payload', () => {
266
+ enricher.setPermission(permission);
267
+ enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
268
+ enricher.enrich({
269
+ name: 'client.call.leave',
270
+ payload: {privacyAndSecurityPermission: permission},
271
+ scope: 'meeting',
272
+ });
273
+
274
+ const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'});
275
+
276
+ assert.property(payload, 'privacyAndSecurityPermission');
277
+ });
278
+
279
+ it('returns unchanged before permission state is supplied', () => {
280
+ const inputPayload = {mediaType: 'audio' as const};
281
+
282
+ const payload = enricher.enrich({
283
+ name: 'client.call.initiated',
284
+ payload: inputPayload,
285
+ scope: 'meeting',
286
+ });
287
+
288
+ assert.strictEqual(payload, inputPayload);
289
+ });
290
+
291
+ it('does not enrich unrelated events', () => {
292
+ enricher.setPermission(permission);
293
+
294
+ const payload = enricher.enrich({name: 'client.alert.displayed', scope: 'meeting'});
295
+
296
+ assert.isUndefined(payload);
297
+ });
298
+
299
+ it('reports enrichment errors and returns the original payload', () => {
300
+ const inputPayload = {};
301
+
302
+ Object.defineProperty(inputPayload, 'privacyAndSecurityPermission', {
303
+ get: () => {
304
+ throw new Error('permission read failed');
305
+ },
306
+ });
307
+
308
+ const payload = enricher.enrich({
309
+ name: 'client.call.initiated',
310
+ payload: inputPayload,
311
+ scope: 'meeting',
312
+ });
313
+
314
+ assert.strictEqual(payload, inputPayload);
315
+ assert.calledOnce(onEnrichmentError);
316
+ });
317
+ });
318
+ });