consent-state.test.ts 16 KB

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