diff --git a/packages/core/src/browser/cookie.ts b/packages/core/src/browser/cookie.ts index 075299fd6f..7d9bfd1389 100644 --- a/packages/core/src/browser/cookie.ts +++ b/packages/core/src/browser/cookie.ts @@ -2,8 +2,6 @@ import { display } from '../tools/display' import { ONE_MINUTE, ONE_SECOND } from '../tools/utils/timeUtils' import { findCommaSeparatedValue, generateUUID } from '../tools/utils/stringUtils' -export const COOKIE_ACCESS_DELAY = ONE_SECOND - export interface CookieOptions { secure?: boolean crossSite?: boolean diff --git a/packages/core/src/domain/configuration/configuration.spec.ts b/packages/core/src/domain/configuration/configuration.spec.ts index b0b6d69045..c8c71f9b3e 100644 --- a/packages/core/src/domain/configuration/configuration.spec.ts +++ b/packages/core/src/domain/configuration/configuration.spec.ts @@ -117,25 +117,40 @@ describe('validateAndBuildConfiguration', () => { }) }) - describe('cookie options', () => { - it('should not be secure nor crossSite by default', () => { - const configuration = validateAndBuildConfiguration({ clientToken })! - expect(configuration.cookieOptions).toEqual({ secure: false, crossSite: false }) + describe('sessionStoreStrategyType', () => { + it('allowFallbackToLocalStorage should not be enabled by default', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + const configuration = validateAndBuildConfiguration({ clientToken }) + expect(configuration?.sessionStoreStrategyType).toBeUndefined() + }) + + it('should contain cookie in the configuration by default', () => { + const configuration = validateAndBuildConfiguration({ clientToken, allowFallbackToLocalStorage: false }) + expect(configuration?.sessionStoreStrategyType).toEqual({ + type: 'Cookie', + cookieOptions: { secure: false, crossSite: false }, + }) }) - it('should be secure when `useSecureSessionCookie` is truthy', () => { - const configuration = validateAndBuildConfiguration({ clientToken, useSecureSessionCookie: true })! - expect(configuration.cookieOptions).toEqual({ secure: true, crossSite: false }) + it('should contain cookie in the configuration when fallback is enabled and cookies are available', () => { + const configuration = validateAndBuildConfiguration({ clientToken, allowFallbackToLocalStorage: true }) + expect(configuration?.sessionStoreStrategyType).toEqual({ + type: 'Cookie', + cookieOptions: { secure: false, crossSite: false }, + }) }) - it('should be secure and crossSite when `useCrossSiteSessionCookie` is truthy', () => { - const configuration = validateAndBuildConfiguration({ clientToken, useCrossSiteSessionCookie: true })! - expect(configuration.cookieOptions).toEqual({ secure: true, crossSite: true }) + it('should contain local storage in the configuration when fallback is enabled and cookies are not available', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + const configuration = validateAndBuildConfiguration({ clientToken, allowFallbackToLocalStorage: true }) + expect(configuration?.sessionStoreStrategyType).toEqual({ type: 'LocalStorage' }) }) - it('should have domain when `trackSessionAcrossSubdomains` is truthy', () => { - const configuration = validateAndBuildConfiguration({ clientToken, trackSessionAcrossSubdomains: true })! - expect(configuration.cookieOptions).toEqual({ secure: false, crossSite: false, domain: jasmine.any(String) }) + it('should not contain any storage if both cookies and local storage are unavailable', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + spyOn(Storage.prototype, 'getItem').and.throwError('unavailable') + const configuration = validateAndBuildConfiguration({ clientToken, allowFallbackToLocalStorage: true }) + expect(configuration?.sessionStoreStrategyType).toBeUndefined() }) }) diff --git a/packages/core/src/domain/configuration/configuration.ts b/packages/core/src/domain/configuration/configuration.ts index c2f57f639e..2be145377d 100644 --- a/packages/core/src/domain/configuration/configuration.ts +++ b/packages/core/src/domain/configuration/configuration.ts @@ -1,5 +1,3 @@ -import type { CookieOptions } from '../../browser/cookie' -import { getCurrentSite } from '../../browser/cookie' import { catchUserErrors } from '../../tools/catchUserErrors' import { display } from '../../tools/display' import type { RawTelemetryConfiguration } from '../telemetry' @@ -10,6 +8,8 @@ import { isPercentage } from '../../tools/utils/numberUtils' import { ONE_KIBI_BYTE } from '../../tools/utils/byteUtils' import { objectHasValue } from '../../tools/utils/objectUtils' import { assign } from '../../tools/utils/polyfills' +import { selectSessionStoreStrategyType } from '../session/sessionStore' +import type { SessionStoreStrategyType } from '../session/storeStrategies/sessionStoreStrategy' import type { TransportConfiguration } from './transportConfiguration' import { computeTransportConfiguration } from './transportConfiguration' @@ -52,6 +52,9 @@ export interface InitConfiguration { useSecureSessionCookie?: boolean | undefined trackSessionAcrossSubdomains?: boolean | undefined + // alternate storage option + allowFallbackToLocalStorage?: boolean | undefined + // internal options enableExperimentalFeatures?: string[] | undefined replica?: ReplicaUserConfiguration | undefined @@ -73,7 +76,7 @@ interface ReplicaUserConfiguration { export interface Configuration extends TransportConfiguration { // Built from init configuration beforeSend: GenericBeforeSendCallback | undefined - cookieOptions: CookieOptions + sessionStoreStrategyType: SessionStoreStrategyType | undefined sessionSampleRate: number telemetrySampleRate: number telemetryConfigurationSampleRate: number @@ -129,7 +132,7 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati { beforeSend: initConfiguration.beforeSend && catchUserErrors(initConfiguration.beforeSend, 'beforeSend threw an error:'), - cookieOptions: buildCookieOptions(initConfiguration), + sessionStoreStrategyType: selectSessionStoreStrategyType(initConfiguration), sessionSampleRate: sessionSampleRate ?? 100, telemetrySampleRate: initConfiguration.telemetrySampleRate ?? 20, telemetryConfigurationSampleRate: initConfiguration.telemetryConfigurationSampleRate ?? 5, @@ -161,36 +164,20 @@ export function validateAndBuildConfiguration(initConfiguration: InitConfigurati ) } -export function buildCookieOptions(initConfiguration: InitConfiguration) { - const cookieOptions: CookieOptions = {} - - cookieOptions.secure = mustUseSecureCookie(initConfiguration) - cookieOptions.crossSite = !!initConfiguration.useCrossSiteSessionCookie - - if (initConfiguration.trackSessionAcrossSubdomains) { - cookieOptions.domain = getCurrentSite() - } - - return cookieOptions -} - -function mustUseSecureCookie(initConfiguration: InitConfiguration) { - return !!initConfiguration.useSecureSessionCookie || !!initConfiguration.useCrossSiteSessionCookie -} - -export function serializeConfiguration(configuration: InitConfiguration): Partial { - const proxy = configuration.proxy ?? configuration.proxyUrl +export function serializeConfiguration(initConfiguration: InitConfiguration): Partial { + const proxy = initConfiguration.proxy ?? initConfiguration.proxyUrl return { - session_sample_rate: configuration.sessionSampleRate ?? configuration.sampleRate, - telemetry_sample_rate: configuration.telemetrySampleRate, - telemetry_configuration_sample_rate: configuration.telemetryConfigurationSampleRate, - use_before_send: !!configuration.beforeSend, - use_cross_site_session_cookie: configuration.useCrossSiteSessionCookie, - use_secure_session_cookie: configuration.useSecureSessionCookie, + session_sample_rate: initConfiguration.sessionSampleRate ?? initConfiguration.sampleRate, + telemetry_sample_rate: initConfiguration.telemetrySampleRate, + telemetry_configuration_sample_rate: initConfiguration.telemetryConfigurationSampleRate, + use_before_send: !!initConfiguration.beforeSend, + use_cross_site_session_cookie: initConfiguration.useCrossSiteSessionCookie, + use_secure_session_cookie: initConfiguration.useSecureSessionCookie, use_proxy: proxy !== undefined ? !!proxy : undefined, - silent_multiple_init: configuration.silentMultipleInit, - track_session_across_subdomains: configuration.trackSessionAcrossSubdomains, - track_resources: configuration.trackResources, - track_long_task: configuration.trackLongTasks, + silent_multiple_init: initConfiguration.silentMultipleInit, + track_session_across_subdomains: initConfiguration.trackSessionAcrossSubdomains, + track_resources: initConfiguration.trackResources, + track_long_task: initConfiguration.trackLongTasks, + allow_fallback_to_local_storage: !!initConfiguration.allowFallbackToLocalStorage, } } diff --git a/packages/core/src/domain/configuration/index.ts b/packages/core/src/domain/configuration/index.ts index eff00051cf..3ebfe3d4cd 100644 --- a/packages/core/src/domain/configuration/index.ts +++ b/packages/core/src/domain/configuration/index.ts @@ -1,7 +1,6 @@ export { Configuration, InitConfiguration, - buildCookieOptions, DefaultPrivacyLevel, validateAndBuildConfiguration, serializeConfiguration, diff --git a/packages/core/src/domain/session/oldCookiesMigration.spec.ts b/packages/core/src/domain/session/oldCookiesMigration.spec.ts index 732df81c6c..f34f61f7c7 100644 --- a/packages/core/src/domain/session/oldCookiesMigration.spec.ts +++ b/packages/core/src/domain/session/oldCookiesMigration.spec.ts @@ -1,4 +1,3 @@ -import type { CookieOptions } from '../../browser/cookie' import { getCookie, setCookie } from '../../browser/cookie' import { OLD_LOGS_COOKIE_NAME, @@ -7,17 +6,18 @@ import { tryOldCookiesMigration, } from './oldCookiesMigration' import { SESSION_EXPIRATION_DELAY } from './sessionConstants' -import { SESSION_COOKIE_NAME } from './sessionCookieStore' +import { initCookieStrategy } from './storeStrategies/sessionInCookie' +import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' describe('old cookies migration', () => { - const options: CookieOptions = {} + const sessionStoreStrategy = initCookieStrategy({}) it('should not touch current cookie', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcde&rum=0&logs=1&expire=1234567890', SESSION_EXPIRATION_DELAY) + setCookie(SESSION_STORE_KEY, 'id=abcde&rum=0&logs=1&expire=1234567890', SESSION_EXPIRATION_DELAY) - tryOldCookiesMigration(options) + tryOldCookiesMigration(sessionStoreStrategy) - expect(getCookie(SESSION_COOKIE_NAME)).toBe('id=abcde&rum=0&logs=1&expire=1234567890') + expect(getCookie(SESSION_STORE_KEY)).toBe('id=abcde&rum=0&logs=1&expire=1234567890') }) it('should create new cookie from old cookie values', () => { @@ -25,26 +25,26 @@ describe('old cookies migration', () => { setCookie(OLD_LOGS_COOKIE_NAME, '1', SESSION_EXPIRATION_DELAY) setCookie(OLD_RUM_COOKIE_NAME, '0', SESSION_EXPIRATION_DELAY) - tryOldCookiesMigration(options) + tryOldCookiesMigration(sessionStoreStrategy) - expect(getCookie(SESSION_COOKIE_NAME)).toContain('id=abcde') - expect(getCookie(SESSION_COOKIE_NAME)).toContain('rum=0') - expect(getCookie(SESSION_COOKIE_NAME)).toContain('logs=1') - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/expire=\d+/) + expect(getCookie(SESSION_STORE_KEY)).toContain('id=abcde') + expect(getCookie(SESSION_STORE_KEY)).toContain('rum=0') + expect(getCookie(SESSION_STORE_KEY)).toContain('logs=1') + expect(getCookie(SESSION_STORE_KEY)).toMatch(/expire=\d+/) }) it('should create new cookie from a single old cookie', () => { setCookie(OLD_RUM_COOKIE_NAME, '0', SESSION_EXPIRATION_DELAY) - tryOldCookiesMigration(options) + tryOldCookiesMigration(sessionStoreStrategy) - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain('id=') - expect(getCookie(SESSION_COOKIE_NAME)).toContain('rum=0') - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/expire=\d+/) + expect(getCookie(SESSION_STORE_KEY)).not.toContain('id=') + expect(getCookie(SESSION_STORE_KEY)).toContain('rum=0') + expect(getCookie(SESSION_STORE_KEY)).toMatch(/expire=\d+/) }) it('should not create a new cookie if no old cookie is present', () => { - tryOldCookiesMigration(options) - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() + tryOldCookiesMigration(sessionStoreStrategy) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() }) }) diff --git a/packages/core/src/domain/session/oldCookiesMigration.ts b/packages/core/src/domain/session/oldCookiesMigration.ts index 6b9a547a18..d9fdbde510 100644 --- a/packages/core/src/domain/session/oldCookiesMigration.ts +++ b/packages/core/src/domain/session/oldCookiesMigration.ts @@ -1,10 +1,8 @@ -import type { CookieOptions } from '../../browser/cookie' import { getCookie } from '../../browser/cookie' -import { dateNow } from '../../tools/utils/timeUtils' -import type { SessionState } from './sessionStore' -import { isSessionInExpiredState } from './sessionStore' -import { SESSION_COOKIE_NAME, persistSessionCookie } from './sessionCookieStore' -import { SESSION_EXPIRATION_DELAY } from './sessionConstants' +import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy' +import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' +import type { SessionState } from './sessionState' +import { expandSessionState, isSessionInExpiredState } from './sessionState' export const OLD_SESSION_COOKIE_NAME = '_dd' export const OLD_RUM_COOKIE_NAME = '_dd_r' @@ -18,13 +16,14 @@ export const LOGS_SESSION_KEY = 'logs' * This migration should remain in the codebase as long as older versions are available/live * to allow older sdk versions to be upgraded to newer versions without compatibility issues. */ -export function tryOldCookiesMigration(options: CookieOptions) { - const sessionString = getCookie(SESSION_COOKIE_NAME) - const oldSessionId = getCookie(OLD_SESSION_COOKIE_NAME) - const oldRumType = getCookie(OLD_RUM_COOKIE_NAME) - const oldLogsType = getCookie(OLD_LOGS_COOKIE_NAME) +export function tryOldCookiesMigration(cookieStoreStrategy: SessionStoreStrategy) { + const sessionString = getCookie(SESSION_STORE_KEY) if (!sessionString) { + const oldSessionId = getCookie(OLD_SESSION_COOKIE_NAME) + const oldRumType = getCookie(OLD_RUM_COOKIE_NAME) + const oldLogsType = getCookie(OLD_LOGS_COOKIE_NAME) const session: SessionState = {} + if (oldSessionId) { session.id = oldSessionId } @@ -36,8 +35,8 @@ export function tryOldCookiesMigration(options: CookieOptions) { } if (!isSessionInExpiredState(session)) { - session.expire = String(dateNow() + SESSION_EXPIRATION_DELAY) - persistSessionCookie(options)(session) + expandSessionState(session) + cookieStoreStrategy.persistSession(session) } } } diff --git a/packages/core/src/domain/session/sessionCookieStore.spec.ts b/packages/core/src/domain/session/sessionCookieStore.spec.ts deleted file mode 100644 index d5d653e439..0000000000 --- a/packages/core/src/domain/session/sessionCookieStore.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { CookieOptions } from '../../browser/cookie' -import { getCookie, setCookie, deleteCookie } from '../../browser/cookie' -import { SESSION_COOKIE_NAME, initCookieStore } from './sessionCookieStore' - -import type { SessionState, SessionStore } from './sessionStore' - -describe('session cookie store', () => { - const sessionState: SessionState = { id: '123', created: '0' } - const noOptions: CookieOptions = {} - let cookieStorage: SessionStore - - beforeEach(() => { - cookieStorage = initCookieStore(noOptions) - }) - - afterEach(() => { - deleteCookie(SESSION_COOKIE_NAME) - }) - - it('should persist a session in a cookie', () => { - cookieStorage.persistSession(sessionState) - const session = cookieStorage.retrieveSession() - expect(session).toEqual({ ...sessionState }) - expect(getCookie(SESSION_COOKIE_NAME)).toBe('id=123&created=0') - }) - - it('should delete the cookie holding the session', () => { - cookieStorage.persistSession(sessionState) - cookieStorage.clearSession() - const session = cookieStorage.retrieveSession() - expect(session).toEqual({}) - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() - }) - - it('should return an empty object if session string is invalid', () => { - setCookie(SESSION_COOKIE_NAME, '{test:42}', 1000) - const session = cookieStorage.retrieveSession() - expect(session).toEqual({}) - }) -}) diff --git a/packages/core/src/domain/session/sessionCookieStore.ts b/packages/core/src/domain/session/sessionCookieStore.ts deleted file mode 100644 index 43e6e0d347..0000000000 --- a/packages/core/src/domain/session/sessionCookieStore.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { CookieOptions } from '../../browser/cookie' -import { deleteCookie, getCookie, setCookie } from '../../browser/cookie' -import { SESSION_EXPIRATION_DELAY } from './sessionConstants' -import type { SessionState, SessionStore } from './sessionStore' -import { toSessionState, toSessionString } from './sessionStore' - -export const SESSION_COOKIE_NAME = '_dd_s' - -export function initCookieStore(options: CookieOptions): SessionStore { - return { - persistSession: persistSessionCookie(options), - retrieveSession: retrieveSessionCookie, - clearSession: deleteSessionCookie(options), - } -} - -export function persistSessionCookie(options: CookieOptions) { - return (session: SessionState) => { - setCookie(SESSION_COOKIE_NAME, toSessionString(session), SESSION_EXPIRATION_DELAY, options) - } -} - -function retrieveSessionCookie(): SessionState { - const sessionString = getCookie(SESSION_COOKIE_NAME) - return toSessionState(sessionString) -} - -export function deleteSessionCookie(options: CookieOptions) { - return () => { - deleteCookie(SESSION_COOKIE_NAME, options) - } -} diff --git a/packages/core/src/domain/session/sessionLocalStorageStore.spec.ts b/packages/core/src/domain/session/sessionLocalStorageStore.spec.ts deleted file mode 100644 index ad69a67dfa..0000000000 --- a/packages/core/src/domain/session/sessionLocalStorageStore.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { LOCAL_STORAGE_KEY, initLocalStorage } from './sessionLocalStorageStore' -import type { SessionState, SessionStore } from './sessionStore' - -describe('session local storage store', () => { - const sessionState: SessionState = { id: '123', created: '0' } - let localStorageStore: SessionStore - - beforeEach(() => { - localStorageStore = initLocalStorage() - }) - - afterEach(() => { - window.localStorage.clear() - }) - - it('should persist a session in local storage', () => { - localStorageStore.persistSession(sessionState) - const session = localStorageStore.retrieveSession() - expect(session).toEqual({ ...sessionState }) - expect(window.localStorage.getItem(LOCAL_STORAGE_KEY)).toMatch(/.*id=.*created/) - }) - - it('should delete the local storage item holding the session', () => { - localStorageStore.persistSession(sessionState) - localStorageStore.clearSession() - const session = localStorageStore.retrieveSession() - expect(session).toEqual({}) - expect(window.localStorage.getItem(LOCAL_STORAGE_KEY)).toBeNull() - }) - - it('should not interfere with other keys present in local storage', () => { - window.localStorage.setItem('test', 'hello') - localStorageStore.persistSession(sessionState) - localStorageStore.retrieveSession() - localStorageStore.clearSession() - expect(window.localStorage.getItem('test')).toEqual('hello') - }) - - it('should return an empty object if session string is invalid', () => { - window.localStorage.setItem(LOCAL_STORAGE_KEY, '{test:42}') - const session = localStorageStore.retrieveSession() - expect(session).toEqual({}) - }) -}) diff --git a/packages/core/src/domain/session/sessionLocalStorageStore.ts b/packages/core/src/domain/session/sessionLocalStorageStore.ts deleted file mode 100644 index f7670d6607..0000000000 --- a/packages/core/src/domain/session/sessionLocalStorageStore.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { SessionState, SessionStore } from './sessionStore' -import { toSessionString, toSessionState } from './sessionStore' - -export const LOCAL_STORAGE_KEY = '_dd_s' - -export function initLocalStorage(): SessionStore { - return { - persistSession: persistInLocalStorage, - retrieveSession: retrieveSessionFromLocalStorage, - clearSession: clearSessionFromLocalStorage, - } -} - -function persistInLocalStorage(sessionState: SessionState) { - localStorage.setItem(LOCAL_STORAGE_KEY, toSessionString(sessionState)) -} - -function retrieveSessionFromLocalStorage(): SessionState { - const sessionString = localStorage.getItem(LOCAL_STORAGE_KEY) - return toSessionState(sessionString) -} - -function clearSessionFromLocalStorage() { - localStorage.removeItem(LOCAL_STORAGE_KEY) -} diff --git a/packages/core/src/domain/session/sessionManager.spec.ts b/packages/core/src/domain/session/sessionManager.spec.ts index a2cbc72d52..36e48bebb0 100644 --- a/packages/core/src/domain/session/sessionManager.spec.ts +++ b/packages/core/src/domain/session/sessionManager.spec.ts @@ -1,15 +1,16 @@ import { createNewEvent, mockClock, restorePageVisibility, setPageVisibility } from '../../../test' import type { Clock } from '../../../test' -import type { CookieOptions } from '../../browser/cookie' -import { COOKIE_ACCESS_DELAY, getCookie, setCookie } from '../../browser/cookie' +import { getCookie, setCookie } from '../../browser/cookie' import type { RelativeTime } from '../../tools/utils/timeUtils' import { isIE } from '../../tools/utils/browserDetection' import { DOM_EVENT } from '../../browser/addEventListener' import { ONE_HOUR, ONE_SECOND } from '../../tools/utils/timeUtils' import type { SessionManager } from './sessionManager' import { startSessionManager, stopSessionManager, VISIBILITY_CHECK_DELAY } from './sessionManager' -import { SESSION_COOKIE_NAME } from './sessionCookieStore' import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants' +import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' +import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' +import { STORAGE_POLL_DELAY } from './sessionStore' const enum FakeTrackingType { NOT_TRACKED = 'not-tracked', @@ -30,27 +31,27 @@ describe('startSessionManager', () => { const DURATION = 123456 const FIRST_PRODUCT_KEY = 'first' const SECOND_PRODUCT_KEY = 'second' - const COOKIE_OPTIONS: CookieOptions = {} + const STORE_TYPE: SessionStoreStrategyType = { type: 'Cookie', cookieOptions: {} } let clock: Clock function expireSessionCookie() { - setCookie(SESSION_COOKIE_NAME, '', DURATION) - clock.tick(COOKIE_ACCESS_DELAY) + setCookie(SESSION_STORE_KEY, '', DURATION) + clock.tick(STORAGE_POLL_DELAY) } function expectSessionIdToBe(sessionManager: SessionManager, sessionId: string) { expect(sessionManager.findActiveSession()!.id).toBe(sessionId) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`id=${sessionId}`) + expect(getCookie(SESSION_STORE_KEY)).toContain(`id=${sessionId}`) } function expectSessionIdToBeDefined(sessionManager: SessionManager) { expect(sessionManager.findActiveSession()!.id).toMatch(/^[a-f0-9-]+$/) - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/id=[a-f0-9-]+/) + expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]+/) } function expectSessionIdToNotBeDefined(sessionManager: SessionManager) { expect(sessionManager.findActiveSession()?.id).toBeUndefined() - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain('id=') + expect(getCookie(SESSION_STORE_KEY)).not.toContain('id=') } function expectTrackingTypeToBe( @@ -59,12 +60,12 @@ describe('startSessionManager', () => { trackingType: FakeTrackingType ) { expect(sessionManager.findActiveSession()!.trackingType).toEqual(trackingType) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${productKey}=${trackingType}`) + expect(getCookie(SESSION_STORE_KEY)).toContain(`${productKey}=${trackingType}`) } function expectTrackingTypeToNotBeDefined(sessionManager: SessionManager, productKey: string) { expect(sessionManager.findActiveSession()?.trackingType).toBeUndefined() - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain(`${productKey}=`) + expect(getCookie(SESSION_STORE_KEY)).not.toContain(`${productKey}=`) } beforeEach(() => { @@ -84,71 +85,38 @@ describe('startSessionManager', () => { describe('cookie management', () => { it('when tracked, should store tracking type and session id', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) expectSessionIdToBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) it('when not tracked should store tracking type', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) expectSessionIdToNotBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) }) it('when tracked should keep existing tracking type and session id', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&first=tracked', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&first=tracked', DURATION) - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) expectSessionIdToBe(sessionManager, 'abcdef') expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.TRACKED) }) it('when not tracked should keep existing tracking type', () => { - setCookie(SESSION_COOKIE_NAME, 'first=not-tracked', DURATION) + setCookie(SESSION_STORE_KEY, 'first=not-tracked', DURATION) - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) expectSessionIdToNotBeDefined(sessionManager) expectTrackingTypeToBe(sessionManager, FIRST_PRODUCT_KEY, FakeTrackingType.NOT_TRACKED) }) }) - describe('cookie options', () => { - ;[ - { - cookieOptions: {}, - cookieString: /^_dd_s=[^;]*;expires=[^;]+;path=\/;samesite=strict$/, - description: 'should set same-site to strict by default', - }, - { - cookieOptions: { crossSite: true }, - cookieString: /^_dd_s=[^;]*;expires=[^;]+;path=\/;samesite=none$/, - description: 'should set same site to none for crossSite', - }, - { - cookieOptions: { secure: true }, - cookieString: /^_dd_s=[^;]*;expires=[^;]+;path=\/;samesite=strict;secure$/, - description: 'should add secure attribute when defined', - }, - { - cookieOptions: { domain: 'foo.bar' }, - cookieString: /^_dd_s=[^;]*;expires=[^;]+;path=\/;samesite=strict;domain=foo\.bar$/, - description: 'should set cookie domain when defined', - }, - ].forEach(({ description, cookieOptions, cookieString }) => { - it(description, () => { - const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') - - startSessionManager(cookieOptions, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) - - expect(cookieSetSpy.calls.argsFor(0)[0]).toMatch(cookieString) - }) - }) - }) - describe('computeSessionState', () => { let spy: (rawTrackingType?: string) => { trackingType: FakeTrackingType; isTracked: boolean } @@ -157,32 +125,32 @@ describe('startSessionManager', () => { }) it('should be called with an empty value if the cookie is not defined', () => { - startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, spy) + startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, spy) expect(spy).toHaveBeenCalledWith(undefined) }) it('should be called with an invalid value if the cookie has an invalid value', () => { - setCookie(SESSION_COOKIE_NAME, 'first=invalid', DURATION) - startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, spy) + setCookie(SESSION_STORE_KEY, 'first=invalid', DURATION) + startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, spy) expect(spy).toHaveBeenCalledWith('invalid') }) it('should be called with TRACKED', () => { - setCookie(SESSION_COOKIE_NAME, 'first=tracked', DURATION) - startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, spy) + setCookie(SESSION_STORE_KEY, 'first=tracked', DURATION) + startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, spy) expect(spy).toHaveBeenCalledWith(FakeTrackingType.TRACKED) }) it('should be called with NOT_TRACKED', () => { - setCookie(SESSION_COOKIE_NAME, 'first=not-tracked', DURATION) - startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, spy) + setCookie(SESSION_STORE_KEY, 'first=not-tracked', DURATION) + startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, spy) expect(spy).toHaveBeenCalledWith(FakeTrackingType.NOT_TRACKED) }) }) describe('session renewal', () => { it('should renew on activity after expiration', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const renewSessionSpy = jasmine.createSpy() sessionManager.renewObservable.subscribe(renewSessionSpy) @@ -200,7 +168,7 @@ describe('startSessionManager', () => { }) it('should not renew on visibility after expiration', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const renewSessionSpy = jasmine.createSpy() sessionManager.renewObservable.subscribe(renewSessionSpy) @@ -215,59 +183,55 @@ describe('startSessionManager', () => { describe('multiple startSessionManager calls', () => { it('should re-use the same session id', () => { - const firstSessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const firstSessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const idA = firstSessionManager.findActiveSession()!.id - const secondSessionManager = startSessionManager(COOKIE_OPTIONS, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const secondSessionManager = startSessionManager(STORE_TYPE, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const idB = secondSessionManager.findActiveSession()!.id expect(idA).toBe(idB) }) it('should not erase other session type', () => { - startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) // schedule an expandOrRenewSession document.dispatchEvent(new CustomEvent('click')) - clock.tick(COOKIE_ACCESS_DELAY / 2) + clock.tick(STORAGE_POLL_DELAY / 2) // expand first session cookie cache document.dispatchEvent(createNewEvent(DOM_EVENT.VISIBILITY_CHANGE)) - startSessionManager(COOKIE_OPTIONS, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + startSessionManager(STORE_TYPE, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) // cookie correctly set - expect(getCookie(SESSION_COOKIE_NAME)).toContain('first') - expect(getCookie(SESSION_COOKIE_NAME)).toContain('second') + expect(getCookie(SESSION_STORE_KEY)).toContain('first') + expect(getCookie(SESSION_STORE_KEY)).toContain('second') - clock.tick(COOKIE_ACCESS_DELAY / 2) + clock.tick(STORAGE_POLL_DELAY / 2) // scheduled expandOrRenewSession should not use cached value - expect(getCookie(SESSION_COOKIE_NAME)).toContain('first') - expect(getCookie(SESSION_COOKIE_NAME)).toContain('second') + expect(getCookie(SESSION_STORE_KEY)).toContain('first') + expect(getCookie(SESSION_STORE_KEY)).toContain('second') }) it('should have independent tracking types', () => { - const firstSessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) - const secondSessionManager = startSessionManager( - COOKIE_OPTIONS, - SECOND_PRODUCT_KEY, - () => NOT_TRACKED_SESSION_STATE - ) + const firstSessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const secondSessionManager = startSessionManager(STORE_TYPE, SECOND_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) expect(firstSessionManager.findActiveSession()!.trackingType).toEqual(FakeTrackingType.TRACKED) expect(secondSessionManager.findActiveSession()!.trackingType).toEqual(FakeTrackingType.NOT_TRACKED) }) it('should notify each expire and renew observables', () => { - const firstSessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const firstSessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionASpy = jasmine.createSpy() firstSessionManager.expireObservable.subscribe(expireSessionASpy) const renewSessionASpy = jasmine.createSpy() firstSessionManager.renewObservable.subscribe(renewSessionASpy) - const secondSessionManager = startSessionManager(COOKIE_OPTIONS, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const secondSessionManager = startSessionManager(STORE_TYPE, SECOND_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionBSpy = jasmine.createSpy() secondSessionManager.expireObservable.subscribe(expireSessionBSpy) const renewSessionBSpy = jasmine.createSpy() @@ -289,38 +253,38 @@ describe('startSessionManager', () => { describe('session timeout', () => { it('should expire the session when the time out delay is reached', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) expect(sessionManager.findActiveSession()).toBeDefined() - expect(getCookie(SESSION_COOKIE_NAME)).toBeDefined() + expect(getCookie(SESSION_STORE_KEY)).toBeDefined() clock.tick(SESSION_TIME_OUT_DELAY) expect(sessionManager.findActiveSession()).toBeUndefined() - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() expect(expireSessionSpy).toHaveBeenCalled() }) it('should renew an existing timed out session', () => { - setCookie(SESSION_COOKIE_NAME, `id=abcde&first=tracked&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) + setCookie(SESSION_STORE_KEY, `id=abcde&first=tracked&created=${Date.now() - SESSION_TIME_OUT_DELAY}`, DURATION) - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) expect(sessionManager.findActiveSession()!.id).not.toBe('abcde') - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`created=${Date.now()}`) + expect(getCookie(SESSION_STORE_KEY)).toContain(`created=${Date.now()}`) expect(expireSessionSpy).not.toHaveBeenCalled() // the session has not been active from the start }) it('should not add created date to an existing session from an older versions', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcde&first=tracked', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcde&first=tracked', DURATION) - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) expect(sessionManager.findActiveSession()!.id).toBe('abcde') - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain('created=') + expect(getCookie(SESSION_STORE_KEY)).not.toContain('created=') }) }) @@ -334,7 +298,7 @@ describe('startSessionManager', () => { }) it('should expire the session after expiration delay', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -346,7 +310,7 @@ describe('startSessionManager', () => { }) it('should expand duration on activity', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -365,7 +329,7 @@ describe('startSessionManager', () => { }) it('should expand not tracked session duration on activity', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -386,7 +350,7 @@ describe('startSessionManager', () => { it('should expand session on visibility', () => { setPageVisibility('visible') - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -407,7 +371,7 @@ describe('startSessionManager', () => { it('should expand not tracked session on visibility', () => { setPageVisibility('visible') - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => NOT_TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -428,7 +392,7 @@ describe('startSessionManager', () => { describe('manual session expiration', () => { it('expires the session when calling expire()', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -439,7 +403,7 @@ describe('startSessionManager', () => { }) it('notifies expired session only once when calling expire() multiple times', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -451,7 +415,7 @@ describe('startSessionManager', () => { }) it('notifies expired session only once when calling expire() after the session has been expired', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) const expireSessionSpy = jasmine.createSpy() sessionManager.expireObservable.subscribe(expireSessionSpy) @@ -463,8 +427,8 @@ describe('startSessionManager', () => { }) it('renew the session on user activity', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) - clock.tick(COOKIE_ACCESS_DELAY) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + clock.tick(STORAGE_POLL_DELAY) sessionManager.expire() @@ -476,24 +440,24 @@ describe('startSessionManager', () => { describe('session history', () => { it('should return undefined when there is no current session and no startTime', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) expireSessionCookie() expect(sessionManager.findActiveSession()).toBeUndefined() }) it('should return the current session context when there is no start time', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) expect(sessionManager.findActiveSession()!.id).toBeDefined() expect(sessionManager.findActiveSession()!.trackingType).toBeDefined() }) it('should return the session context corresponding to startTime', () => { - const sessionManager = startSessionManager(COOKIE_OPTIONS, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) + const sessionManager = startSessionManager(STORE_TYPE, FIRST_PRODUCT_KEY, () => TRACKED_SESSION_STATE) // 0s to 10s: first session - clock.tick(10 * ONE_SECOND - COOKIE_ACCESS_DELAY) + clock.tick(10 * ONE_SECOND - STORAGE_POLL_DELAY) const firstSessionId = sessionManager.findActiveSession()!.id const firstSessionTrackingType = sessionManager.findActiveSession()!.trackingType expireSessionCookie() diff --git a/packages/core/src/domain/session/sessionManager.ts b/packages/core/src/domain/session/sessionManager.ts index 724fe58c2c..c23c8f4ffc 100644 --- a/packages/core/src/domain/session/sessionManager.ts +++ b/packages/core/src/domain/session/sessionManager.ts @@ -1,4 +1,3 @@ -import type { CookieOptions } from '../../browser/cookie' import type { Observable } from '../../tools/observable' import type { Context } from '../../tools/serialisation/context' import { ValueHistory } from '../../tools/valueHistory' @@ -6,9 +5,9 @@ import type { RelativeTime } from '../../tools/utils/timeUtils' import { relativeNow, clocksOrigin, ONE_MINUTE } from '../../tools/utils/timeUtils' import { DOM_EVENT, addEventListener, addEventListeners } from '../../browser/addEventListener' import { clearInterval, setInterval } from '../../tools/timer' -import { tryOldCookiesMigration } from './oldCookiesMigration' -import { startSessionStoreManager } from './sessionStoreManager' import { SESSION_TIME_OUT_DELAY } from './sessionConstants' +import { startSessionStore } from './sessionStore' +import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' export interface SessionManager { findActiveSession: (startTime?: RelativeTime) => SessionContext | undefined @@ -27,12 +26,11 @@ const SESSION_CONTEXT_TIMEOUT_DELAY = SESSION_TIME_OUT_DELAY let stopCallbacks: Array<() => void> = [] export function startSessionManager( - options: CookieOptions, + sessionStoreStrategyType: SessionStoreStrategyType, productKey: string, computeSessionState: (rawTrackingType?: string) => { trackingType: TrackingType; isTracked: boolean } ): SessionManager { - tryOldCookiesMigration(options) - const sessionStore = startSessionStoreManager(options, productKey, computeSessionState) + const sessionStore = startSessionStore(sessionStoreStrategyType, productKey, computeSessionState) stopCallbacks.push(() => sessionStore.stop()) const sessionContextHistory = new ValueHistory>(SESSION_CONTEXT_TIMEOUT_DELAY) diff --git a/packages/core/src/domain/session/sessionState.spec.ts b/packages/core/src/domain/session/sessionState.spec.ts new file mode 100644 index 0000000000..0f04b5fb2f --- /dev/null +++ b/packages/core/src/domain/session/sessionState.spec.ts @@ -0,0 +1,55 @@ +import { dateNow } from '../../tools/utils/timeUtils' +import { SESSION_EXPIRATION_DELAY } from './sessionConstants' +import type { SessionState } from './sessionState' +import { expandSessionState, isSessionInExpiredState, toSessionString, toSessionState } from './sessionState' + +describe('session state utilities', () => { + const EXPIRED_SESSION: SessionState = {} + const SERIALIZED_EXPIRED_SESSION = '' + const LIVE_SESSION: SessionState = { created: '0', id: '123' } + const SERIALIZED_LIVE_SESSION = 'created=0&id=123' + + describe('isSessionInExpiredState', () => { + it('should correctly identify a session in expired state', () => { + expect(isSessionInExpiredState(EXPIRED_SESSION)).toBe(true) + }) + + it('should correctly identify a session in live state', () => { + expect(isSessionInExpiredState(LIVE_SESSION)).toBe(false) + }) + }) + + describe('toSessionString', () => { + it('should serialize a sessionState to a string', () => { + expect(toSessionString(LIVE_SESSION)).toEqual(SERIALIZED_LIVE_SESSION) + }) + + it('should handle empty sessionStates', () => { + expect(toSessionString(EXPIRED_SESSION)).toEqual(SERIALIZED_EXPIRED_SESSION) + }) + }) + + describe('sessionStringToSessionState', () => { + it('should deserialize a session string to a sessionState', () => { + expect(toSessionState(SERIALIZED_LIVE_SESSION)).toEqual(LIVE_SESSION) + }) + + it('should handle empty session strings', () => { + expect(toSessionState(SERIALIZED_EXPIRED_SESSION)).toEqual(EXPIRED_SESSION) + }) + + it('should handle invalid session strings', () => { + const sessionString = '{invalid: true}' + expect(toSessionState(sessionString)).toEqual(EXPIRED_SESSION) + }) + }) + + describe('expandSessionState', () => { + it('should modify the expire property of the session', () => { + const session = { ...LIVE_SESSION } + const now = dateNow() + expandSessionState(session) + expect(session.expire).toBeGreaterThanOrEqual(now + SESSION_EXPIRATION_DELAY) + }) + }) +}) diff --git a/packages/core/src/domain/session/sessionState.ts b/packages/core/src/domain/session/sessionState.ts new file mode 100644 index 0000000000..5f34e81543 --- /dev/null +++ b/packages/core/src/domain/session/sessionState.ts @@ -0,0 +1,51 @@ +import { isEmptyObject } from '../../tools/utils/objectUtils' +import { objectEntries } from '../../tools/utils/polyfills' +import { dateNow } from '../../tools/utils/timeUtils' +import { SESSION_EXPIRATION_DELAY } from './sessionConstants' + +const SESSION_ENTRY_REGEXP = /^([a-z]+)=([a-z0-9-]+)$/ +const SESSION_ENTRY_SEPARATOR = '&' + +export interface SessionState { + id?: string + created?: string + expire?: string + lock?: string + + [key: string]: string | undefined +} + +export function isSessionInExpiredState(session: SessionState) { + return isEmptyObject(session) +} + +export function expandSessionState(session: SessionState) { + session.expire = String(dateNow() + SESSION_EXPIRATION_DELAY) +} + +export function toSessionString(session: SessionState) { + return objectEntries(session) + .map(([key, value]) => `${key}=${value as string}`) + .join(SESSION_ENTRY_SEPARATOR) +} + +export function toSessionState(sessionString: string | undefined | null) { + const session: SessionState = {} + if (isValidSessionString(sessionString)) { + sessionString.split(SESSION_ENTRY_SEPARATOR).forEach((entry) => { + const matches = SESSION_ENTRY_REGEXP.exec(entry) + if (matches !== null) { + const [, key, value] = matches + session[key] = value + } + }) + } + return session +} + +function isValidSessionString(sessionString: string | undefined | null): sessionString is string { + return ( + !!sessionString && + (sessionString.indexOf(SESSION_ENTRY_SEPARATOR) !== -1 || SESSION_ENTRY_REGEXP.test(sessionString)) + ) +} diff --git a/packages/core/src/domain/session/sessionStore.spec.ts b/packages/core/src/domain/session/sessionStore.spec.ts index f3e181d942..a897357103 100644 --- a/packages/core/src/domain/session/sessionStore.spec.ts +++ b/packages/core/src/domain/session/sessionStore.spec.ts @@ -1,44 +1,398 @@ -import type { SessionState } from './sessionStore' -import { isSessionInExpiredState, toSessionString, toSessionState } from './sessionStore' +import type { Clock } from '../../../test' +import { mockClock } from '../../../test' +import { getCookie, setCookie } from '../../browser/cookie' +import type { SessionStore } from './sessionStore' +import { STORAGE_POLL_DELAY, startSessionStore, selectSessionStoreStrategyType } from './sessionStore' +import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants' +import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' -describe('session store utilities', () => { - const EXPIRED_SESSION: SessionState = {} - const SERIALIZED_EXPIRED_SESSION = '' - const LIVE_SESSION: SessionState = { created: '0', id: '123' } - const SERIALIZED_LIVE_SESSION = 'created=0&id=123' +const enum FakeTrackingType { + TRACKED = 'tracked', + NOT_TRACKED = 'not-tracked', +} - describe('isSessionInExpiredState', () => { - it('should correctly identify a session in expired state', () => { - expect(isSessionInExpiredState(EXPIRED_SESSION)).toBe(true) +const DURATION = 123456 +const PRODUCT_KEY = 'product' +const FIRST_ID = 'first' +const SECOND_ID = 'second' + +function setSessionInStore(trackingType: FakeTrackingType = FakeTrackingType.TRACKED, id?: string, expire?: number) { + setCookie( + SESSION_STORE_KEY, + `${id ? `id=${id}&` : ''}${PRODUCT_KEY}=${trackingType}&created=${Date.now()}&expire=${ + expire || Date.now() + SESSION_EXPIRATION_DELAY + }`, + DURATION + ) +} + +function expectTrackedSessionToBeInStore(id?: string) { + expect(getCookie(SESSION_STORE_KEY)).toMatch(new RegExp(`id=${id ? id : '[a-f0-9-]+'}`)) + expect(getCookie(SESSION_STORE_KEY)).toContain(`${PRODUCT_KEY}=${FakeTrackingType.TRACKED}`) +} + +function expectNotTrackedSessionToBeInStore() { + expect(getCookie(SESSION_STORE_KEY)).not.toContain('id=') + expect(getCookie(SESSION_STORE_KEY)).toContain(`${PRODUCT_KEY}=${FakeTrackingType.NOT_TRACKED}`) +} + +function getStoreExpiration() { + return /expire=(\d+)/.exec(getCookie(SESSION_STORE_KEY)!)?.[1] +} + +function resetSessionInStore() { + setCookie(SESSION_STORE_KEY, '', DURATION) +} + +describe('session store', () => { + describe('getSessionStoreStrategyType', () => { + it('should return a type cookie when cookies are available', () => { + const sessionStoreStrategyType = selectSessionStoreStrategyType({ + clientToken: 'abc', + allowFallbackToLocalStorage: true, + }) + expect(sessionStoreStrategyType).toEqual(jasmine.objectContaining({ type: 'Cookie' })) }) - it('should correctly identify a session in live state', () => { - expect(isSessionInExpiredState(LIVE_SESSION)).toBe(false) + it('should report undefined when cookies are not available, and fallback is not allowed', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + const sessionStoreStrategyType = selectSessionStoreStrategyType({ + clientToken: 'abc', + allowFallbackToLocalStorage: false, + }) + expect(sessionStoreStrategyType).toBeUndefined() }) - }) - describe('toSessionString', () => { - it('should serialize a sessionState to a string', () => { - expect(toSessionString(LIVE_SESSION)).toEqual(SERIALIZED_LIVE_SESSION) + it('should fallback to localStorage when cookies are not available', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + const sessionStoreStrategyType = selectSessionStoreStrategyType({ + clientToken: 'abc', + allowFallbackToLocalStorage: true, + }) + expect(sessionStoreStrategyType).toEqual({ type: 'LocalStorage' }) }) - it('should handle empty sessionStates', () => { - expect(toSessionString(EXPIRED_SESSION)).toEqual(SERIALIZED_EXPIRED_SESSION) + it('should report undefined when no storage is available', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + spyOn(Storage.prototype, 'getItem').and.throwError('unavailable') + const sessionStoreStrategyType = selectSessionStoreStrategyType({ + clientToken: 'abc', + allowFallbackToLocalStorage: true, + }) + expect(sessionStoreStrategyType).toBeUndefined() }) }) - describe('sessionStringToSessionState', () => { - it('should deserialize a session string to a sessionState', () => { - expect(toSessionState(SERIALIZED_LIVE_SESSION)).toEqual(LIVE_SESSION) + describe('session lifecyle mechanism', () => { + let expireSpy: () => void + let renewSpy: () => void + let sessionStoreManager: SessionStore + let clock: Clock + + function setupSessionStore( + computeSessionState: (rawTrackingType?: string) => { + trackingType: FakeTrackingType + isTracked: boolean + } = () => ({ + isTracked: true, + trackingType: FakeTrackingType.TRACKED, + }) + ) { + const sessionStoreStrategyType = selectSessionStoreStrategyType({ + clientToken: 'abc', + allowFallbackToLocalStorage: false, + }) + if (sessionStoreStrategyType?.type !== 'Cookie') { + fail('Unable to initialize cookie storage') + return + } + sessionStoreManager = startSessionStore(sessionStoreStrategyType, PRODUCT_KEY, computeSessionState) + sessionStoreManager.expireObservable.subscribe(expireSpy) + sessionStoreManager.renewObservable.subscribe(renewSpy) + } + + beforeEach(() => { + expireSpy = jasmine.createSpy('expire session') + renewSpy = jasmine.createSpy('renew session') + clock = mockClock() + }) + + afterEach(() => { + resetSessionInStore() + clock.cleanup() + sessionStoreManager.stop() + }) + + describe('expand or renew session', () => { + it( + 'when session not in cache, session not in store and new session tracked, ' + + 'should create new session and trigger renew session ', + () => { + setupSessionStore() + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBeDefined() + expectTrackedSessionToBeInStore() + expect(expireSpy).not.toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalled() + } + ) + + it( + 'when session not in cache, session not in store and new session not tracked, ' + + 'should store not tracked session', + () => { + setupSessionStore(() => ({ isTracked: false, trackingType: FakeTrackingType.NOT_TRACKED })) + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expectNotTrackedSessionToBeInStore() + expect(expireSpy).not.toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() + } + ) + + it('when session not in cache and session in store, should expand session and trigger renew session', () => { + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) + expectTrackedSessionToBeInStore(FIRST_ID) + expect(expireSpy).not.toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalled() + }) + + it( + 'when session in cache, session not in store and new session tracked, ' + + 'should expire session, create a new one and trigger renew session', + () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + resetSessionInStore() + + sessionStoreManager.expandOrRenewSession() + + const sessionId = sessionStoreManager.getSession().id + expect(sessionId).toBeDefined() + expect(sessionId).not.toBe(FIRST_ID) + expectTrackedSessionToBeInStore(sessionId) + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalled() + } + ) + + it( + 'when session in cache, session not in store and new session not tracked, ' + + 'should expire session and store not tracked session', + () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore(() => ({ isTracked: false, trackingType: FakeTrackingType.NOT_TRACKED })) + resetSessionInStore() + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(sessionStoreManager.getSession()[PRODUCT_KEY]).toBeDefined() + expectNotTrackedSessionToBeInStore() + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() + } + ) + + it( + 'when session not tracked in cache, session not in store and new session not tracked, ' + + 'should expire session and store not tracked session', + () => { + setSessionInStore(FakeTrackingType.NOT_TRACKED) + setupSessionStore(() => ({ isTracked: false, trackingType: FakeTrackingType.NOT_TRACKED })) + resetSessionInStore() + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(sessionStoreManager.getSession()[PRODUCT_KEY]).toBeDefined() + expectNotTrackedSessionToBeInStore() + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() + } + ) + + it('when session in cache is same session than in store, should expand session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + + clock.tick(10) + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) + expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) + expectTrackedSessionToBeInStore(FIRST_ID) + expect(expireSpy).not.toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() + }) + + it( + 'when session in cache is different session than in store and store session is tracked, ' + + 'should expire session, expand store session and trigger renew', + () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, SECOND_ID) + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBe(SECOND_ID) + expectTrackedSessionToBeInStore(SECOND_ID) + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).toHaveBeenCalled() + } + ) + + it( + 'when session in cache is different session than in store and store session is not tracked, ' + + 'should expire session and store not tracked session', + () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore((rawTrackingType) => ({ + isTracked: rawTrackingType === FakeTrackingType.TRACKED, + trackingType: rawTrackingType as FakeTrackingType, + })) + setSessionInStore(FakeTrackingType.NOT_TRACKED, '') + + sessionStoreManager.expandOrRenewSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expectNotTrackedSessionToBeInStore() + expect(expireSpy).toHaveBeenCalled() + expect(renewSpy).not.toHaveBeenCalled() + } + ) }) - it('should handle empty session strings', () => { - expect(toSessionState(SERIALIZED_EXPIRED_SESSION)).toEqual(EXPIRED_SESSION) + describe('expand session', () => { + it('when session not in cache and session not in store, should do nothing', () => { + setupSessionStore() + + sessionStoreManager.expandSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).not.toHaveBeenCalled() + }) + + it('when session not in cache and session in store, should do nothing', () => { + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + + sessionStoreManager.expandSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).not.toHaveBeenCalled() + }) + + it('when session in cache and session not in store, should expire session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + resetSessionInStore() + + sessionStoreManager.expandSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).toHaveBeenCalled() + }) + + it('when session in cache is same session than in store, should expand session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + + clock.tick(10) + sessionStoreManager.expandSession() + + expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) + expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) + expect(expireSpy).not.toHaveBeenCalled() + }) + + it('when session in cache is different session than in store, should expire session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, SECOND_ID) + + sessionStoreManager.expandSession() + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expectTrackedSessionToBeInStore(SECOND_ID) + expect(expireSpy).toHaveBeenCalled() + }) }) - it('should handle invalid session strings', () => { - const sessionString = '{invalid: true}' - expect(toSessionState(sessionString)).toEqual(EXPIRED_SESSION) + describe('regular watch', () => { + it('when session not in cache and session not in store, should do nothing', () => { + setupSessionStore() + + clock.tick(STORAGE_POLL_DELAY) + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).not.toHaveBeenCalled() + }) + + it('when session not in cache and session in store, should do nothing', () => { + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + + clock.tick(STORAGE_POLL_DELAY) + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).not.toHaveBeenCalled() + }) + + it('when session in cache and session not in store, should expire session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + resetSessionInStore() + + clock.tick(STORAGE_POLL_DELAY) + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).toHaveBeenCalled() + }) + + it('when session in cache is same session than in store, should synchronize session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID, Date.now() + SESSION_TIME_OUT_DELAY + 10) + + clock.tick(STORAGE_POLL_DELAY) + + expect(sessionStoreManager.getSession().id).toBe(FIRST_ID) + expect(sessionStoreManager.getSession().expire).toBe(getStoreExpiration()) + expect(expireSpy).not.toHaveBeenCalled() + }) + + it('when session id in cache is different than session id in store, should expire session', () => { + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, SECOND_ID) + + clock.tick(STORAGE_POLL_DELAY) + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).toHaveBeenCalled() + }) + + it('when session type in cache is different than session type in store, should expire session', () => { + setSessionInStore(FakeTrackingType.NOT_TRACKED, FIRST_ID) + setupSessionStore() + setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) + + clock.tick(STORAGE_POLL_DELAY) + + expect(sessionStoreManager.getSession().id).toBeUndefined() + expect(expireSpy).toHaveBeenCalled() + }) }) }) }) diff --git a/packages/core/src/domain/session/sessionStore.ts b/packages/core/src/domain/session/sessionStore.ts index 185e4e48a8..449cca08d3 100644 --- a/packages/core/src/domain/session/sessionStore.ts +++ b/packages/core/src/domain/session/sessionStore.ts @@ -1,54 +1,185 @@ -import type { CookieOptions } from '../../browser/cookie' -import { isEmptyObject } from '../../tools/utils/objectUtils' -import { objectEntries } from '../../tools/utils/polyfills' +import { clearInterval, setInterval } from '../../tools/timer' +import { Observable } from '../../tools/observable' +import { ONE_SECOND, dateNow } from '../../tools/utils/timeUtils' +import { throttle } from '../../tools/utils/functionUtils' +import { generateUUID } from '../../tools/utils/stringUtils' +import type { InitConfiguration } from '../configuration' +import { SESSION_TIME_OUT_DELAY } from './sessionConstants' +import { selectCookieStrategy, initCookieStrategy } from './storeStrategies/sessionInCookie' +import type { SessionStoreStrategyType } from './storeStrategies/sessionStoreStrategy' +import type { SessionState } from './sessionState' +import { initLocalStorageStrategy, selectLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage' +import { processSessionStoreOperations } from './sessionStoreOperations' -const SESSION_ENTRY_REGEXP = /^([a-z]+)=([a-z0-9-]+)$/ -const SESSION_ENTRY_SEPARATOR = '&' +export interface SessionStore { + expandOrRenewSession: () => void + expandSession: () => void + getSession: () => SessionState + renewObservable: Observable + expireObservable: Observable + expire: () => void + stop: () => void +} -export interface SessionState { - id?: string - created?: string - expire?: string - lock?: string +/** + * Every second, the storage will be polled to check for any change that can occur + * to the session state in another browser tab, or another window. + * This value has been determined from our previous cookie-only implementation. + */ +export const STORAGE_POLL_DELAY = ONE_SECOND - [key: string]: string | undefined +/** + * Checks if cookies are available as the preferred storage + * Else, checks if LocalStorage is allowed and available + */ +export function selectSessionStoreStrategyType( + initConfiguration: InitConfiguration +): SessionStoreStrategyType | undefined { + let sessionStoreStrategyType = selectCookieStrategy(initConfiguration) + if (!sessionStoreStrategyType && initConfiguration.allowFallbackToLocalStorage) { + sessionStoreStrategyType = selectLocalStorageStrategy() + } + return sessionStoreStrategyType } -export type StoreInitOptions = CookieOptions +/** + * Different session concepts: + * - tracked, the session has an id and is updated along the user navigation + * - not tracked, the session does not have an id but it is updated along the user navigation + * - inactive, no session in store or session expired, waiting for a renew session + */ +export function startSessionStore( + sessionStoreStrategyType: SessionStoreStrategyType, + productKey: string, + computeSessionState: (rawTrackingType?: string) => { trackingType: TrackingType; isTracked: boolean } +): SessionStore { + const renewObservable = new Observable() + const expireObservable = new Observable() -export interface SessionStore { - persistSession: (session: SessionState) => void - retrieveSession: () => SessionState - clearSession: () => void -} + const sessionStoreStrategy = + sessionStoreStrategyType.type === 'Cookie' + ? initCookieStrategy(sessionStoreStrategyType.cookieOptions) + : initLocalStorageStrategy() + const { clearSession, retrieveSession } = sessionStoreStrategy -export function isSessionInExpiredState(session: SessionState) { - return isEmptyObject(session) -} + const watchSessionTimeoutId = setInterval(watchSession, STORAGE_POLL_DELAY) + let sessionCache: SessionState = retrieveActiveSession() -export function toSessionString(session: SessionState) { - return objectEntries(session) - .map(([key, value]) => `${key}=${value as string}`) - .join(SESSION_ENTRY_SEPARATOR) -} + function expandOrRenewSession() { + let isTracked: boolean + processSessionStoreOperations( + { + process: (sessionState) => { + const synchronizedSession = synchronizeSession(sessionState) + isTracked = expandOrRenewSessionState(synchronizedSession) + return synchronizedSession + }, + after: (sessionState) => { + if (isTracked && !hasSessionInCache()) { + renewSessionInCache(sessionState) + } + sessionCache = sessionState + }, + }, + sessionStoreStrategy + ) + } + + function expandSession() { + processSessionStoreOperations( + { + process: (sessionState) => (hasSessionInCache() ? synchronizeSession(sessionState) : undefined), + }, + sessionStoreStrategy + ) + } -export function toSessionState(sessionString: string | undefined | null) { - const session: SessionState = {} - if (isValidSessionString(sessionString)) { - sessionString.split(SESSION_ENTRY_SEPARATOR).forEach((entry) => { - const matches = SESSION_ENTRY_REGEXP.exec(entry) - if (matches !== null) { - const [, key, value] = matches - session[key] = value + /** + * allows two behaviors: + * - if the session is active, synchronize the session cache without updating the session store + * - if the session is not active, clear the session store and expire the session cache + */ + function watchSession() { + processSessionStoreOperations( + { + process: (sessionState) => (!isActiveSession(sessionState) ? {} : undefined), + after: synchronizeSession, + }, + sessionStoreStrategy + ) + } + + function synchronizeSession(sessionState: SessionState) { + if (!isActiveSession(sessionState)) { + sessionState = {} + } + if (hasSessionInCache()) { + if (isSessionInCacheOutdated(sessionState)) { + expireSessionInCache() + } else { + sessionCache = sessionState } - }) + } + return sessionState + } + + function expandOrRenewSessionState(sessionState: SessionState) { + const { trackingType, isTracked } = computeSessionState(sessionState[productKey]) + sessionState[productKey] = trackingType + if (isTracked && !sessionState.id) { + sessionState.id = generateUUID() + sessionState.created = String(dateNow()) + } + return isTracked + } + + function hasSessionInCache() { + return sessionCache[productKey] !== undefined + } + + function isSessionInCacheOutdated(sessionState: SessionState) { + return sessionCache.id !== sessionState.id || sessionCache[productKey] !== sessionState[productKey] + } + + function expireSessionInCache() { + sessionCache = {} + expireObservable.notify() } - return session -} -function isValidSessionString(sessionString: string | undefined | null): sessionString is string { - return ( - !!sessionString && - (sessionString.indexOf(SESSION_ENTRY_SEPARATOR) !== -1 || SESSION_ENTRY_REGEXP.test(sessionString)) - ) + function renewSessionInCache(sessionState: SessionState) { + sessionCache = sessionState + renewObservable.notify() + } + + function retrieveActiveSession(): SessionState { + const session = retrieveSession() + if (isActiveSession(session)) { + return session + } + return {} + } + + function isActiveSession(sessionState: SessionState) { + // created and expire can be undefined for versions which was not storing them + // these checks could be removed when older versions will not be available/live anymore + return ( + (sessionState.created === undefined || dateNow() - Number(sessionState.created) < SESSION_TIME_OUT_DELAY) && + (sessionState.expire === undefined || dateNow() < Number(sessionState.expire)) + ) + } + + return { + expandOrRenewSession: throttle(expandOrRenewSession, STORAGE_POLL_DELAY).throttled, + expandSession, + getSession: () => sessionCache, + renewObservable, + expireObservable, + expire: () => { + clearSession() + synchronizeSession({}) + }, + stop: () => { + clearInterval(watchSessionTimeoutId) + }, + } } diff --git a/packages/core/src/domain/session/sessionStoreManager.spec.ts b/packages/core/src/domain/session/sessionStoreManager.spec.ts deleted file mode 100644 index 53b6faf90d..0000000000 --- a/packages/core/src/domain/session/sessionStoreManager.spec.ts +++ /dev/null @@ -1,354 +0,0 @@ -import type { Clock } from '../../../test' -import { mockClock } from '../../../test' -import type { CookieOptions } from '../../browser/cookie' -import { getCookie, setCookie, COOKIE_ACCESS_DELAY } from '../../browser/cookie' -import type { SessionStoreManager } from './sessionStoreManager' -import { startSessionStoreManager } from './sessionStoreManager' -import { SESSION_COOKIE_NAME } from './sessionCookieStore' -import { SESSION_EXPIRATION_DELAY, SESSION_TIME_OUT_DELAY } from './sessionConstants' - -const enum FakeTrackingType { - TRACKED = 'tracked', - NOT_TRACKED = 'not-tracked', -} - -const DURATION = 123456 -const PRODUCT_KEY = 'product' -const FIRST_ID = 'first' -const SECOND_ID = 'second' -const COOKIE_OPTIONS: CookieOptions = {} - -function setSessionInStore(trackingType: FakeTrackingType = FakeTrackingType.TRACKED, id?: string, expire?: number) { - setCookie( - SESSION_COOKIE_NAME, - `${id ? `id=${id}&` : ''}${PRODUCT_KEY}=${trackingType}&created=${Date.now()}&expire=${ - expire || Date.now() + SESSION_EXPIRATION_DELAY - }`, - DURATION - ) -} - -function expectTrackedSessionToBeInStore(id?: string) { - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(new RegExp(`id=${id ? id : '[a-f0-9-]+'}`)) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${PRODUCT_KEY}=${FakeTrackingType.TRACKED}`) -} - -function expectNotTrackedSessionToBeInStore() { - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain('id=') - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${PRODUCT_KEY}=${FakeTrackingType.NOT_TRACKED}`) -} - -function getStoreExpiration() { - return /expire=(\d+)/.exec(getCookie(SESSION_COOKIE_NAME)!)?.[1] -} - -function resetSessionInStore() { - setCookie(SESSION_COOKIE_NAME, '', DURATION) -} - -describe('session store', () => { - describe('session lifecyle mechanism', () => { - let expireSpy: () => void - let renewSpy: () => void - let sessionStore: SessionStoreManager - let clock: Clock - - function setupSessionStore( - computeSessionState: (rawTrackingType?: string) => { - trackingType: FakeTrackingType - isTracked: boolean - } = () => ({ - isTracked: true, - trackingType: FakeTrackingType.TRACKED, - }) - ) { - sessionStore = startSessionStoreManager(COOKIE_OPTIONS, PRODUCT_KEY, computeSessionState) - sessionStore.expireObservable.subscribe(expireSpy) - sessionStore.renewObservable.subscribe(renewSpy) - } - - beforeEach(() => { - expireSpy = jasmine.createSpy('expire session') - renewSpy = jasmine.createSpy('renew session') - clock = mockClock() - }) - - afterEach(() => { - resetSessionInStore() - clock.cleanup() - sessionStore.stop() - }) - - describe('expand or renew session', () => { - it( - 'when session not in cache, session not in store and new session tracked, ' + - 'should create new session and trigger renew session ', - () => { - setupSessionStore() - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBeDefined() - expectTrackedSessionToBeInStore() - expect(expireSpy).not.toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalled() - } - ) - - it( - 'when session not in cache, session not in store and new session not tracked, ' + - 'should store not tracked session', - () => { - setupSessionStore(() => ({ isTracked: false, trackingType: FakeTrackingType.NOT_TRACKED })) - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).not.toHaveBeenCalled() - expect(renewSpy).not.toHaveBeenCalled() - } - ) - - it('when session not in cache and session in store, should expand session and trigger renew session', () => { - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBe(FIRST_ID) - expectTrackedSessionToBeInStore(FIRST_ID) - expect(expireSpy).not.toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalled() - }) - - it( - 'when session in cache, session not in store and new session tracked, ' + - 'should expire session, create a new one and trigger renew session', - () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - resetSessionInStore() - - sessionStore.expandOrRenewSession() - - const sessionId = sessionStore.getSession().id - expect(sessionId).toBeDefined() - expect(sessionId).not.toBe(FIRST_ID) - expectTrackedSessionToBeInStore(sessionId) - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalled() - } - ) - - it( - 'when session in cache, session not in store and new session not tracked, ' + - 'should expire session and store not tracked session', - () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore(() => ({ isTracked: false, trackingType: FakeTrackingType.NOT_TRACKED })) - resetSessionInStore() - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expect(sessionStore.getSession()[PRODUCT_KEY]).toBeDefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).not.toHaveBeenCalled() - } - ) - - it( - 'when session not tracked in cache, session not in store and new session not tracked, ' + - 'should expire session and store not tracked session', - () => { - setSessionInStore(FakeTrackingType.NOT_TRACKED) - setupSessionStore(() => ({ isTracked: false, trackingType: FakeTrackingType.NOT_TRACKED })) - resetSessionInStore() - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expect(sessionStore.getSession()[PRODUCT_KEY]).toBeDefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).not.toHaveBeenCalled() - } - ) - - it('when session in cache is same session than in store, should expand session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - - clock.tick(10) - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBe(FIRST_ID) - expect(sessionStore.getSession().expire).toBe(getStoreExpiration()) - expectTrackedSessionToBeInStore(FIRST_ID) - expect(expireSpy).not.toHaveBeenCalled() - expect(renewSpy).not.toHaveBeenCalled() - }) - - it( - 'when session in cache is different session than in store and store session is tracked, ' + - 'should expire session, expand store session and trigger renew', - () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, SECOND_ID) - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBe(SECOND_ID) - expectTrackedSessionToBeInStore(SECOND_ID) - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).toHaveBeenCalled() - } - ) - - it( - 'when session in cache is different session than in store and store session is not tracked, ' + - 'should expire session and store not tracked session', - () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore((rawTrackingType) => ({ - isTracked: rawTrackingType === FakeTrackingType.TRACKED, - trackingType: rawTrackingType as FakeTrackingType, - })) - setSessionInStore(FakeTrackingType.NOT_TRACKED, '') - - sessionStore.expandOrRenewSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expectNotTrackedSessionToBeInStore() - expect(expireSpy).toHaveBeenCalled() - expect(renewSpy).not.toHaveBeenCalled() - } - ) - }) - - describe('expand session', () => { - it('when session not in cache and session not in store, should do nothing', () => { - setupSessionStore() - - sessionStore.expandSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).not.toHaveBeenCalled() - }) - - it('when session not in cache and session in store, should do nothing', () => { - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - - sessionStore.expandSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).not.toHaveBeenCalled() - }) - - it('when session in cache and session not in store, should expire session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - resetSessionInStore() - - sessionStore.expandSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).toHaveBeenCalled() - }) - - it('when session in cache is same session than in store, should expand session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - - clock.tick(10) - sessionStore.expandSession() - - expect(sessionStore.getSession().id).toBe(FIRST_ID) - expect(sessionStore.getSession().expire).toBe(getStoreExpiration()) - expect(expireSpy).not.toHaveBeenCalled() - }) - - it('when session in cache is different session than in store, should expire session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, SECOND_ID) - - sessionStore.expandSession() - - expect(sessionStore.getSession().id).toBeUndefined() - expectTrackedSessionToBeInStore(SECOND_ID) - expect(expireSpy).toHaveBeenCalled() - }) - }) - - describe('regular watch', () => { - it('when session not in cache and session not in store, should do nothing', () => { - setupSessionStore() - - clock.tick(COOKIE_ACCESS_DELAY) - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).not.toHaveBeenCalled() - }) - - it('when session not in cache and session in store, should do nothing', () => { - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - - clock.tick(COOKIE_ACCESS_DELAY) - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).not.toHaveBeenCalled() - }) - - it('when session in cache and session not in store, should expire session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - resetSessionInStore() - - clock.tick(COOKIE_ACCESS_DELAY) - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).toHaveBeenCalled() - }) - - it('when session in cache is same session than in store, should synchronize session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID, Date.now() + SESSION_TIME_OUT_DELAY + 10) - - clock.tick(COOKIE_ACCESS_DELAY) - - expect(sessionStore.getSession().id).toBe(FIRST_ID) - expect(sessionStore.getSession().expire).toBe(getStoreExpiration()) - expect(expireSpy).not.toHaveBeenCalled() - }) - - it('when session id in cache is different than session id in store, should expire session', () => { - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, SECOND_ID) - - clock.tick(COOKIE_ACCESS_DELAY) - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).toHaveBeenCalled() - }) - - it('when session type in cache is different than session type in store, should expire session', () => { - setSessionInStore(FakeTrackingType.NOT_TRACKED, FIRST_ID) - setupSessionStore() - setSessionInStore(FakeTrackingType.TRACKED, FIRST_ID) - - clock.tick(COOKIE_ACCESS_DELAY) - - expect(sessionStore.getSession().id).toBeUndefined() - expect(expireSpy).toHaveBeenCalled() - }) - }) - }) -}) diff --git a/packages/core/src/domain/session/sessionStoreManager.ts b/packages/core/src/domain/session/sessionStoreManager.ts deleted file mode 100644 index 5e70f68476..0000000000 --- a/packages/core/src/domain/session/sessionStoreManager.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { clearInterval, setInterval } from '../../tools/timer' -import { Observable } from '../../tools/observable' -import { ONE_SECOND, dateNow } from '../../tools/utils/timeUtils' -import { throttle } from '../../tools/utils/functionUtils' -import { generateUUID } from '../../tools/utils/stringUtils' -import { SESSION_TIME_OUT_DELAY } from './sessionConstants' -import { initCookieStore } from './sessionCookieStore' -import type { SessionState, StoreInitOptions } from './sessionStore' -import { processSessionStoreOperations } from './sessionStoreOperations' - -export interface SessionStoreManager { - expandOrRenewSession: () => void - expandSession: () => void - getSession: () => SessionState - renewObservable: Observable - expireObservable: Observable - expire: () => void - stop: () => void -} - -const POLL_DELAY = ONE_SECOND - -/** - * Different session concepts: - * - tracked, the session has an id and is updated along the user navigation - * - not tracked, the session does not have an id but it is updated along the user navigation - * - inactive, no session in store or session expired, waiting for a renew session - */ -export function startSessionStoreManager( - options: StoreInitOptions, - productKey: string, - computeSessionState: (rawTrackingType?: string) => { trackingType: TrackingType; isTracked: boolean } -): SessionStoreManager { - const renewObservable = new Observable() - const expireObservable = new Observable() - - const sessionStore = initCookieStore(options) - const { clearSession, retrieveSession } = sessionStore - - const watchSessionTimeoutId = setInterval(watchSession, POLL_DELAY) - let sessionCache: SessionState = retrieveActiveSession() - - function expandOrRenewSession() { - let isTracked: boolean - processSessionStoreOperations( - { - process: (sessionState) => { - const synchronizedSession = synchronizeSession(sessionState) - isTracked = expandOrRenewSessionState(synchronizedSession) - return synchronizedSession - }, - after: (sessionState) => { - if (isTracked && !hasSessionInCache()) { - renewSessionInCache(sessionState) - } - sessionCache = sessionState - }, - }, - sessionStore - ) - } - - function expandSession() { - processSessionStoreOperations( - { - process: (sessionState) => (hasSessionInCache() ? synchronizeSession(sessionState) : undefined), - }, - sessionStore - ) - } - - /** - * allows two behaviors: - * - if the session is active, synchronize the session cache without updating the session store - * - if the session is not active, clear the session store and expire the session cache - */ - function watchSession() { - processSessionStoreOperations( - { - process: (sessionState) => (!isActiveSession(sessionState) ? {} : undefined), - after: synchronizeSession, - }, - sessionStore - ) - } - - function synchronizeSession(sessionState: SessionState) { - if (!isActiveSession(sessionState)) { - sessionState = {} - } - if (hasSessionInCache()) { - if (isSessionInCacheOutdated(sessionState)) { - expireSessionInCache() - } else { - sessionCache = sessionState - } - } - return sessionState - } - - function expandOrRenewSessionState(sessionState: SessionState) { - const { trackingType, isTracked } = computeSessionState(sessionState[productKey]) - sessionState[productKey] = trackingType - if (isTracked && !sessionState.id) { - sessionState.id = generateUUID() - sessionState.created = String(dateNow()) - } - return isTracked - } - - function hasSessionInCache() { - return sessionCache[productKey] !== undefined - } - - function isSessionInCacheOutdated(sessionState: SessionState) { - return sessionCache.id !== sessionState.id || sessionCache[productKey] !== sessionState[productKey] - } - - function expireSessionInCache() { - sessionCache = {} - expireObservable.notify() - } - - function renewSessionInCache(sessionState: SessionState) { - sessionCache = sessionState - renewObservable.notify() - } - - function retrieveActiveSession(): SessionState { - const session = retrieveSession() - if (isActiveSession(session)) { - return session - } - return {} - } - - function isActiveSession(sessionDate: SessionState) { - // created and expire can be undefined for versions which was not storing them - // these checks could be removed when older versions will not be available/live anymore - return ( - (sessionDate.created === undefined || dateNow() - Number(sessionDate.created) < SESSION_TIME_OUT_DELAY) && - (sessionDate.expire === undefined || dateNow() < Number(sessionDate.expire)) - ) - } - - return { - expandOrRenewSession: throttle(expandOrRenewSession, POLL_DELAY).throttled, - expandSession, - getSession: () => sessionCache, - renewObservable, - expireObservable, - expire: () => { - clearSession() - synchronizeSession({}) - }, - stop: () => { - clearInterval(watchSessionTimeoutId) - }, - } -} diff --git a/packages/core/src/domain/session/sessionStoreOperations.spec.ts b/packages/core/src/domain/session/sessionStoreOperations.spec.ts index 1b1f1612e8..0ae57375f4 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.spec.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.spec.ts @@ -1,261 +1,280 @@ -import { stubCookie, mockClock } from '../../../test' -import { isChromium } from '../../tools/utils/browserDetection' -import { SESSION_EXPIRATION_DELAY } from './sessionConstants' -import { initCookieStore, SESSION_COOKIE_NAME } from './sessionCookieStore' -import type { SessionState, SessionStore } from './sessionStore' -import { toSessionString } from './sessionStore' +import type { StubStorage } from '../../../test' +import { mockClock, stubCookieProvider, stubLocalStorageProvider } from '../../../test' +import type { CookieOptions } from '../../browser/cookie' +import { initCookieStrategy } from './storeStrategies/sessionInCookie' +import { initLocalStorageStrategy } from './storeStrategies/sessionInLocalStorage' +import type { SessionState } from './sessionState' +import { expandSessionState, toSessionString } from './sessionState' import { processSessionStoreOperations, isLockEnabled, LOCK_MAX_TRIES, LOCK_RETRY_DELAY, } from './sessionStoreOperations' +import { SESSION_STORE_KEY } from './storeStrategies/sessionStoreStrategy' + +const cookieOptions: CookieOptions = {} + +;( + [ + { + title: 'Cookie Storage', + sessionStoreStrategy: initCookieStrategy(cookieOptions), + stubStorageProvider: stubCookieProvider, + storageKey: SESSION_STORE_KEY, + }, + { + title: 'Local Storage', + sessionStoreStrategy: initLocalStorageStrategy(), + stubStorageProvider: stubLocalStorageProvider, + storageKey: SESSION_STORE_KEY, + }, + ] as const +).forEach(({ title, sessionStoreStrategy, stubStorageProvider, storageKey }) => { + describe(`process operations mechanism with ${title}`, () => { + let initialSession: SessionState + let otherSession: SessionState + let processSpy: jasmine.Spy + let afterSpy: jasmine.Spy + let stubStorage: StubStorage -describe('process operations mechanism', () => { - const COOKIE_OPTIONS = {} - let initialSession: SessionState - let otherSession: SessionState - let processSpy: jasmine.Spy - let afterSpy: jasmine.Spy - let cookie: ReturnType - let cookieStorage: SessionStore - - beforeEach(() => { - cookieStorage = initCookieStore(COOKIE_OPTIONS) - initialSession = { id: '123', created: '0' } - otherSession = { id: '456', created: '100' } - processSpy = jasmine.createSpy('process') - afterSpy = jasmine.createSpy('after') - cookie = stubCookie() - }) - - describe('with cookie-lock disabled', () => { beforeEach(() => { - isChromium() && pending('cookie-lock only disabled on non chromium browsers') + sessionStoreStrategy.clearSession() + initialSession = { id: '123', created: '0' } + otherSession = { id: '456', created: '100' } + processSpy = jasmine.createSpy('process') + afterSpy = jasmine.createSpy('after') + stubStorage = stubStorageProvider.get() }) - it('should persist session when process returns a value', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.returnValue({ ...otherSession }) + describe('with lock access disabled', () => { + beforeEach(() => { + isLockEnabled() && pending('lock-access required') + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + it('should persist session when process returns a value', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.returnValue({ ...otherSession }) - expect(processSpy).toHaveBeenCalledWith(initialSession) - const expectedSession = { ...otherSession, expire: jasmine.any(String) } - expect(cookieStorage.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - it('should clear session when process return an empty value', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.returnValue({}) + expect(processSpy).toHaveBeenCalledWith(initialSession) + const expectedSession = { ...otherSession, expire: jasmine.any(String) } + expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) + expect(afterSpy).toHaveBeenCalledWith(expectedSession) + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + it('should clear session when process returns an empty value', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.returnValue({}) - expect(processSpy).toHaveBeenCalledWith(initialSession) - const expectedSession = {} - expect(cookieStorage.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - it('should not persist session when process return undefined', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.returnValue(undefined) + expect(processSpy).toHaveBeenCalledWith(initialSession) + const expectedSession = {} + expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) + expect(afterSpy).toHaveBeenCalledWith(expectedSession) + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + it('should not persist session when process returns undefined', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.returnValue(undefined) - expect(processSpy).toHaveBeenCalledWith(initialSession) - expect(cookieStorage.retrieveSession()).toEqual(initialSession) - expect(afterSpy).toHaveBeenCalledWith(initialSession) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - it('LOCK_MAX_TRIES value should not influence the behavior when lock mechanism is not enabled', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.returnValue({ ...otherSession }) + expect(processSpy).toHaveBeenCalledWith(initialSession) + expect(sessionStoreStrategy.retrieveSession()).toEqual(initialSession) + expect(afterSpy).toHaveBeenCalledWith(initialSession) + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage, LOCK_MAX_TRIES) + it('LOCK_MAX_TRIES value should not influence the behavior when lock mechanism is not enabled', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.returnValue({ ...otherSession }) - expect(processSpy).toHaveBeenCalledWith(initialSession) - const expectedSession = { ...otherSession, expire: jasmine.any(String) } - expect(cookieStorage.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy, LOCK_MAX_TRIES) - describe('with cookie-lock enabled', () => { - beforeEach(() => { - !isChromium() && pending('cookie-lock only enabled on chromium browsers') + expect(processSpy).toHaveBeenCalledWith(initialSession) + const expectedSession = { ...otherSession, expire: jasmine.any(String) } + expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) + expect(afterSpy).toHaveBeenCalledWith(expectedSession) + }) }) - it('should persist session when process return a value', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.callFake((session) => ({ ...otherSession, lock: session.lock })) + describe('with lock access enabled', () => { + beforeEach(() => { + !isLockEnabled() && pending('lock-access not enabled') + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + it('should persist session when process returns a value', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.callFake((session) => ({ ...otherSession, lock: session.lock })) - expect(processSpy).toHaveBeenCalledWith({ ...initialSession, lock: jasmine.any(String) }) - const expectedSession = { ...otherSession, expire: jasmine.any(String) } - expect(cookieStorage.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - it('should clear session when process return an empty value', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.returnValue({}) + expect(processSpy).toHaveBeenCalledWith({ ...initialSession, lock: jasmine.any(String) }) + const expectedSession = { ...otherSession, expire: jasmine.any(String) } + expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) + expect(afterSpy).toHaveBeenCalledWith(expectedSession) + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + it('should clear session when process returns an empty value', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.returnValue({}) - expect(processSpy).toHaveBeenCalledWith({ ...initialSession, lock: jasmine.any(String) }) - const expectedSession = {} - expect(cookieStorage.retrieveSession()).toEqual(expectedSession) - expect(afterSpy).toHaveBeenCalledWith(expectedSession) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - it('should not persist session when process return undefined', () => { - cookieStorage.persistSession(initialSession) - processSpy.and.returnValue(undefined) + expect(processSpy).toHaveBeenCalledWith({ ...initialSession, lock: jasmine.any(String) }) + const expectedSession = {} + expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) + expect(afterSpy).toHaveBeenCalledWith(expectedSession) + }) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + it('should not persist session when process returns undefined', () => { + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.returnValue(undefined) - expect(processSpy).toHaveBeenCalledWith({ ...initialSession, lock: jasmine.any(String) }) - expect(cookieStorage.retrieveSession()).toEqual(initialSession) - expect(afterSpy).toHaveBeenCalledWith(initialSession) - }) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - type OnLockCheck = () => { currentState: SessionState; retryState: SessionState } - - function lockScenario({ - onInitialLockCheck, - onAcquiredLockCheck, - onPostProcessLockCheck, - onPostPersistLockCheck, - }: { - onInitialLockCheck?: OnLockCheck - onAcquiredLockCheck?: OnLockCheck - onPostProcessLockCheck?: OnLockCheck - onPostPersistLockCheck?: OnLockCheck - }) { - const onLockChecks = [onInitialLockCheck, onAcquiredLockCheck, onPostProcessLockCheck, onPostPersistLockCheck] - cookie.getSpy.and.callFake(() => { - const currentOnLockCheck = onLockChecks.shift() - if (!currentOnLockCheck) { - return cookie.currentValue() - } - const { currentState, retryState } = currentOnLockCheck() - cookie.setCurrentValue(buildSessionString(retryState)) - return buildSessionString(currentState) + expect(processSpy).toHaveBeenCalledWith({ ...initialSession, lock: jasmine.any(String) }) + expect(sessionStoreStrategy.retrieveSession()).toEqual(initialSession) + expect(afterSpy).toHaveBeenCalledWith(initialSession) }) - } - - function buildSessionString(currentState: SessionState) { - return `${SESSION_COOKIE_NAME}=${toSessionString(currentState)}` - } - - ;[ - { - description: 'should wait for lock to be free', - lockConflict: 'onInitialLockCheck', - }, - { - description: 'should retry if lock was acquired before process', - lockConflict: 'onAcquiredLockCheck', - }, - { - description: 'should retry if lock was acquired after process', - lockConflict: 'onPostProcessLockCheck', - }, - { - description: 'should retry if lock was acquired after persist', - lockConflict: 'onPostPersistLockCheck', - }, - ].forEach(({ description, lockConflict }) => { - it(description, (done) => { - lockScenario({ - [lockConflict]: () => ({ - currentState: { ...initialSession, lock: 'locked' }, - retryState: { ...initialSession, other: 'other' }, - }), + + type OnLockCheck = () => { currentState: SessionState; retryState: SessionState } + + function lockScenario({ + onInitialLockCheck, + onAcquiredLockCheck, + onPostProcessLockCheck, + onPostPersistLockCheck, + }: { + onInitialLockCheck?: OnLockCheck + onAcquiredLockCheck?: OnLockCheck + onPostProcessLockCheck?: OnLockCheck + onPostPersistLockCheck?: OnLockCheck + }) { + const onLockChecks = [onInitialLockCheck, onAcquiredLockCheck, onPostProcessLockCheck, onPostPersistLockCheck] + stubStorage.getSpy.and.callFake(() => { + const currentOnLockCheck = onLockChecks.shift() + if (!currentOnLockCheck) { + return stubStorage.currentValue(storageKey) + } + const { currentState, retryState } = currentOnLockCheck() + stubStorage.setCurrentValue(storageKey, toSessionString(retryState)) + return buildSessionString(currentState) }) - initialSession.expire = String(Date.now() + SESSION_EXPIRATION_DELAY) - cookieStorage.persistSession(initialSession) - processSpy.and.callFake((session) => ({ ...session, processed: 'processed' } as SessionState)) + } - processSessionStoreOperations( - { - process: processSpy, - after: (afterSession) => { - // session with 'other' value on process - expect(processSpy).toHaveBeenCalledWith({ - ...initialSession, - other: 'other', - lock: jasmine.any(String), - expire: jasmine.any(String), - }) - - // end state with session 'other' and 'processed' value - const expectedSession = { - ...initialSession, - other: 'other', - processed: 'processed', - expire: jasmine.any(String), - } - expect(cookieStorage.retrieveSession()).toEqual(expectedSession) - expect(afterSession).toEqual(expectedSession) - done() + function buildSessionString(currentState: SessionState) { + return `${storageKey}=${toSessionString(currentState)}` + } + + ;[ + { + description: 'should wait for lock to be free', + lockConflict: 'onInitialLockCheck', + }, + { + description: 'should retry if lock was acquired before process', + lockConflict: 'onAcquiredLockCheck', + }, + { + description: 'should retry if lock was acquired after process', + lockConflict: 'onPostProcessLockCheck', + }, + { + description: 'should retry if lock was acquired after persist', + lockConflict: 'onPostPersistLockCheck', + }, + ].forEach(({ description, lockConflict }) => { + it(description, (done) => { + lockScenario({ + [lockConflict]: () => ({ + currentState: { ...initialSession, lock: 'locked' }, + retryState: { ...initialSession, other: 'other' }, + }), + }) + expandSessionState(initialSession) + sessionStoreStrategy.persistSession(initialSession) + processSpy.and.callFake((session) => ({ ...session, processed: 'processed' } as SessionState)) + + processSessionStoreOperations( + { + process: processSpy, + after: (afterSession) => { + // session with 'other' value on process + expect(processSpy).toHaveBeenCalledWith({ + ...initialSession, + other: 'other', + lock: jasmine.any(String), + expire: jasmine.any(String), + }) + + // end state with session 'other' and 'processed' value + const expectedSession = { + ...initialSession, + other: 'other', + processed: 'processed', + expire: jasmine.any(String), + } + expect(sessionStoreStrategy.retrieveSession()).toEqual(expectedSession) + expect(afterSession).toEqual(expectedSession) + done() + }, }, - }, - cookieStorage - ) + sessionStoreStrategy + ) + }) }) - }) - - it('should abort after a max number of retry', () => { - const clock = mockClock() - cookieStorage.persistSession(initialSession) - cookie.setSpy.calls.reset() + it('should abort after a max number of retry', () => { + const clock = mockClock() - cookie.getSpy.and.returnValue(buildSessionString({ ...initialSession, lock: 'locked' })) - processSessionStoreOperations({ process: processSpy, after: afterSpy }, cookieStorage) + sessionStoreStrategy.persistSession(initialSession) + stubStorage.setSpy.calls.reset() - const lockMaxTries = isLockEnabled() ? LOCK_MAX_TRIES : 0 - const lockRetryDelay = isLockEnabled() ? LOCK_RETRY_DELAY : 0 + stubStorage.getSpy.and.returnValue(buildSessionString({ ...initialSession, lock: 'locked' })) + processSessionStoreOperations({ process: processSpy, after: afterSpy }, sessionStoreStrategy) - clock.tick(lockMaxTries * lockRetryDelay) - expect(processSpy).not.toHaveBeenCalled() - expect(afterSpy).not.toHaveBeenCalled() - expect(cookie.setSpy).not.toHaveBeenCalled() + const lockMaxTries = isLockEnabled() ? LOCK_MAX_TRIES : 0 + const lockRetryDelay = isLockEnabled() ? LOCK_RETRY_DELAY : 0 - clock.cleanup() - }) + clock.tick(lockMaxTries * lockRetryDelay) + expect(processSpy).not.toHaveBeenCalled() + expect(afterSpy).not.toHaveBeenCalled() + expect(stubStorage.setSpy).not.toHaveBeenCalled() - it('should execute cookie accesses in order', (done) => { - lockScenario({ - onInitialLockCheck: () => ({ - currentState: { ...initialSession, lock: 'locked' }, // force to retry the first access later - retryState: initialSession, - }), + clock.cleanup() }) - cookieStorage.persistSession(initialSession) - processSessionStoreOperations( - { - process: (session) => ({ ...session, value: 'foo' }), - after: afterSpy, - }, - cookieStorage - ) - processSessionStoreOperations( - { - process: (session) => ({ ...session, value: `${session.value || ''}bar` }), - after: (session) => { - expect(session.value).toBe('foobar') - expect(afterSpy).toHaveBeenCalled() - done() + it('should execute cookie accesses in order', (done) => { + lockScenario({ + onInitialLockCheck: () => ({ + currentState: { ...initialSession, lock: 'locked' }, // force to retry the first access later + retryState: initialSession, + }), + }) + sessionStoreStrategy.persistSession(initialSession) + + processSessionStoreOperations( + { + process: (session) => ({ ...session, value: 'foo' }), + after: afterSpy, }, - }, - cookieStorage - ) + sessionStoreStrategy + ) + processSessionStoreOperations( + { + process: (session) => ({ ...session, value: `${session.value || ''}bar` }), + after: (session) => { + expect(session.value).toBe('foobar') + expect(afterSpy).toHaveBeenCalled() + done() + }, + }, + sessionStoreStrategy + ) + }) }) }) }) diff --git a/packages/core/src/domain/session/sessionStoreOperations.ts b/packages/core/src/domain/session/sessionStoreOperations.ts index a47558c0e0..aaa3d94395 100644 --- a/packages/core/src/domain/session/sessionStoreOperations.ts +++ b/packages/core/src/domain/session/sessionStoreOperations.ts @@ -1,10 +1,9 @@ import { setTimeout } from '../../tools/timer' -import { dateNow } from '../../tools/utils/timeUtils' import { generateUUID } from '../../tools/utils/stringUtils' import { isChromium } from '../../tools/utils/browserDetection' -import { SESSION_EXPIRATION_DELAY } from './sessionConstants' -import type { SessionState, SessionStore } from './sessionStore' -import { isSessionInExpiredState } from './sessionStore' +import type { SessionStoreStrategy } from './storeStrategies/sessionStoreStrategy' +import type { SessionState } from './sessionState' +import { expandSessionState, isSessionInExpiredState } from './sessionState' type Operations = { process: (sessionState: SessionState) => SessionState | undefined @@ -16,8 +15,12 @@ export const LOCK_MAX_TRIES = 100 const bufferedOperations: Operations[] = [] let ongoingOperations: Operations | undefined -export function processSessionStoreOperations(operations: Operations, sessionStore: SessionStore, numberOfRetries = 0) { - const { retrieveSession, persistSession, clearSession } = sessionStore +export function processSessionStoreOperations( + operations: Operations, + sessionStoreStrategy: SessionStoreStrategy, + numberOfRetries = 0 +) { + const { retrieveSession, persistSession, clearSession } = sessionStoreStrategy const lockEnabled = isLockEnabled() if (!ongoingOperations) { @@ -28,7 +31,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto return } if (lockEnabled && numberOfRetries >= LOCK_MAX_TRIES) { - next(sessionStore) + next(sessionStoreStrategy) return } let currentLock: string @@ -36,7 +39,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto if (lockEnabled) { // if someone has lock, retry later if (currentSession.lock) { - retryLater(operations, sessionStore, numberOfRetries) + retryLater(operations, sessionStoreStrategy, numberOfRetries) return } // acquire lock @@ -46,7 +49,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto // if lock is not acquired, retry later currentSession = retrieveSession() if (currentSession.lock !== currentLock) { - retryLater(operations, sessionStore, numberOfRetries) + retryLater(operations, sessionStoreStrategy, numberOfRetries) return } } @@ -55,7 +58,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto // if lock corrupted after process, retry later currentSession = retrieveSession() if (currentSession.lock !== currentLock!) { - retryLater(operations, sessionStore, numberOfRetries) + retryLater(operations, sessionStoreStrategy, numberOfRetries) return } } @@ -63,7 +66,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto if (isSessionInExpiredState(processedSession)) { clearSession() } else { - processedSession.expire = String(dateNow() + SESSION_EXPIRATION_DELAY) + expandSessionState(processedSession) persistSession(processedSession) } } @@ -74,7 +77,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto // if lock corrupted after persist, retry later currentSession = retrieveSession() if (currentSession.lock !== currentLock!) { - retryLater(operations, sessionStore, numberOfRetries) + retryLater(operations, sessionStoreStrategy, numberOfRetries) return } delete currentSession.lock @@ -85,7 +88,7 @@ export function processSessionStoreOperations(operations: Operations, sessionSto // call after even if session is not persisted in order to perform operations on // up-to-date session state value => the value could have been modified by another tab operations.after?.(processedSession || currentSession) - next(sessionStore) + next(sessionStoreStrategy) } /** @@ -94,13 +97,13 @@ export function processSessionStoreOperations(operations: Operations, sessionSto */ export const isLockEnabled = () => isChromium() -function retryLater(operations: Operations, sessionStore: SessionStore, currentNumberOfRetries: number) { +function retryLater(operations: Operations, sessionStore: SessionStoreStrategy, currentNumberOfRetries: number) { setTimeout(() => { processSessionStoreOperations(operations, sessionStore, currentNumberOfRetries + 1) }, LOCK_RETRY_DELAY) } -function next(sessionStore: SessionStore) { +function next(sessionStore: SessionStoreStrategy) { ongoingOperations = undefined const nextOperations = bufferedOperations.shift() if (nextOperations) { diff --git a/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts b/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts new file mode 100644 index 0000000000..ce5b91cfda --- /dev/null +++ b/packages/core/src/domain/session/storeStrategies/sessionInCookie.spec.ts @@ -0,0 +1,100 @@ +import { setCookie, deleteCookie, getCookie, getCurrentSite } from '../../../browser/cookie' +import type { SessionState } from '../sessionState' +import { buildCookieOptions, selectCookieStrategy, initCookieStrategy } from './sessionInCookie' +import type { SessionStoreStrategy } from './sessionStoreStrategy' +import { SESSION_STORE_KEY } from './sessionStoreStrategy' + +describe('session in cookie strategy', () => { + const sessionState: SessionState = { id: '123', created: '0' } + let cookieStorageStrategy: SessionStoreStrategy + + beforeEach(() => { + cookieStorageStrategy = initCookieStrategy({}) + }) + + afterEach(() => { + deleteCookie(SESSION_STORE_KEY) + }) + + it('should persist a session in a cookie', () => { + cookieStorageStrategy.persistSession(sessionState) + const session = cookieStorageStrategy.retrieveSession() + expect(session).toEqual({ ...sessionState }) + expect(getCookie(SESSION_STORE_KEY)).toBe('id=123&created=0') + }) + + it('should delete the cookie holding the session', () => { + cookieStorageStrategy.persistSession(sessionState) + cookieStorageStrategy.clearSession() + const session = cookieStorageStrategy.retrieveSession() + expect(session).toEqual({}) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + }) + + it('should return an empty object if session string is invalid', () => { + setCookie(SESSION_STORE_KEY, '{test:42}', 1000) + const session = cookieStorageStrategy.retrieveSession() + expect(session).toEqual({}) + }) + + describe('build cookie options', () => { + const clientToken = 'abc' + + it('should not be secure nor crossSite by default', () => { + const cookieOptions = buildCookieOptions({ clientToken })! + expect(cookieOptions).toEqual({ secure: false, crossSite: false }) + }) + + it('should be secure when `useSecureSessionCookie` is truthy', () => { + const cookieOptions = buildCookieOptions({ clientToken, useSecureSessionCookie: true })! + expect(cookieOptions).toEqual({ secure: true, crossSite: false }) + }) + + it('should be secure and crossSite when `useCrossSiteSessionCookie` is truthy', () => { + const cookieOptions = buildCookieOptions({ clientToken, useCrossSiteSessionCookie: true })! + expect(cookieOptions).toEqual({ secure: true, crossSite: true }) + }) + + it('should have domain when `trackSessionAcrossSubdomains` is truthy', () => { + const cookieOptions = buildCookieOptions({ clientToken, trackSessionAcrossSubdomains: true })! + expect(cookieOptions).toEqual({ secure: false, crossSite: false, domain: jasmine.any(String) }) + }) + }) + + describe('cookie options', () => { + ;[ + { + initConfiguration: { clientToken: 'abc' }, + cookieOptions: {}, + cookieString: /^dd_cookie_test_[\w-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict$/, + description: 'should set samesite to strict by default', + }, + { + initConfiguration: { clientToken: 'abc', useCrossSiteSessionCookie: true }, + cookieOptions: { crossSite: true, secure: true }, + cookieString: /^dd_cookie_test_[\w-]+=[^;]*;expires=[^;]+;path=\/;samesite=none;secure$/, + description: 'should set samesite to none and secure to true for crossSite', + }, + { + initConfiguration: { clientToken: 'abc', useSecureSessionCookie: true }, + cookieOptions: { secure: true }, + cookieString: /^dd_cookie_test_[\w-]+=[^;]*;expires=[^;]+;path=\/;samesite=strict;secure$/, + description: 'should add secure attribute when defined', + }, + { + initConfiguration: { clientToken: 'abc', trackSessionAcrossSubdomains: true }, + cookieOptions: { domain: 'foo.bar' }, + cookieString: new RegExp( + `^dd_cookie_test_[\\w-]+=[^;]*;expires=[^;]+;path=\\/;samesite=strict;domain=${getCurrentSite()}$` + ), + description: 'should set cookie domain when tracking accross subdomains', + }, + ].forEach(({ description, initConfiguration, cookieString }) => { + it(description, () => { + const cookieSetSpy = spyOnProperty(document, 'cookie', 'set') + selectCookieStrategy(initConfiguration) + expect(cookieSetSpy.calls.argsFor(0)[0]).toMatch(cookieString) + }) + }) + }) +}) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts b/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts new file mode 100644 index 0000000000..afb7d6b508 --- /dev/null +++ b/packages/core/src/domain/session/storeStrategies/sessionInCookie.ts @@ -0,0 +1,56 @@ +import type { CookieOptions } from '../../../browser/cookie' +import { getCurrentSite, areCookiesAuthorized, deleteCookie, getCookie, setCookie } from '../../../browser/cookie' +import type { InitConfiguration } from '../../configuration' +import { tryOldCookiesMigration } from '../oldCookiesMigration' +import { SESSION_EXPIRATION_DELAY } from '../sessionConstants' +import type { SessionState } from '../sessionState' +import { toSessionString, toSessionState } from '../sessionState' +import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy' +import { SESSION_STORE_KEY } from './sessionStoreStrategy' + +export function selectCookieStrategy(initConfiguration: InitConfiguration): SessionStoreStrategyType | undefined { + const cookieOptions = buildCookieOptions(initConfiguration) + return areCookiesAuthorized(cookieOptions) ? { type: 'Cookie', cookieOptions } : undefined +} + +export function initCookieStrategy(cookieOptions: CookieOptions): SessionStoreStrategy { + const cookieStore = { + persistSession: persistSessionCookie(cookieOptions), + retrieveSession: retrieveSessionCookie, + clearSession: deleteSessionCookie(cookieOptions), + } + + tryOldCookiesMigration(cookieStore) + + return cookieStore +} + +function persistSessionCookie(options: CookieOptions) { + return (session: SessionState) => { + setCookie(SESSION_STORE_KEY, toSessionString(session), SESSION_EXPIRATION_DELAY, options) + } +} + +function retrieveSessionCookie(): SessionState { + const sessionString = getCookie(SESSION_STORE_KEY) + return toSessionState(sessionString) +} + +function deleteSessionCookie(options: CookieOptions) { + return () => { + deleteCookie(SESSION_STORE_KEY, options) + } +} + +export function buildCookieOptions(initConfiguration: InitConfiguration) { + const cookieOptions: CookieOptions = {} + + cookieOptions.secure = !!initConfiguration.useSecureSessionCookie || !!initConfiguration.useCrossSiteSessionCookie + cookieOptions.crossSite = !!initConfiguration.useCrossSiteSessionCookie + + if (initConfiguration.trackSessionAcrossSubdomains) { + cookieOptions.domain = getCurrentSite() + } + + return cookieOptions +} diff --git a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts new file mode 100644 index 0000000000..3e0b1ddc8a --- /dev/null +++ b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.spec.ts @@ -0,0 +1,55 @@ +import type { SessionState } from '../sessionState' +import { selectLocalStorageStrategy, initLocalStorageStrategy } from './sessionInLocalStorage' +import { SESSION_STORE_KEY } from './sessionStoreStrategy' + +describe('session in local storage strategy', () => { + const sessionState: SessionState = { id: '123', created: '0' } + + afterEach(() => { + window.localStorage.clear() + }) + + it('should report local storage as available', () => { + const available = selectLocalStorageStrategy() + expect(available).toEqual({ type: 'LocalStorage' }) + }) + + it('should report local storage as not available', () => { + spyOn(Storage.prototype, 'getItem').and.throwError('Unavailable') + const available = selectLocalStorageStrategy() + expect(available).toBeUndefined() + }) + + it('should persist a session in local storage', () => { + const localStorageStrategy = initLocalStorageStrategy() + localStorageStrategy.persistSession(sessionState) + const session = localStorageStrategy.retrieveSession() + expect(session).toEqual({ ...sessionState }) + expect(window.localStorage.getItem(SESSION_STORE_KEY)).toMatch(/.*id=.*created/) + }) + + it('should delete the local storage item holding the session', () => { + const localStorageStrategy = initLocalStorageStrategy() + localStorageStrategy.persistSession(sessionState) + localStorageStrategy.clearSession() + const session = localStorageStrategy?.retrieveSession() + expect(session).toEqual({}) + expect(window.localStorage.getItem(SESSION_STORE_KEY)).toBeNull() + }) + + it('should not interfere with other keys present in local storage', () => { + window.localStorage.setItem('test', 'hello') + const localStorageStrategy = initLocalStorageStrategy() + localStorageStrategy.persistSession(sessionState) + localStorageStrategy.retrieveSession() + localStorageStrategy.clearSession() + expect(window.localStorage.getItem('test')).toEqual('hello') + }) + + it('should return an empty object if session string is invalid', () => { + const localStorageStrategy = initLocalStorageStrategy() + localStorage.setItem(SESSION_STORE_KEY, '{test:42}') + const session = localStorageStrategy?.retrieveSession() + expect(session).toEqual({}) + }) +}) diff --git a/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts new file mode 100644 index 0000000000..b07832b6ab --- /dev/null +++ b/packages/core/src/domain/session/storeStrategies/sessionInLocalStorage.ts @@ -0,0 +1,41 @@ +import { generateUUID } from '../../../tools/utils/stringUtils' +import type { SessionState } from '../sessionState' +import { toSessionString, toSessionState } from '../sessionState' +import type { SessionStoreStrategy, SessionStoreStrategyType } from './sessionStoreStrategy' +import { SESSION_STORE_KEY } from './sessionStoreStrategy' + +const LOCAL_STORAGE_TEST_KEY = '_dd_test_' + +export function selectLocalStorageStrategy(): SessionStoreStrategyType | undefined { + try { + const id = generateUUID() + const testKey = `${LOCAL_STORAGE_TEST_KEY}${id}` + localStorage.setItem(testKey, id) + const retrievedId = localStorage.getItem(testKey) + localStorage.removeItem(testKey) + return id === retrievedId ? { type: 'LocalStorage' } : undefined + } catch (e) { + return undefined + } +} + +export function initLocalStorageStrategy(): SessionStoreStrategy { + return { + persistSession: persistInLocalStorage, + retrieveSession: retrieveSessionFromLocalStorage, + clearSession: clearSessionFromLocalStorage, + } +} + +function persistInLocalStorage(sessionState: SessionState) { + localStorage.setItem(SESSION_STORE_KEY, toSessionString(sessionState)) +} + +function retrieveSessionFromLocalStorage(): SessionState { + const sessionString = localStorage.getItem(SESSION_STORE_KEY) + return toSessionState(sessionString) +} + +function clearSessionFromLocalStorage() { + localStorage.removeItem(SESSION_STORE_KEY) +} diff --git a/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts b/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts new file mode 100644 index 0000000000..d4c9fab265 --- /dev/null +++ b/packages/core/src/domain/session/storeStrategies/sessionStoreStrategy.ts @@ -0,0 +1,12 @@ +import type { CookieOptions } from '../../../browser/cookie' +import type { SessionState } from '../sessionState' + +export const SESSION_STORE_KEY = '_dd_s' + +export type SessionStoreStrategyType = { type: 'Cookie'; cookieOptions: CookieOptions } | { type: 'LocalStorage' } + +export interface SessionStoreStrategy { + persistSession: (session: SessionState) => void + retrieveSession: () => SessionState + clearSession: () => void +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1e69af120c..4338cb3c6f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,6 @@ export { Configuration, InitConfiguration, - buildCookieOptions, validateAndBuildConfiguration, DefaultPrivacyLevel, EndpointBuilder, @@ -85,7 +84,7 @@ export { } from './domain/error/error' export { NonErrorPrefix } from './domain/error/error.types' export { Context, ContextArray, ContextValue } from './tools/serialisation/context' -export { areCookiesAuthorized, getCookie, setCookie, deleteCookie, COOKIE_ACCESS_DELAY } from './browser/cookie' +export { areCookiesAuthorized, getCookie, setCookie, deleteCookie } from './browser/cookie' export { initXhrObservable, XhrCompleteContext, XhrStartContext } from './browser/xhrObservable' export { initFetchObservable, FetchResolveContext, FetchStartContext, FetchContext } from './browser/fetchObservable' export { createPageExitObservable, PageExitEvent, PageExitReason, isPageExitReason } from './browser/pageExitObservable' @@ -102,7 +101,8 @@ export { } from './tools/serialisation/heavyCustomerDataWarning' export { ValueHistory, ValueHistoryEntry, CLEAR_OLD_VALUES_INTERVAL } from './tools/valueHistory' export { readBytesFromStream } from './tools/readBytesFromStream' -export { SESSION_COOKIE_NAME } from './domain/session/sessionCookieStore' +export { STORAGE_POLL_DELAY } from './domain/session/sessionStore' +export { SESSION_STORE_KEY } from './domain/session/storeStrategies/sessionStoreStrategy' export { willSyntheticsInjectRum, getSyntheticsTestId, diff --git a/packages/core/test/emulate/cookie.ts b/packages/core/test/emulate/cookie.ts deleted file mode 100644 index a053a560c8..0000000000 --- a/packages/core/test/emulate/cookie.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function stubCookie() { - let cookie = '' - return { - getSpy: spyOnProperty(Document.prototype, 'cookie', 'get').and.callFake(() => cookie), - setSpy: spyOnProperty(Document.prototype, 'cookie', 'set').and.callFake((newCookie) => { - cookie = newCookie - }), - currentValue: () => cookie, - setCurrentValue: (newCookie: string) => { - cookie = newCookie - }, - } -} diff --git a/packages/core/test/emulate/stubStorages.ts b/packages/core/test/emulate/stubStorages.ts new file mode 100644 index 0000000000..a0e030e3ca --- /dev/null +++ b/packages/core/test/emulate/stubStorages.ts @@ -0,0 +1,33 @@ +export interface StubStorage { + getSpy: jasmine.Spy + setSpy: jasmine.Spy + currentValue: (key: string) => string + setCurrentValue: (key: string, value: string) => void +} + +export const stubCookieProvider = { + get: (): StubStorage => { + let cookie = '' + return { + getSpy: spyOnProperty(Document.prototype, 'cookie', 'get').and.callFake(() => cookie), + setSpy: spyOnProperty(Document.prototype, 'cookie', 'set').and.callFake((newCookie) => (cookie = newCookie)), + currentValue: () => cookie, + setCurrentValue: (key, newCookie: string) => (cookie = `${key}=${newCookie}`), + } + }, +} + +export const stubLocalStorageProvider = { + get: (): StubStorage => { + const store: Record = {} + spyOn(Storage.prototype, 'removeItem').and.callFake((key) => { + delete store[key] + }) + return { + getSpy: spyOn(Storage.prototype, 'getItem').and.callFake((key) => store[key] || null), + setSpy: spyOn(Storage.prototype, 'setItem').and.callFake((key, newValue) => (store[key] = newValue)), + currentValue: (key) => store[key], + setCurrentValue: (key, newValue: string) => (store[key] = newValue), + } + }, +} diff --git a/packages/core/test/index.ts b/packages/core/test/index.ts index 452e6062c6..4e37843f2b 100644 --- a/packages/core/test/index.ts +++ b/packages/core/test/index.ts @@ -14,5 +14,5 @@ export * from './emulate/navigatorOnLine' export * from './emulate/eventBridge' export * from './emulate/eventBridge' export * from './emulate/windowOnError' -export * from './emulate/cookie' +export * from './emulate/stubStorages' export * from './emulate/mockFlushController' diff --git a/packages/logs/src/boot/startLogs.spec.ts b/packages/logs/src/boot/startLogs.spec.ts index 3c0b482007..cca957bc5c 100644 --- a/packages/logs/src/boot/startLogs.spec.ts +++ b/packages/logs/src/boot/startLogs.spec.ts @@ -1,4 +1,4 @@ -import { ErrorSource, display, stopSessionManager, getCookie, SESSION_COOKIE_NAME } from '@datadog/browser-core' +import { ErrorSource, display, stopSessionManager, getCookie, SESSION_STORE_KEY } from '@datadog/browser-core' import type { Request } from '@datadog/browser-core/test' import { interceptRequests, @@ -163,21 +163,21 @@ describe('logs', () => { it('creates a session on normal conditions', () => { ;({ handleLog } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT, logger)) - expect(getCookie(SESSION_COOKIE_NAME)).not.toBeUndefined() + expect(getCookie(SESSION_STORE_KEY)).not.toBeUndefined() }) it('does not create a session if event bridge is present', () => { initEventBridgeStub() ;({ handleLog } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT, logger)) - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() }) it('does not create a session if synthetics worker will inject RUM', () => { mockSyntheticsWorkerValues({ injectsRum: true }) ;({ handleLog } = startLogs(initConfiguration, baseConfiguration, () => COMMON_CONTEXT, logger)) - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() }) }) }) diff --git a/packages/logs/src/boot/startLogs.ts b/packages/logs/src/boot/startLogs.ts index 5a4ef0c402..b436893a80 100644 --- a/packages/logs/src/boot/startLogs.ts +++ b/packages/logs/src/boot/startLogs.ts @@ -4,7 +4,6 @@ import { createPageExitObservable, TelemetryService, willSyntheticsInjectRum, - areCookiesAuthorized, canUseEventBridge, getEventBridge, startTelemetry, @@ -55,7 +54,7 @@ export function startLogs( const pageExitObservable = createPageExitObservable() const session = - areCookiesAuthorized(configuration.cookieOptions) && !canUseEventBridge() && !willSyntheticsInjectRum() + configuration.sessionStoreStrategyType && !canUseEventBridge() && !willSyntheticsInjectRum() ? startLogsSessionManager(configuration) : startLogsSessionManagerStub(configuration) diff --git a/packages/logs/src/domain/logsSessionManager.spec.ts b/packages/logs/src/domain/logsSessionManager.spec.ts index 14d51be950..62f7aea34d 100644 --- a/packages/logs/src/domain/logsSessionManager.spec.ts +++ b/packages/logs/src/domain/logsSessionManager.spec.ts @@ -1,8 +1,8 @@ import type { RelativeTime } from '@datadog/browser-core' import { - COOKIE_ACCESS_DELAY, + STORAGE_POLL_DELAY, + SESSION_STORE_KEY, getCookie, - SESSION_COOKIE_NAME, setCookie, stopSessionManager, ONE_SECOND, @@ -20,7 +20,10 @@ import { describe('logs session manager', () => { const DURATION = 123456 - const configuration: Partial = { sessionSampleRate: 0.5 } + const configuration: Partial = { + sessionSampleRate: 0.5, + sessionStoreStrategyType: { type: 'Cookie', cookieOptions: {} }, + } let clock: Clock let tracked: boolean @@ -43,8 +46,8 @@ describe('logs session manager', () => { startLogsSessionManager(configuration as LogsConfiguration) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/id=[a-f0-9-]+/) + expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]+/) }) it('when not tracked should store tracking type', () => { @@ -52,67 +55,67 @@ describe('logs session manager', () => { startLogsSessionManager(configuration as LogsConfiguration) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.NOT_TRACKED}`) - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain('id=') + expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.NOT_TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).not.toContain('id=') }) it('when tracked should keep existing tracking type and session id', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&logs=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) startLogsSessionManager(configuration as LogsConfiguration) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) - expect(getCookie(SESSION_COOKIE_NAME)).toContain('id=abcdef') + expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).toContain('id=abcdef') }) it('when not tracked should keep existing tracking type', () => { - setCookie(SESSION_COOKIE_NAME, 'logs=0', DURATION) + setCookie(SESSION_STORE_KEY, 'logs=0', DURATION) startLogsSessionManager(configuration as LogsConfiguration) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.NOT_TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.NOT_TRACKED}`) }) it('should renew on activity after expiration', () => { startLogsSessionManager(configuration as LogsConfiguration) - setCookie(SESSION_COOKIE_NAME, '', DURATION) - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() - clock.tick(COOKIE_ACCESS_DELAY) + setCookie(SESSION_STORE_KEY, '', DURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() + clock.tick(STORAGE_POLL_DELAY) tracked = true document.body.click() - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/id=[a-f0-9-]+/) - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]+/) + expect(getCookie(SESSION_STORE_KEY)).toContain(`${LOGS_SESSION_KEY}=${LoggerTrackingType.TRACKED}`) }) describe('findSession', () => { it('should return the current session', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&logs=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) expect(logsSessionManager.findTrackedSession()!.id).toBe('abcdef') }) it('should return undefined if the session is not tracked', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&logs=0', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=0', DURATION) const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) expect(logsSessionManager.findTrackedSession()).toBe(undefined) }) it('should return undefined if the session has expired', () => { const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) - setCookie(SESSION_COOKIE_NAME, '', DURATION) - clock.tick(COOKIE_ACCESS_DELAY) + setCookie(SESSION_STORE_KEY, '', DURATION) + clock.tick(STORAGE_POLL_DELAY) expect(logsSessionManager.findTrackedSession()).toBe(undefined) }) it('should return session corresponding to start time', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&logs=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&logs=1', DURATION) const logsSessionManager = startLogsSessionManager(configuration as LogsConfiguration) clock.tick(10 * ONE_SECOND) - setCookie(SESSION_COOKIE_NAME, '', DURATION) - clock.tick(COOKIE_ACCESS_DELAY) + setCookie(SESSION_STORE_KEY, '', DURATION) + clock.tick(STORAGE_POLL_DELAY) expect(logsSessionManager.findTrackedSession()).toBeUndefined() expect(logsSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('abcdef') }) diff --git a/packages/logs/src/domain/logsSessionManager.ts b/packages/logs/src/domain/logsSessionManager.ts index 9293e46f26..26fe80a6b0 100644 --- a/packages/logs/src/domain/logsSessionManager.ts +++ b/packages/logs/src/domain/logsSessionManager.ts @@ -19,8 +19,11 @@ export const enum LoggerTrackingType { } export function startLogsSessionManager(configuration: LogsConfiguration): LogsSessionManager { - const sessionManager = startSessionManager(configuration.cookieOptions, LOGS_SESSION_KEY, (rawTrackingType) => - computeSessionState(configuration, rawTrackingType) + const sessionManager = startSessionManager( + // TODO - Improve configuration type and remove assertion + configuration.sessionStoreStrategyType!, + LOGS_SESSION_KEY, + (rawTrackingType) => computeSessionState(configuration, rawTrackingType) ) return { findTrackedSession: (startTime) => { diff --git a/packages/rum-core/src/boot/rumPublicApi.spec.ts b/packages/rum-core/src/boot/rumPublicApi.spec.ts index 455f13fdd6..4cb83c8e88 100644 --- a/packages/rum-core/src/boot/rumPublicApi.spec.ts +++ b/packages/rum-core/src/boot/rumPublicApi.spec.ts @@ -96,6 +96,13 @@ describe('rum public api', () => { rumPublicApi.init(invalidConfiguration as RumInitConfiguration) expect(rumPublicApi.getInitConfiguration()?.sessionSampleRate).toEqual(100) }) + + it('should initialize even if session cannot be handled', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + const rumPublicApi = makeRumPublicApi(startRumSpy, noopRecorderApi, {}) + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + expect(startRumSpy).toHaveBeenCalled() + }) }) }) @@ -110,6 +117,15 @@ describe('rum public api', () => { cleanupSyntheticsWorkerValues() }) + it('should not initialize if session cannot be handled and bridge is not present', () => { + spyOnProperty(document, 'cookie', 'get').and.returnValue('') + const displaySpy = spyOn(display, 'warn') + const rumPublicApi = makeRumPublicApi(startRumSpy, noopRecorderApi, {}) + rumPublicApi.init(DEFAULT_INIT_CONFIGURATION) + expect(startRumSpy).not.toHaveBeenCalled() + expect(displaySpy).toHaveBeenCalled() + }) + describe('skipInitIfSyntheticsWillInjectRum option', () => { it('when true, ignores init() call if Synthetics will inject its own instance of RUM', () => { mockSyntheticsWorkerValues({ injectsRum: true }) diff --git a/packages/rum-core/src/boot/rumPublicApi.ts b/packages/rum-core/src/boot/rumPublicApi.ts index 584e8341fb..5727916997 100644 --- a/packages/rum-core/src/boot/rumPublicApi.ts +++ b/packages/rum-core/src/boot/rumPublicApi.ts @@ -5,7 +5,6 @@ import { willSyntheticsInjectRum, assign, BoundedBuffer, - buildCookieOptions, createContextManager, deepClone, makePublicApi, @@ -16,7 +15,6 @@ import { callMonitored, createHandlingStack, canUseEventBridge, - areCookiesAuthorized, checkUser, sanitizeUser, sanitize, @@ -110,10 +108,9 @@ export function makeRumPublicApi( return } - if (canUseEventBridge()) { + const eventBridgeAvailable = canUseEventBridge() + if (eventBridgeAvailable) { initConfiguration = overrideInitConfigurationForBridge(initConfiguration) - } else if (!canHandleSession(initConfiguration)) { - return } if (!canInitRum(initConfiguration)) { @@ -125,6 +122,11 @@ export function makeRumPublicApi( return } + if (!eventBridgeAvailable && !configuration.sessionStoreStrategyType) { + display.warn('No storage available for session. We will not send any data.') + return + } + if (!configuration.trackViewsManually) { doStartRum(initConfiguration, configuration) } else { @@ -273,19 +275,6 @@ export function makeRumPublicApi( return rumPublicApi - function canHandleSession(initConfiguration: RumInitConfiguration): boolean { - if (!areCookiesAuthorized(buildCookieOptions(initConfiguration))) { - display.warn('Cookies are not authorized, we will not send any data.') - return false - } - - if (isLocalFile()) { - display.error('Execution is not allowed in the current context.') - return false - } - return true - } - function canInitRum(initConfiguration: RumInitConfiguration) { if (isAlreadyInitialized) { if (!initConfiguration.silentMultipleInit) { @@ -303,8 +292,4 @@ export function makeRumPublicApi( sessionSampleRate: 100, }) } - - function isLocalFile() { - return window.location.protocol === 'file:' - } } diff --git a/packages/rum-core/src/domain/rumSessionManager.spec.ts b/packages/rum-core/src/domain/rumSessionManager.spec.ts index c59eccbb84..7199060a2e 100644 --- a/packages/rum-core/src/domain/rumSessionManager.spec.ts +++ b/packages/rum-core/src/domain/rumSessionManager.spec.ts @@ -1,9 +1,9 @@ import type { RelativeTime } from '@datadog/browser-core' import { - COOKIE_ACCESS_DELAY, + STORAGE_POLL_DELAY, + SESSION_STORE_KEY, getCookie, isIE, - SESSION_COOKIE_NAME, setCookie, stopSessionManager, ONE_SECOND, @@ -70,10 +70,10 @@ describe('rum session manager', () => { expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getCookie(SESSION_COOKIE_NAME)).toContain( + expect(getCookie(SESSION_STORE_KEY)).toContain( `${RUM_SESSION_KEY}=${RumTrackingType.TRACKED_WITH_SESSION_REPLAY}` ) - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/id=[a-f0-9-]/) + expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]/) }) it('when tracked without session replay should store session type and id', () => { @@ -83,10 +83,10 @@ describe('rum session manager', () => { expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getCookie(SESSION_COOKIE_NAME)).toContain( + expect(getCookie(SESSION_STORE_KEY)).toContain( `${RUM_SESSION_KEY}=${RumTrackingType.TRACKED_WITHOUT_SESSION_REPLAY}` ) - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/id=[a-f0-9-]/) + expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]/) }) it('when not tracked should store session type', () => { @@ -96,93 +96,93 @@ describe('rum session manager', () => { expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${RUM_SESSION_KEY}=${RumTrackingType.NOT_TRACKED}`) - expect(getCookie(SESSION_COOKIE_NAME)).not.toContain('id=') + expect(getCookie(SESSION_STORE_KEY)).toContain(`${RUM_SESSION_KEY}=${RumTrackingType.NOT_TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).not.toContain('id=') }) it('when tracked should keep existing session type and id', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) startRumSessionManager(configuration, lifeCycle) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getCookie(SESSION_COOKIE_NAME)).toContain( + expect(getCookie(SESSION_STORE_KEY)).toContain( `${RUM_SESSION_KEY}=${RumTrackingType.TRACKED_WITH_SESSION_REPLAY}` ) - expect(getCookie(SESSION_COOKIE_NAME)).toContain('id=abcdef') + expect(getCookie(SESSION_STORE_KEY)).toContain('id=abcdef') }) it('when not tracked should keep existing session type', () => { - setCookie(SESSION_COOKIE_NAME, 'rum=0', DURATION) + setCookie(SESSION_STORE_KEY, 'rum=0', DURATION) startRumSessionManager(configuration, lifeCycle) expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - expect(getCookie(SESSION_COOKIE_NAME)).toContain(`${RUM_SESSION_KEY}=${RumTrackingType.NOT_TRACKED}`) + expect(getCookie(SESSION_STORE_KEY)).toContain(`${RUM_SESSION_KEY}=${RumTrackingType.NOT_TRACKED}`) }) it('should renew on activity after expiration', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) startRumSessionManager(configuration, lifeCycle) - setCookie(SESSION_COOKIE_NAME, '', DURATION) - expect(getCookie(SESSION_COOKIE_NAME)).toBeUndefined() + setCookie(SESSION_STORE_KEY, '', DURATION) + expect(getCookie(SESSION_STORE_KEY)).toBeUndefined() expect(expireSessionSpy).not.toHaveBeenCalled() expect(renewSessionSpy).not.toHaveBeenCalled() - clock.tick(COOKIE_ACCESS_DELAY) + clock.tick(STORAGE_POLL_DELAY) setupDraws({ tracked: true, trackedWithSessionReplay: true }) document.dispatchEvent(new CustomEvent('click')) expect(expireSessionSpy).toHaveBeenCalled() expect(renewSessionSpy).toHaveBeenCalled() - expect(getCookie(SESSION_COOKIE_NAME)).toContain( + expect(getCookie(SESSION_STORE_KEY)).toContain( `${RUM_SESSION_KEY}=${RumTrackingType.TRACKED_WITH_SESSION_REPLAY}` ) - expect(getCookie(SESSION_COOKIE_NAME)).toMatch(/id=[a-f0-9-]/) + expect(getCookie(SESSION_STORE_KEY)).toMatch(/id=[a-f0-9-]/) }) }) describe('findSession', () => { it('should return the current session', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) const rumSessionManager = startRumSessionManager(configuration, lifeCycle) expect(rumSessionManager.findTrackedSession()!.id).toBe('abcdef') }) it('should return undefined if the session is not tracked', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=0', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=0', DURATION) const rumSessionManager = startRumSessionManager(configuration, lifeCycle) expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) it('should return undefined if the session has expired', () => { const rumSessionManager = startRumSessionManager(configuration, lifeCycle) - setCookie(SESSION_COOKIE_NAME, '', DURATION) - clock.tick(COOKIE_ACCESS_DELAY) + setCookie(SESSION_STORE_KEY, '', DURATION) + clock.tick(STORAGE_POLL_DELAY) expect(rumSessionManager.findTrackedSession()).toBe(undefined) }) it('should return session corresponding to start time', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) const rumSessionManager = startRumSessionManager(configuration, lifeCycle) clock.tick(10 * ONE_SECOND) - setCookie(SESSION_COOKIE_NAME, '', DURATION) - clock.tick(COOKIE_ACCESS_DELAY) + setCookie(SESSION_STORE_KEY, '', DURATION) + clock.tick(STORAGE_POLL_DELAY) expect(rumSessionManager.findTrackedSession()).toBeUndefined() expect(rumSessionManager.findTrackedSession(0 as RelativeTime)!.id).toBe('abcdef') }) it('should return session with plan WITH_SESSION_REPLAY', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=1', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=1', DURATION) const rumSessionManager = startRumSessionManager(configuration, lifeCycle) expect(rumSessionManager.findTrackedSession()!.plan).toBe(RumSessionPlan.WITH_SESSION_REPLAY) }) it('should return session with plan WITHOUT_SESSION_REPLAY', () => { - setCookie(SESSION_COOKIE_NAME, 'id=abcdef&rum=2', DURATION) + setCookie(SESSION_STORE_KEY, 'id=abcdef&rum=2', DURATION) const rumSessionManager = startRumSessionManager(configuration, lifeCycle) expect(rumSessionManager.findTrackedSession()!.plan).toBe(RumSessionPlan.WITHOUT_SESSION_REPLAY) }) diff --git a/packages/rum-core/src/domain/rumSessionManager.ts b/packages/rum-core/src/domain/rumSessionManager.ts index e78c294833..b232475695 100644 --- a/packages/rum-core/src/domain/rumSessionManager.ts +++ b/packages/rum-core/src/domain/rumSessionManager.ts @@ -35,8 +35,11 @@ export const enum RumTrackingType { } export function startRumSessionManager(configuration: RumConfiguration, lifeCycle: LifeCycle): RumSessionManager { - const sessionManager = startSessionManager(configuration.cookieOptions, RUM_SESSION_KEY, (rawTrackingType) => - computeSessionState(configuration, rawTrackingType) + const sessionManager = startSessionManager( + // TODO - Improve configuration type and remove assertion + configuration.sessionStoreStrategyType!, + RUM_SESSION_KEY, + (rawTrackingType) => computeSessionState(configuration, rawTrackingType) ) sessionManager.expireObservable.subscribe(() => { diff --git a/test/e2e/lib/helpers/session.ts b/test/e2e/lib/helpers/session.ts index 499f551177..36a4efbe1b 100644 --- a/test/e2e/lib/helpers/session.ts +++ b/test/e2e/lib/helpers/session.ts @@ -1,4 +1,4 @@ -import { SESSION_COOKIE_NAME } from '@datadog/browser-core' +import { SESSION_STORE_KEY } from '@datadog/browser-core' import { deleteAllCookies } from './browser' export async function renewSession() { @@ -16,7 +16,7 @@ export async function expireSession() { } export async function findSessionCookie() { - const cookies = await browser.getCookies(SESSION_COOKIE_NAME) + const cookies = await browser.getCookies(SESSION_STORE_KEY) // In some case, the session cookie is returned but with an empty value. Let's consider it expired // in this case. return cookies[0]?.value || undefined diff --git a/test/e2e/scenario/sessionStore.scenario.ts b/test/e2e/scenario/sessionStore.scenario.ts new file mode 100644 index 0000000000..9a9187b732 --- /dev/null +++ b/test/e2e/scenario/sessionStore.scenario.ts @@ -0,0 +1,59 @@ +import { SESSION_STORE_KEY } from '@datadog/browser-core' +import { createTest } from '../lib/framework' + +describe('Session Stores', () => { + describe('Cookies', () => { + createTest('Cookie Initialization') + .withLogs() + .withRum() + .run(async () => { + const [cookie] = await browser.getCookies([SESSION_STORE_KEY]) + const cookieSessionId = cookie.value.match(/id=([\w-]+)/)![1] + + const logsContext = await browser.execute(() => window.DD_LOGS?.getInternalContext()) + const rumContext = await browser.execute(() => window.DD_RUM?.getInternalContext()) + + expect(logsContext?.session_id).toBe(cookieSessionId) + expect(rumContext?.session_id).toBe(cookieSessionId) + }) + }) + + describe('Local Storage', () => { + createTest('Local Storage Initialization') + .withLogs({ allowFallbackToLocalStorage: true }) + .withRum({ allowFallbackToLocalStorage: true }) + // This will force the SDKs to initialize using local storage + .withHead('') + .run(async () => { + const sessionStateString = await browser.execute((key) => window.localStorage.getItem(key), SESSION_STORE_KEY) + const sessionId = sessionStateString?.match(/id=([\w-]+)/)![1] + + const logsContext = await browser.execute(() => window.DD_LOGS?.getInternalContext()) + const rumContext = await browser.execute(() => window.DD_RUM?.getInternalContext()) + + expect(logsContext?.session_id).toBe(sessionId) + expect(rumContext?.session_id).toBe(sessionId) + }) + }) + + describe('No storage available', () => { + createTest('RUM should fail init / Logs should succeed') + .withLogs({ allowFallbackToLocalStorage: true }) + .withRum({ allowFallbackToLocalStorage: true }) + // This will ensure no storage is available + .withHead( + ` + ` + ) + .run(async () => { + const logsContext = await browser.execute(() => window.DD_LOGS?.getInternalContext()) + const rumContext = await browser.execute(() => window.DD_RUM?.getInternalContext()) + + expect(logsContext).not.toBeNull() + expect(rumContext).toBeNull() + }) + }) +})