@owlmeans/consent 0.1.18-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/build/consts.d.ts +64 -0
  2. package/build/consts.d.ts.map +1 -0
  3. package/build/consts.js +90 -0
  4. package/build/consts.js.map +1 -0
  5. package/build/consts.local.d.ts +4 -0
  6. package/build/consts.local.d.ts.map +1 -0
  7. package/build/consts.local.js +4 -0
  8. package/build/consts.local.js.map +1 -0
  9. package/build/gtm.d.ts +72 -0
  10. package/build/gtm.d.ts.map +1 -0
  11. package/build/gtm.js +182 -0
  12. package/build/gtm.js.map +1 -0
  13. package/build/i18n/be.json +36 -0
  14. package/build/i18n/de.json +36 -0
  15. package/build/i18n/en.json +36 -0
  16. package/build/i18n/es.json +36 -0
  17. package/build/i18n/fr.json +36 -0
  18. package/build/i18n/pl.json +36 -0
  19. package/build/i18n/ru.json +36 -0
  20. package/build/i18n/uk.json +36 -0
  21. package/build/i18n.d.ts +16 -0
  22. package/build/i18n.d.ts.map +1 -0
  23. package/build/i18n.js +29 -0
  24. package/build/i18n.js.map +1 -0
  25. package/build/index.d.ts +12 -0
  26. package/build/index.d.ts.map +1 -0
  27. package/build/index.js +9 -0
  28. package/build/index.js.map +1 -0
  29. package/build/linker.d.ts +55 -0
  30. package/build/linker.d.ts.map +1 -0
  31. package/build/linker.js +334 -0
  32. package/build/linker.js.map +1 -0
  33. package/build/plugins.d.ts +30 -0
  34. package/build/plugins.d.ts.map +1 -0
  35. package/build/plugins.js +73 -0
  36. package/build/plugins.js.map +1 -0
  37. package/build/storage.d.ts +27 -0
  38. package/build/storage.d.ts.map +1 -0
  39. package/build/storage.js +86 -0
  40. package/build/storage.js.map +1 -0
  41. package/build/store.d.ts +16 -0
  42. package/build/store.d.ts.map +1 -0
  43. package/build/store.js +100 -0
  44. package/build/store.js.map +1 -0
  45. package/build/types.d.ts +205 -0
  46. package/build/types.d.ts.map +1 -0
  47. package/build/types.js +2 -0
  48. package/build/types.js.map +1 -0
  49. package/build/types.local.d.ts +5 -0
  50. package/build/types.local.d.ts.map +1 -0
  51. package/build/types.local.js +2 -0
  52. package/build/types.local.js.map +1 -0
  53. package/package.json +33 -0
  54. package/src/consts.local.ts +4 -0
  55. package/src/consts.ts +103 -0
  56. package/src/gtm.ts +216 -0
  57. package/src/i18n/be.json +36 -0
  58. package/src/i18n/de.json +36 -0
  59. package/src/i18n/en.json +36 -0
  60. package/src/i18n/es.json +36 -0
  61. package/src/i18n/fr.json +36 -0
  62. package/src/i18n/pl.json +36 -0
  63. package/src/i18n/ru.json +36 -0
  64. package/src/i18n/uk.json +36 -0
  65. package/src/i18n.ts +39 -0
  66. package/src/index.ts +14 -0
  67. package/src/linker.ts +369 -0
  68. package/src/plugins.ts +88 -0
  69. package/src/storage.ts +96 -0
  70. package/src/store.ts +126 -0
  71. package/src/types.local.ts +4 -0
  72. package/src/types.ts +218 -0
  73. package/tests/consent.spec.ts +256 -0
  74. package/tests/linker.spec.ts +774 -0
  75. package/tests/tracking.spec.ts +191 -0
  76. package/tsconfig.json +11 -0
@@ -0,0 +1,774 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
2
+ import { CONSENT_ANALYTICS, CONSENT_ESSENTIAL, CONSENT_KEY, CONSENT_LANGUAGE_KEY, CONSENT_MARKETING, DEFAULT_CONSENT_CATEGORIES, CONSENT_LINK_MAX_AGE, CONSENT_LINK_PARAM } from '../src/consts.js'
3
+ import { consentBootstrapScript } from '../src/gtm.js'
4
+ import { consentLinker, consentLinkerScript, decodeConsentLink, encodeConsentLink, stripConsentLinkParam, writeConsentLanguage } from '../src/linker.js'
5
+ import { adoptConsentLanguage, consentDomains, decorateConsentUrl, registerConsentPlugin } from '../src/plugins.js'
6
+ import { makeConsentStore } from '../src/store.js'
7
+ import { readConsent, writeConsent } from '../src/storage.js'
8
+ import type { ConsentRecord } from '../src/types.js'
9
+
10
+ /** A minimal browser: storage, a settable referrer, and a `location`/`history` pair that actually
11
+ * track `replaceState`, so stripping can be asserted on what the URL becomes. */
12
+ const browser = () => {
13
+ const store = new Map<string, string>()
14
+ let cookie = ''
15
+ let current = new URL('https://platform.test/dispatcher')
16
+ ;(globalThis as any).localStorage = {
17
+ getItem: (k: string) => store.get(k) ?? null,
18
+ setItem: (k: string, v: string) => { store.set(k, v) },
19
+ removeItem: (k: string) => { store.delete(k) },
20
+ }
21
+ ;(globalThis as any).document = {
22
+ referrer: '',
23
+ documentElement: { lang: '' },
24
+ get cookie() { return cookie },
25
+ set cookie(value: string) {
26
+ const [pair] = value.split(';')
27
+ cookie = cookie === '' ? pair : `${cookie}; ${pair}`
28
+ },
29
+ // `consentLinker().start()` registers listeners on init; nothing here dispatches a click, so a
30
+ // no-op is enough to let `store.init`/`startConsentPlugins` run without throwing.
31
+ addEventListener: () => {},
32
+ removeEventListener: () => {},
33
+ }
34
+ ;(globalThis as any).location = new Proxy({}, {
35
+ get: (_t, prop) => (current as unknown as Record<string, unknown>)[prop as string],
36
+ })
37
+ ;(globalThis as any).history = {
38
+ state: null,
39
+ replaceState: (_state: unknown, _title: string, next: string) => {
40
+ current = new URL(next, current.origin)
41
+ },
42
+ }
43
+ ;(globalThis as any).window = globalThis
44
+
45
+ return {
46
+ store,
47
+ goto: (href: string, referrer = '') => {
48
+ current = new URL(href)
49
+ ;(globalThis as any).document.referrer = referrer
50
+ },
51
+ href: () => current.toString(),
52
+ lang: (value: string) => { (globalThis as any).document.documentElement.lang = value },
53
+ reset: () => {
54
+ store.clear(); cookie = ''; (globalThis as any).document.referrer = ''; (globalThis as any).document.documentElement.lang = ''
55
+ },
56
+ }
57
+ }
58
+
59
+ const env = browser()
60
+ const DOMAINS = ['platform.test', 'site.test']
61
+ const linkerOpts = { linker: { domains: DOMAINS } }
62
+
63
+ const nowSeconds = (): number => Math.floor(Date.now() / 1000)
64
+
65
+ beforeEach(() => {
66
+ env.reset()
67
+ env.goto('https://platform.test/dispatcher')
68
+ delete (globalThis as any).dataLayer
69
+ delete (globalThis as any).cookieConsentSetup
70
+ })
71
+
72
+ describe('encodeConsentLink / decodeConsentLink', () => {
73
+ test('round-trips a record through the optional categories only', () => {
74
+ const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
75
+ const encoded = encodeConsentLink(record)
76
+ const decoded = decodeConsentLink(encoded)
77
+
78
+ expect(decoded?.v).toBe(2)
79
+ expect(decoded?.c).toEqual({ [CONSENT_ANALYTICS]: 1, [CONSENT_MARKETING]: 0 })
80
+ expect(decoded?.t).toBeGreaterThan(0)
81
+ })
82
+
83
+ test('rejects garbage rather than throwing', () => {
84
+ expect(decodeConsentLink('not-base64url-json')).toBeNull()
85
+ expect(decodeConsentLink('')).toBeNull()
86
+ })
87
+
88
+ test('rejects a payload of the wrong version', () => {
89
+ const bad = Buffer.from(JSON.stringify({ v: 1, c: {}, t: nowSeconds() })).toString('base64url')
90
+
91
+ expect(decodeConsentLink(bad)).toBeNull()
92
+ })
93
+ })
94
+
95
+ describe('consentLinker().decorate', () => {
96
+ const plugin = consentLinker()
97
+ const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
98
+
99
+ test('decorates a listed, foreign host', () => {
100
+ const decorated = plugin.decorate!(new URL('https://site.test/legal/cookies'), record, linkerOpts)
101
+
102
+ expect(decorated).not.toBeNull()
103
+ const payload = decodeConsentLink(decorated!.searchParams.get(CONSENT_LINK_PARAM)!)
104
+ expect(payload?.c[CONSENT_ANALYTICS]).toBe(1)
105
+ })
106
+
107
+ test('leaves the current host alone', () => {
108
+ expect(plugin.decorate!(new URL('https://platform.test/account'), record, linkerOpts)).toBeNull()
109
+ })
110
+
111
+ test('leaves an unlisted host alone', () => {
112
+ expect(plugin.decorate!(new URL('https://elsewhere.test/'), record, linkerOpts)).toBeNull()
113
+ })
114
+
115
+ test('replaces a stale parameter rather than appending a second one', () => {
116
+ const stale = new URL('https://site.test/legal/cookies?owlcc=stale-value')
117
+ const decorated = plugin.decorate!(stale, record, linkerOpts)!
118
+
119
+ expect(decorated.searchParams.getAll(CONSENT_LINK_PARAM)).toHaveLength(1)
120
+ expect(decorated.searchParams.get(CONSENT_LINK_PARAM)).not.toBe('stale-value')
121
+ })
122
+
123
+ test('no-ops with no linker configured', () => {
124
+ expect(plugin.decorate!(new URL('https://site.test/'), record, {})).toBeNull()
125
+ })
126
+ })
127
+
128
+ describe('consentLinker().adopt — every trust rule', () => {
129
+ const plugin = consentLinker()
130
+ const valid = () => encodeConsentLink({ [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
131
+
132
+ test('adopts a fresh, referred, fully-covered decision', () => {
133
+ env.goto(`https://platform.test/dispatcher?owlcc=${valid()}`, 'https://site.test/')
134
+
135
+ const adopted = plugin.adopt!(linkerOpts)
136
+ expect(adopted?.[CONSENT_ESSENTIAL]).toBe(true)
137
+ expect(adopted?.[CONSENT_ANALYTICS]).toBe(true)
138
+ expect(adopted?.[CONSENT_MARKETING]).toBe(false)
139
+ })
140
+
141
+ test('refuses with no parameter at all', () => {
142
+ env.goto('https://platform.test/dispatcher', 'https://site.test/')
143
+
144
+ expect(plugin.adopt!(linkerOpts)).toBeNull()
145
+ })
146
+
147
+ test('refuses with no referrer', () => {
148
+ env.goto(`https://platform.test/dispatcher?owlcc=${valid()}`, '')
149
+
150
+ expect(plugin.adopt!(linkerOpts)).toBeNull()
151
+ })
152
+
153
+ test('refuses a referrer that is not a listed domain', () => {
154
+ env.goto(`https://platform.test/dispatcher?owlcc=${valid()}`, 'https://evil.test/')
155
+
156
+ expect(plugin.adopt!(linkerOpts)).toBeNull()
157
+ })
158
+
159
+ test('refuses a stale link past maxAge', () => {
160
+ const stale = Buffer.from(JSON.stringify({
161
+ v: 2, c: { [CONSENT_ANALYTICS]: 1, [CONSENT_MARKETING]: 0 }, t: nowSeconds() - CONSENT_LINK_MAX_AGE - 30,
162
+ })).toString('base64url')
163
+ env.goto(`https://platform.test/dispatcher?owlcc=${stale}`, 'https://site.test/')
164
+
165
+ expect(plugin.adopt!(linkerOpts)).toBeNull()
166
+ })
167
+
168
+ test('refuses a timestamp too far in the future for clock skew', () => {
169
+ const future = Buffer.from(JSON.stringify({
170
+ v: 2, c: { [CONSENT_ANALYTICS]: 1, [CONSENT_MARKETING]: 0 }, t: nowSeconds() + 3600,
171
+ })).toString('base64url')
172
+ env.goto(`https://platform.test/dispatcher?owlcc=${future}`, 'https://site.test/')
173
+
174
+ expect(plugin.adopt!(linkerOpts)).toBeNull()
175
+ })
176
+
177
+ test('refuses a payload missing one of this site\'s optional categories', () => {
178
+ const partial = Buffer.from(JSON.stringify({
179
+ v: 2, c: { [CONSENT_ANALYTICS]: 1 }, t: nowSeconds(),
180
+ })).toString('base64url')
181
+ env.goto(`https://platform.test/dispatcher?owlcc=${partial}`, 'https://site.test/')
182
+
183
+ expect(plugin.adopt!(linkerOpts)).toBeNull()
184
+ })
185
+
186
+ test('refuses with no linker configured', () => {
187
+ env.goto(`https://platform.test/dispatcher?owlcc=${valid()}`, 'https://site.test/')
188
+
189
+ expect(plugin.adopt!({})).toBeNull()
190
+ })
191
+ })
192
+
193
+ describe('stripConsentLinkParam', () => {
194
+ test('removes only the parameter, keeping the rest and the hash', () => {
195
+ env.goto('https://platform.test/dispatcher?owlcc=abc&next=%2Fhome&ref=site#panel')
196
+ stripConsentLinkParam(linkerOpts)
197
+
198
+ expect(env.href()).toBe('https://platform.test/dispatcher?next=%2Fhome&ref=site#panel')
199
+ })
200
+
201
+ test('is a no-op with no parameter present', () => {
202
+ env.goto('https://platform.test/dispatcher?next=%2Fhome')
203
+ stripConsentLinkParam(linkerOpts)
204
+
205
+ expect(env.href()).toBe('https://platform.test/dispatcher?next=%2Fhome')
206
+ })
207
+
208
+ test('is a no-op with no linker configured', () => {
209
+ env.goto('https://platform.test/dispatcher?owlcc=abc')
210
+ stripConsentLinkParam({})
211
+
212
+ expect(env.href()).toBe('https://platform.test/dispatcher?owlcc=abc')
213
+ })
214
+ })
215
+
216
+ describe('consentDomains / decorateConsentUrl (registry level)', () => {
217
+ test('discloses the current host plus every plugin-named domain, deduplicated', () => {
218
+ registerConsentPlugin(consentLinker())
219
+ const domains = consentDomains(linkerOpts)
220
+
221
+ expect(domains).toContain('platform.test')
222
+ expect(domains).toContain('site.test')
223
+ expect(new Set(domains).size).toBe(domains.length)
224
+ })
225
+
226
+ test('decorateConsentUrl applies the registered plugin to a plain URL', () => {
227
+ registerConsentPlugin(consentLinker())
228
+ const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false }
229
+ const decorated = decorateConsentUrl('https://site.test/legal/cookies', record, linkerOpts)
230
+
231
+ expect(decorated.searchParams.has(CONSENT_LINK_PARAM)).toBe(true)
232
+ })
233
+
234
+ test('registering the same alias twice replaces rather than duplicates', () => {
235
+ registerConsentPlugin(consentLinker())
236
+ registerConsentPlugin(consentLinker())
237
+ const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true }
238
+ const decorated = decorateConsentUrl('https://site.test/', record, linkerOpts)
239
+
240
+ // A duplicate `decorate` implementation would still be idempotent (same value both times), so
241
+ // what this actually guards is that the registry never grows unbounded per alias.
242
+ expect(decorated.searchParams.getAll(CONSENT_LINK_PARAM)).toHaveLength(1)
243
+ })
244
+ })
245
+
246
+ describe('the store, with a linker configured', () => {
247
+ test('adopts on init when nothing is stored yet, and applies it', () => {
248
+ const record = { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
249
+ env.goto(`https://platform.test/dispatcher?owlcc=${encodeConsentLink(record)}`, 'https://site.test/')
250
+
251
+ const store = makeConsentStore()
252
+ store.init({ silent: true, linker: { domains: DOMAINS } })
253
+
254
+ expect(store.get().open).toBe(false)
255
+ expect(store.get().record?.[CONSENT_ANALYTICS]).toBe(true)
256
+ expect(store.granted(CONSENT_ESSENTIAL)).toBe(true)
257
+ // Written through, not just held in memory — a second page load must see it too.
258
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
259
+ })
260
+
261
+ test('strips the parameter from the URL either way', () => {
262
+ env.goto('https://platform.test/dispatcher?owlcc=whatever-not-even-valid')
263
+
264
+ const store = makeConsentStore()
265
+ store.init({ silent: true, linker: { domains: DOMAINS } })
266
+
267
+ expect(env.href()).toBe('https://platform.test/dispatcher')
268
+ })
269
+
270
+ test('never overwrites a decision this document already made', () => {
271
+ writeConsent({ [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false })
272
+ const incoming = { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true }
273
+ env.goto(`https://platform.test/dispatcher?owlcc=${encodeConsentLink(incoming)}`, 'https://site.test/')
274
+
275
+ const store = makeConsentStore()
276
+ store.init({ silent: true, linker: { domains: DOMAINS } })
277
+
278
+ expect(store.get().record?.[CONSENT_ANALYTICS]).toBe(false)
279
+ })
280
+
281
+ test('opens as usual when adoption is refused and nothing is stored', () => {
282
+ env.goto('https://platform.test/dispatcher', '')
283
+
284
+ const store = makeConsentStore()
285
+ store.init({ silent: true, linker: { domains: DOMAINS } })
286
+
287
+ expect(store.get().open).toBe(true)
288
+ expect(store.get().reason).toBe('initial')
289
+ })
290
+ })
291
+
292
+ describe('consentBootstrapScript with a linker: order and shape', () => {
293
+ test('the linker fragment sits after the default push and before the storage read', () => {
294
+ const script = consentBootstrapScript({ linker: { domains: DOMAINS } })
295
+ const defaultAt = script.indexOf("'consent','default'")
296
+ const linkerAt = script.indexOf('URLSearchParams')
297
+ const storageAt = script.indexOf('w.localStorage.getItem')
298
+
299
+ expect(defaultAt).toBeGreaterThan(-1)
300
+ expect(linkerAt).toBeGreaterThan(defaultAt)
301
+ expect(storageAt).toBeGreaterThan(linkerAt)
302
+ })
303
+
304
+ test('emits nothing extra with no linker configured', () => {
305
+ expect(consentBootstrapScript()).not.toContain('URLSearchParams')
306
+ })
307
+
308
+ test('escapes "<" inside embedded strings', () => {
309
+ const script = consentLinkerScript({ linker: { domains: ['<script>evil.test'] } })
310
+
311
+ expect(script).not.toContain('<script>evil.test')
312
+ expect(script).toContain('\\u003cscript>evil.test')
313
+ })
314
+ })
315
+
316
+ describe('consentLinkerScript, executed', () => {
317
+ test('adopts a valid link and strips the parameter, via new Function', () => {
318
+ const record = { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
319
+ env.goto(`https://platform.test/dispatcher?owlcc=${encodeConsentLink(record)}`, 'https://site.test/')
320
+
321
+ const script = consentLinkerScript({ linker: { domains: DOMAINS } })
322
+ // eslint-disable-next-line no-new-func
323
+ new Function(script)()
324
+
325
+ expect(env.href()).toBe('https://platform.test/dispatcher')
326
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
327
+ })
328
+
329
+ test('never overwrites an existing record, and still strips', () => {
330
+ writeConsent({ [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false })
331
+ const incoming = { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true }
332
+ env.goto(`https://platform.test/dispatcher?owlcc=${encodeConsentLink(incoming)}`, 'https://site.test/')
333
+
334
+ const script = consentLinkerScript({ linker: { domains: DOMAINS } })
335
+ // eslint-disable-next-line no-new-func
336
+ new Function(script)()
337
+
338
+ expect(env.href()).toBe('https://platform.test/dispatcher')
339
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(false)
340
+ })
341
+
342
+ test('is an empty string with no linker configured', () => {
343
+ expect(consentLinkerScript({})).toBe('')
344
+ })
345
+ })
346
+
347
+ describe('every category the built-in bundle carries', () => {
348
+ test('DEFAULT_CONSENT_CATEGORIES still has exactly one required (essential) category', () => {
349
+ // Sanity check the fixture the tests above lean on: if this ever changes, the "every local
350
+ // optional category" adopt rule above is exercised against a different set than intended.
351
+ expect(DEFAULT_CONSENT_CATEGORIES.filter(c => c.required === true).map(c => c.key)).toEqual([CONSENT_ESSENTIAL])
352
+ })
353
+
354
+ test('the optional categories are analytics and marketing — nothing asks about the language', () => {
355
+ // The interface language is strictly necessary storage: no category governs it, so the default
356
+ // set has no "functional" row that would offer a choice which changes nothing.
357
+ expect(DEFAULT_CONSENT_CATEGORIES.filter(c => c.required !== true).map(c => c.key))
358
+ .toEqual([CONSENT_ANALYTICS, CONSENT_MARKETING])
359
+ })
360
+ })
361
+
362
+ describe('language: the link parameter', () => {
363
+ const withLanguage = { linker: { domains: DOMAINS, language: {} } }
364
+
365
+ test('carries the page language as `l`, lower-cased, when the sender turned it on', () => {
366
+ env.lang('PL')
367
+ const decoded = decodeConsentLink(encodeConsentLink({ [CONSENT_ANALYTICS]: true }, withLanguage))
368
+
369
+ expect(decoded?.l).toBe('pl')
370
+ expect(decoded?.c).toEqual({ [CONSENT_ANALYTICS]: 1, [CONSENT_MARKETING]: 0 })
371
+ })
372
+
373
+ test('carries no `l` when the sender did not turn it on, whatever the page says', () => {
374
+ env.lang('pl')
375
+
376
+ expect(decodeConsentLink(encodeConsentLink({ [CONSENT_ANALYTICS]: true }, linkerOpts))?.l).toBeUndefined()
377
+ })
378
+
379
+ test('carries no `l` for a page with no usable language', () => {
380
+ for (const lang of ['', 'not a language!', '12']) {
381
+ env.lang(lang)
382
+
383
+ expect(decodeConsentLink(encodeConsentLink(null, withLanguage))?.l).toBeUndefined()
384
+ }
385
+ })
386
+
387
+ test('encodes no decision at all as an empty `c`', () => {
388
+ env.lang('de')
389
+ const decoded = decodeConsentLink(encodeConsentLink(null, withLanguage))
390
+
391
+ expect(decoded?.c).toEqual({})
392
+ expect(decoded?.l).toBe('de')
393
+ })
394
+
395
+ test('a malformed `l` is dropped without refusing the decision beside it', () => {
396
+ const raw = Buffer.from(JSON.stringify({
397
+ v: 2, c: { [CONSENT_ANALYTICS]: 1 }, t: nowSeconds(), l: '../../etc',
398
+ })).toString('base64url')
399
+ const decoded = decodeConsentLink(raw)
400
+
401
+ expect(decoded).not.toBeNull()
402
+ expect(decoded?.l).toBeUndefined()
403
+ expect(decoded?.c[CONSENT_ANALYTICS]).toBe(1)
404
+ })
405
+ })
406
+
407
+ describe('language: consentLinker().decorate', () => {
408
+ const plugin = consentLinker()
409
+ const withLanguage = { linker: { domains: DOMAINS, language: {} } }
410
+ const record: ConsentRecord = { [CONSENT_ESSENTIAL]: true, [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
411
+
412
+ test('decorates with the language alone before any decision was made', () => {
413
+ env.lang('uk')
414
+ const decorated = plugin.decorate!(new URL('https://site.test/start'), null, withLanguage)
415
+
416
+ expect(decorated).not.toBeNull()
417
+ const payload = decodeConsentLink(decorated!.searchParams.get(CONSENT_LINK_PARAM)!)
418
+ expect(payload?.l).toBe('uk')
419
+ expect(payload?.c).toEqual({})
420
+ })
421
+
422
+ test('carries the decision and the language together', () => {
423
+ env.lang('pl')
424
+ const payload = decodeConsentLink(
425
+ plugin.decorate!(new URL('https://site.test/start'), record, withLanguage)!.searchParams.get(CONSENT_LINK_PARAM)!
426
+ )
427
+
428
+ expect(payload?.l).toBe('pl')
429
+ expect(payload?.c[CONSENT_ANALYTICS]).toBe(1)
430
+ })
431
+
432
+ test('has nothing to say without a decision and without the language turned on', () => {
433
+ env.lang('pl')
434
+
435
+ expect(plugin.decorate!(new URL('https://site.test/start'), null, linkerOpts)).toBeNull()
436
+ })
437
+
438
+ test('has nothing to say without a decision when the page names no language', () => {
439
+ env.lang('')
440
+
441
+ expect(plugin.decorate!(new URL('https://site.test/start'), null, withLanguage)).toBeNull()
442
+ })
443
+
444
+ test('still refuses an unlisted host, language or not', () => {
445
+ env.lang('pl')
446
+
447
+ expect(plugin.decorate!(new URL('https://elsewhere.test/'), record, withLanguage)).toBeNull()
448
+ })
449
+ })
450
+
451
+ describe('language: consentLinker().adoptLanguage', () => {
452
+ const plugin = consentLinker()
453
+ const receiving = { linker: { domains: DOMAINS, language: { supported: ['en', 'pl', 'de', 'fr'] } } }
454
+ const carried = (lang: string, record: ConsentRecord | null = null): string => {
455
+ env.lang(lang)
456
+
457
+ return encodeConsentLink(record, { linker: { domains: DOMAINS, language: {} } })
458
+ }
459
+ const arrive = (owlcc: string, referrer = 'https://site.test/') => {
460
+ env.goto(`https://platform.test/dispatcher?owlcc=${owlcc}`, referrer)
461
+ env.lang('en')
462
+ }
463
+
464
+ test('adopts a supported language', () => {
465
+ arrive(carried('pl'))
466
+
467
+ expect(plugin.adoptLanguage!(receiving)).toBe('pl')
468
+ })
469
+
470
+ test('matches a regional tag by its base', () => {
471
+ arrive(carried('de-AT'))
472
+
473
+ expect(plugin.adoptLanguage!(receiving)).toBe('de')
474
+ })
475
+
476
+ test('answers with the receiver\'s own spelling of the language', () => {
477
+ arrive(carried('pl'))
478
+
479
+ expect(plugin.adoptLanguage!({ linker: { domains: DOMAINS, language: { supported: ['PL'] } } })).toBe('PL')
480
+ })
481
+
482
+ test('ignores a language the application cannot render', () => {
483
+ arrive(carried('ja'))
484
+
485
+ expect(plugin.adoptLanguage!(receiving)).toBeNull()
486
+ })
487
+
488
+ test('a site that only sends never adopts', () => {
489
+ arrive(carried('pl'))
490
+
491
+ expect(plugin.adoptLanguage!({ linker: { domains: DOMAINS, language: {} } })).toBeNull()
492
+ expect(plugin.adoptLanguage!(linkerOpts)).toBeNull()
493
+ })
494
+
495
+ test('shares the consent trust rule: a foreign referrer, a stale link and no referrer all refuse', () => {
496
+ arrive(carried('pl'), 'https://evil.test/')
497
+ expect(plugin.adoptLanguage!(receiving)).toBeNull()
498
+
499
+ arrive(carried('pl'), '')
500
+ expect(plugin.adoptLanguage!(receiving)).toBeNull()
501
+
502
+ const stale = Buffer.from(JSON.stringify({
503
+ v: 2, c: {}, t: nowSeconds() - CONSENT_LINK_MAX_AGE - 30, l: 'pl',
504
+ })).toString('base64url')
505
+ arrive(stale)
506
+ expect(plugin.adoptLanguage!(receiving)).toBeNull()
507
+ })
508
+
509
+ test('a language-only link carries no decision: `adopt` defers while `adoptLanguage` answers', () => {
510
+ arrive(carried('pl'))
511
+
512
+ expect(plugin.adopt!(receiving)).toBeNull()
513
+ expect(plugin.adoptLanguage!(receiving)).toBe('pl')
514
+ })
515
+
516
+ test('a receiver that predates the language field still adopts the decision from the same link', () => {
517
+ arrive(carried('pl', { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }))
518
+
519
+ expect(plugin.adopt!(linkerOpts)?.[CONSENT_ANALYTICS]).toBe(true)
520
+ })
521
+
522
+ test('registry: adoptConsentLanguage asks the registered plugin', () => {
523
+ registerConsentPlugin(consentLinker())
524
+ arrive(carried('pl'))
525
+
526
+ expect(adoptConsentLanguage(receiving)).toBe('pl')
527
+ expect(adoptConsentLanguage(linkerOpts)).toBeNull()
528
+ })
529
+ })
530
+
531
+ describe('language: strictly necessary, stored whatever the cookie decision is', () => {
532
+ const receiving = { silent: true, linker: { domains: DOMAINS, language: { supported: ['en', 'pl', 'de'] } } }
533
+ const arrive = (lang: string, record: ConsentRecord | null = null, referrer = 'https://site.test/') => {
534
+ env.lang(lang)
535
+ const owlcc = encodeConsentLink(record, { linker: { domains: DOMAINS, language: {} } })
536
+ env.goto(`https://platform.test/dispatcher?owlcc=${owlcc}`, referrer)
537
+ env.lang('en')
538
+ }
539
+ const accepted: ConsentRecord = { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true }
540
+ const refused: ConsentRecord = { [CONSENT_ANALYTICS]: false, [CONSENT_MARKETING]: false }
541
+ /** Saved before the `functional` category was retired: an extra key nobody reads any more. */
542
+ const withRetiredKey: ConsentRecord = { functional: false, [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
543
+
544
+ test('writeConsentLanguage writes with no decision, a refusal, an acceptance or an old record', () => {
545
+ for (const record of [null, refused, accepted, withRetiredKey]) {
546
+ env.reset()
547
+ if (record != null) {
548
+ writeConsent(record)
549
+ }
550
+ expect(writeConsentLanguage('pl', receiving)).toBe(true)
551
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
552
+ }
553
+ })
554
+
555
+ test('storage that refuses the write is not an error', () => {
556
+ const storage = (globalThis as any).localStorage
557
+ const setItem = storage.setItem
558
+ storage.setItem = () => { throw new Error('blocked') }
559
+ try {
560
+ expect(writeConsentLanguage('pl', receiving)).toBe(false)
561
+ } finally {
562
+ // The mock storage is shared by every test in the file.
563
+ storage.setItem = setItem
564
+ }
565
+ })
566
+
567
+ describe('the store', () => {
568
+ test('with no decision the language is stored at once, the dialog asks, the parameter is stripped', () => {
569
+ arrive('pl')
570
+
571
+ const store = makeConsentStore()
572
+ store.init(receiving)
573
+
574
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
575
+ expect(store.get().open).toBe(true)
576
+ expect(env.href()).toBe('https://platform.test/dispatcher')
577
+ })
578
+
579
+ test('a decision carried by the link is stored together with the language', () => {
580
+ arrive('de', { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
581
+
582
+ const store = makeConsentStore()
583
+ store.init(receiving)
584
+
585
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('de')
586
+ expect(store.get().open).toBe(false)
587
+ expect(store.get().record?.[CONSENT_ANALYTICS]).toBe(true)
588
+ })
589
+
590
+ test('a carried "reject all" stores the decision AND the language', () => {
591
+ arrive('de', refused)
592
+
593
+ const store = makeConsentStore()
594
+ store.init(receiving)
595
+
596
+ expect(store.get().open).toBe(false)
597
+ expect(store.get().record?.[CONSENT_ANALYTICS]).toBe(false)
598
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('de')
599
+ })
600
+
601
+ test('a stored decision of any kind lets the link\'s language through, and still wins over its decision', () => {
602
+ for (const stored of [refused, accepted, withRetiredKey]) {
603
+ env.reset()
604
+ writeConsent(stored)
605
+ arrive('pl', { [CONSENT_ANALYTICS]: !stored[CONSENT_ANALYTICS], [CONSENT_MARKETING]: true })
606
+
607
+ const store = makeConsentStore()
608
+ store.init(receiving)
609
+
610
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
611
+ expect(store.get().record?.[CONSENT_ANALYTICS]).toBe(stored[CONSENT_ANALYTICS])
612
+ }
613
+ })
614
+
615
+ test('the carried language outranks one chosen here earlier', () => {
616
+ env.store.set(CONSENT_LANGUAGE_KEY, 'de')
617
+ arrive('pl')
618
+
619
+ makeConsentStore().init(receiving)
620
+
621
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
622
+ })
623
+
624
+ test('answering the dialog never touches the language, in either direction', () => {
625
+ arrive('pl')
626
+ const store = makeConsentStore()
627
+ store.init(receiving)
628
+
629
+ store.save(refused)
630
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
631
+
632
+ store.save(accepted)
633
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
634
+ })
635
+
636
+ test('a language stored earlier survives every decision', () => {
637
+ env.store.set(CONSENT_LANGUAGE_KEY, 'de')
638
+ const store = makeConsentStore()
639
+ store.init(receiving)
640
+ store.save(refused)
641
+
642
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('de')
643
+ })
644
+
645
+ test('honours a custom storage key', () => {
646
+ arrive('pl')
647
+
648
+ makeConsentStore().init({ ...receiving, linker: { ...receiving.linker, language: { supported: ['pl'], storageKey: 'app-lng' } } })
649
+
650
+ expect(env.store.get('app-lng')).toBe('pl')
651
+ expect(env.store.has(CONSENT_LANGUAGE_KEY)).toBe(false)
652
+ })
653
+
654
+ test('writes nothing for an unsupported language or an untrusted referrer', () => {
655
+ arrive('ja')
656
+ makeConsentStore().init(receiving)
657
+ expect(env.store.has(CONSENT_LANGUAGE_KEY)).toBe(false)
658
+
659
+ arrive('pl', null, 'https://evil.test/')
660
+ makeConsentStore().init(receiving)
661
+ expect(env.store.has(CONSENT_LANGUAGE_KEY)).toBe(false)
662
+ })
663
+ })
664
+
665
+ describe('the inline fragment', () => {
666
+ const run = (opts: Parameters<typeof consentLinkerScript>[0] = receiving) => {
667
+ // eslint-disable-next-line no-new-func
668
+ new Function(consentLinkerScript(opts))()
669
+ }
670
+ const all = { linker: { domains: DOMAINS, language: { supported: ['en', 'pl', 'de', 'fr'] } } }
671
+
672
+ test('with no decision anywhere it stores the language, adopts nothing, and still strips', () => {
673
+ arrive('pl')
674
+ run()
675
+
676
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
677
+ expect(readConsent()).toBeNull()
678
+ expect(env.href()).toBe('https://platform.test/dispatcher')
679
+ })
680
+
681
+ test('a link that carries a decision writes decision and language together', () => {
682
+ arrive('fr', { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
683
+ run(all)
684
+
685
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('fr')
686
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(true)
687
+ })
688
+
689
+ test('a link that carries "reject all" adopts the decision and stores the language', () => {
690
+ arrive('fr', refused)
691
+ run(all)
692
+
693
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(false)
694
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('fr')
695
+ })
696
+
697
+ test('a stored record of any kind lets the language through, and the link\'s decision never overwrites it', () => {
698
+ for (const stored of [refused, accepted, withRetiredKey]) {
699
+ env.reset()
700
+ writeConsent(stored)
701
+ arrive('de', { [CONSENT_ANALYTICS]: !stored[CONSENT_ANALYTICS], [CONSENT_MARKETING]: true })
702
+ run()
703
+
704
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('de')
705
+ expect(readConsent()?.[CONSENT_ANALYTICS]).toBe(stored[CONSENT_ANALYTICS])
706
+ }
707
+ })
708
+
709
+ test('an unparseable stored record still lets the language through, and nothing is adopted over it', () => {
710
+ env.store.set(CONSENT_KEY, '{not json')
711
+ arrive('pl', { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: true })
712
+ run()
713
+
714
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('pl')
715
+ expect(env.store.get(CONSENT_KEY)).toBe('{not json')
716
+ })
717
+
718
+ test('matches by base tag and by the receiver\'s spelling, like the TypeScript path', () => {
719
+ const grant = { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false }
720
+ arrive('de-CH', grant)
721
+ run()
722
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('de')
723
+
724
+ env.reset()
725
+ arrive('pl', grant)
726
+ run({ linker: { domains: DOMAINS, language: { supported: ['PL'] } } })
727
+ expect(env.store.get(CONSENT_LANGUAGE_KEY)).toBe('PL')
728
+ })
729
+
730
+ test('agrees with the TypeScript path on what it refuses', () => {
731
+ arrive('ja')
732
+ run()
733
+ expect(env.store.has(CONSENT_LANGUAGE_KEY)).toBe(false)
734
+
735
+ arrive('pl', null, 'https://evil.test/')
736
+ run()
737
+ expect(env.store.has(CONSENT_LANGUAGE_KEY)).toBe(false)
738
+
739
+ arrive('pl', null, '')
740
+ run()
741
+ expect(env.store.has(CONSENT_LANGUAGE_KEY)).toBe(false)
742
+ })
743
+
744
+ test('honours a custom storage key', () => {
745
+ arrive('pl', { [CONSENT_ANALYTICS]: true, [CONSENT_MARKETING]: false })
746
+ run({ linker: { domains: DOMAINS, language: { supported: ['pl'], storageKey: 'app-lng' } } })
747
+
748
+ expect(env.store.get('app-lng')).toBe('pl')
749
+ })
750
+
751
+ test('a page that only sends carries no language code at all', () => {
752
+ const script = consentLinkerScript({ linker: { domains: DOMAINS, language: {} } })
753
+
754
+ expect(script).not.toContain(CONSENT_LANGUAGE_KEY)
755
+ expect(consentLinkerScript(linkerOpts)).toBe(script)
756
+ })
757
+
758
+ test('the bootstrap embeds it, after the defaults and before its own storage read', () => {
759
+ const script = consentBootstrapScript(receiving)
760
+
761
+ expect(script.indexOf(CONSENT_LANGUAGE_KEY)).toBeGreaterThan(script.indexOf("'consent','default'"))
762
+ // The fragment reads storage itself (to know whether a decision exists) before it writes the
763
+ // language; the bootstrap's own read is the LAST one, after the whole fragment.
764
+ expect(script.indexOf(CONSENT_LANGUAGE_KEY)).toBeLessThan(script.lastIndexOf('w.localStorage.getItem'))
765
+ })
766
+
767
+ test('escapes "<" in a configured language too', () => {
768
+ const script = consentLinkerScript({ linker: { domains: DOMAINS, language: { supported: ['<b>'], storageKey: '<k>' } } })
769
+
770
+ expect(script).not.toContain('<b>')
771
+ expect(script).not.toContain('<k>')
772
+ })
773
+ })
774
+ })