| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450 |
- // @vitest-environment jsdom
- import type { UserDecision } from '@usercentrics/cmp-browser-sdk'
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { applyPriorDecisionToSDK, consentState, detectPriorConsent } from './consent-state'
- // jsdom's localStorage can be flaky in vitest, so ensure it's available
- const storage = new Map<string, string>()
- const mockLocalStorage = {
- getItem: (key: string) => storage.get(key) ?? null,
- setItem: (key: string, value: string) => storage.set(key, value),
- removeItem: (key: string) => storage.delete(key),
- clear: () => storage.clear(),
- get length() {
- return storage.size
- },
- key: (index: number) => Array.from(storage.keys())[index] ?? null,
- }
- beforeEach(() => {
- storage.clear()
- Object.defineProperty(globalThis, 'localStorage', {
- value: mockLocalStorage,
- writable: true,
- configurable: true,
- })
- })
- afterEach(() => {
- storage.clear()
- })
- describe('detectPriorConsent', () => {
- describe('scenario 1: GTM compressed format (ucData)', () => {
- it('returns uniform-accept when every service has consent: true', () => {
- storage.set(
- 'ucData',
- JSON.stringify({
- gcm: { adStorage: 'granted', analyticsStorage: 'granted' },
- consent: {
- services: {
- svc1: { name: 'Google Analytics', consent: true },
- svc2: { name: 'Stripe', consent: true },
- svc3: { name: 'Sentry', consent: true },
- },
- },
- })
- )
- expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' })
- })
- // Real customer data shape from GROWTH-790: essentials and functional
- // services accepted, marketing/tracking services denied. This is what
- // Opt out or a Privacy Settings toggle of the Marketing category produces
- // in production — essentials are locked on and cannot actually be denied.
- it('returns per-service decisions when ucData has a mixed consent state (GROWTH-790)', () => {
- storage.set(
- 'ucData',
- JSON.stringify({
- gcm: {
- adsDataRedaction: true,
- adStorage: 'denied',
- adPersonalization: 'denied',
- adUserData: 'denied',
- analyticsStorage: 'denied',
- },
- consent: {
- services: {
- J39GyuWQq: { name: 'Amazon Web Services', consent: true },
- HkIVcNiuoZX: { name: 'Cloudflare', consent: true },
- H1PKqNodoWQ: { name: 'Google AJAX', consent: true },
- HkPBYFofN: { name: 'Google Fonts', consent: true },
- QjO6LaiOd: { name: 'hCaptcha', consent: true },
- 'F-REmjGq7': { name: 'JSDelivr', consent: true },
- Hko_qNsui_Q: { name: 'reCAPTCHA', consent: true },
- rH1vNPCFR: { name: 'Sentry', consent: true },
- ry3w9Vo_oZ7: { name: 'Stripe', consent: true },
- BJTzqNi_i_m: { name: 'Twitter Plugin', consent: true },
- HLap0udLC: { name: 'Twitter Syndication', consent: true },
- H1Vl5NidjWX: { name: 'Usercentrics Consent Management Platform', consent: true },
- BJz7qNsdj_7: { name: 'YouTube Video', consent: true },
- S1_9Vsuj_Q: { name: 'Google Ads', consent: false },
- HkocEodjb7: { name: 'Google Analytics', consent: false },
- BJ59EidsWQ: { name: 'Google Tag Manager', consent: false },
- nV4hGUA8RQK5Ez: { name: 'Briven Event Tracking', consent: false },
- },
- },
- })
- )
- const result = detectPriorConsent()
- if (result === null || result.kind !== 'decisions') {
- throw new Error(`expected decisions result, got ${JSON.stringify(result)}`)
- }
- expect(result.decisions).toHaveLength(17)
- // Essentials / functional services preserved as accepted
- expect(result.decisions).toContainEqual({ serviceId: 'J39GyuWQq', status: true })
- expect(result.decisions).toContainEqual({ serviceId: 'rH1vNPCFR', status: true })
- // Tracking services preserved as denied
- expect(result.decisions).toContainEqual({ serviceId: 'S1_9Vsuj_Q', status: false })
- expect(result.decisions).toContainEqual({ serviceId: 'HkocEodjb7', status: false })
- expect(result.decisions).toContainEqual({ serviceId: 'BJ59EidsWQ', status: false })
- expect(result.decisions).toContainEqual({ serviceId: 'nV4hGUA8RQK5Ez', status: false })
- })
- it('returns per-service decisions (all false) when every service was denied', () => {
- storage.set(
- 'ucData',
- JSON.stringify({
- consent: {
- services: {
- svc1: { name: 'Google Analytics', consent: false },
- svc2: { name: 'Stripe', consent: false },
- },
- },
- })
- )
- expect(detectPriorConsent()).toEqual({
- kind: 'decisions',
- decisions: [
- { serviceId: 'svc1', status: false },
- { serviceId: 'svc2', status: false },
- ],
- })
- })
- it('returns null when services object is empty', () => {
- storage.set('ucData', JSON.stringify({ consent: { services: {} } }))
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when ucData is malformed JSON', () => {
- storage.set('ucData', 'not-json')
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when ucData has no consent.services', () => {
- storage.set('ucData', JSON.stringify({ gcm: {} }))
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when every service value is non-object', () => {
- storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'not-an-object' } } }))
- expect(detectPriorConsent()).toBeNull()
- })
- // Partial parse failures must fail closed. Cherry-picking the valid
- // subset and calling it uniform-accept would let corrupted third-party
- // storage silently upgrade a user's consent — the worst-direction bias
- // in this domain.
- it('returns null when any entry fails schema (fails closed on partial corruption)', () => {
- storage.set(
- 'ucData',
- JSON.stringify({
- consent: {
- services: {
- svc1: 'not-an-object',
- svc2: { name: 'Valid', consent: true },
- },
- },
- })
- )
- expect(detectPriorConsent()).toBeNull()
- })
- })
- describe('scenario 2: fast cross-app navigation (uc_settings gated by uc_user_interaction)', () => {
- const buildUcSettings = (services: Array<{ id: string; status: boolean }>) =>
- JSON.stringify({
- controllerId: 'test-controller',
- id: 'test-settings',
- language: 'en',
- services: services.map((s) => ({
- id: s.id,
- status: s.status,
- processorId: 'test-processor',
- history: [],
- version: '1.0.0',
- })),
- version: '1.0.0',
- })
- it('returns uniform-accept when uc_settings has every service accepted', () => {
- storage.set('uc_user_interaction', 'true')
- storage.set(
- 'uc_settings',
- buildUcSettings([
- { id: 'svc1', status: true },
- { id: 'svc2', status: true },
- ])
- )
- expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' })
- })
- it('returns per-service decisions when uc_settings has a mixed state', () => {
- storage.set('uc_user_interaction', 'true')
- storage.set(
- 'uc_settings',
- buildUcSettings([
- { id: 'essential1', status: true },
- { id: 'tracking1', status: false },
- { id: 'tracking2', status: false },
- ])
- )
- const result = detectPriorConsent()
- if (result === null || result.kind !== 'decisions') {
- throw new Error(`expected decisions result, got ${JSON.stringify(result)}`)
- }
- expect(result.decisions).toHaveLength(3)
- expect(result.decisions).toContainEqual({ serviceId: 'essential1', status: true })
- expect(result.decisions).toContainEqual({ serviceId: 'tracking1', status: false })
- expect(result.decisions).toContainEqual({ serviceId: 'tracking2', status: false })
- })
- it('returns null when uc_user_interaction is "true" but uc_settings is absent', () => {
- storage.set('uc_user_interaction', 'true')
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when uc_settings is malformed JSON', () => {
- storage.set('uc_user_interaction', 'true')
- storage.set('uc_settings', 'not-json')
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when uc_settings services array is empty', () => {
- storage.set('uc_user_interaction', 'true')
- storage.set('uc_settings', buildUcSettings([]))
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when any uc_settings service entry fails schema', () => {
- storage.set('uc_user_interaction', 'true')
- storage.set(
- 'uc_settings',
- JSON.stringify({
- services: [
- { id: 'svc1', status: true, processorId: 'p', history: [], version: '1' },
- { id: 'svc2', status: 'not-a-boolean', processorId: 'p', history: [], version: '1' },
- ],
- })
- )
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when uc_user_interaction is "false" (even if uc_settings is valid)', () => {
- storage.set('uc_user_interaction', 'false')
- storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }]))
- expect(detectPriorConsent()).toBeNull()
- })
- it('returns null when uc_user_interaction is absent', () => {
- storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }]))
- expect(detectPriorConsent()).toBeNull()
- })
- })
- describe('combined scenarios', () => {
- it('returns uniform-accept when ucData is fully accepted (ignoring uc_user_interaction/uc_settings)', () => {
- storage.set(
- 'ucData',
- JSON.stringify({
- consent: { services: { svc1: { name: 'GA', consent: true } } },
- })
- )
- storage.set('uc_user_interaction', 'true')
- expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' })
- })
- it('prefers ucData decisions over uc_settings when both exist', () => {
- storage.set(
- 'ucData',
- JSON.stringify({
- consent: { services: { svc1: { name: 'GA', consent: false } } },
- })
- )
- storage.set('uc_user_interaction', 'true')
- storage.set(
- 'uc_settings',
- JSON.stringify({
- services: [{ id: 'svc1', status: true, processorId: 'p', history: [], version: '1' }],
- })
- )
- expect(detectPriorConsent()).toEqual({
- kind: 'decisions',
- decisions: [{ serviceId: 'svc1', status: false }],
- })
- })
- it('falls back to uc_settings when ucData is corrupt (scenario 1 fails closed, scenario 2 recovers)', () => {
- storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'corrupt' } } }))
- storage.set('uc_user_interaction', 'true')
- storage.set(
- 'uc_settings',
- JSON.stringify({
- services: [{ id: 'svc1', status: false, processorId: 'p', history: [], version: '1' }],
- })
- )
- expect(detectPriorConsent()).toEqual({
- kind: 'decisions',
- decisions: [{ serviceId: 'svc1', status: false }],
- })
- })
- it('returns null when localStorage is completely empty', () => {
- expect(detectPriorConsent()).toBeNull()
- })
- })
- })
- describe('applyPriorDecisionToSDK', () => {
- type MockService = { id: string; isEssential: boolean }
- const makeMockUC = (
- opts: {
- services?: MockService[]
- areAllAccepted?: boolean
- onAcceptAll?: () => Promise<void>
- onUpdateServices?: (decisions: UserDecision[]) => Promise<void>
- } = {}
- ) => {
- const services = opts.services ?? []
- return {
- acceptAllServices: vi.fn(opts.onAcceptAll ?? (() => Promise.resolve())),
- denyAllServices: vi.fn(() => Promise.resolve()),
- updateServices: vi.fn(opts.onUpdateServices ?? (() => Promise.resolve())),
- getCategoriesBaseInfo: vi.fn(() => []),
- getServicesBaseInfo: vi.fn(() => services),
- areAllConsentsAccepted: vi.fn(() => opts.areAllAccepted ?? false),
- }
- }
- beforeEach(() => {
- consentState.UC = null
- consentState.categories = null
- consentState.showConsentToast = false
- consentState.hasConsented = false
- })
- it('uniform-accept: calls acceptAllServices, sets hasConsented, suppresses banner', () => {
- const UC = makeMockUC()
- applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'uniform-accept' })
- expect(UC.acceptAllServices).toHaveBeenCalledOnce()
- expect(UC.updateServices).not.toHaveBeenCalled()
- expect(consentState.hasConsented).toBe(true)
- expect(consentState.showConsentToast).toBe(false)
- })
- it('decisions with full coverage: calls updateServices with stored decisions, suppresses banner', () => {
- const UC = makeMockUC({
- services: [
- { id: 'essential1', isEssential: true },
- { id: 'tracking1', isEssential: false },
- { id: 'tracking2', isEssential: false },
- ],
- })
- const decisions: UserDecision[] = [
- { serviceId: 'tracking1', status: true },
- { serviceId: 'tracking2', status: false },
- ]
- applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'decisions', decisions })
- expect(UC.updateServices).toHaveBeenCalledWith(decisions)
- expect(UC.acceptAllServices).not.toHaveBeenCalled()
- expect(consentState.showConsentToast).toBe(false)
- })
- // GROWTH-790 compliance guard: when the ruleset adds a new non-essential
- // service after the user's stored decisions were written, we must NOT
- // silently restore — the user has never seen this service and can't have
- // consented to it. Show the banner instead.
- it('decisions with uncovered non-essential service: shows banner, does not mutate SDK', () => {
- const UC = makeMockUC({
- services: [
- { id: 'tracking1', isEssential: false },
- { id: 'tracking_new', isEssential: false }, // in ruleset, not in decisions
- ],
- })
- applyPriorDecisionToSDK(
- UC as never,
- { initialLayer: 0 },
- { kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] }
- )
- expect(UC.updateServices).not.toHaveBeenCalled()
- expect(UC.acceptAllServices).not.toHaveBeenCalled()
- expect(consentState.showConsentToast).toBe(true)
- })
- // Essentials are SDK-forced-on regardless of user decision, so a new
- // essential service appearing since the stored decisions were written
- // should not trigger a re-prompt — the user has nothing meaningful to
- // decide about it.
- it('decisions covering only non-essentials: still restores (essentials ignored in coverage check)', () => {
- const UC = makeMockUC({
- services: [
- { id: 'essential_new', isEssential: true }, // not in decisions, but essential
- { id: 'tracking1', isEssential: false },
- ],
- })
- applyPriorDecisionToSDK(
- UC as never,
- { initialLayer: 0 },
- { kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] }
- )
- expect(UC.updateServices).toHaveBeenCalledOnce()
- expect(consentState.showConsentToast).toBe(false)
- })
- it('null prior decision: shows banner at default', () => {
- const UC = makeMockUC()
- applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, null)
- expect(UC.updateServices).not.toHaveBeenCalled()
- expect(UC.acceptAllServices).not.toHaveBeenCalled()
- expect(consentState.showConsentToast).toBe(true)
- })
- it('SDK not requesting first-layer banner: no restore attempted even if priorDecision exists', () => {
- const UC = makeMockUC({ areAllAccepted: true })
- applyPriorDecisionToSDK(UC as never, { initialLayer: 1 }, { kind: 'uniform-accept' })
- expect(UC.acceptAllServices).not.toHaveBeenCalled()
- expect(UC.updateServices).not.toHaveBeenCalled()
- expect(consentState.hasConsented).toBe(true)
- expect(consentState.showConsentToast).toBe(false)
- })
- it('SDK already considers user consented: passes through, no restore', () => {
- const UC = makeMockUC({ areAllAccepted: true })
- applyPriorDecisionToSDK(
- UC as never,
- { initialLayer: 0 },
- {
- kind: 'decisions',
- decisions: [{ serviceId: 'svc1', status: false }],
- }
- )
- expect(UC.updateServices).not.toHaveBeenCalled()
- expect(consentState.hasConsented).toBe(true)
- })
- })
|