diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index caf67cf1..962031fc 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -17,8 +17,11 @@ import mutate, { MutationController } from 'dom-mutator'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; +import { ConsentAwareStorage } from './storage/consent-aware-storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { + ConsentOptions, + ConsentStatus, Defaults, WebExperimentClient, WebExperimentConfig, @@ -33,15 +36,9 @@ import { RevertVariantsOptions, } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; -import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { - getStorageItem, - setStorageItem, - removeStorageItem, -} from './util/storage'; import { getUrlParams, removeQueryParams, @@ -103,6 +100,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // Preview mode is set by url params, postMessage or session storage, not chrome extension isPreviewMode = false; previewFlags: Record = {}; + private consentOptions: ConsentOptions = { + status: ConsentStatus.GRANTED, + }; + private storage: ConsentAwareStorage; constructor( apiKey: string, @@ -127,6 +128,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ...(this.globalScope.experimentConfig ?? {}), }; + if (this.config.consentOptions) { + this.consentOptions = this.config.consentOptions; + } + + this.storage = new ConsentAwareStorage(this.consentOptions.status); + this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -175,7 +182,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const urlParams = getUrlParams(); this.isVisualEditorMode = urlParams[VISUAL_EDITOR_PARAM] === 'true' || - getStorageItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== null; + this.storage.getItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== + null; this.subscriptionManager = new SubscriptionManager( this, this.messageBus, @@ -210,7 +218,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; const user = - getStorageItem( + this.storage.getItem( 'localStorage', experimentStorageName, ) || {}; @@ -222,10 +230,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (!user.web_exp_id) { user.web_exp_id = user.device_id || UUID(); delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } else if (user.web_exp_id && user.device_id) { delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } // evaluate variants for page targeting @@ -524,6 +532,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } + public setConsentStatus(consentStatus: ConsentStatus) { + this.consentOptions.status = consentStatus; + this.storage.setConsentStatus(consentStatus); + } + private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -578,7 +591,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // set previous url - relevant for SPA if redirect happens before push/replaceState is complete this.previousUrl = this.globalScope.location.href; - setMarketingCookie(this.apiKey).then(); + this.storage.setMarketingCookie(this.apiKey).then(); // perform redirection if (this.customRedirectHandler) { this.customRedirectHandler(targetUrl); @@ -799,16 +812,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; // Store the current flag and variant for exposure tracking after redirect const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; storedRedirects[flagKey] = { redirectUrl, variant }; - setStorageItem('sessionStorage', redirectStorageKey, storedRedirects); + this.storage.setItem('sessionStorage', redirectStorageKey, storedRedirects); } private fireStoredRedirectImpressions() { // Check for stored redirects and process them const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; // If we have stored redirects, track exposures for them if (Object.keys(storedRedirects).length > 0) { @@ -833,7 +846,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // track exposure with timeout of 500ms this.globalScope.setTimeout(() => { const redirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; for (const storedFlagKey in redirects) { this.exposureWithDedupe( storedFlagKey, @@ -841,10 +854,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { true, ); } - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); }, 500); } else { - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); } } @@ -857,7 +870,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } }); - setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { + this.storage.setItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { previewFlags: this.previewFlags, }); const previewParamsToRemove = [ @@ -875,7 +888,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // if in preview mode, listen for ForceVariant messages WindowMessenger.setup(); } else { - const previewState: PreviewState | null = getStorageItem( + const previewState: PreviewState | null = this.storage.getItem( 'sessionStorage', PREVIEW_MODE_SESSION_KEY, ); diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index 3ddf260d..f58648d0 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -3,7 +3,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient } from './experiment'; import { HttpClient } from './preview/http'; import { SdkPreviewApi } from './preview/preview-api'; -import { WebExperimentConfig } from './types'; +import { ConsentStatus, WebExperimentConfig } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { isPreviewMode } from './util/url'; @@ -13,6 +13,12 @@ export const initialize = ( pageObjects: string, config: WebExperimentConfig, ): void => { + if ( + getGlobalScope()?.experimentConfig.consentOptions.status === + ConsentStatus.REJECTED + ) { + return; + } const shouldFetchConfigs = isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts new file mode 100644 index 00000000..a716be1a --- /dev/null +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -0,0 +1,140 @@ +import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import type { Campaign } from '@amplitude/analytics-core'; + +import { ConsentStatus } from '../types'; + +import { + getStorage, + getStorageItem, + removeStorageItem, + setStorageItem, + StorageType, +} from './storage'; + +/** + * Consent-aware storage manager that handles persistence based on consent status + */ +export class ConsentAwareStorage { + private inMemoryStorage: Map> = new Map(); + private inMemoryMarketingCookies: Map = new Map(); + private consentStatus: ConsentStatus; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Set the consent status and handle persistence accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + this.consentStatus = consentStatus; + + if (consentStatus === ConsentStatus.GRANTED) { + for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); + } + } + } + this.inMemoryStorage.clear(); + this.persistMarketingCookies().then(); + } + } + + /** + * Persist marketing cookies from memory to actual cookies + */ + private async persistMarketingCookies(): Promise { + for (const [ + storageKey, + campaign, + ] of this.inMemoryMarketingCookies.entries()) { + try { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } catch (error) { + console.warn( + `Failed to persist marketing cookie for key ${storageKey}:`, + error, + ); + } + } + this.inMemoryMarketingCookies.clear(); + } + + /** + * Get a JSON value from storage with consent awareness + */ + public getItem(storageType: StorageType, key: string): T | null { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) as T; + } + + return null; + } + + /** + * Set a JSON value in storage with consent awareness + */ + public setItem(storageType: StorageType, key: string, value: unknown): void { + if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } else { + if (!this.inMemoryStorage.has(storageType)) { + this.inMemoryStorage.set(storageType, new Map()); + } + this.inMemoryStorage.get(storageType)?.set(key, value); + } + } + + /** + * Remove a value from storage with consent awareness + */ + public removeItem(storageType: StorageType, key: string): void { + const storageMap = this.inMemoryStorage.get(storageType); + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + return; + } + if (storageMap) { + storageMap.delete(key); + if (storageMap.size === 0) { + this.inMemoryStorage.delete(storageType); + } + } + } + + /** + * Set marketing cookie with consent awareness + * Parses current campaign data from URL and referrer, then stores it in the marketing cookie + */ + public async setMarketingCookie(apiKey: string): Promise { + try { + const parser = new CampaignParser(); + const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; + const campaign = await parser.parse(); + + if (this.consentStatus === ConsentStatus.GRANTED) { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } else { + this.inMemoryMarketingCookies.set(storageKey, campaign); + } + } catch (error) { + console.warn('Failed to set marketing cookie:', error); + } + } +} diff --git a/packages/experiment-tag/src/util/storage.ts b/packages/experiment-tag/src/storage/storage.ts similarity index 96% rename from packages/experiment-tag/src/util/storage.ts rename to packages/experiment-tag/src/storage/storage.ts index 51c1cbf3..29f19644 100644 --- a/packages/experiment-tag/src/util/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -59,7 +59,7 @@ export const removeStorageItem = ( } }; -const getStorage = (storageType: StorageType): Storage | null => { +export const getStorage = (storageType: StorageType): Storage | null => { const globalScope = getGlobalScope(); if (!globalScope) { return null; diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index aa7b0b2f..dc426eb0 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -43,14 +43,25 @@ export type PageObject = { export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; +export enum ConsentStatus { + REJECTED = 0, + GRANTED = 1, + PENDING = 2, +} + +export type ConsentOptions = { + status: ConsentStatus; +}; + export interface WebExperimentConfig extends ExperimentConfig { /** - * Determines whether the default implementation for handling navigation will be used + * Determines whether the default implementation for handling navigation will be used * If this is set to false, for single-page applications: * 1. The variant actions applied will be based on the context (user, page URL) when the web experiment script was loaded * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context */ useDefaultNavigationHandler?: boolean; + consentOptions?: ConsentOptions; } export const Defaults: WebExperimentConfig = { @@ -79,6 +90,8 @@ export interface WebExperimentClient { getActivePages(): PageObjects; setRedirectHandler(handler: (url: string) => void): void; + + setConsentStatus(consentStatus: ConsentStatus): void; } export type WebExperimentUser = { diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts deleted file mode 100644 index 2042b693..00000000 --- a/packages/experiment-tag/src/util/cookie.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CampaignParser } from '@amplitude/analytics-core'; -import { CookieStorage } from '@amplitude/analytics-core'; -import { MKTG } from '@amplitude/analytics-core'; -import type { Campaign } from '@amplitude/analytics-core'; - -/** - * Utility function to generate and set marketing cookie - * Parses current campaign data from URL and referrer, then stores it in the marketing cookie - */ -export async function setMarketingCookie(apiKey: string) { - const storage = new CookieStorage({ - sameSite: 'Lax', - }); - - const parser = new CampaignParser(); - const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; - const campaign = await parser.parse(); - await storage.set(storageKey, campaign); -} diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 97dd5db9..1cf0bb19 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from './storage'; +import { getStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 743f2cbc..12089f27 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,10 +1,9 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; +import { getStorageItem } from '../storage/storage'; import { PreviewState } from '../types'; -import { getStorageItem } from './storage'; - export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); const searchParams = new URLSearchParams(globalScope?.location.search); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 059882be..1cdc7263 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,8 +1,10 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as experimentCore from '@amplitude/experiment-core'; import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; import { DefaultWebExperimentClient } from 'src/experiment'; +import { ConsentStatus } from 'src/types'; import * as antiFlickerUtils from 'src/util/anti-flicker'; import * as uuid from 'src/util/uuid'; import { stringify } from 'ts-jest'; @@ -26,6 +28,20 @@ jest.mock('src/util/messenger', () => { }; }); +jest.mock('@amplitude/analytics-core', () => ({ + ...jest.requireActual('@amplitude/analytics-core'), + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + const newMockGlobal = (overrides?: Record) => { const createStorageMock = () => { let store: Record = {}; @@ -1228,6 +1244,268 @@ describe('initializeExperiment', () => { expect(mockGlobal.sessionStorage.getItem(redirectStorageKey)).toBeNull(); }); + describe('consent status initialization and storage persistence', () => { + let mockConsentAwareStorage: any; + + beforeEach(() => { + // Mock ConsentAwareStorage + mockConsentAwareStorage = { + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), + setConsentStatus: jest.fn(), + }; + }); + + it('should initialize experiment with PENDING consent and store data in memory only', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // With PENDING consent, data should be stored in memory only, not in actual localStorage + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should initialize experiment with GRANTED consent and store data directly in actual storage', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + 'EXP_' + stringify(apiKey), + JSON.stringify({ web_exp_id: 'mock' }), + ); + }); + + it('should handle consent status change from PENDING to GRANTED during experiment lifecycle', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + client.setConsentStatus(ConsentStatus.GRANTED); + + // Verify that previously stored data is now persisted to actual storage + expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); + }); + + it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + client.setConsentStatus(ConsentStatus.REJECTED); + + const experimentStorageKey = `EXP_${stringify(apiKey).slice(0, 10)}`; + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalledWith( + experimentStorageKey, + expect.stringContaining('web_exp_id'), + ); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('marketing cookie with different consent status', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly during redirect when consent is GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + `AMP_MKTG_ORIGINAL_${stringify(apiKey).substring(0, 10)}`, + mockCampaign, + ); + }); + + it('should store marketing cookie in memory during redirect when consent is PENDING', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should not set marketing cookie during redirect when consent is REJECTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + }); + describe('remote evaluation - flag already stored in session storage', () => { const sessionStorageMock = () => { let store = {}; diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts new file mode 100644 index 00000000..0e3eff17 --- /dev/null +++ b/packages/experiment-tag/test/storage.test.ts @@ -0,0 +1,408 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; +import * as coreUtil from '@amplitude/experiment-core'; +import { ConsentAwareStorage } from 'src/storage/consent-aware-storage'; +import { ConsentStatus } from 'src/types'; + +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); + +// Mock CampaignParser and CookieStorage +jest.mock('@amplitude/analytics-core', () => ({ + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + +describe('ConsentAwareStorage', () => { + let mockGlobal: any; + let storage: ConsentAwareStorage; + + const createStorageMock = () => { + let store: Record = {}; + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + length: jest.fn(() => Object.keys(store).length), + key: jest.fn((index: number) => Object.keys(store)[index] || null), + }; + }; + + beforeEach(() => { + mockGlobal = { + localStorage: createStorageMock(), + sessionStorage: createStorageMock(), + }; + spyGetGlobalScope.mockReturnValue(mockGlobal); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('setItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should store data in memory for localStorage', () => { + const testData = { key: 'value', number: 42 }; + storage.setItem('localStorage', 'testKey', testData); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data in memory for sessionStorage', () => { + const testData = { key: 'value', array: [1, 2, 3] }; + storage.setItem('sessionStorage', 'testKey', testData); + + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should handle multiple keys in the same storage type', () => { + storage.setItem('localStorage', 'key1', 'value1'); + storage.setItem('localStorage', 'key2', 'value2'); + + expect(storage.getItem('localStorage', 'key1')).toBe('value1'); + expect(storage.getItem('localStorage', 'key2')).toBe('value2'); + }); + + it('should handle multiple storage types independently', () => { + storage.setItem('localStorage', 'key', 'localValue'); + storage.setItem('sessionStorage', 'key', 'sessionValue'); + + expect(storage.getItem('localStorage', 'key')).toBe('localValue'); + expect(storage.getItem('sessionStorage', 'key')).toBe('sessionValue'); + }); + }); + + describe('setItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should store data directly in localStorage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data directly in sessionStorage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); + + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + }); + + describe('setItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should store data in memory but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('localStorage', 'testKey')).toEqual(testData); + }); + + it('should store data in memory for sessionStorage but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); + + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('sessionStorage', 'testKey')).toEqual(testData); + }); + }); + + describe('getItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should return data from memory if available', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null if not in memory and consent is pending', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('getItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should return data from actual storage, not memory', () => { + const testData = { key: 'value' }; + + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + }); + }); + + describe('getItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should return data from memory and not access actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('removeItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should remove data from memory', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + expect(storage.getItem('localStorage', 'testKey')).not.toBeNull(); + + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'nonExistentKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should clean up empty storage maps', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + storage.setItem('sessionStorage', 'otherKey', { other: 'value' }); + expect(storage.getItem('sessionStorage', 'otherKey')).not.toBeNull(); + }); + }); + + describe('removeItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should remove data from actual storage', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + }); + + describe('removeItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should not remove from actual storage', () => { + storage.removeItem('localStorage', 'testKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('setConsentStatus', () => { + it('should persist in-memory data when changing from PENDING to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store data in memory + const localData = { local: 'value' }; + const sessionData = { session: 'value' }; + storage.setItem('localStorage', 'localKey', localData); + storage.setItem('sessionStorage', 'sessionKey', sessionData); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify data is now accessible from actual storage (not memory) + expect(storage.getItem('localStorage', 'localKey')).toEqual(localData); + expect(storage.getItem('sessionStorage', 'sessionKey')).toEqual( + sessionData, + ); + }); + + it('should handle persistence errors gracefully', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Store data that will cause JSON.stringify to fail + const circularData: any = {}; + circularData.self = circularData; + storage.setItem('localStorage', 'circularKey', circularData); + + storage.setConsentStatus(ConsentStatus.GRANTED); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist data for key circularKey:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should not persist data when changing from PENDING to REJECTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.REJECTED); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('setMarketingCookie', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + const testApiKey = 'test-api-key-1234567890'; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly when consent is GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should store marketing cookie in memory when consent is PENDING', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should persist marketing cookies when consent changes from PENDING to GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Set marketing cookie while consent is pending + await storage.setMarketingCookie(testApiKey); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Wait for async persistence to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should handle marketing cookie errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Cookie error'); + mockCookieStorage.set.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('should handle campaign parser errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Parser error'); + mockCampaignParser.parse.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + }); +});