consent-state.test.ts450 lines · main
1// @vitest-environment jsdom
2import type { UserDecision } from '@usercentrics/cmp-browser-sdk'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
5import { applyPriorDecisionToSDK, consentState, detectPriorConsent } from './consent-state'
6
7// jsdom's localStorage can be flaky in vitest, so ensure it's available
8const storage = new Map<string, string>()
9const 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
20beforeEach(() => {
21 storage.clear()
22 Object.defineProperty(globalThis, 'localStorage', {
23 value: mockLocalStorage,
24 writable: true,
25 configurable: true,
26 })
27})
28
29afterEach(() => {
30 storage.clear()
31})
32
33describe('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
313describe('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})