diff --git a/.changeset/mean-geese-wash.md b/.changeset/mean-geese-wash.md new file mode 100644 index 000000000..989b12884 --- /dev/null +++ b/.changeset/mean-geese-wash.md @@ -0,0 +1,6 @@ +--- +'@segment/analytics-consent-tools': minor +'@segment/analytics-consent-wrapper-onetrust': minor +--- + +Add consent changed event diff --git a/packages/consent/consent-tools-integration-tests/package.json b/packages/consent/consent-tools-integration-tests/package.json index bec31854e..3d3b8d9db 100644 --- a/packages/consent/consent-tools-integration-tests/package.json +++ b/packages/consent/consent-tools-integration-tests/package.json @@ -2,7 +2,7 @@ "name": "@internal/consent-tools-integration-tests", "private": true, "scripts": { - ".": "yarn -T turbo run --filter=@internal/consent-tools-integration-tests", + ".": "yarn run -T turbo run --filter=@internal/consent-tools-integration-tests...", "dev": "yarn concurrently 'yarn watch' 'yarn build serve --open'", "build": "webpack", "watch": "yarn build --watch", diff --git a/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts b/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts index 7a0b2be2f..62da28671 100644 --- a/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts +++ b/packages/consent/consent-tools-integration-tests/src/page-bundles/onetrust/index.ts @@ -4,6 +4,7 @@ import { oneTrust } from '@segment/analytics-consent-wrapper-onetrust' export const analytics = new AnalyticsBrowser() oneTrust(analytics, { + disableConsentChangedEvent: false, integrationCategoryMappings: { Fullstory: ['C0001'], 'Actions Amplitude': ['C0004'], diff --git a/packages/consent/consent-tools/README.md b/packages/consent/consent-tools/README.md index 475800b5e..3461491fa 100644 --- a/packages/consent/consent-tools/README.md +++ b/packages/consent/consent-tools/README.md @@ -1,16 +1,17 @@ # @segment/analytics-consent-tools - - ## Quick Start + ```ts // wrapper.js import { createWrapper, resolveWhen } from '@segment/analytics-consent-tools' export const withCMP = createWrapper({ shouldLoad: (ctx) => { - await resolveWhen(() => - window.CMP !== undefined && !window.CMP.popUpVisible(), 500) + await resolveWhen( + () => window.CMP !== undefined && !window.CMP.popUpVisible(), + 500 + ) if (noConsentNeeded) { ctx.abort({ loadSegmentNormally: true }) @@ -18,23 +19,24 @@ export const withCMP = createWrapper({ ctx.abort({ loadSegmentNormally: false }) } }, - getCategories: () => { + getCategories: () => { // e.g. { Advertising: true, Functional: false } - return normalizeCategories(window.CMP.consentedCategories()) - } + return normalizeCategories(window.CMP.consentedCategories()) + }, }) ``` - ## Wrapper Usage API + ## `npm` + ```js import { withCMP } from './wrapper' import { AnalyticsBrowser } from '@segment/analytics-next' export const analytics = new AnalyticsBrowser() -withCmp(analytics) +withCMP(analytics) analytics.load({ writeKey: ' @@ -43,6 +45,7 @@ analytics.load({ ``` ## Snippet users (window.analytics) + 1. Delete the `analytics.load()` line from the snippet ```diff @@ -50,29 +53,33 @@ analytics.load({ ``` 2. Import Analytics + ```js import { withCMP } from './wrapper' -withCmp(window.analytics) +withCMP(window.analytics) window.analytics.load(' = const analyticsLoadSpy: jest.MockedFn = jest.fn() const addSourceMiddlewareSpy = jest.fn() let analyticsOnSpy: jest.MockedFn +const analyticsTrackSpy: jest.MockedFn = jest.fn() let consoleErrorSpy: jest.SpiedFunction const getAnalyticsLoadLastCall = () => { @@ -61,6 +64,7 @@ beforeEach(() => { }) class MockAnalytics implements AnyAnalytics { + track = analyticsTrackSpy on = analyticsOnSpy load = analyticsLoadSpy addSourceMiddleware = addSourceMiddlewareSpy @@ -300,8 +304,16 @@ describe(createWrapper, () => { ) }) - describe('Validation', () => { - it('should throw an error if categories are in the wrong format', async () => { + describe('Settings Validation', () => { + /* NOTE: This test suite is meant to be minimal -- please see validation/__tests__ */ + + test('createWrapper should throw if user-defined settings/configuration/options are invalid', () => { + expect(() => + wrapTestAnalytics({ getCategories: {} as any }) + ).toThrowError(/validation/i) + }) + + test('analytics.load should reject if categories are in the wrong format', async () => { wrapTestAnalytics({ shouldLoad: () => Promise.resolve('sup' as any), }) @@ -310,7 +322,7 @@ describe(createWrapper, () => { ) }) - it('should throw an error if categories are undefined', async () => { + test('analytics.load should reject if categories are undefined', async () => { wrapTestAnalytics({ getCategories: () => undefined as any, shouldLoad: () => undefined, @@ -736,4 +748,60 @@ describe(createWrapper, () => { }) }) }) + + describe('registerOnConsentChanged', () => { + const sendConsentChangedEventSpy = jest.spyOn( + ConsentChanged, + 'sendConsentChangedEvent' + ) + + let categoriesChangedCb: (categories: Categories) => void = () => { + throw new Error('Not implemented') + } + + const registerOnConsentChanged = jest.fn( + (consentChangedCb: (c: Categories) => void) => { + // simulate a OneTrust.onConsentChanged event callback + categoriesChangedCb = jest.fn((categories: Categories) => + consentChangedCb(categories) + ) + } + ) + it('should expect a callback', async () => { + wrapTestAnalytics({ + registerOnConsentChanged: registerOnConsentChanged, + }) + await analytics.load(DEFAULT_LOAD_SETTINGS) + + expect(sendConsentChangedEventSpy).not.toBeCalled() + expect(registerOnConsentChanged).toBeCalledTimes(1) + categoriesChangedCb({ C0001: true, C0002: false }) + expect(registerOnConsentChanged).toBeCalledTimes(1) + expect(sendConsentChangedEventSpy).toBeCalledTimes(1) + + // if OnConsentChanged callback is called with categories, it should send event + expect(analyticsTrackSpy).toBeCalledWith( + 'Segment Consent Preference', + undefined, + { consent: { categoryPreferences: { C0001: true, C0002: false } } } + ) + }) + it('should throw an error if categories are invalid', async () => { + consoleErrorSpy.mockImplementationOnce(() => undefined) + + wrapTestAnalytics({ + registerOnConsentChanged: registerOnConsentChanged, + }) + + await analytics.load(DEFAULT_LOAD_SETTINGS) + expect(consoleErrorSpy).not.toBeCalled() + categoriesChangedCb(['OOPS'] as any) + expect(consoleErrorSpy).toBeCalledTimes(1) + const err = consoleErrorSpy.mock.lastCall[0] + expect(err.toString()).toMatch(/validation/i) + // if OnConsentChanged callback is called with categories, it should send event + expect(sendConsentChangedEventSpy).not.toBeCalled() + expect(analyticsTrackSpy).not.toBeCalled() + }) + }) }) diff --git a/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts b/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts index c7cab6332..06b69090a 100644 --- a/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts +++ b/packages/consent/consent-tools/src/domain/__tests__/typedef-tests.ts @@ -2,10 +2,17 @@ import type { AnalyticsSnippet, AnalyticsBrowser, } from '@segment/analytics-next' -import { createWrapper } from '../../index' +import { createWrapper, AnyAnalytics } from '../../index' + +type Extends = T extends U ? true : false { const wrap = createWrapper({ getCategories: () => ({ foo: true }) }) wrap({} as AnalyticsBrowser) wrap({} as AnalyticsSnippet) + + // see AnalyticsSnippet and AnalyticsBrowser extend AnyAnalytics + const f: Extends = true + const g: Extends = true + console.log(f, g) } diff --git a/packages/consent/consent-tools/src/domain/consent-changed.ts b/packages/consent/consent-tools/src/domain/consent-changed.ts new file mode 100644 index 000000000..5a67a707d --- /dev/null +++ b/packages/consent/consent-tools/src/domain/consent-changed.ts @@ -0,0 +1,36 @@ +import { AnyAnalytics, Categories } from '../types' + +/** + * Dispatch an event that looks like: + * ```ts + * { + * "type": "track", + * "event": "Segment Consent Preference", + * "context": { + * "consent": { + * "categoryPreferences" : { + * "C0001": true, + * "C0002": false, + * } + * } + * ... + * ``` + */ +export const sendConsentChangedEvent = ( + analytics: AnyAnalytics, + categories: Categories +): void => { + analytics.track( + CONSENT_CHANGED_EVENT, + undefined, + createConsentChangedCtxDto(categories) + ) +} + +const CONSENT_CHANGED_EVENT = 'Segment Consent Preference' + +const createConsentChangedCtxDto = (categories: Categories) => ({ + consent: { + categoryPreferences: categories, + }, +}) diff --git a/packages/consent/consent-tools/src/domain/create-wrapper.ts b/packages/consent/consent-tools/src/domain/create-wrapper.ts index 7e582ba1b..7ef94e588 100644 --- a/packages/consent/consent-tools/src/domain/create-wrapper.ts +++ b/packages/consent/consent-tools/src/domain/create-wrapper.ts @@ -6,14 +6,19 @@ import { CreateWrapperSettings, CDNSettings, } from '../types' -import { validateCategories, validateOptions } from './validation' +import { + validateAnalyticsInstance, + validateCategories, + validateSettings, +} from './validation' import { createConsentStampingMiddleware } from './consent-stamping' import { pipe, pick, uniq } from '../utils' import { AbortLoadError, LoadContext } from './load-cancellation' import { ValidationError } from './validation/validation-error' +import { sendConsentChangedEvent } from './consent-changed' export const createWrapper: CreateWrapper = (createWrapperOptions) => { - validateOptions(createWrapperOptions) + validateSettings(createWrapperOptions) const { shouldDisableSegment, @@ -23,9 +28,11 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { integrationCategoryMappings, shouldEnableIntegration, pruneUnmappedCategories, + registerOnConsentChanged, } = createWrapperOptions return (analytics) => { + validateAnalyticsInstance(analytics) const ogLoad = analytics.load const loadWithConsent: AnyAnalytics['load'] = async ( @@ -118,6 +125,17 @@ export const createWrapper: CreateWrapper = (createWrapperOptions) => { createConsentStampingMiddleware(getValidCategoriesForConsentStamping) ) + // whenever consent changes, dispatch a new event with the latest consent information + registerOnConsentChanged?.((categories) => { + try { + validateCategories(categories) + sendConsentChangedEvent(analytics, categories) + } catch (err) { + // Not sure if there's a better way to handle this, but this makes testing a bit easier. + console.error(err) + } + }) + const updateCDNSettings: InitOptions['updateCDNSettings'] = ( cdnSettings ) => { diff --git a/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts b/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts index 58c21ad72..0d31a74d6 100644 --- a/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts +++ b/packages/consent/consent-tools/src/domain/validation/__tests__/options-validators.test.ts @@ -1,36 +1,52 @@ -import { validateCategories, validateOptions } from '../options-validators' +import { CreateWrapperSettings } from '../../../types' +import { validateCategories, validateSettings } from '../options-validators' import { ValidationError } from '../validation-error' -describe(validateOptions, () => { +const DEFAULT_OPTIONS: CreateWrapperSettings = { + getCategories: () => ({}), +} + +describe(validateSettings, () => { it('should throw if options is not a plain object', () => { - expect(() => validateOptions(null as any)).toThrow() - expect(() => validateOptions(undefined as any)).toThrow() - expect(() => validateOptions('hello' as any)).toThrow() + expect(() => validateSettings(null as any)).toThrow() + expect(() => validateSettings(undefined as any)).toThrow() + expect(() => validateSettings('hello' as any)).toThrow() }) it('should throw an instance of ValidationError', () => { - expect(() => validateOptions(null as any)).toThrowError(ValidationError) + expect(() => validateSettings(null as any)).toThrowError(ValidationError) }) it('should throw with the expected error', () => { expect(() => - validateOptions(null as any) + validateSettings(null as any) ).toThrowErrorMatchingInlineSnapshot( `"[Validation] Options should be an object (Received: null)"` ) }) it('should throw if required property(s) are not included', () => { - expect(() => validateOptions({} as any)).toThrow() + expect(() => validateSettings({} as any)).toThrow() }) it('should throw if getCategories() is not a function', () => { expect(() => - validateOptions({ + validateSettings({ getCategories: {}, }) ).toThrow() }) + + it('should throw if registerOnChanged() is not a function', () => { + expect(() => + validateSettings({ + ...DEFAULT_OPTIONS, + registerOnConsentChanged: {} as any, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[Validation] registerOnConsentChanged is not a function (Received: {})"` + ) + }) }) describe(validateCategories, () => { diff --git a/packages/consent/consent-tools/src/domain/validation/options-validators.ts b/packages/consent/consent-tools/src/domain/validation/options-validators.ts index 504663a27..a07af6ac0 100644 --- a/packages/consent/consent-tools/src/domain/validation/options-validators.ts +++ b/packages/consent/consent-tools/src/domain/validation/options-validators.ts @@ -1,4 +1,4 @@ -import { Categories, CreateWrapperSettings } from '../../types' +import { AnyAnalytics, Categories, CreateWrapperSettings } from '../../types' import { assertIsFunction, assertIsObject } from './common-validators' import { ValidationError } from './validation-error' @@ -23,7 +23,7 @@ export function validateCategories( } } -export function validateOptions(options: { +export function validateSettings(options: { [k in keyof CreateWrapperSettings]: unknown }): asserts options is CreateWrapperSettings { if (typeof options !== 'object' || !options) { @@ -51,4 +51,24 @@ export function validateOptions(options: { options.integrationCategoryMappings, 'integrationCategoryMappings' ) + + options.registerOnConsentChanged && + assertIsFunction( + options.registerOnConsentChanged, + 'registerOnConsentChanged' + ) +} + +export function validateAnalyticsInstance( + analytics: unknown +): asserts analytics is AnyAnalytics { + assertIsObject(analytics, 'analytics') + if ( + 'load' in analytics && + 'on' in analytics && + 'addSourceMiddleware' in analytics + ) { + return + } + throw new ValidationError('analytics is not an Analytics instance', analytics) } diff --git a/packages/consent/consent-tools/src/domain/validation/validation-error.ts b/packages/consent/consent-tools/src/domain/validation/validation-error.ts index 174a3b31c..68dd9d370 100644 --- a/packages/consent/consent-tools/src/domain/validation/validation-error.ts +++ b/packages/consent/consent-tools/src/domain/validation/validation-error.ts @@ -1,10 +1,11 @@ import { AnalyticsConsentError } from '../../types/errors' export class ValidationError extends AnalyticsConsentError { - constructor(message: string, received: any) { - super( - 'ValidationError', - `[Validation] ${message} (${`Received: ${JSON.stringify(received)})`}` - ) + constructor(message: string, received?: any) { + if (arguments.length === 2) { + // to ensure that explicitly passing undefined as second argument still works + message += ` (Received: ${JSON.stringify(received)})` + } + super('ValidationError', `[Validation] ${message}`) } } diff --git a/packages/consent/consent-tools/src/types/errors.ts b/packages/consent/consent-tools/src/types/errors.ts index 40f9845d9..724b4065e 100644 --- a/packages/consent/consent-tools/src/types/errors.ts +++ b/packages/consent/consent-tools/src/types/errors.ts @@ -1,12 +1,12 @@ /** - * Base consent + * Base Consent Error */ export abstract class AnalyticsConsentError extends Error { /** * * @param name - Pass the name explicitly to work around the limitation that 'name' is automatically set to the parent class. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/extends#using_extends - * @param message + * @param message - Error message */ constructor(public name: string, message: string) { super(message) diff --git a/packages/consent/consent-tools/src/types/settings.ts b/packages/consent/consent-tools/src/types/settings.ts index 3800ab9a0..9b535be1e 100644 --- a/packages/consent/consent-tools/src/types/settings.ts +++ b/packages/consent/consent-tools/src/types/settings.ts @@ -27,6 +27,35 @@ export interface CreateWrapperSettings { **/ getCategories: () => Categories | Promise + /** + * Function to register a listener for consent changes to programatically send a "Segment Consent Preference" event to Segment when consent preferences change. + * + * #### Note: The callback requires the categories to be in the shape of { "C0001": true, "C0002": false }, so some normalization may be needed. + * @example + * ```ts + * (categoriesChangedCb) => { + * window.MyCMP.OnConsentChanged((event.detail) => categoriesChangedCb(normalizeCategories(event.detail)) + * } + * + * /* event payload + * { + * "type": "track", + * "event": "Segment Consent Preference", + * "context": { + * "consent": { + * "version": 2, + * "categoryPreferences" : { + * "C0001": true, + * "C0002": false, + * } + * } + * .. + * ``` + */ + registerOnConsentChanged?: ( + categoriesChangedCb: (categories: Categories) => void + ) => void + /** * This permanently disables any consent requirement (i.e device mode gating, event pref stamping). * Called on wrapper initialization. **shouldLoad will never be called** diff --git a/packages/consent/consent-tools/src/types/wrapper.ts b/packages/consent/consent-tools/src/types/wrapper.ts index 96d9fdbc9..5d8267a4f 100644 --- a/packages/consent/consent-tools/src/types/wrapper.ts +++ b/packages/consent/consent-tools/src/types/wrapper.ts @@ -23,6 +23,7 @@ export interface InitOptions { export interface AnyAnalytics { addSourceMiddleware(...args: any[]): any on(event: 'initialize', callback: (settings: CDNSettings) => void): void + track(event: string, properties?: unknown, ...args: any[]): void /** * This interface is meant to be compatible with both the snippet (`window.analytics.load`) @@ -35,19 +36,17 @@ export interface AnyAnalytics { } /** - * This function returns a "wrapped" version of analytics. - */ -export interface Wrapper { - // Returns void rather than analytics to emphasize that this function replaces the .load function of the underlying instance. - (analytics: AnyAnalytics): void -} + * This function modifies an analytics instance to add consent management. + * This is an analytics instance (either window.analytics, new AnalyticsBrowser(), or the instance returned by `AnalyticsBrowser.load({...})` + **/ +// Why type this as 'object' rather than 'AnyAnalytics'? IMO, the chance of a false positive is much higher than the chance that someone will pass in an object that is not an analytics instance. +// We have an assertion function that throws an error if the analytics instance is not compatible. +export type Wrapper = (analyticsInstance: object) => void /** - * This function returns a function which returns a "wrapped" version of analytics + * Create a function which wraps analytics instances to add consent management. */ -export interface CreateWrapper { - (options: CreateWrapperSettings): Wrapper -} +export type CreateWrapper = (settings: CreateWrapperSettings) => Wrapper export interface Categories { [category: string]: boolean diff --git a/packages/consent/consent-wrapper-onetrust/package.json b/packages/consent/consent-wrapper-onetrust/package.json index f6ef1de30..86f4d50cb 100644 --- a/packages/consent/consent-wrapper-onetrust/package.json +++ b/packages/consent/consent-wrapper-onetrust/package.json @@ -6,13 +6,15 @@ "types": "./dist/types/index.d.ts", "sideEffects": false, "files": [ + "LICENSE", "dist/", "src/", "!**/__tests__/**", + "!**/test-helpers/**", "!*.tsbuildinfo" ], "scripts": { - ".": "yarn run -T turbo run --filter=@segment/analytics-consent-wrapper-onetrust", + ".": "yarn run -T turbo run --filter=@segment/analytics-consent-wrapper-onetrust...", "test": "yarn jest", "lint": "yarn concurrently 'yarn:eslint .' 'yarn:tsc --noEmit'", "build": "rm -rf dist && yarn concurrently 'yarn:build:*'", diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts index 2184f5db7..cf18d33ec 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/__tests__/wrapper.test.ts @@ -1,6 +1,8 @@ import * as ConsentTools from '@segment/analytics-consent-tools' import * as OneTrustAPI from '../../lib/onetrust-api' import { sleep } from '@internal/test-helpers' +import { oneTrust } from '../wrapper' +import { OneTrustMockGlobal, analyticsMock } from '../../test-helpers/mocks' const throwNotImplemented = (): never => { throw new Error('not implemented') @@ -18,43 +20,35 @@ const grpFixture = { }, } -/** - * This can be used to mock the OneTrust global object in individual tests - */ -const OneTrustMockGlobal: jest.Mocked = { - GetDomainData: jest.fn().mockImplementationOnce(throwNotImplemented), - IsAlertBoxClosed: jest.fn().mockImplementationOnce(() => false), - onConsentChanged: jest.fn(), // not implemented atm -} - const getConsentedGroupIdsSpy = jest .spyOn(OneTrustAPI, 'getConsentedGroupIds') .mockImplementationOnce(throwNotImplemented) -const helpers = { - _createWrapperSpy: jest.spyOn(ConsentTools, 'createWrapper'), +const createWrapperSpyHelper = { + _spy: jest.spyOn(ConsentTools, 'createWrapper'), get shouldLoad() { - return helpers._createWrapperSpy.mock.lastCall[0].shouldLoad! + return createWrapperSpyHelper._spy.mock.lastCall[0].shouldLoad! }, get getCategories() { - return helpers._createWrapperSpy.mock.lastCall[0].getCategories! + return createWrapperSpyHelper._spy.mock.lastCall[0].getCategories! + }, + get registerOnConsentChanged() { + return createWrapperSpyHelper._spy.mock.lastCall[0] + .registerOnConsentChanged! }, } -import { oneTrust } from '../wrapper' - /** * These tests are not meant to be comprehensive, but they should cover the most important cases. * We should prefer unit tests for most functionality (see lib/__tests__) */ describe('High level "integration" tests', () => { beforeEach(() => { - oneTrust({} as any) - getConsentedGroupIdsSpy.mockReset() - Object.values(OneTrustMockGlobal).forEach((fn) => fn.mockReset()) jest .spyOn(OneTrustAPI, 'getOneTrustGlobal') .mockImplementation(() => OneTrustMockGlobal) + getConsentedGroupIdsSpy.mockReset() + Object.values(OneTrustMockGlobal).forEach((fn) => fn.mockReset()) }) describe('shouldLoad', () => { @@ -75,13 +69,16 @@ describe('High level "integration" tests', () => { }) it('should be resolved successfully', async () => { + oneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValueOnce({ Groups: [grpFixture.StrictlyNeccessary, grpFixture.Performance], }) getConsentedGroupIdsSpy.mockImplementation(() => [ grpFixture.StrictlyNeccessary.CustomGroupId, ]) - const shouldLoadP = Promise.resolve(helpers.shouldLoad({} as any)) + const shouldLoadP = Promise.resolve( + createWrapperSpyHelper.shouldLoad({} as any) + ) let shouldLoadResolved = false void shouldLoadP.then(() => (shouldLoadResolved = true)) await sleep(0) @@ -95,6 +92,7 @@ describe('High level "integration" tests', () => { describe('getCategories', () => { it('should get categories successfully', async () => { + oneTrust(analyticsMock) OneTrustMockGlobal.GetDomainData.mockReturnValue({ Groups: [ grpFixture.StrictlyNeccessary, @@ -105,7 +103,7 @@ describe('High level "integration" tests', () => { getConsentedGroupIdsSpy.mockImplementation(() => [ grpFixture.StrictlyNeccessary.CustomGroupId, ]) - const categories = helpers.getCategories() + const categories = createWrapperSpyHelper.getCategories() // contain both consented and denied category expect(categories).toEqual({ C0001: true, @@ -114,4 +112,36 @@ describe('High level "integration" tests', () => { }) }) }) + + describe('Consent changed', () => { + it('should enable consent changed by default', async () => { + oneTrust(analyticsMock) + OneTrustMockGlobal.GetDomainData.mockReturnValue({ + Groups: [ + grpFixture.StrictlyNeccessary, + grpFixture.Performance, + grpFixture.Targeting, + ], + }) + const onCategoriesChangedCb = jest.fn() + createWrapperSpyHelper.registerOnConsentChanged(onCategoriesChangedCb) + onCategoriesChangedCb() + const onConsentChangedArg = + OneTrustMockGlobal.OnConsentChanged.mock.lastCall[0] + onConsentChangedArg( + new CustomEvent('', { + detail: [ + grpFixture.StrictlyNeccessary.CustomGroupId, + grpFixture.Performance.CustomGroupId, + ], + }) + ) + // expect to be normalized! + expect(onCategoriesChangedCb.mock.lastCall[0]).toEqual({ + [grpFixture.StrictlyNeccessary.CustomGroupId]: true, + [grpFixture.Performance.CustomGroupId]: true, + [grpFixture.Targeting.CustomGroupId]: false, + }) + }) + }) }) diff --git a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts index bb4013b1d..9596959a1 100644 --- a/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts +++ b/packages/consent/consent-wrapper-onetrust/src/domain/wrapper.ts @@ -1,25 +1,30 @@ import { - AnyAnalytics, - Categories, createWrapper, CreateWrapperSettings, resolveWhen, } from '@segment/analytics-consent-tools' import { + getNormalizedCategoriesFromGroupData, + getNormalizedCategoriesFromGroupIds, getConsentedGroupIds, - getGroupData, getOneTrustGlobal, } from '../lib/onetrust-api' -interface OneTrustOptions { +export interface OneTrustSettings { integrationCategoryMappings?: CreateWrapperSettings['integrationCategoryMappings'] + disableConsentChangedEvent?: boolean } +/** + * + * @param analyticsInstance - An analytics instance. Either `window.analytics`, or the instance returned by `new AnalyticsBrowser()` or `AnalyticsBrowser.load({...})` + * @param settings - Optional settings for configuring your OneTrust wrapper + */ export const oneTrust = ( - analytics: AnyAnalytics, - options: OneTrustOptions = {} -) => + analyticsInstance: object, // typing this as 'object', rather than AnyAnalytics to avoid misc type mismatches. createWrapper will throw an error if the analytics instance is not compatible. + settings: OneTrustSettings = {} +) => { createWrapper({ shouldLoad: async () => { await resolveWhen(() => { @@ -31,37 +36,19 @@ export const oneTrust = ( ) }, 500) }, - getCategories: (): Categories => { - // so basically, if a user has 2 categories defined in the UI: [Functional, Advertising], - // we need _all_ those categories to be valid - - // Scenarios: - // - if the user is being asked to select categories, so the popup is still visible - // - if user has no categories selected because they deliberately do not consent to anything and the popup has been dismissed in this session - // - if user has selected categories in a past session - // - if the user has selected categories this session - // - if user has no categories selected because they deliberately do not consent to anything and the popup was dismissed in a previous session - const { userSetConsentGroups, userDeniedConsentGroups } = getGroupData() - const consentedCategories = userSetConsentGroups.reduce( - (acc, c) => { - return { - ...acc, - [c.groupId]: true, - } - }, - {} - ) - - const deniedCategories = userDeniedConsentGroups.reduce( - (acc, c) => { - return { - ...acc, - [c.groupId]: false, - } - }, - {} - ) - return { ...consentedCategories, ...deniedCategories } + getCategories: () => { + const results = getNormalizedCategoriesFromGroupData() + return results }, - integrationCategoryMappings: options.integrationCategoryMappings, - })(analytics) + registerOnConsentChanged: settings.disableConsentChangedEvent + ? undefined + : (onCategoriesChangedCb) => + getOneTrustGlobal()?.OnConsentChanged((event) => { + const normalizedCategories = getNormalizedCategoriesFromGroupIds( + event.detail + ) + onCategoriesChangedCb(normalizedCategories) + }), + integrationCategoryMappings: settings.integrationCategoryMappings, + })(analyticsInstance) +} diff --git a/packages/consent/consent-wrapper-onetrust/src/index.ts b/packages/consent/consent-wrapper-onetrust/src/index.ts index ccd646d80..74e16597c 100644 --- a/packages/consent/consent-wrapper-onetrust/src/index.ts +++ b/packages/consent/consent-wrapper-onetrust/src/index.ts @@ -3,3 +3,4 @@ * We avoid using splat (*) exports so that we can control what is exposed. */ export { oneTrust } from './domain/wrapper' +export type { OneTrustSettings } from './domain/wrapper' diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts index 0b93ea680..fdce55850 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-api.test.ts @@ -1,6 +1,16 @@ -import './onetrust-globals.d.ts' +import '../../test-helpers/onetrust-globals.d.ts' -import { getConsentedGroupIds, getGroupData } from '../onetrust-api' +import { + getConsentedGroupIds, + getGroupDataFromGroupIds, + getNormalizedCategoriesFromGroupData, + getOneTrustActiveGroups, + getNormalizedCategoriesFromGroupIds, + getOneTrustGlobal, + getAllGroups, +} from '../onetrust-api' +import { OneTrustMockGlobal } from '../../test-helpers/mocks' +import { OneTrustApiValidationError } from '../validation' beforeEach(() => { // @ts-ignore @@ -9,42 +19,111 @@ beforeEach(() => { delete window.OneTrust }) -describe(getConsentedGroupIds, () => { - it('should return formatted groups', () => { - window.OnetrustActiveGroups = ',C0001,C0004,C0003,STACK42,' - expect(getConsentedGroupIds()).toEqual([ - 'C0001', - 'C0004', - 'C0003', - 'STACK42', +describe(getOneTrustGlobal, () => { + it('should get the global', () => { + ;(window as any).OneTrust = OneTrustMockGlobal + expect(getOneTrustGlobal()).toEqual(OneTrustMockGlobal) + }) + + it('should throw an error if the global is missing fields', () => { + ;(window as any).OneTrust = {} + expect(() => getOneTrustGlobal()).toThrow(OneTrustApiValidationError) + }) +}) + +describe(getAllGroups, () => { + it('works if OneTrust global is not available', () => { + ;(window as any).OneTrust = undefined + expect(getAllGroups()).toEqual([]) + }) + it('get the normalized groups', () => { + ;(window as any).OneTrust = OneTrustMockGlobal + window.OneTrust = { + ...OneTrustMockGlobal, + GetDomainData: () => ({ + Groups: [ + { + CustomGroupId: 'C0001', + }, + { + CustomGroupId: 'C0004', + }, + { + CustomGroupId: ' C0005', + }, + { + CustomGroupId: 'C0006 ', + }, + ], + }), + } + expect(getAllGroups()).toEqual([ + { groupId: 'C0001' }, + { groupId: 'C0004' }, + { groupId: 'C0005' }, + { groupId: 'C0006' }, ]) }) - it('should work even without the strange leading/trailing commas that onetrust adds', () => { - window.OnetrustActiveGroups = 'C0001,C0004' - expect(getConsentedGroupIds()).toEqual(['C0001', 'C0004']) +}) + +describe(getNormalizedCategoriesFromGroupData, () => { + it('should return a set of groups', () => { + expect( + getNormalizedCategoriesFromGroupData({ + userSetConsentGroups: [{ groupId: 'C0003' }], + userDeniedConsentGroups: [{ groupId: 'C0001' }, { groupId: 'C0002' }], + }) + ).toEqual({ C0003: true, C0001: false, C0002: false }) }) +}) - it('should return an array with only 1 active group if that is the only one consented', () => { - window.OnetrustActiveGroups = ',C0001,' - expect(getConsentedGroupIds()).toEqual(['C0001']) +describe(getOneTrustActiveGroups, () => { + it('should return the global string', () => { + window.OnetrustActiveGroups = 'hello' + expect(getOneTrustActiveGroups()).toBe('hello') + }) + it('should return undefined if no groups are defined', () => { + // @ts-ignore + window.OnetrustActiveGroups = undefined + expect(getOneTrustActiveGroups()).toBe(undefined) + + // @ts-ignore + window.OnetrustActiveGroups = null + expect(getOneTrustActiveGroups()).toBe(undefined) + + window.OnetrustActiveGroups = '' + expect(getOneTrustActiveGroups()).toBe(undefined) + }) + + it('should throw an error if OneTrustActiveGroups is invalid', () => { + // @ts-ignore + window.OnetrustActiveGroups = [] + expect(() => getOneTrustActiveGroups()).toThrow(OneTrustApiValidationError) + }) +}) + +describe(getConsentedGroupIds, () => { + it('should normalize groupIds', () => { + expect(getConsentedGroupIds(',C0001,')).toEqual(['C0001']) + expect(getConsentedGroupIds('C0001,C0004')).toEqual(['C0001', 'C0004']) + expect(getConsentedGroupIds(',C0001,C0004')).toEqual(['C0001', 'C0004']) + expect(getConsentedGroupIds(',')).toEqual([]) + expect(getConsentedGroupIds('')).toEqual([]) + expect(getConsentedGroupIds(',,')).toEqual([]) }) it('should return an empty array if no groups are defined', () => { - window.OnetrustActiveGroups = ',,' - expect(getConsentedGroupIds()).toEqual([]) - window.OnetrustActiveGroups = ',' - expect(getConsentedGroupIds()).toEqual([]) // @ts-ignore window.OnetrustActiveGroups = undefined expect(getConsentedGroupIds()).toEqual([]) }) }) -describe(getGroupData, () => { +describe(getGroupDataFromGroupIds, () => { it('should partition groups into consent/deny', () => { window.OnetrustActiveGroups = ',C0001,C0004' window.OneTrust = { - ...window.OneTrust, + ...OneTrustMockGlobal, GetDomainData: () => ({ Groups: [ { @@ -59,7 +138,7 @@ describe(getGroupData, () => { ], }), } - const data = getGroupData() + const data = getGroupDataFromGroupIds() expect(data.userSetConsentGroups).toEqual([ { @@ -77,3 +156,26 @@ describe(getGroupData, () => { ]) }) }) + +describe(getNormalizedCategoriesFromGroupIds, () => { + it('should get normalized categories', () => { + window.OneTrust = { + ...OneTrustMockGlobal, + GetDomainData: () => ({ + Groups: [ + { + CustomGroupId: 'C0001', + }, + { + CustomGroupId: 'C0004', + }, + { + CustomGroupId: 'SOME_OTHER_GROUP', + }, + ], + }), + } + const ids = getNormalizedCategoriesFromGroupIds(['C0001']) + expect(ids).toEqual({ C0001: true, C0004: false, SOME_OTHER_GROUP: false }) + }) +}) diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts index 7e740dc77..25c81ccc9 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts +++ b/packages/consent/consent-wrapper-onetrust/src/lib/onetrust-api.ts @@ -1,14 +1,26 @@ +import { Categories } from '@segment/analytics-consent-tools' +import { OneTrustApiValidationError } from './validation' +/** + * @example ["C0001", "C0002"] + */ +type ConsentGroupIds = string[] + /** * @example - * ",CAT001,FOO456" => ["CAT001", "FOO456"] + * ",C0001,C0002" => ["C0001", "C0002"] */ -const normalizeActiveGroupIds = (c: string): string[] => - c.trim().split(',').filter(Boolean) +const normalizeActiveGroupIds = ( + oneTrustActiveGroups: string +): ConsentGroupIds => { + return oneTrustActiveGroups.trim().split(',').filter(Boolean) +} type GroupInfoDto = { CustomGroupId: string } +type OtConsentChangedEvent = CustomEvent + /** * The data model used by the OneTrust lib */ @@ -23,18 +35,43 @@ export interface OneTrustGlobal { * - if a user makes a selection * - if a user rejects all */ - onConsentChanged: (cb: (groupIds: string[]) => void) => void + OnConsentChanged: (cb: (event: OtConsentChangedEvent) => void) => void IsAlertBoxClosed: () => boolean } -export const getOneTrustGlobal = (): OneTrustGlobal | undefined => - (window as any).OneTrust +export const getOneTrustGlobal = (): OneTrustGlobal | undefined => { + const oneTrust = (window as any).OneTrust + if (!oneTrust) return undefined + if ( + typeof oneTrust === 'object' && + 'OnConsentChanged' in oneTrust && + 'IsAlertBoxClosed' in oneTrust && + 'GetDomainData' in oneTrust + ) { + return oneTrust + } + + throw new OneTrustApiValidationError( + 'window.OneTrust is not in expected format', + oneTrust + ) +} -const getOneTrustActiveGroups = (): string | undefined => - (window as any).OnetrustActiveGroups +export const getOneTrustActiveGroups = (): string | undefined => { + const groups = (window as any).OnetrustActiveGroups + if (!groups) return undefined + if (typeof groups !== 'string') { + throw new OneTrustApiValidationError( + `window.OnetrustActiveGroups is not a string`, + groups + ) + } + return groups +} -export const getConsentedGroupIds = (): string[] => { - const groups = getOneTrustActiveGroups() +export const getConsentedGroupIds = ( + groups = getOneTrustActiveGroups() +): ConsentGroupIds => { if (!groups) { return [] } @@ -52,7 +89,7 @@ const normalizeGroupInfo = (groupInfo: GroupInfoDto): GroupInfo => ({ /** * get *all* groups / categories, not just active ones */ -const getAllGroups = (): GroupInfo[] => { +export const getAllGroups = (): GroupInfo[] => { const oneTrustGlobal = getOneTrustGlobal() if (!oneTrustGlobal) return [] return oneTrustGlobal.GetDomainData().Groups.map(normalizeGroupInfo) @@ -64,9 +101,9 @@ type UserConsentGroupData = { } // derive the groupIds from the active groups -export const getGroupData = (): UserConsentGroupData => { - const userSetConsentGroupIds = getConsentedGroupIds() - +export const getGroupDataFromGroupIds = ( + userSetConsentGroupIds = getConsentedGroupIds() +): UserConsentGroupData => { // partition all groups into "consent" or "deny" const userConsentGroupData = getAllGroups().reduce( (acc, group) => { @@ -82,3 +119,37 @@ export const getGroupData = (): UserConsentGroupData => { return userConsentGroupData } + +export const getNormalizedCategoriesFromGroupData = ( + groupData = getGroupDataFromGroupIds() +): Categories => { + const { userSetConsentGroups, userDeniedConsentGroups } = groupData + const consentedCategories = userSetConsentGroups.reduce( + (acc, c) => { + return { + ...acc, + [c.groupId]: true, + } + }, + {} + ) + + const deniedCategories = userDeniedConsentGroups.reduce( + (acc, c) => { + return { + ...acc, + [c.groupId]: false, + } + }, + {} + ) + return { ...consentedCategories, ...deniedCategories } +} + +export const getNormalizedCategoriesFromGroupIds = ( + groupIds: ConsentGroupIds +): Categories => { + return getNormalizedCategoriesFromGroupData( + getGroupDataFromGroupIds(groupIds) + ) +} diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts b/packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts new file mode 100644 index 000000000..8edb0657d --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/lib/validation/index.ts @@ -0,0 +1 @@ +export * from './onetrust-api-error' diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts b/packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts new file mode 100644 index 000000000..9eb1ac974 --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/lib/validation/onetrust-api-error.ts @@ -0,0 +1,11 @@ +/** + * An Errot that represents that the OneTrust API is not in the expected format. + * This is not something that could happen unless our API types are wrong and something is very wonky. + * Not a recoverable error. + */ +export class OneTrustApiValidationError extends Error { + name = 'OtConsentWrapperValidationError' + constructor(message: string, received: any) { + super(`Invariant: ${message} (Received: ${JSON.stringify(received)})`) + } +} diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts new file mode 100644 index 000000000..32640d75b --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/mocks.ts @@ -0,0 +1,23 @@ +import { OneTrustGlobal } from '../lib/onetrust-api' +import { throwNotImplemented } from './utils' +import type { AnyAnalytics } from '@segment/analytics-consent-tools' +/** + * This can be used to mock the OneTrust global object in individual tests + * @example + * ```ts + * import * as OneTrustAPI from '../onetrust-api' + * jest.spyOn(OneTrustAPI, 'getOneTrustGlobal').mockImplementation(() => OneTrustMockGlobal) + * ```` + */ +export const OneTrustMockGlobal: jest.Mocked = { + GetDomainData: jest.fn().mockImplementation(throwNotImplemented), + IsAlertBoxClosed: jest.fn().mockImplementation(throwNotImplemented), + OnConsentChanged: jest.fn().mockImplementation(throwNotImplemented), // not implemented atm +} + +export const analyticsMock: jest.Mocked = { + addSourceMiddleware: jest.fn().mockImplementation(throwNotImplemented), + load: jest.fn().mockImplementation(throwNotImplemented), + on: jest.fn().mockImplementation(throwNotImplemented), + track: jest.fn().mockImplementation(throwNotImplemented), +} diff --git a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-globals.d.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/onetrust-globals.d.ts similarity index 83% rename from packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-globals.d.ts rename to packages/consent/consent-wrapper-onetrust/src/test-helpers/onetrust-globals.d.ts index 738eeb6a2..fae1f3074 100644 --- a/packages/consent/consent-wrapper-onetrust/src/lib/__tests__/onetrust-globals.d.ts +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/onetrust-globals.d.ts @@ -1,4 +1,4 @@ -import { OneTrustGlobal } from '../onetrust-api' +import { OneTrustGlobal } from '../lib/onetrust-api' /** * ALERT: It's OK to declare ambient globals in test code, but __not__ in library code * This file should not be included in the final package diff --git a/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts new file mode 100644 index 000000000..dca42f3ae --- /dev/null +++ b/packages/consent/consent-wrapper-onetrust/src/test-helpers/utils.ts @@ -0,0 +1,3 @@ +export const throwNotImplemented = (): never => { + throw new Error('not implemented') +} diff --git a/packages/consent/consent-wrapper-onetrust/tsconfig.build.json b/packages/consent/consent-wrapper-onetrust/tsconfig.build.json index b74e28ced..c11c2b9dd 100644 --- a/packages/consent/consent-wrapper-onetrust/tsconfig.build.json +++ b/packages/consent/consent-wrapper-onetrust/tsconfig.build.json @@ -1,7 +1,7 @@ { "extends": "./tsconfig.json", "include": ["src"], - "exclude": ["**/__tests__/**"], + "exclude": ["**/__tests__/**", "**/test-helpers/**"], "compilerOptions": { "outDir": "./dist/esm", "declarationDir": "./dist/types"