consent-state.test.ts450 lines · main
| 1 | // @vitest-environment jsdom |
| 2 | import type { UserDecision } from '@usercentrics/cmp-browser-sdk' |
| 3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' |
| 4 | |
| 5 | import { applyPriorDecisionToSDK, consentState, detectPriorConsent } from './consent-state' |
| 6 | |
| 7 | // jsdom's localStorage can be flaky in vitest, so ensure it's available |
| 8 | const storage = new Map<string, string>() |
| 9 | const mockLocalStorage = { |
| 10 | getItem: (key: string) => storage.get(key) ?? null, |
| 11 | setItem: (key: string, value: string) => storage.set(key, value), |
| 12 | removeItem: (key: string) => storage.delete(key), |
| 13 | clear: () => storage.clear(), |
| 14 | get length() { |
| 15 | return storage.size |
| 16 | }, |
| 17 | key: (index: number) => Array.from(storage.keys())[index] ?? null, |
| 18 | } |
| 19 | |
| 20 | beforeEach(() => { |
| 21 | storage.clear() |
| 22 | Object.defineProperty(globalThis, 'localStorage', { |
| 23 | value: mockLocalStorage, |
| 24 | writable: true, |
| 25 | configurable: true, |
| 26 | }) |
| 27 | }) |
| 28 | |
| 29 | afterEach(() => { |
| 30 | storage.clear() |
| 31 | }) |
| 32 | |
| 33 | describe('detectPriorConsent', () => { |
| 34 | describe('scenario 1: GTM compressed format (ucData)', () => { |
| 35 | it('returns uniform-accept when every service has consent: true', () => { |
| 36 | storage.set( |
| 37 | 'ucData', |
| 38 | JSON.stringify({ |
| 39 | gcm: { adStorage: 'granted', analyticsStorage: 'granted' }, |
| 40 | consent: { |
| 41 | services: { |
| 42 | svc1: { name: 'Google Analytics', consent: true }, |
| 43 | svc2: { name: 'Stripe', consent: true }, |
| 44 | svc3: { name: 'Sentry', consent: true }, |
| 45 | }, |
| 46 | }, |
| 47 | }) |
| 48 | ) |
| 49 | |
| 50 | expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' }) |
| 51 | }) |
| 52 | |
| 53 | // Real customer data shape from GROWTH-790: essentials and functional |
| 54 | // services accepted, marketing/tracking services denied. This is what |
| 55 | // Opt out or a Privacy Settings toggle of the Marketing category produces |
| 56 | // in production — essentials are locked on and cannot actually be denied. |
| 57 | it('returns per-service decisions when ucData has a mixed consent state (GROWTH-790)', () => { |
| 58 | storage.set( |
| 59 | 'ucData', |
| 60 | JSON.stringify({ |
| 61 | gcm: { |
| 62 | adsDataRedaction: true, |
| 63 | adStorage: 'denied', |
| 64 | adPersonalization: 'denied', |
| 65 | adUserData: 'denied', |
| 66 | analyticsStorage: 'denied', |
| 67 | }, |
| 68 | consent: { |
| 69 | services: { |
| 70 | J39GyuWQq: { name: 'Amazon Web Services', consent: true }, |
| 71 | HkIVcNiuoZX: { name: 'Cloudflare', consent: true }, |
| 72 | H1PKqNodoWQ: { name: 'Google AJAX', consent: true }, |
| 73 | HkPBYFofN: { name: 'Google Fonts', consent: true }, |
| 74 | QjO6LaiOd: { name: 'hCaptcha', consent: true }, |
| 75 | 'F-REmjGq7': { name: 'JSDelivr', consent: true }, |
| 76 | Hko_qNsui_Q: { name: 'reCAPTCHA', consent: true }, |
| 77 | rH1vNPCFR: { name: 'Sentry', consent: true }, |
| 78 | ry3w9Vo_oZ7: { name: 'Stripe', consent: true }, |
| 79 | BJTzqNi_i_m: { name: 'Twitter Plugin', consent: true }, |
| 80 | HLap0udLC: { name: 'Twitter Syndication', consent: true }, |
| 81 | H1Vl5NidjWX: { name: 'Usercentrics Consent Management Platform', consent: true }, |
| 82 | BJz7qNsdj_7: { name: 'YouTube Video', consent: true }, |
| 83 | S1_9Vsuj_Q: { name: 'Google Ads', consent: false }, |
| 84 | HkocEodjb7: { name: 'Google Analytics', consent: false }, |
| 85 | BJ59EidsWQ: { name: 'Google Tag Manager', consent: false }, |
| 86 | nV4hGUA8RQK5Ez: { name: 'Briven Event Tracking', consent: false }, |
| 87 | }, |
| 88 | }, |
| 89 | }) |
| 90 | ) |
| 91 | |
| 92 | const result = detectPriorConsent() |
| 93 | if (result === null || result.kind !== 'decisions') { |
| 94 | throw new Error(`expected decisions result, got ${JSON.stringify(result)}`) |
| 95 | } |
| 96 | expect(result.decisions).toHaveLength(17) |
| 97 | // Essentials / functional services preserved as accepted |
| 98 | expect(result.decisions).toContainEqual({ serviceId: 'J39GyuWQq', status: true }) |
| 99 | expect(result.decisions).toContainEqual({ serviceId: 'rH1vNPCFR', status: true }) |
| 100 | // Tracking services preserved as denied |
| 101 | expect(result.decisions).toContainEqual({ serviceId: 'S1_9Vsuj_Q', status: false }) |
| 102 | expect(result.decisions).toContainEqual({ serviceId: 'HkocEodjb7', status: false }) |
| 103 | expect(result.decisions).toContainEqual({ serviceId: 'BJ59EidsWQ', status: false }) |
| 104 | expect(result.decisions).toContainEqual({ serviceId: 'nV4hGUA8RQK5Ez', status: false }) |
| 105 | }) |
| 106 | |
| 107 | it('returns per-service decisions (all false) when every service was denied', () => { |
| 108 | storage.set( |
| 109 | 'ucData', |
| 110 | JSON.stringify({ |
| 111 | consent: { |
| 112 | services: { |
| 113 | svc1: { name: 'Google Analytics', consent: false }, |
| 114 | svc2: { name: 'Stripe', consent: false }, |
| 115 | }, |
| 116 | }, |
| 117 | }) |
| 118 | ) |
| 119 | |
| 120 | expect(detectPriorConsent()).toEqual({ |
| 121 | kind: 'decisions', |
| 122 | decisions: [ |
| 123 | { serviceId: 'svc1', status: false }, |
| 124 | { serviceId: 'svc2', status: false }, |
| 125 | ], |
| 126 | }) |
| 127 | }) |
| 128 | |
| 129 | it('returns null when services object is empty', () => { |
| 130 | storage.set('ucData', JSON.stringify({ consent: { services: {} } })) |
| 131 | expect(detectPriorConsent()).toBeNull() |
| 132 | }) |
| 133 | |
| 134 | it('returns null when ucData is malformed JSON', () => { |
| 135 | storage.set('ucData', 'not-json') |
| 136 | expect(detectPriorConsent()).toBeNull() |
| 137 | }) |
| 138 | |
| 139 | it('returns null when ucData has no consent.services', () => { |
| 140 | storage.set('ucData', JSON.stringify({ gcm: {} })) |
| 141 | expect(detectPriorConsent()).toBeNull() |
| 142 | }) |
| 143 | |
| 144 | it('returns null when every service value is non-object', () => { |
| 145 | storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'not-an-object' } } })) |
| 146 | expect(detectPriorConsent()).toBeNull() |
| 147 | }) |
| 148 | |
| 149 | // Partial parse failures must fail closed. Cherry-picking the valid |
| 150 | // subset and calling it uniform-accept would let corrupted third-party |
| 151 | // storage silently upgrade a user's consent — the worst-direction bias |
| 152 | // in this domain. |
| 153 | it('returns null when any entry fails schema (fails closed on partial corruption)', () => { |
| 154 | storage.set( |
| 155 | 'ucData', |
| 156 | JSON.stringify({ |
| 157 | consent: { |
| 158 | services: { |
| 159 | svc1: 'not-an-object', |
| 160 | svc2: { name: 'Valid', consent: true }, |
| 161 | }, |
| 162 | }, |
| 163 | }) |
| 164 | ) |
| 165 | expect(detectPriorConsent()).toBeNull() |
| 166 | }) |
| 167 | }) |
| 168 | |
| 169 | describe('scenario 2: fast cross-app navigation (uc_settings gated by uc_user_interaction)', () => { |
| 170 | const buildUcSettings = (services: Array<{ id: string; status: boolean }>) => |
| 171 | JSON.stringify({ |
| 172 | controllerId: 'test-controller', |
| 173 | id: 'test-settings', |
| 174 | language: 'en', |
| 175 | services: services.map((s) => ({ |
| 176 | id: s.id, |
| 177 | status: s.status, |
| 178 | processorId: 'test-processor', |
| 179 | history: [], |
| 180 | version: '1.0.0', |
| 181 | })), |
| 182 | version: '1.0.0', |
| 183 | }) |
| 184 | |
| 185 | it('returns uniform-accept when uc_settings has every service accepted', () => { |
| 186 | storage.set('uc_user_interaction', 'true') |
| 187 | storage.set( |
| 188 | 'uc_settings', |
| 189 | buildUcSettings([ |
| 190 | { id: 'svc1', status: true }, |
| 191 | { id: 'svc2', status: true }, |
| 192 | ]) |
| 193 | ) |
| 194 | expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' }) |
| 195 | }) |
| 196 | |
| 197 | it('returns per-service decisions when uc_settings has a mixed state', () => { |
| 198 | storage.set('uc_user_interaction', 'true') |
| 199 | storage.set( |
| 200 | 'uc_settings', |
| 201 | buildUcSettings([ |
| 202 | { id: 'essential1', status: true }, |
| 203 | { id: 'tracking1', status: false }, |
| 204 | { id: 'tracking2', status: false }, |
| 205 | ]) |
| 206 | ) |
| 207 | const result = detectPriorConsent() |
| 208 | if (result === null || result.kind !== 'decisions') { |
| 209 | throw new Error(`expected decisions result, got ${JSON.stringify(result)}`) |
| 210 | } |
| 211 | expect(result.decisions).toHaveLength(3) |
| 212 | expect(result.decisions).toContainEqual({ serviceId: 'essential1', status: true }) |
| 213 | expect(result.decisions).toContainEqual({ serviceId: 'tracking1', status: false }) |
| 214 | expect(result.decisions).toContainEqual({ serviceId: 'tracking2', status: false }) |
| 215 | }) |
| 216 | |
| 217 | it('returns null when uc_user_interaction is "true" but uc_settings is absent', () => { |
| 218 | storage.set('uc_user_interaction', 'true') |
| 219 | expect(detectPriorConsent()).toBeNull() |
| 220 | }) |
| 221 | |
| 222 | it('returns null when uc_settings is malformed JSON', () => { |
| 223 | storage.set('uc_user_interaction', 'true') |
| 224 | storage.set('uc_settings', 'not-json') |
| 225 | expect(detectPriorConsent()).toBeNull() |
| 226 | }) |
| 227 | |
| 228 | it('returns null when uc_settings services array is empty', () => { |
| 229 | storage.set('uc_user_interaction', 'true') |
| 230 | storage.set('uc_settings', buildUcSettings([])) |
| 231 | expect(detectPriorConsent()).toBeNull() |
| 232 | }) |
| 233 | |
| 234 | it('returns null when any uc_settings service entry fails schema', () => { |
| 235 | storage.set('uc_user_interaction', 'true') |
| 236 | storage.set( |
| 237 | 'uc_settings', |
| 238 | JSON.stringify({ |
| 239 | services: [ |
| 240 | { id: 'svc1', status: true, processorId: 'p', history: [], version: '1' }, |
| 241 | { id: 'svc2', status: 'not-a-boolean', processorId: 'p', history: [], version: '1' }, |
| 242 | ], |
| 243 | }) |
| 244 | ) |
| 245 | expect(detectPriorConsent()).toBeNull() |
| 246 | }) |
| 247 | |
| 248 | it('returns null when uc_user_interaction is "false" (even if uc_settings is valid)', () => { |
| 249 | storage.set('uc_user_interaction', 'false') |
| 250 | storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }])) |
| 251 | expect(detectPriorConsent()).toBeNull() |
| 252 | }) |
| 253 | |
| 254 | it('returns null when uc_user_interaction is absent', () => { |
| 255 | storage.set('uc_settings', buildUcSettings([{ id: 'svc1', status: true }])) |
| 256 | expect(detectPriorConsent()).toBeNull() |
| 257 | }) |
| 258 | }) |
| 259 | |
| 260 | describe('combined scenarios', () => { |
| 261 | it('returns uniform-accept when ucData is fully accepted (ignoring uc_user_interaction/uc_settings)', () => { |
| 262 | storage.set( |
| 263 | 'ucData', |
| 264 | JSON.stringify({ |
| 265 | consent: { services: { svc1: { name: 'GA', consent: true } } }, |
| 266 | }) |
| 267 | ) |
| 268 | storage.set('uc_user_interaction', 'true') |
| 269 | expect(detectPriorConsent()).toEqual({ kind: 'uniform-accept' }) |
| 270 | }) |
| 271 | |
| 272 | it('prefers ucData decisions over uc_settings when both exist', () => { |
| 273 | storage.set( |
| 274 | 'ucData', |
| 275 | JSON.stringify({ |
| 276 | consent: { services: { svc1: { name: 'GA', consent: false } } }, |
| 277 | }) |
| 278 | ) |
| 279 | storage.set('uc_user_interaction', 'true') |
| 280 | storage.set( |
| 281 | 'uc_settings', |
| 282 | JSON.stringify({ |
| 283 | services: [{ id: 'svc1', status: true, processorId: 'p', history: [], version: '1' }], |
| 284 | }) |
| 285 | ) |
| 286 | expect(detectPriorConsent()).toEqual({ |
| 287 | kind: 'decisions', |
| 288 | decisions: [{ serviceId: 'svc1', status: false }], |
| 289 | }) |
| 290 | }) |
| 291 | |
| 292 | it('falls back to uc_settings when ucData is corrupt (scenario 1 fails closed, scenario 2 recovers)', () => { |
| 293 | storage.set('ucData', JSON.stringify({ consent: { services: { svc1: 'corrupt' } } })) |
| 294 | storage.set('uc_user_interaction', 'true') |
| 295 | storage.set( |
| 296 | 'uc_settings', |
| 297 | JSON.stringify({ |
| 298 | services: [{ id: 'svc1', status: false, processorId: 'p', history: [], version: '1' }], |
| 299 | }) |
| 300 | ) |
| 301 | expect(detectPriorConsent()).toEqual({ |
| 302 | kind: 'decisions', |
| 303 | decisions: [{ serviceId: 'svc1', status: false }], |
| 304 | }) |
| 305 | }) |
| 306 | |
| 307 | it('returns null when localStorage is completely empty', () => { |
| 308 | expect(detectPriorConsent()).toBeNull() |
| 309 | }) |
| 310 | }) |
| 311 | }) |
| 312 | |
| 313 | describe('applyPriorDecisionToSDK', () => { |
| 314 | type MockService = { id: string; isEssential: boolean } |
| 315 | |
| 316 | const makeMockUC = ( |
| 317 | opts: { |
| 318 | services?: MockService[] |
| 319 | areAllAccepted?: boolean |
| 320 | onAcceptAll?: () => Promise<void> |
| 321 | onUpdateServices?: (decisions: UserDecision[]) => Promise<void> |
| 322 | } = {} |
| 323 | ) => { |
| 324 | const services = opts.services ?? [] |
| 325 | return { |
| 326 | acceptAllServices: vi.fn(opts.onAcceptAll ?? (() => Promise.resolve())), |
| 327 | denyAllServices: vi.fn(() => Promise.resolve()), |
| 328 | updateServices: vi.fn(opts.onUpdateServices ?? (() => Promise.resolve())), |
| 329 | getCategoriesBaseInfo: vi.fn(() => []), |
| 330 | getServicesBaseInfo: vi.fn(() => services), |
| 331 | areAllConsentsAccepted: vi.fn(() => opts.areAllAccepted ?? false), |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | beforeEach(() => { |
| 336 | consentState.UC = null |
| 337 | consentState.categories = null |
| 338 | consentState.showConsentToast = false |
| 339 | consentState.hasConsented = false |
| 340 | }) |
| 341 | |
| 342 | it('uniform-accept: calls acceptAllServices, sets hasConsented, suppresses banner', () => { |
| 343 | const UC = makeMockUC() |
| 344 | applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'uniform-accept' }) |
| 345 | |
| 346 | expect(UC.acceptAllServices).toHaveBeenCalledOnce() |
| 347 | expect(UC.updateServices).not.toHaveBeenCalled() |
| 348 | expect(consentState.hasConsented).toBe(true) |
| 349 | expect(consentState.showConsentToast).toBe(false) |
| 350 | }) |
| 351 | |
| 352 | it('decisions with full coverage: calls updateServices with stored decisions, suppresses banner', () => { |
| 353 | const UC = makeMockUC({ |
| 354 | services: [ |
| 355 | { id: 'essential1', isEssential: true }, |
| 356 | { id: 'tracking1', isEssential: false }, |
| 357 | { id: 'tracking2', isEssential: false }, |
| 358 | ], |
| 359 | }) |
| 360 | const decisions: UserDecision[] = [ |
| 361 | { serviceId: 'tracking1', status: true }, |
| 362 | { serviceId: 'tracking2', status: false }, |
| 363 | ] |
| 364 | |
| 365 | applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, { kind: 'decisions', decisions }) |
| 366 | |
| 367 | expect(UC.updateServices).toHaveBeenCalledWith(decisions) |
| 368 | expect(UC.acceptAllServices).not.toHaveBeenCalled() |
| 369 | expect(consentState.showConsentToast).toBe(false) |
| 370 | }) |
| 371 | |
| 372 | // GROWTH-790 compliance guard: when the ruleset adds a new non-essential |
| 373 | // service after the user's stored decisions were written, we must NOT |
| 374 | // silently restore — the user has never seen this service and can't have |
| 375 | // consented to it. Show the banner instead. |
| 376 | it('decisions with uncovered non-essential service: shows banner, does not mutate SDK', () => { |
| 377 | const UC = makeMockUC({ |
| 378 | services: [ |
| 379 | { id: 'tracking1', isEssential: false }, |
| 380 | { id: 'tracking_new', isEssential: false }, // in ruleset, not in decisions |
| 381 | ], |
| 382 | }) |
| 383 | |
| 384 | applyPriorDecisionToSDK( |
| 385 | UC as never, |
| 386 | { initialLayer: 0 }, |
| 387 | { kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] } |
| 388 | ) |
| 389 | |
| 390 | expect(UC.updateServices).not.toHaveBeenCalled() |
| 391 | expect(UC.acceptAllServices).not.toHaveBeenCalled() |
| 392 | expect(consentState.showConsentToast).toBe(true) |
| 393 | }) |
| 394 | |
| 395 | // Essentials are SDK-forced-on regardless of user decision, so a new |
| 396 | // essential service appearing since the stored decisions were written |
| 397 | // should not trigger a re-prompt — the user has nothing meaningful to |
| 398 | // decide about it. |
| 399 | it('decisions covering only non-essentials: still restores (essentials ignored in coverage check)', () => { |
| 400 | const UC = makeMockUC({ |
| 401 | services: [ |
| 402 | { id: 'essential_new', isEssential: true }, // not in decisions, but essential |
| 403 | { id: 'tracking1', isEssential: false }, |
| 404 | ], |
| 405 | }) |
| 406 | |
| 407 | applyPriorDecisionToSDK( |
| 408 | UC as never, |
| 409 | { initialLayer: 0 }, |
| 410 | { kind: 'decisions', decisions: [{ serviceId: 'tracking1', status: false }] } |
| 411 | ) |
| 412 | |
| 413 | expect(UC.updateServices).toHaveBeenCalledOnce() |
| 414 | expect(consentState.showConsentToast).toBe(false) |
| 415 | }) |
| 416 | |
| 417 | it('null prior decision: shows banner at default', () => { |
| 418 | const UC = makeMockUC() |
| 419 | applyPriorDecisionToSDK(UC as never, { initialLayer: 0 }, null) |
| 420 | |
| 421 | expect(UC.updateServices).not.toHaveBeenCalled() |
| 422 | expect(UC.acceptAllServices).not.toHaveBeenCalled() |
| 423 | expect(consentState.showConsentToast).toBe(true) |
| 424 | }) |
| 425 | |
| 426 | it('SDK not requesting first-layer banner: no restore attempted even if priorDecision exists', () => { |
| 427 | const UC = makeMockUC({ areAllAccepted: true }) |
| 428 | applyPriorDecisionToSDK(UC as never, { initialLayer: 1 }, { kind: 'uniform-accept' }) |
| 429 | |
| 430 | expect(UC.acceptAllServices).not.toHaveBeenCalled() |
| 431 | expect(UC.updateServices).not.toHaveBeenCalled() |
| 432 | expect(consentState.hasConsented).toBe(true) |
| 433 | expect(consentState.showConsentToast).toBe(false) |
| 434 | }) |
| 435 | |
| 436 | it('SDK already considers user consented: passes through, no restore', () => { |
| 437 | const UC = makeMockUC({ areAllAccepted: true }) |
| 438 | applyPriorDecisionToSDK( |
| 439 | UC as never, |
| 440 | { initialLayer: 0 }, |
| 441 | { |
| 442 | kind: 'decisions', |
| 443 | decisions: [{ serviceId: 'svc1', status: false }], |
| 444 | } |
| 445 | ) |
| 446 | |
| 447 | expect(UC.updateServices).not.toHaveBeenCalled() |
| 448 | expect(consentState.hasConsented).toBe(true) |
| 449 | }) |
| 450 | }) |