From 3ba5a9ad4cf1fb50891225c50328601a72d47d67 Mon Sep 17 00:00:00 2001 From: Anna Voelker Date: Thu, 21 Dec 2023 13:01:48 +0000 Subject: [PATCH] refactor and wrap all exported storage from libs --- src/cmpCookies.tests.ts | 113 ++++++++++++ src/cmpCookies.ts | 62 +++++++ src/cmpStorage.test.ts | 142 +++------------ src/cmpStorage.ts | 298 +++++++++++++++++-------------- src/hasConsentForUseCase.test.ts | 78 ++++++++ src/hasConsentForUseCase.ts | 38 ++++ src/index.ts | 3 +- src/types/consentUseCases.ts | 14 ++ 8 files changed, 492 insertions(+), 256 deletions(-) create mode 100644 src/cmpCookies.tests.ts create mode 100644 src/cmpCookies.ts create mode 100644 src/hasConsentForUseCase.test.ts create mode 100644 src/hasConsentForUseCase.ts create mode 100644 src/types/consentUseCases.ts diff --git a/src/cmpCookies.tests.ts b/src/cmpCookies.tests.ts new file mode 100644 index 000000000..84178e507 --- /dev/null +++ b/src/cmpCookies.tests.ts @@ -0,0 +1,113 @@ +import { onConsentChange } from './onConsentChange'; +import type { Callback, ConsentState } from './types'; +import type { TCFv2ConsentState } from './types/tcfv2'; +import {cmpGetCookie, cmpSetCookie } from './cmpCookies'; +import { getCookie as getCookie_, setCookie as setCookie_} from '@guardian/libs'; + +jest.mock('./onConsentChange'); +jest.mock('@guardian/libs', () => ({ + getCookie: jest.fn(), + setCookie: jest.fn(), + storage: { + local: { + get: jest.fn(), + set: jest.fn(), + }, + session: { + get: jest.fn(), + set: jest.fn(), + }, + } +})); + +const tcfv2ConsentState: TCFv2ConsentState = { + consents: { 1: true }, + eventStatus: 'tcloaded', + vendorConsents: { + ['5efefe25b8e05c06542b2a77']: true, + }, + addtlConsent: 'xyz', + gdprApplies: true, + tcString: 'YAAA', +}; + +const tcfv2ConsentStateNoConsent: TCFv2ConsentState = { + consents: { 1: false }, + eventStatus: 'tcloaded', + vendorConsents: {}, + addtlConsent: 'xyz', + gdprApplies: true, + tcString: 'YAAA', +}; + +const mockOnConsentChange = (consentState: ConsentState) => + (onConsentChange as jest.Mock).mockImplementation((cb: Callback) => cb(consentState)); + +describe('cookies return the expected consent', () => { + let mockContains:any; + + beforeEach(() => { + mockContains = 'someTestData'; + + (getCookie_ as jest.Mock).mockImplementation(({name }: { + name: string; + }) => { + if (name === 'gu.mock') {return mockContains} + else {return(null)} + }); + + (setCookie_ as jest.Mock).mockImplementation(({ name, value }: { + name: string; + value: string; + }) => { + if (name === 'gu.mock') {mockContains = value;} + }); + }); + + test('Targeted advertising get cookie returns null when canTarget is false', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentState, + canTarget: false, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const cookieValue = await cmpGetCookie({useCase: 'Targeted advertising', name: 'gu.mock'}); + expect(cookieValue).toEqual(null); + }); + test('Targeted advertising can set and get cookies when canTarget is true', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentState, + canTarget: true, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const cookieValueDefault = await cmpGetCookie({useCase: 'Targeted advertising', name: 'gu.mock'}); + expect(cookieValueDefault).toEqual('someTestData'); + await cmpSetCookie({useCase: 'Essential', name: 'gu.mock', value: 'testdataAd'}); + const cookieValue = await cmpGetCookie({useCase: 'Targeted advertising', name: 'gu.mock'}); + expect(cookieValue).toEqual('testdataAd'); + }); + test('Essential can set and get cookies when no consents', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentStateNoConsent, + canTarget: false, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const cookieValueDefault = await cmpGetCookie({useCase: 'Essential', name:'gu.mock'}); + expect(cookieValueDefault).toEqual('someTestData'); + await cmpSetCookie({useCase: 'Essential', name: 'gu.mock', value: 'testdata'}); + const cookieValue = await cmpGetCookie({useCase: 'Essential', name: 'gu.mock'}); + expect(cookieValue).toEqual('testdata'); + }); + test('get null if cookie does not exist', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentStateNoConsent, + canTarget: false, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const cookieValue = await cmpGetCookie({useCase: 'Essential', name: 'gu.does_not_exist'}); + expect(cookieValue).toEqual(null); + }); +}); diff --git a/src/cmpCookies.ts b/src/cmpCookies.ts new file mode 100644 index 000000000..dfe691c65 --- /dev/null +++ b/src/cmpCookies.ts @@ -0,0 +1,62 @@ +import { getCookie, setCookie, setSessionCookie } from '@guardian/libs'; +import type { ConsentUseCases } from './types/consentUseCases'; +import { hasConsentForUseCase } from './hasConsentForUseCase'; + +//TODO: Write wrappers for the other cookie functions in @guardian/libs + +export const cmpGetCookie = async({ useCase, name, shouldMemoize, }: { + useCase: ConsentUseCases, + name: string; + shouldMemoize?: boolean | undefined; +}): Promise => +{ + console.log('in cmpGetCookie'); + + if(await hasConsentForUseCase(useCase)) + { + return getCookie({name: name, shouldMemoize: shouldMemoize}) + } + else + { + console.error('cmp', `Cannot get cookie ${name} due to missing consent for use-case ${useCase}`) + return(null) + } +}; + +export const cmpSetCookie = async ({ useCase, name, value, daysToLive, isCrossSubdomain, }: { + useCase: ConsentUseCases, + name: string; + value: string; + daysToLive?: number | undefined; + isCrossSubdomain?: boolean | undefined; +}): Promise => +{ + console.log('in cmpSetCookie'); + + if(await hasConsentForUseCase(useCase)) + { + setCookie({name:name, value:value, daysToLive:daysToLive, isCrossSubdomain:isCrossSubdomain}) + } + else + { + console.error('cmp', `Cannot set cookie ${name} due to missing consent for use-case ${useCase}`) + } +}; + +export const cmpSetSessionCookie = async ({ useCase, name, value }: { + useCase: ConsentUseCases, + name: string; + value: string; +}): Promise => +{ + console.log('in cmpSetSessionCookie'); + + if(await hasConsentForUseCase(useCase)) + { + setSessionCookie({name:name, value:value}) + } + else + { + console.error('cmp', `Cannot set cookie ${name} due to missing consent for use-case ${useCase}`) + } +}; diff --git a/src/cmpStorage.test.ts b/src/cmpStorage.test.ts index 41a622aea..533ffbd11 100644 --- a/src/cmpStorage.test.ts +++ b/src/cmpStorage.test.ts @@ -1,8 +1,10 @@ import { onConsentChange } from './onConsentChange'; import type { Callback, ConsentState } from './types'; import type { TCFv2ConsentState } from './types/tcfv2'; -import {cmpGetLocalStorageItem, cmpSetLocalStorageItem, cmpGetSessionStorageItem, cmpSetSessionStorageItem, cmpGetCookie, cmpSetCookie, hasConsentForUseCase } from './cmpStorage'; -import { getCookie as getCookie_, setCookie as setCookie_, storage as storageStub} from '@guardian/libs'; +import {storage} from './cmpStorage'; +import { storage as storageStub} from '@guardian/libs'; + +//TODO: add tests for all use-cases and all other storage functions jest.mock('./onConsentChange'); jest.mock('@guardian/libs', () => ({ @@ -43,40 +45,6 @@ const tcfv2ConsentStateNoConsent: TCFv2ConsentState = { const mockOnConsentChange = (consentState: ConsentState) => (onConsentChange as jest.Mock).mockImplementation((cb: Callback) => cb(consentState)); -describe('cmpStorage.hasConsentForUseCase returns the expected consent', () => { - test('Targeted advertising has consent when canTarget is true', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentState, - canTarget: true, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const hasConsent = await hasConsentForUseCase('Targeted advertising'); - expect(hasConsent).toEqual(true); - }); - test('Targeted advertising has no consent when canTarget is false', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentState, - canTarget: false, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const hasConsent = await hasConsentForUseCase('Targeted advertising'); - expect(hasConsent).toEqual(false); - }); - test('Essential has consent even when ConsentState has no consents', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentStateNoConsent, - canTarget: false, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const hasConsent = await hasConsentForUseCase('Essential'); - expect(hasConsent).toEqual(true); - }); -}); - - describe('local storage returns the expected consent', () => { let mockContains:any; @@ -100,7 +68,7 @@ describe('local storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const localStorageValue = await cmpGetLocalStorageItem('Targeted advertising', 'gu.mock'); + const localStorageValue = await storage.local.get('Targeted advertising', 'gu.mock'); expect(localStorageValue).toEqual(null); }); test('Targeted advertising can set and get local storage value when canTarget is true', async () => { @@ -110,10 +78,10 @@ describe('local storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const localStorageValueDefault = await cmpGetLocalStorageItem('Targeted advertising', 'gu.mock'); + const localStorageValueDefault = await storage.local.get('Targeted advertising', 'gu.mock'); expect(localStorageValueDefault).toEqual('someTestData'); - await cmpSetLocalStorageItem('Essential', 'gu.mock', 'testdataAd'); - const localStorageValue = await cmpGetLocalStorageItem('Targeted advertising', 'gu.mock'); + await storage.local.set('Essential', 'gu.mock', 'testdataAd'); + const localStorageValue = await storage.local.get('Targeted advertising', 'gu.mock'); expect(localStorageValue).toEqual('testdataAd'); }); test('Essential can set and get local storage when no consents', async () => { @@ -123,10 +91,10 @@ describe('local storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const localStorageValueDefault = await cmpGetLocalStorageItem('Essential', 'gu.mock'); + const localStorageValueDefault = await storage.local.get('Essential', 'gu.mock'); expect(localStorageValueDefault).toEqual('someTestData'); - await cmpSetLocalStorageItem('Essential', 'gu.mock', 'testdata'); - const localStorageValue = await cmpGetLocalStorageItem('Essential', 'gu.mock'); + await storage.local.set('Essential', 'gu.mock', 'testdata'); + const localStorageValue = await storage.local.get('Essential', 'gu.mock'); expect(localStorageValue).toEqual('testdata'); }); test('get null if local storage item does not exist', async () => { @@ -136,7 +104,7 @@ describe('local storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const localStorageValue = await cmpGetLocalStorageItem('Essential', 'gu.does_not_exist'); + const localStorageValue = await storage.local.get('Essential', 'gu.does_not_exist'); expect(localStorageValue).toEqual(null); }); }); @@ -165,7 +133,7 @@ describe('session storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const sessionStorageValue = await cmpGetSessionStorageItem('Targeted advertising', 'gu.mock'); + const sessionStorageValue = await storage.session.get('Targeted advertising', 'gu.mock'); expect(sessionStorageValue).toEqual(null); }); test('Targeted advertising can set and get session storage value when canTarget is true', async () => { @@ -175,10 +143,10 @@ describe('session storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const sessionStorageValueDefault = await cmpGetSessionStorageItem('Targeted advertising', 'gu.mock'); + const sessionStorageValueDefault = await storage.session.get('Targeted advertising', 'gu.mock'); expect(sessionStorageValueDefault).toEqual('someTestData'); - await cmpSetSessionStorageItem('Essential', 'gu.mock', 'testdataAd'); - const sessionStorageValue = await cmpGetSessionStorageItem('Targeted advertising', 'gu.mock'); + await storage.session.set('Essential', 'gu.mock', 'testdataAd'); + const sessionStorageValue = await storage.session.get('Targeted advertising', 'gu.mock'); expect(sessionStorageValue).toEqual('testdataAd'); }); test('Essential can set and get session storage when no consents', async () => { @@ -188,10 +156,10 @@ describe('session storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const sessionStorageValueDefault = await cmpGetSessionStorageItem('Essential', 'gu.mock'); + const sessionStorageValueDefault = await storage.session.get('Essential', 'gu.mock'); expect(sessionStorageValueDefault).toEqual('someTestData'); - await cmpSetSessionStorageItem('Essential', 'gu.mock', 'testdata'); - const sessionStorageValue = await cmpGetSessionStorageItem('Essential', 'gu.mock'); + await storage.session.set('Essential', 'gu.mock', 'testdata'); + const sessionStorageValue = await storage.session.get('Essential', 'gu.mock'); expect(sessionStorageValue).toEqual('testdata'); }); test('get null if session storage item does not exist', async () => { @@ -201,77 +169,7 @@ describe('session storage returns the expected consent', () => { framework: 'tcfv2', }; mockOnConsentChange(consentState); - const sessionStorageValue = await cmpGetSessionStorageItem('Essential', 'gu.does_not_exist'); + const sessionStorageValue = await storage.session.get('Essential', 'gu.does_not_exist'); expect(sessionStorageValue).toEqual(null); }); }); - - -describe('cookies return the expected consent', () => { - let mockContains:any; - - beforeEach(() => { - mockContains = 'someTestData'; - - (getCookie_ as jest.Mock).mockImplementation(({name }: { - name: string; - }) => { - if (name === 'gu.mock') {return mockContains} - else {return(null)} - }); - - (setCookie_ as jest.Mock).mockImplementation(({ name, value }: { - name: string; - value: string; - }) => { - if (name === 'gu.mock') {mockContains = value;} - }); - }); - - test('Targeted advertising get cookie returns null when canTarget is false', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentState, - canTarget: false, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const cookieValue = await cmpGetCookie({useCase: 'Targeted advertising', name: 'gu.mock'}); - expect(cookieValue).toEqual(null); - }); - test('Targeted advertising can set and get cookies when canTarget is true', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentState, - canTarget: true, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const cookieValueDefault = await cmpGetCookie({useCase: 'Targeted advertising', name: 'gu.mock'}); - expect(cookieValueDefault).toEqual('someTestData'); - await cmpSetCookie({useCase: 'Essential', name: 'gu.mock', value: 'testdataAd'}); - const cookieValue = await cmpGetCookie({useCase: 'Targeted advertising', name: 'gu.mock'}); - expect(cookieValue).toEqual('testdataAd'); - }); - test('Essential can set and get cookies when no consents', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentStateNoConsent, - canTarget: false, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const cookieValueDefault = await cmpGetCookie({useCase: 'Essential', name:'gu.mock'}); - expect(cookieValueDefault).toEqual('someTestData'); - await cmpSetCookie({useCase: 'Essential', name: 'gu.mock', value: 'testdata'}); - const cookieValue = await cmpGetCookie({useCase: 'Essential', name: 'gu.mock'}); - expect(cookieValue).toEqual('testdata'); - }); - test('get null if cookie does not exist', async () => { - const consentState: ConsentState = { - tcfv2: tcfv2ConsentStateNoConsent, - canTarget: false, - framework: 'tcfv2', - }; - mockOnConsentChange(consentState); - const cookieValue = await cmpGetCookie({useCase: 'Essential', name: 'gu.does_not_exist'}); - expect(cookieValue).toEqual(null); - }); -}); diff --git a/src/cmpStorage.ts b/src/cmpStorage.ts index 1a40f6ea8..7a9563b9f 100644 --- a/src/cmpStorage.ts +++ b/src/cmpStorage.ts @@ -1,153 +1,185 @@ -import { storage, getCookie, setCookie } from '@guardian/libs'; -import { onConsent } from './onConsent'; +import { storage as libsStorage } from '@guardian/libs'; +import type { ConsentUseCases } from './types/consentUseCases'; +import { hasConsentForUseCase } from './hasConsentForUseCase'; -/** - * List of permitted use-cases for cookies/storage: - * - * - `Targeted advertising`: if the user can be targeted for personalised advertising according to the active consent framework - * - 'Targeted marketing': if the user can be targeted for personalised marketing, e.g. article count - * - `Essential`: if essential cookies/storage can be used according to the active consent framework - * - */ -export const UseCaseOptions = [ - "Targeted advertising", - "Targeted marketing", - "Essential" +export const storageOptions = [ + "localStorage", + "sessionStorage" ] as const; -export type UseCases = typeof UseCaseOptions[number]; - -export const hasConsentForUseCase = async (useCase: UseCases): Promise => -{ - const consentState = await onConsent(); - - /*console.log(`consentState.tcfv2?.consents['1']: ${consentState.tcfv2?.consents['1']}`); - console.log(`consentState.tcfv2?.consents['2']: ${consentState.tcfv2?.consents['2']}`); - console.log(`consentState.tcfv2?.consents['3']: ${consentState.tcfv2?.consents['3']}`); - console.log(`consentState.tcfv2?.consents['4']: ${consentState.tcfv2?.consents['4']}`); - console.log(`consentState.tcfv2?.consents['5']: ${consentState.tcfv2?.consents['5']}`); - console.log(`consentState.tcfv2?.consents['6']: ${consentState.tcfv2?.consents['6']}`); - console.log(`consentState.tcfv2?.consents['7']: ${consentState.tcfv2?.consents['7']}`); - console.log(`consentState.tcfv2?.consents['8']: ${consentState.tcfv2?.consents['8']}`); - console.log(`consentState.tcfv2?.consents['9']: ${consentState.tcfv2?.consents['9']}`); - console.log(`consentState.tcfv2?.consents['10']: ${consentState.tcfv2?.consents['10']}`); - console.log(`consentState.tcfv2?.consents['11']: ${consentState.tcfv2?.consents['11']}`); - console.log(`consentState.canTarget: ${consentState.canTarget}`); - */ - - switch(useCase) { - case "Targeted advertising": return(consentState.canTarget) - case "Targeted marketing":{ - if(( - consentState.tcfv2?.consents['1'] - && consentState.tcfv2?.consents['3'] - && consentState.tcfv2?.consents['7']) - || !consentState.ccpa?.doNotSell - || consentState.aus?.personalisedAdvertising) - return(true) - else return(false) - } - case "Essential": return(true) //could check for allow-list of essential cookies/storage here in the future - default: return(false) - } - -} +export type StorageOptions = typeof storageOptions[number]; +class StorageFactory { + #storageHandler: StorageOptions; -export const cmpGetLocalStorageItem = async (useCase: UseCases, storageItem: string): Promise => -{ - console.log('in cmpGetLocalStorageItem'); - - if(await hasConsentForUseCase(useCase)) - { - return storage.local.get(storageItem) + constructor(storageHandler: StorageOptions) { + this.#storageHandler = storageHandler; } - else - { - console.error('cmp', `Cannot get local storage item ${storageItem} due to missing consent for use-case ${useCase}`) - return(null) + + /** + * Check whether storage is available. + */ + isAvailable(): boolean { + switch(this.#storageHandler) { + case 'localStorage': { + return Boolean(libsStorage.local.isAvailable()) + } + case 'sessionStorage': { + return Boolean(libsStorage.session.isAvailable()) + } + } } -}; -export const cmpSetLocalStorageItem = async (useCase: UseCases, storageItem: string, value:unknown, expires?: string | number | Date): Promise => -{ - console.log('in cmpSetLocalStorageItem'); + /** + * Retrieve an item from storage. + * + * @param key - the name of the item + */ + async get(useCase: ConsentUseCases, key: string): Promise { + console.log('in cmp get storage'); + if(await hasConsentForUseCase(useCase)) + { + switch(this.#storageHandler) { + case 'localStorage': { + console.log('in cmp get local'); + return libsStorage.local.get(key) + } + case 'sessionStorage': { + console.log('in cmp get session'); + return libsStorage.session.get(key) + } + } + } + else + { + console.error('cmp', `Cannot get local storage item ${key} due to missing consent for use-case ${useCase}`) + return(null) + } + } - if(await hasConsentForUseCase(useCase)) - { - return storage.local.set(storageItem, value, expires) + /** + * Save a value to storage. + * + * @param key - the name of the item + * @param value - the data to save + * @param expires - optional date on which this data will expire + */ + async set(useCase:ConsentUseCases, key: string, value: unknown, expires?: string | number | Date): Promise { + console.log('in cmp set storage'); + if(await hasConsentForUseCase(useCase)) + { + switch(this.#storageHandler) { + case 'localStorage': return libsStorage.local.set(key, value, expires) + case 'sessionStorage': return libsStorage.session.set(key, value, expires) + } + } + else + { + console.error('cmp', `Cannot set local storage item ${key} due to missing consent for use-case ${useCase}`) + } } - else - { - console.error('cmp', `Cannot set local storage item ${storageItem} due to missing consent for use-case ${useCase}`) + + /** + * Remove an item from storage. + * + * @param key - the name of the item + */ + remove(key: string): void { + + switch(this.#storageHandler) { + case 'localStorage': { + return libsStorage.local.remove(key); + } + case 'sessionStorage': { + return libsStorage.session.remove(key); + } + } } -}; -export const cmpGetSessionStorageItem = async (useCase: UseCases, storageItem: string): Promise => -{ - console.log('in cmpGetSessionStorageItem'); + /** + * Removes all items from storage. + */ + clear(): void { + + switch(this.#storageHandler) { + case 'localStorage': { + return libsStorage.local.clear(); + } + case 'sessionStorage': { + return libsStorage.session.clear(); + } + } + } - if(await hasConsentForUseCase(useCase)) - { - return storage.session.get(storageItem) + /** + * Retrieve an item from storage in its raw state. + * + * @param key - the name of the item + */ + async getRaw(useCase: ConsentUseCases, key: string): Promise { + if(await hasConsentForUseCase(useCase)) + { + switch(this.#storageHandler) { + case 'localStorage': { + return libsStorage.local.getRaw(key) + } + case 'sessionStorage': { + return libsStorage.session.getRaw(key) + } + } + } + else + { + console.error('cmp', `Cannot get local storage item ${key} due to missing consent for use-case ${useCase}`) + return(null) + } } - else - { - console.error('cmp', `Cannot get session storage item ${storageItem} due to missing consent for use-case ${useCase}`) - return(null) + + /** + * Save a raw value to storage. + * + * @param key - the name of the item + * @param value - the data to save + */ + async setRaw(useCase: ConsentUseCases, key: string, value: string): Promise { + if(await hasConsentForUseCase(useCase)) + { + switch(this.#storageHandler) { + case 'localStorage': return libsStorage.local.setRaw(key, value) + case 'sessionStorage': return libsStorage.session.setRaw(key, value) + } + } + else + { + console.error('cmp', `Cannot set local storage item ${key} due to missing consent for use-case ${useCase}`) + } } -}; +} + +/** + * Manages using `localStorage` and `sessionStorage`. + * + * Has a few advantages over the native API, including + * - failing gracefully if storage is not available + * - you can save and retrieve any JSONable data + * + * All methods are available for both `localStorage` and `sessionStorage`. + */ +export const storage = new (class { + #local: StorageFactory | undefined; + #session: StorageFactory | undefined; -export const cmpSetSessionStorageItem = async (useCase: UseCases, storageItem: string, value:unknown, expires?: string | number | Date): Promise => -{ - console.log('in cmpSetSessionStorageItem'); + // creating the instance requires testing the native implementation + // which is blocking. therefore, only create new instances of the factory + // when it's accessed i.e. we know we're going to use it - if(await hasConsentForUseCase(useCase)) - { - return storage.session.set(storageItem, value, expires) - } - else - { - console.error('cmp', `Cannot set session storage item ${storageItem} due to missing consent for use-case ${useCase}`) - } -}; - -export const cmpGetCookie = async({ useCase, name, shouldMemoize, }: { - useCase: UseCases, - name: string; - shouldMemoize?: boolean | undefined; -}): Promise => -{ - console.log('in cmpGetCookie'); - - if(await hasConsentForUseCase(useCase)) - { - return getCookie({name: name, shouldMemoize: shouldMemoize}) + get local() { + return (this.#local ||= new StorageFactory('localStorage')); } - else - { - console.error('cmp', `Cannot get cookie ${name} due to missing consent for use-case ${useCase}`) - return(null) - } -}; - -export const cmpSetCookie = async ({ useCase, name, value, daysToLive, isCrossSubdomain, }: { - useCase: UseCases, - name: string; - value: string; - daysToLive?: number | undefined; - isCrossSubdomain?: boolean | undefined; -}): Promise => -{ - console.log('in cmpSetCookie'); - - if(await hasConsentForUseCase(useCase)) - { - setCookie({name:name, value:value, daysToLive:daysToLive, isCrossSubdomain:isCrossSubdomain}) - } - else - { - console.error('cmp', `Cannot set cookie ${name} due to missing consent for use-case ${useCase}`) + + get session() { + return (this.#session ||= new StorageFactory('sessionStorage')); } -}; +})(); + //await cmpGetLocalStorageItem("Targeted advertising", "dep") //await cmpGetLocalStorageItem("invalid", "dep") diff --git a/src/hasConsentForUseCase.test.ts b/src/hasConsentForUseCase.test.ts new file mode 100644 index 000000000..93f281a49 --- /dev/null +++ b/src/hasConsentForUseCase.test.ts @@ -0,0 +1,78 @@ +import { onConsentChange } from './onConsentChange'; +import type { Callback, ConsentState } from './types'; +import type { TCFv2ConsentState } from './types/tcfv2'; +import { hasConsentForUseCase } from './hasConsentForUseCase'; + +//TODO: add tests for all use-cases + +jest.mock('./onConsentChange'); +jest.mock('@guardian/libs', () => ({ + getCookie: jest.fn(), + setCookie: jest.fn(), + storage: { + local: { + get: jest.fn(), + set: jest.fn(), + }, + session: { + get: jest.fn(), + set: jest.fn(), + }, + } +})); + +const tcfv2ConsentState: TCFv2ConsentState = { + consents: { 1: true }, + eventStatus: 'tcloaded', + vendorConsents: { + ['5efefe25b8e05c06542b2a77']: true, + }, + addtlConsent: 'xyz', + gdprApplies: true, + tcString: 'YAAA', +}; + +const tcfv2ConsentStateNoConsent: TCFv2ConsentState = { + consents: { 1: false }, + eventStatus: 'tcloaded', + vendorConsents: {}, + addtlConsent: 'xyz', + gdprApplies: true, + tcString: 'YAAA', +}; + +const mockOnConsentChange = (consentState: ConsentState) => + (onConsentChange as jest.Mock).mockImplementation((cb: Callback) => cb(consentState)); + +describe('cmpStorage.hasConsentForUseCase returns the expected consent', () => { + test('Targeted advertising has consent when canTarget is true', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentState, + canTarget: true, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const hasConsent = await hasConsentForUseCase('Targeted advertising'); + expect(hasConsent).toEqual(true); + }); + test('Targeted advertising has no consent when canTarget is false', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentState, + canTarget: false, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const hasConsent = await hasConsentForUseCase('Targeted advertising'); + expect(hasConsent).toEqual(false); + }); + test('Essential has consent even when ConsentState has no consents', async () => { + const consentState: ConsentState = { + tcfv2: tcfv2ConsentStateNoConsent, + canTarget: false, + framework: 'tcfv2', + }; + mockOnConsentChange(consentState); + const hasConsent = await hasConsentForUseCase('Essential'); + expect(hasConsent).toEqual(true); + }); +}); diff --git a/src/hasConsentForUseCase.ts b/src/hasConsentForUseCase.ts new file mode 100644 index 000000000..30e1da655 --- /dev/null +++ b/src/hasConsentForUseCase.ts @@ -0,0 +1,38 @@ +import { onConsent } from './onConsent'; +import type { ConsentUseCases } from './types/consentUseCases'; + +export const hasConsentForUseCase = async (useCase: ConsentUseCases): Promise => +{ + const consentState = await onConsent(); + + /*console.log(`consentState.tcfv2?.consents['1']: ${consentState.tcfv2?.consents['1']}`); + console.log(`consentState.tcfv2?.consents['2']: ${consentState.tcfv2?.consents['2']}`); + console.log(`consentState.tcfv2?.consents['3']: ${consentState.tcfv2?.consents['3']}`); + console.log(`consentState.tcfv2?.consents['4']: ${consentState.tcfv2?.consents['4']}`); + console.log(`consentState.tcfv2?.consents['5']: ${consentState.tcfv2?.consents['5']}`); + console.log(`consentState.tcfv2?.consents['6']: ${consentState.tcfv2?.consents['6']}`); + console.log(`consentState.tcfv2?.consents['7']: ${consentState.tcfv2?.consents['7']}`); + console.log(`consentState.tcfv2?.consents['8']: ${consentState.tcfv2?.consents['8']}`); + console.log(`consentState.tcfv2?.consents['9']: ${consentState.tcfv2?.consents['9']}`); + console.log(`consentState.tcfv2?.consents['10']: ${consentState.tcfv2?.consents['10']}`); + console.log(`consentState.tcfv2?.consents['11']: ${consentState.tcfv2?.consents['11']}`); + console.log(`consentState.canTarget: ${consentState.canTarget}`); + */ + + switch(useCase) { + case "Targeted advertising": return(consentState.canTarget) + case "Targeted marketing":{ + if(( + consentState.tcfv2?.consents['1'] + && consentState.tcfv2?.consents['3'] + && consentState.tcfv2?.consents['7']) + || !consentState.ccpa?.doNotSell + || consentState.aus?.personalisedAdvertising) + return(true) + else return(false) + } + case "Essential": return(true) //could check for allow-list of essential cookies/storage here in the future + default: return(false) + } + +} diff --git a/src/index.ts b/src/index.ts index 4b2a3b8d4..86a8686de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,8 @@ import { import type { CMP, InitCMP, WillShowPrivacyMessage } from './types'; import { initVendorDataManager } from './vendorDataManager'; -export {cmpGetLocalStorageItem, cmpSetLocalStorageItem, cmpGetSessionStorageItem, cmpSetSessionStorageItem, cmpGetCookie, cmpSetCookie} from './cmpStorage'; +export { cmpGetCookie, cmpSetCookie, cmpSetSessionCookie} from './cmpCookies'; +export { storage } from './cmpStorage' // Store some bits in the global scope for reuse, in case there's more // than one instance of the CMP on the page in different scopes. diff --git a/src/types/consentUseCases.ts b/src/types/consentUseCases.ts new file mode 100644 index 000000000..cb000edc9 --- /dev/null +++ b/src/types/consentUseCases.ts @@ -0,0 +1,14 @@ +/** + * List of permitted use-cases for cookies/storage: + * + * - `Targeted advertising`: if the user can be targeted for personalised advertising according to the active consent framework + * - 'Targeted marketing': if the user can be targeted for personalised marketing, e.g. article count + * - `Essential`: if essential cookies/storage can be used according to the active consent framework + * + */ +export const ConsentUseCaseOptions = [ + "Targeted advertising", + "Targeted marketing", + "Essential" +] as const; +export type ConsentUseCases = typeof ConsentUseCaseOptions[number];