diff --git a/packages/@webex/internal-plugin-metrics/src/index.ts b/packages/@webex/internal-plugin-metrics/src/index.ts index 83608270df0..8ec214a2e73 100644 --- a/packages/@webex/internal-plugin-metrics/src/index.ts +++ b/packages/@webex/internal-plugin-metrics/src/index.ts @@ -21,6 +21,7 @@ import { PreComputedLatencies, SubmitFeatureEvent, LocusSyncLatencyEventName, + PrivacyAndSecurityPermission, } from './metrics.types'; import * as CALL_DIAGNOSTIC_CONFIG from './call-diagnostic/config'; import * as CallDiagnosticUtils from './call-diagnostic/call-diagnostic-metrics.util'; @@ -70,4 +71,5 @@ export type { PreComputedLatencies, SubmitFeatureEvent, LocusSyncLatencyEventName, + PrivacyAndSecurityPermission, }; diff --git a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts index 15e21513770..b4f34355d61 100644 --- a/packages/@webex/internal-plugin-metrics/src/metrics.types.ts +++ b/packages/@webex/internal-plugin-metrics/src/metrics.types.ts @@ -23,6 +23,15 @@ export type ClientUserNameInput = NonNullable; export type ClientEmailInput = NonNullable; +export type PrivacyAndSecurityPermission = NonNullable< + RawClientEvent['privacyAndSecurityPermission'] +>; + +export type PrivacyAndSecurityPermissionResource = keyof PrivacyAndSecurityPermission; + +export type PrivacyAndSecurityPermissionState = + PrivacyAndSecurityPermission[PrivacyAndSecurityPermissionResource]; + export type BrowserLaunchMethodType = NonNullable< RawEvent['origin']['clientInfo'] >['browserLaunchMethod']; diff --git a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts index 79848a79a59..9ed12b6fd7f 100644 --- a/packages/@webex/internal-plugin-metrics/src/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/src/new-metrics.ts @@ -19,18 +19,19 @@ import { ClientEvent, FeatureEvent, EventPayload, - OperationalEvent, MediaQualityEvent, InternalEvent, SubmitClientEventOptions, Table, DelayedClientEvent, DelayedClientFeatureEvent, + PrivacyAndSecurityPermission, } from './metrics.types'; import CallDiagnosticLatencies from './call-diagnostic/call-diagnostic-metrics-latencies'; import {setMetricTimings} from './call-diagnostic/call-diagnostic-metrics.util'; import {generateCommonErrorMetadata} from './utils'; import {isAutomatedUser as detectAutomatedUser} from './automated-user'; +import PrivacyAndSecurityPermissionEnricher from './privacy-and-security-permission-enricher'; /** * Metrics plugin to centralize all types of metrics. @@ -68,6 +69,8 @@ class Metrics extends WebexPlugin { delayedClientFeatureEventsOverrides: Partial = {}; + private privacyAndSecurityPermissionEnricher: PrivacyAndSecurityPermissionEnricher; + /** * Constructor * @param args @@ -78,6 +81,15 @@ class Metrics extends WebexPlugin { constructor(...args) { super(...args); + this.privacyAndSecurityPermissionEnricher = new PrivacyAndSecurityPermissionEnricher( + (error) => { + // @ts-ignore + this.webex.logger.error( + 'NewMetrics: @submitClientEvent. Privacy and security permission enrichment failed.', + error + ); + } + ); // @ts-ignore this.callDiagnosticLatencies = new CallDiagnosticLatencies({}, {parent: this.webex}); // @ts-ignore @@ -381,14 +393,41 @@ class Metrics extends WebexPlugin { options: {meetingId: options?.meetingId}, }); - return this.callDiagnosticMetrics.submitClientEvent({ + const enrichedPayload = this.privacyAndSecurityPermissionEnricher.enrich({ name, payload, + scope: this.getPermissionScope(options), + }); + + return this.callDiagnosticMetrics.submitClientEvent({ + name, + payload: enrichedPayload, options, delaySubmitEvent: this.delaySubmitClientEvents, }); } + /** + * Stores the latest browser permission state supplied by the client. + * @param permission latest normalized permission snapshot + */ + public setPrivacyAndSecurityPermission(permission: PrivacyAndSecurityPermission): void { + this.privacyAndSecurityPermissionEnricher.setPermission(permission); + } + + /** + * Resolves event options to the call identity used by Call Diagnostic where possible. + * @param options client event options + * @returns the permission history scope + */ + private getPermissionScope(options?: SubmitClientEventOptions): string { + const meeting = options?.meetingId + ? (this as any).webex.meetings?.getBasicMeetingInformation?.(options.meetingId) + : undefined; + + return options?.correlationId ?? meeting?.correlationId ?? 'default'; + } + /** * Issue request to alias a user's pre-login ID with their CI UUID * @param {string} preLoginId diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts new file mode 100644 index 00000000000..ea02f6a345d --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/constants.ts @@ -0,0 +1,33 @@ +import type {ClientEvent} from '../metrics.types'; +import type {PermissionEnrichmentPolicy} from './types'; + +export const CAMERA_AND_MICROPHONE_PERMISSION_EVENTS = new Set([ + 'client.call.initiated', + 'client.media.capabilities', + 'client.ice.end', + 'client.locus.join.request', + 'client.locus.join.response', + 'client.media-engine.ready', +]); + +export const MEDIA_TX_PERMISSION_EVENTS = new Set([ + 'client.media.tx.start', + 'client.media.tx.stop', +]); + +export const CONTENT_SHARE_PERMISSION_EVENTS = new Set([ + 'client.share.initiated', + 'client.share.floor-grant.request', + 'client.share.floor-granted.local', +]); + +export const FINAL_PERMISSION_EVENTS = new Set([ + 'client.call.leave', + 'client.call.remote-ended', + 'client.call.aborted', +]); + +export const NO_PERMISSION_ENRICHMENT: PermissionEnrichmentPolicy = { + resources: [], + terminal: false, +}; diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts new file mode 100644 index 00000000000..8c566b7d0aa --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/index.ts @@ -0,0 +1,142 @@ +import type {ClientEventPayload, PrivacyAndSecurityPermission} from '../metrics.types'; +import { + CAMERA_AND_MICROPHONE_PERMISSION_EVENTS, + CONTENT_SHARE_PERMISSION_EVENTS, + FINAL_PERMISSION_EVENTS, + MEDIA_TX_PERMISSION_EVENTS, + NO_PERMISSION_ENRICHMENT, +} from './constants'; +import type { + PermissionEnrichmentContext, + PermissionEnrichmentPolicy, + PermissionEnrichmentRule, +} from './types'; +import { + getChangedPermission, + projectPrivacyAndSecurityPermission, + resolveContentShareResources, + resolveMediaResources, +} from './utils'; + +export const PERMISSION_ENRICHMENT_RULES = [ + { + events: CAMERA_AND_MICROPHONE_PERMISSION_EVENTS, + resolve: () => ({resources: ['camera', 'microphone'], terminal: false}), + }, + { + events: MEDIA_TX_PERMISSION_EVENTS, + resolve: (payload) => ({resources: resolveMediaResources(payload), terminal: false}), + }, + { + events: CONTENT_SHARE_PERMISSION_EVENTS, + resolve: (payload) => ({ + resources: resolveContentShareResources(payload), + terminal: false, + }), + }, + { + events: FINAL_PERMISSION_EVENTS, + resolve: () => ({resources: ['camera', 'microphone', 'contentShare'], terminal: true}), + }, +] satisfies readonly PermissionEnrichmentRule[]; + +const resolvePermissionEnrichmentPolicy = ( + name: PermissionEnrichmentContext['name'], + payload?: ClientEventPayload +): PermissionEnrichmentPolicy => + PERMISSION_ENRICHMENT_RULES.find(({events}) => events.has(name))?.resolve(payload) ?? + NO_PERMISSION_ENRICHMENT; + +/** + * Enriches eligible client events with relevant browser permission changes. + */ +export default class PrivacyAndSecurityPermissionEnricher { + private permission?: PrivacyAndSecurityPermission; + + private lastReported = new Map(); + + private readonly onEnrichmentError: (error: unknown) => void; + + /** + * Creates a permission enricher. + * @param {Function} onEnrichmentError permission enrichment error handler + */ + constructor(onEnrichmentError: (error: unknown) => void) { + this.onEnrichmentError = onEnrichmentError; + } + + /** + * Stores the latest normalized browser permission state. + * @param {PrivacyAndSecurityPermission} permission permission snapshot + * @returns {void} + */ + public setPermission(permission: PrivacyAndSecurityPermission): void { + this.permission = projectPrivacyAndSecurityPermission(permission, [ + 'camera', + 'microphone', + 'contentShare', + ]); + } + + /** + * Returns the original payload or a copy enriched with relevant permission changes. + * @param {PermissionEnrichmentContext} context event context and permission history scope + * @returns {ClientEventPayload | undefined} + */ + public enrich({ + name, + payload, + scope, + }: PermissionEnrichmentContext): ClientEventPayload | undefined { + const policy = resolvePermissionEnrichmentPolicy(name, payload); + + try { + if (payload?.privacyAndSecurityPermission !== undefined) { + // An explicitly supplied permission payload is authoritative for this event. + if (!policy.terminal) { + this.lastReported.set(scope, { + ...this.lastReported.get(scope), + ...(payload.privacyAndSecurityPermission as PrivacyAndSecurityPermission), + }); + } + + return payload; + } + + if (policy.resources.length === 0) { + return payload; + } + + const projectedPermission = this.permission + ? projectPrivacyAndSecurityPermission(this.permission, policy.resources) + : undefined; + + if (!projectedPermission) { + return payload; + } + + if (policy.terminal) { + return {...payload, privacyAndSecurityPermission: projectedPermission}; + } + + const lastReported = this.lastReported.get(scope) ?? {}; + const changedPermission = getChangedPermission(projectedPermission, lastReported); + + if (!changedPermission) { + return payload; + } + + this.lastReported.set(scope, {...lastReported, ...changedPermission}); + + return {...payload, privacyAndSecurityPermission: changedPermission}; + } catch (error) { + this.onEnrichmentError(error); + + return payload; + } finally { + if (policy.terminal) { + this.lastReported.delete(scope); + } + } + } +} diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts new file mode 100644 index 00000000000..de8f660ddf5 --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/types.ts @@ -0,0 +1,21 @@ +import type { + ClientEvent, + ClientEventPayload, + PrivacyAndSecurityPermissionResource, +} from '../metrics.types'; + +export type PermissionEnrichmentPolicy = { + resources: readonly PrivacyAndSecurityPermissionResource[]; + terminal: boolean; +}; + +export type PermissionEnrichmentRule = { + events: ReadonlySet; + resolve: (payload?: ClientEventPayload) => PermissionEnrichmentPolicy; +}; + +export type PermissionEnrichmentContext = { + name: ClientEvent['name']; + payload?: ClientEventPayload; + scope: string; +}; diff --git a/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts new file mode 100644 index 00000000000..aa645ef84cd --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/src/privacy-and-security-permission-enricher/utils.ts @@ -0,0 +1,82 @@ +import {isEqual} from 'lodash'; + +import type { + ClientEventPayload, + PrivacyAndSecurityPermission, + PrivacyAndSecurityPermissionResource, +} from '../metrics.types'; + +export const resolveMediaResources = ( + payload?: ClientEventPayload +): PrivacyAndSecurityPermissionResource[] => { + switch (payload?.mediaType) { + case 'audio': + return ['microphone']; + case 'video': + return ['camera']; + case 'share': + return ['contentShare']; + default: + return []; + } +}; + +export const resolveContentShareResources = ( + payload?: ClientEventPayload +): PrivacyAndSecurityPermissionResource[] => { + if (payload?.mediaType !== 'share') { + return []; + } + + return ['contentShare']; +}; + +const copyPermissionState = ( + source: PrivacyAndSecurityPermission, + target: PrivacyAndSecurityPermission, + resource: PrivacyAndSecurityPermissionResource +): void => { + switch (resource) { + case 'camera': + if (source.camera) { + target.camera = {...source.camera}; + } + break; + case 'microphone': + if (source.microphone) { + target.microphone = {...source.microphone}; + } + break; + case 'contentShare': + if (source.contentShare) { + target.contentShare = {...source.contentShare}; + } + break; + default: + break; + } +}; + +export const projectPrivacyAndSecurityPermission = ( + permission: PrivacyAndSecurityPermission, + resources: readonly PrivacyAndSecurityPermissionResource[] +): PrivacyAndSecurityPermission | undefined => { + const projectedPermission: PrivacyAndSecurityPermission = {}; + + resources.forEach((resource) => { + copyPermissionState(permission, projectedPermission, resource); + }); + + return Object.keys(projectedPermission).length > 0 ? projectedPermission : undefined; +}; + +export const getChangedPermission = ( + current: PrivacyAndSecurityPermission, + previous: PrivacyAndSecurityPermission +): PrivacyAndSecurityPermission | undefined => { + const changedResources = (Object.keys(current) as PrivacyAndSecurityPermissionResource[]).filter( + (resource) => !isEqual(current[resource], previous[resource]) + ); + + return projectPrivacyAndSecurityPermission(current, changedResources); +}; diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts index fe1b9005d36..506b173bcbf 100644 --- a/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/new-metrics.ts @@ -9,19 +9,23 @@ import MockWebex from '@webex/test-helper-mock-webex'; import sinon from 'sinon'; describe('internal-plugin-metrics', () => { - - const mockWebex = () => new MockWebex({ - children: { - newMetrics: NewMetrics, - }, - meetings: { - }, - request: sinon.stub().resolves({}), - logger: { - log: sinon.stub(), - error: sinon.stub(), - } - }); + const mockWebex = () => + new MockWebex({ + children: { + newMetrics: NewMetrics, + }, + meetings: { + getBasicMeetingInformation: sinon.stub().callsFake((meetingId) => ({ + id: meetingId, + correlationId: `correlation-${meetingId}`, + })), + }, + request: sinon.stub().resolves({}), + logger: { + log: sinon.stub(), + error: sinon.stub(), + }, + }); describe('check submitClientEvent, submitFeatureEvent when webex is not ready', () => { let webex; @@ -50,12 +54,14 @@ describe('internal-plugin-metrics', () => { payload: { meetingSummaryInfo: { featureName: 'syncSystemMuteStatus', - featureActions: [{ - actionName: 'syncMeetingMicUnmuteStatusToSystem', - actionId: '14200', - isInitialValue: false, - clickCount: '1' - }] + featureActions: [ + { + actionName: 'syncMeetingMicUnmuteStatusToSystem', + actionId: '14200', + isInitialValue: false, + clickCount: '1', + }, + ], }, }, }); @@ -68,7 +74,6 @@ describe('internal-plugin-metrics', () => { describe('new-metrics contstructor', () => { it('checks callDiagnosticLatencies is defined before ready emit', () => { - const webex = mockWebex(); assert.instanceOf(webex.internal.newMetrics.callDiagnosticLatencies, CallDiagnosticLatencies); @@ -124,7 +129,7 @@ describe('internal-plugin-metrics', () => { afterEach(() => { sinon.restore(); - }) + }); it('lazy metrics backend initialization when checking if backend ready', () => { assert.isUndefined(webex.internal.newMetrics.behavioralMetrics); @@ -135,10 +140,10 @@ describe('internal-plugin-metrics', () => { webex.internal.newMetrics.isReadyToSubmitOperationalEvents(); assert.isDefined(webex.internal.newMetrics.operationalMetrics); - assert.isUndefined(webex.internal.newMetrics.businessMetrics) + assert.isUndefined(webex.internal.newMetrics.businessMetrics); webex.internal.newMetrics.isReadyToSubmitBusinessEvents(); assert.isDefined(webex.internal.newMetrics.businessMetrics); - }) + }); it('returns the automated user classification', () => { assert.strictEqual( @@ -148,7 +153,7 @@ describe('internal-plugin-metrics', () => { }); it('passes the table through to the business metrics', () => { - assert.isUndefined(webex.internal.newMetrics.businessMetrics) + assert.isUndefined(webex.internal.newMetrics.businessMetrics); webex.internal.newMetrics.isReadyToSubmitBusinessEvents(); assert.isDefined(webex.internal.newMetrics.businessMetrics); webex.internal.newMetrics.businessMetrics.submitBusinessEvent = sinon.stub(); @@ -156,14 +161,14 @@ describe('internal-plugin-metrics', () => { name: 'foobar', payload: {}, table: 'test', - metadata: { foo: 'bar' }, + metadata: {foo: 'bar'}, }); assert.calledWith(webex.internal.newMetrics.businessMetrics.submitBusinessEvent, { name: 'foobar', payload: {}, table: 'test', - metadata: { foo: 'bar' }, + metadata: {foo: 'bar'}, }); }); @@ -187,6 +192,87 @@ describe('internal-plugin-metrics', () => { }); }); + describe('privacy and security permission enrichment', () => { + const permission = { + camera: {status: 'GRANTED' as const}, + microphone: {status: 'DENIED' as const, reason: 'DENIED_BY_USER' as const}, + contentShare: {status: 'REQUESTING' as const}, + }; + + it('enriches an eligible client event through the public metrics API', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'}); + + const submittedPayload = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0] + .payload; + + assert.deepEqual(submittedPayload.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + + it('uses the meeting correlation id to preserve history across identifier transitions', () => { + webex.meetings.getBasicMeetingInformation + .withArgs('meeting-1') + .returns({id: 'meeting-1', correlationId: 'correlation-1'}); + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); + + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + options: {correlationId: 'correlation-1'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.ice.end', + payload: {}, + options: {meetingId: 'meeting-1'}, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission'); + assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); + }); + + it('uses the default scope when no correlation id can be resolved', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); + + webex.internal.newMetrics.submitClientEvent({ + name: 'client.call.initiated', + options: {sessionCorrelationId: 'session-1'}, + }); + webex.internal.newMetrics.submitClientEvent({ + name: 'client.ice.end', + payload: {}, + options: {sessionCorrelationId: 'session-2'}, + }); + + const submissions = webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.args; + + assert.property(submissions[0][0].payload, 'privacyAndSecurityPermission'); + assert.notProperty(submissions[1][0].payload, 'privacyAndSecurityPermission'); + }); + + it('captures the permission snapshot before a delayed event is queued', () => { + webex.internal.newMetrics.setPrivacyAndSecurityPermission(permission); + webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: true}); + webex.internal.newMetrics.submitClientEvent({name: 'client.call.initiated'}); + webex.internal.newMetrics.setPrivacyAndSecurityPermission({ + camera: {status: 'DENIED' as const}, + }); + + const submission = + webex.internal.newMetrics.callDiagnosticMetrics.submitClientEvent.firstCall.args[0]; + + assert.isTrue(submission.delaySubmitEvent); + assert.deepEqual(submission.payload.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + }); + it('submits feature Event successfully', () => { webex.internal.newMetrics.submitFeatureEvent({ name: 'client.feature.meeting.summary', @@ -196,12 +282,14 @@ describe('internal-plugin-metrics', () => { payload: { meetingSummaryInfo: { featureName: 'syncSystemMuteStatus', - featureActions: [{ - actionName: 'syncMeetingMicUnmuteStatusToSystem', - actionId: '14200', - isInitialValue: false, - clickCount: '1' - }] + featureActions: [ + { + actionName: 'syncMeetingMicUnmuteStatusToSystem', + actionId: '14200', + isInitialValue: false, + clickCount: '1', + }, + ], }, }, }); @@ -215,12 +303,14 @@ describe('internal-plugin-metrics', () => { payload: { meetingSummaryInfo: { featureName: 'syncSystemMuteStatus', - featureActions: [{ - actionName: 'syncMeetingMicUnmuteStatusToSystem', - actionId: '14200', - isInitialValue: false, - clickCount: '1' - }] + featureActions: [ + { + actionName: 'syncMeetingMicUnmuteStatusToSystem', + actionId: '14200', + isInitialValue: false, + clickCount: '1', + }, + ], }, }, options: {meetingId: '123'}, @@ -281,9 +371,9 @@ describe('internal-plugin-metrics', () => { method: 'POST', api: 'metrics', resource: 'clientmetrics', - headers: { 'x-prelogin-userid': 'my-id' }, + headers: {'x-prelogin-userid': 'my-id'}, body: {}, - qs: { alias: true }, + qs: {alias: true}, }); assert.calledWith( webex.logger.log, @@ -292,8 +382,8 @@ describe('internal-plugin-metrics', () => { }); it('handles failed request correctly', async () => { - webex.request.rejects(new Error("test error")); - sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError') + webex.request.rejects(new Error('test error')); + sinon.stub(Utils, 'generateCommonErrorMetadata').returns('formattedError'); try { await webex.internal.newMetrics.clientMetricsAliasUser({event: 'test'}, 'my-id'); } catch (err) { @@ -375,19 +465,26 @@ describe('internal-plugin-metrics', () => { sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, true); sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {}); - webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false, overrides: {foo: 'bar'}}); + webex.internal.newMetrics.setDelaySubmitClientEvents({ + shouldDelay: false, + overrides: {foo: 'bar'}, + }); - assert.calledOnce(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents); - assert.calledWith(webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, {foo: 'bar'}); + assert.calledOnce( + webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents + ); + assert.calledWith( + webex.internal.newMetrics.callDiagnosticMetrics.submitDelayedClientEvents, + {foo: 'bar'} + ); sinon.assert.match(webex.internal.newMetrics.delaySubmitClientEvents, false); sinon.assert.match(webex.internal.newMetrics.delayedClientEventsOverrides, {foo: 'bar'}); }); it('should not fail when called before webex is ready', () => { - // Create mock - webex = mockWebex() + webex = mockWebex(); webex.internal.newMetrics.callDiagnosticLatencies.saveTimestamp = sinon.stub(); webex.internal.newMetrics.callDiagnosticLatencies.clearTimestamps = sinon.stub(); @@ -402,7 +499,6 @@ describe('internal-plugin-metrics', () => { webex.internal.newMetrics.setDelaySubmitClientEvents({shouldDelay: false}); // Webex is ready webex.emit('ready'); - }); }); }); diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts new file mode 100644 index 00000000000..b163d044ecb --- /dev/null +++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/privacy-and-security-permission-enricher.ts @@ -0,0 +1,318 @@ +import {assert} from '@webex/test-helper-chai'; +import sinon from 'sinon'; + +import type {ClientEvent, PrivacyAndSecurityPermission} from '../../../src/metrics.types'; +import PrivacyAndSecurityPermissionEnricher, { + PERMISSION_ENRICHMENT_RULES, +} from '../../../src/privacy-and-security-permission-enricher'; + +describe('PrivacyAndSecurityPermissionEnricher', () => { + const permission: PrivacyAndSecurityPermission = { + camera: {status: 'GRANTED'}, + microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}, + contentShare: {status: 'REQUESTING'}, + }; + let onEnrichmentError: sinon.SinonStub; + let enricher: PrivacyAndSecurityPermissionEnricher; + + beforeEach(() => { + onEnrichmentError = sinon.stub(); + enricher = new PrivacyAndSecurityPermissionEnricher(onEnrichmentError); + }); + + afterEach(() => { + sinon.restore(); + }); + + describe('permission enrichment rules', () => { + it('registers each production event in only one rule', () => { + const registeredEvents = PERMISSION_ENRICHMENT_RULES.flatMap(({events}) => [...events]); + + assert.lengthOf(new Set(registeredEvents), registeredEvents.length); + }); + + [ + 'client.call.initiated', + 'client.media.capabilities', + 'client.ice.end', + 'client.locus.join.request', + 'client.locus.join.response', + 'client.media-engine.ready', + ].forEach((name) => { + it(`projects camera and microphone for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + camera: permission.camera, + microphone: permission.microphone, + }); + }); + }); + + ['client.media.tx.start', 'client.media.tx.stop'].forEach((name) => { + [ + {mediaType: 'audio' as const, expected: {microphone: permission.microphone}}, + {mediaType: 'video' as const, expected: {camera: permission.camera}}, + {mediaType: 'share' as const, expected: {contentShare: permission.contentShare}}, + ].forEach(({mediaType, expected}) => { + it(`projects ${mediaType} permission for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({ + name: name as ClientEvent['name'], + payload: {mediaType}, + scope: 'meeting', + }); + + assert.deepEqual(payload?.privacyAndSecurityPermission, expected); + }); + }); + }); + + [ + 'client.share.initiated', + 'client.share.floor-grant.request', + 'client.share.floor-granted.local', + ].forEach((name) => { + it(`projects content-share permission for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({ + name: name as ClientEvent['name'], + payload: {mediaType: 'share'}, + scope: 'meeting', + }); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + contentShare: permission.contentShare, + }); + }); + }); + + ['client.call.leave', 'client.call.remote-ended', 'client.call.aborted'].forEach((name) => { + it(`projects every available permission for ${name}`, () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({name: name as ClientEvent['name'], scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, permission); + }); + }); + }); + + describe('setPermission', () => { + it('stores a defensive copy of the permission snapshot', () => { + const mutablePermission: PrivacyAndSecurityPermission = { + camera: {status: 'GRANTED'}, + }; + + enricher.setPermission(mutablePermission); + mutablePermission.camera = {status: 'DENIED', reason: 'DENIED_BY_USER'}; + + const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + camera: {status: 'GRANTED'}, + }); + }); + }); + + describe('enrich', () => { + it('does not mutate the input payload or stored permission state', () => { + const inputPayload = {mediaType: 'audio' as const}; + + enricher.setPermission(permission); + const payload = enricher.enrich({ + name: 'client.call.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.notStrictEqual(payload, inputPayload); + assert.notProperty(inputPayload, 'privacyAndSecurityPermission'); + assert.notStrictEqual(payload?.privacyAndSecurityPermission?.camera, permission.camera); + }); + + it('returns the original payload when the requested permission is unavailable', () => { + const inputPayload = {mediaType: 'audio' as const}; + + enricher.setPermission({camera: permission.camera}); + const payload = enricher.enrich({ + name: 'client.media.tx.start', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + }); + + it('returns the original payload for unsupported share media types', () => { + const inputPayload = {mediaType: 'whiteboard' as const}; + + enricher.setPermission(permission); + const payload = enricher.enrich({ + name: 'client.share.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + }); + + it('reports initial permission once and suppresses an unchanged later event', () => { + enricher.setPermission(permission); + + const initialPayload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + const unchangedPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'}); + + assert.property(initialPayload, 'privacyAndSecurityPermission'); + assert.isUndefined(unchangedPayload); + }); + + it('reports only permission resources that changed', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + enricher.setPermission({ + ...permission, + camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}, + }); + + const payload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'}); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + camera: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}, + }); + }); + + it('reports a changed reason when status is unchanged', () => { + enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_USER'}}); + enricher.enrich({ + name: 'client.media.tx.start', + payload: {mediaType: 'audio'}, + scope: 'meeting', + }); + enricher.setPermission({microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}}); + + const payload = enricher.enrich({ + name: 'client.media.tx.stop', + payload: {mediaType: 'audio'}, + scope: 'meeting', + }); + + assert.deepEqual(payload?.privacyAndSecurityPermission, { + microphone: {status: 'DENIED', reason: 'DENIED_BY_SYSTEM'}, + }); + }); + + it('shares resource history across enrichment rules', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + + const payload = enricher.enrich({ + name: 'client.media.tx.start', + payload: {mediaType: 'audio'}, + scope: 'meeting', + }); + + assert.notProperty(payload, 'privacyAndSecurityPermission'); + }); + + it('tracks permission history independently by scope', () => { + enricher.setPermission(permission); + + const firstMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'}); + const secondMeeting = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'}); + + assert.property(firstMeeting, 'privacyAndSecurityPermission'); + assert.property(secondMeeting, 'privacyAndSecurityPermission'); + }); + + it('clears only the terminal event scope', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'}); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting-2'}); + enricher.enrich({name: 'client.call.leave', scope: 'meeting-1'}); + + const reusedScope = enricher.enrich({name: 'client.call.initiated', scope: 'meeting-1'}); + const activeScope = enricher.enrich({name: 'client.ice.end', scope: 'meeting-2'}); + + assert.property(reusedScope, 'privacyAndSecurityPermission'); + assert.isUndefined(activeScope); + }); + + it('preserves an explicit permission payload and uses it as the baseline', () => { + const explicitPayload = { + privacyAndSecurityPermission: { + camera: permission.camera, + microphone: permission.microphone, + }, + }; + + enricher.setPermission(permission); + const returnedPayload = enricher.enrich({ + name: 'client.call.initiated', + payload: explicitPayload, + scope: 'meeting', + }); + const laterPayload = enricher.enrich({name: 'client.ice.end', scope: 'meeting'}); + + assert.strictEqual(returnedPayload, explicitPayload); + assert.isUndefined(laterPayload); + }); + + it('clears history after an explicit terminal payload', () => { + enricher.setPermission(permission); + enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + enricher.enrich({ + name: 'client.call.leave', + payload: {privacyAndSecurityPermission: permission}, + scope: 'meeting', + }); + + const payload = enricher.enrich({name: 'client.call.initiated', scope: 'meeting'}); + + assert.property(payload, 'privacyAndSecurityPermission'); + }); + + it('returns unchanged before permission state is supplied', () => { + const inputPayload = {mediaType: 'audio' as const}; + + const payload = enricher.enrich({ + name: 'client.call.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + }); + + it('does not enrich unrelated events', () => { + enricher.setPermission(permission); + + const payload = enricher.enrich({name: 'client.alert.displayed', scope: 'meeting'}); + + assert.isUndefined(payload); + }); + + it('reports enrichment errors and returns the original payload', () => { + const inputPayload = {}; + + Object.defineProperty(inputPayload, 'privacyAndSecurityPermission', { + get: () => { + throw new Error('permission read failed'); + }, + }); + + const payload = enricher.enrich({ + name: 'client.call.initiated', + payload: inputPayload, + scope: 'meeting', + }); + + assert.strictEqual(payload, inputPayload); + assert.calledOnce(onEnrichmentError); + }); + }); +});