@consentify/core 1.0.0 → 2.0.0
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.
- package/README.md +210 -118
- package/dist/index.d.ts +43 -34
- package/dist/index.js +176 -46
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +788 -0
- package/package.json +7 -2
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { createConsentify, enableConsentMode } from './index';
|
|
3
|
+
function stableStringify(o) {
|
|
4
|
+
if (o === null || typeof o !== 'object')
|
|
5
|
+
return JSON.stringify(o);
|
|
6
|
+
if (Array.isArray(o))
|
|
7
|
+
return `[${o.map(stableStringify).join(',')}]`;
|
|
8
|
+
const e = Object.entries(o).sort((a, b) => a[0].localeCompare(b[0]));
|
|
9
|
+
return `{${e.map(([k, v]) => JSON.stringify(k) + ':' + stableStringify(v)).join(',')}}`;
|
|
10
|
+
}
|
|
11
|
+
function fnv1a(str) {
|
|
12
|
+
let h = 0x811c9dc5 >>> 0;
|
|
13
|
+
for (let i = 0; i < str.length; i++) {
|
|
14
|
+
h ^= str.charCodeAt(i);
|
|
15
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
16
|
+
}
|
|
17
|
+
return ('00000000' + h.toString(16)).slice(-8);
|
|
18
|
+
}
|
|
19
|
+
function hashPolicy(categories, identifier) {
|
|
20
|
+
return fnv1a(stableStringify({ categories: [...categories].sort(), identifier: identifier ?? null }));
|
|
21
|
+
}
|
|
22
|
+
const enc = (o) => encodeURIComponent(JSON.stringify(o));
|
|
23
|
+
function setCookie(name, value) {
|
|
24
|
+
document.cookie = `${name}=${value}; Path=/`;
|
|
25
|
+
}
|
|
26
|
+
function clearAllCookies() {
|
|
27
|
+
document.cookie.split(';').forEach(c => {
|
|
28
|
+
const name = c.split('=')[0].trim();
|
|
29
|
+
if (name)
|
|
30
|
+
document.cookie = `${name}=; Max-Age=0; Path=/`;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
describe('stableStringify', () => {
|
|
34
|
+
it('produces deterministic output regardless of key order', () => {
|
|
35
|
+
expect(stableStringify({ b: 2, a: 1 })).toBe(stableStringify({ a: 1, b: 2 }));
|
|
36
|
+
});
|
|
37
|
+
it('handles nested objects', () => {
|
|
38
|
+
expect(stableStringify({ z: { b: 1, a: 2 } })).toBe('{"z":{"a":2,"b":1}}');
|
|
39
|
+
});
|
|
40
|
+
it('handles arrays', () => {
|
|
41
|
+
expect(stableStringify([3, 1, 2])).toBe('[3,1,2]');
|
|
42
|
+
});
|
|
43
|
+
it('handles null and primitives', () => {
|
|
44
|
+
expect(stableStringify(null)).toBe('null');
|
|
45
|
+
expect(stableStringify('hello')).toBe('"hello"');
|
|
46
|
+
expect(stableStringify(42)).toBe('42');
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('fnv1a', () => {
|
|
50
|
+
it('returns consistent 8-char hex string', () => {
|
|
51
|
+
const h = fnv1a('test');
|
|
52
|
+
expect(h).toMatch(/^[0-9a-f]{8}$/);
|
|
53
|
+
expect(fnv1a('test')).toBe(h);
|
|
54
|
+
});
|
|
55
|
+
it('produces different hashes for different inputs', () => {
|
|
56
|
+
expect(fnv1a('abc')).not.toBe(fnv1a('def'));
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
describe('hashPolicy', () => {
|
|
60
|
+
it('is stable across category order', () => {
|
|
61
|
+
expect(hashPolicy(['a', 'b'])).toBe(hashPolicy(['b', 'a']));
|
|
62
|
+
});
|
|
63
|
+
it('changes when categories change', () => {
|
|
64
|
+
expect(hashPolicy(['a'])).not.toBe(hashPolicy(['a', 'b']));
|
|
65
|
+
});
|
|
66
|
+
it('folds identifier into hash', () => {
|
|
67
|
+
expect(hashPolicy(['a'], 'v1')).not.toBe(hashPolicy(['a']));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('readCookie (via server.get)', () => {
|
|
71
|
+
it('returns unset when no cookie', () => {
|
|
72
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
73
|
+
expect(c.server.get('')).toEqual({ decision: 'unset' });
|
|
74
|
+
});
|
|
75
|
+
it('returns unset for null/undefined', () => {
|
|
76
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
77
|
+
expect(c.server.get(null)).toEqual({ decision: 'unset' });
|
|
78
|
+
expect(c.server.get(undefined)).toEqual({ decision: 'unset' });
|
|
79
|
+
});
|
|
80
|
+
it('parses cookie among multiple cookies', () => {
|
|
81
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
82
|
+
const snapshot = {
|
|
83
|
+
policy: c.policy.identifier,
|
|
84
|
+
givenAt: new Date().toISOString(),
|
|
85
|
+
choices: { necessary: true, analytics: true },
|
|
86
|
+
};
|
|
87
|
+
const header = `other=foo; consentify=${enc(snapshot)}; another=bar`;
|
|
88
|
+
const state = c.server.get(header);
|
|
89
|
+
expect(state.decision).toBe('decided');
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe('writeCookie (via client)', () => {
|
|
93
|
+
beforeEach(clearAllCookies);
|
|
94
|
+
it('writes to document.cookie via client.set()', () => {
|
|
95
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
96
|
+
c.client.set({ analytics: true });
|
|
97
|
+
expect(document.cookie).toContain('consentify=');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe('isValidSnapshot (via server.get)', () => {
|
|
101
|
+
const makeInstance = () => createConsentify({ policy: { categories: ['analytics'] } });
|
|
102
|
+
it('accepts a valid snapshot', () => {
|
|
103
|
+
const c = makeInstance();
|
|
104
|
+
const snapshot = {
|
|
105
|
+
policy: c.policy.identifier,
|
|
106
|
+
givenAt: new Date().toISOString(),
|
|
107
|
+
choices: { necessary: true, analytics: false },
|
|
108
|
+
};
|
|
109
|
+
const header = `consentify=${enc(snapshot)}`;
|
|
110
|
+
expect(c.server.get(header).decision).toBe('decided');
|
|
111
|
+
});
|
|
112
|
+
it('rejects missing fields', () => {
|
|
113
|
+
const c = makeInstance();
|
|
114
|
+
const bad = { policy: c.policy.identifier, choices: { necessary: true, analytics: false } };
|
|
115
|
+
expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
|
|
116
|
+
});
|
|
117
|
+
it('rejects non-boolean choices', () => {
|
|
118
|
+
const c = makeInstance();
|
|
119
|
+
const bad = {
|
|
120
|
+
policy: c.policy.identifier,
|
|
121
|
+
givenAt: new Date().toISOString(),
|
|
122
|
+
choices: { necessary: true, analytics: 'yes' },
|
|
123
|
+
};
|
|
124
|
+
expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
|
|
125
|
+
});
|
|
126
|
+
it('rejects invalid dates', () => {
|
|
127
|
+
const c = makeInstance();
|
|
128
|
+
const bad = {
|
|
129
|
+
policy: c.policy.identifier,
|
|
130
|
+
givenAt: 'not-a-date',
|
|
131
|
+
choices: { necessary: true, analytics: false },
|
|
132
|
+
};
|
|
133
|
+
expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
|
|
134
|
+
});
|
|
135
|
+
it('rejects empty policy string', () => {
|
|
136
|
+
const c = makeInstance();
|
|
137
|
+
const bad = {
|
|
138
|
+
policy: '',
|
|
139
|
+
givenAt: new Date().toISOString(),
|
|
140
|
+
choices: { necessary: true, analytics: false },
|
|
141
|
+
};
|
|
142
|
+
expect(c.server.get(`consentify=${enc(bad)}`).decision).toBe('unset');
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
describe('server API', () => {
|
|
146
|
+
it('get() returns unset when no cookie', () => {
|
|
147
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
148
|
+
expect(c.server.get('')).toEqual({ decision: 'unset' });
|
|
149
|
+
});
|
|
150
|
+
it('get() returns decided with valid cookie', () => {
|
|
151
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
152
|
+
const snapshot = {
|
|
153
|
+
policy: c.policy.identifier,
|
|
154
|
+
givenAt: new Date().toISOString(),
|
|
155
|
+
choices: { necessary: true, analytics: true },
|
|
156
|
+
};
|
|
157
|
+
const state = c.server.get(`consentify=${enc(snapshot)}`);
|
|
158
|
+
expect(state.decision).toBe('decided');
|
|
159
|
+
if (state.decision === 'decided') {
|
|
160
|
+
expect(state.snapshot.choices.analytics).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
it('get() returns unset on policy mismatch', () => {
|
|
164
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
165
|
+
const snapshot = {
|
|
166
|
+
policy: 'wrong-hash',
|
|
167
|
+
givenAt: new Date().toISOString(),
|
|
168
|
+
choices: { necessary: true, analytics: true },
|
|
169
|
+
};
|
|
170
|
+
expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
|
|
171
|
+
});
|
|
172
|
+
it('get() returns unset on expired consent', () => {
|
|
173
|
+
const c = createConsentify({
|
|
174
|
+
policy: { categories: ['analytics'] },
|
|
175
|
+
consentMaxAgeDays: 1,
|
|
176
|
+
});
|
|
177
|
+
const oldDate = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
|
|
178
|
+
const snapshot = {
|
|
179
|
+
policy: c.policy.identifier,
|
|
180
|
+
givenAt: oldDate,
|
|
181
|
+
choices: { necessary: true, analytics: true },
|
|
182
|
+
};
|
|
183
|
+
expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
|
|
184
|
+
});
|
|
185
|
+
it('set() returns a Set-Cookie header string', () => {
|
|
186
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
187
|
+
const header = c.server.set({ analytics: true });
|
|
188
|
+
expect(header).toContain('consentify=');
|
|
189
|
+
expect(header).toContain('Path=/');
|
|
190
|
+
expect(header).toContain('SameSite=Lax');
|
|
191
|
+
});
|
|
192
|
+
it('clear() returns a clearing header with Max-Age=0', () => {
|
|
193
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
194
|
+
const header = c.server.clear();
|
|
195
|
+
expect(header).toContain('Max-Age=0');
|
|
196
|
+
expect(header).toContain('consentify=;');
|
|
197
|
+
});
|
|
198
|
+
it('necessary is always true in server.set()', () => {
|
|
199
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
200
|
+
const header = c.server.set({ necessary: false });
|
|
201
|
+
const val = header.split(';')[0].split('=').slice(1).join('=');
|
|
202
|
+
const snapshot = JSON.parse(decodeURIComponent(val));
|
|
203
|
+
expect(snapshot.choices.necessary).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
describe('client API', () => {
|
|
207
|
+
beforeEach(clearAllCookies);
|
|
208
|
+
it('get() returns unset initially', () => {
|
|
209
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
210
|
+
expect(c.client.get()).toEqual({ decision: 'unset' });
|
|
211
|
+
});
|
|
212
|
+
it('get(category) returns boolean', () => {
|
|
213
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
214
|
+
expect(c.client.get('necessary')).toBe(true);
|
|
215
|
+
expect(c.client.get('analytics')).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
it('set() stores and reads back', () => {
|
|
218
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
219
|
+
c.client.set({ analytics: true });
|
|
220
|
+
const state = c.client.get();
|
|
221
|
+
expect(state.decision).toBe('decided');
|
|
222
|
+
if (state.decision === 'decided') {
|
|
223
|
+
expect(state.snapshot.choices.analytics).toBe(true);
|
|
224
|
+
expect(state.snapshot.choices.necessary).toBe(true);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
it('set() race condition: sequential sets preserve both', () => {
|
|
228
|
+
const c = createConsentify({ policy: { categories: ['analytics', 'marketing'] } });
|
|
229
|
+
c.client.set({ analytics: true });
|
|
230
|
+
c.client.set({ marketing: true });
|
|
231
|
+
const state = c.client.get();
|
|
232
|
+
expect(state.decision).toBe('decided');
|
|
233
|
+
if (state.decision === 'decided') {
|
|
234
|
+
expect(state.snapshot.choices.analytics).toBe(true);
|
|
235
|
+
expect(state.snapshot.choices.marketing).toBe(true);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
it('clear() resets to unset', () => {
|
|
239
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
240
|
+
c.client.set({ analytics: true });
|
|
241
|
+
expect(c.client.get().decision).toBe('decided');
|
|
242
|
+
c.client.clear();
|
|
243
|
+
expect(c.client.get()).toEqual({ decision: 'unset' });
|
|
244
|
+
});
|
|
245
|
+
it('subscribe() callback fired on set and clear', () => {
|
|
246
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
247
|
+
const cb = vi.fn();
|
|
248
|
+
const unsub = c.client.subscribe(cb);
|
|
249
|
+
c.client.set({ analytics: true });
|
|
250
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
251
|
+
c.client.clear();
|
|
252
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
253
|
+
unsub();
|
|
254
|
+
c.client.set({ analytics: false });
|
|
255
|
+
expect(cb).toHaveBeenCalledTimes(2);
|
|
256
|
+
});
|
|
257
|
+
it('subscribe() one error does not break other listeners', () => {
|
|
258
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
259
|
+
const bad = vi.fn(() => { throw new Error('boom'); });
|
|
260
|
+
const good = vi.fn();
|
|
261
|
+
c.client.subscribe(bad);
|
|
262
|
+
c.client.subscribe(good);
|
|
263
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
264
|
+
c.client.set({ analytics: true });
|
|
265
|
+
expect(bad).toHaveBeenCalled();
|
|
266
|
+
expect(good).toHaveBeenCalled();
|
|
267
|
+
spy.mockRestore();
|
|
268
|
+
});
|
|
269
|
+
it('subscribe() error is logged via console.error', () => {
|
|
270
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
271
|
+
const err = new Error('boom');
|
|
272
|
+
c.client.subscribe(() => { throw err; });
|
|
273
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
274
|
+
c.client.set({ analytics: true });
|
|
275
|
+
expect(spy).toHaveBeenCalledWith('[consentify] Listener callback threw:', err);
|
|
276
|
+
spy.mockRestore();
|
|
277
|
+
});
|
|
278
|
+
it('getServerSnapshot() always returns unset', () => {
|
|
279
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
280
|
+
c.client.set({ analytics: true });
|
|
281
|
+
expect(c.client.getServerSnapshot()).toEqual({ decision: 'unset' });
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
describe('storage fallback', () => {
|
|
285
|
+
beforeEach(clearAllCookies);
|
|
286
|
+
it('localStorage primary with cookie mirror', () => {
|
|
287
|
+
const c = createConsentify({
|
|
288
|
+
policy: { categories: ['analytics'] },
|
|
289
|
+
storage: ['localStorage', 'cookie'],
|
|
290
|
+
});
|
|
291
|
+
c.client.set({ analytics: true });
|
|
292
|
+
expect(window.localStorage.getItem('consentify')).toBeTruthy();
|
|
293
|
+
expect(document.cookie).toContain('consentify=');
|
|
294
|
+
});
|
|
295
|
+
it('localStorage failure falls back gracefully', () => {
|
|
296
|
+
const orig = window.localStorage.setItem;
|
|
297
|
+
window.localStorage.setItem = () => { throw new DOMException('QuotaExceeded'); };
|
|
298
|
+
const c = createConsentify({
|
|
299
|
+
policy: { categories: ['analytics'] },
|
|
300
|
+
storage: ['localStorage', 'cookie'],
|
|
301
|
+
});
|
|
302
|
+
const spy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
|
303
|
+
expect(() => c.client.set({ analytics: true })).not.toThrow();
|
|
304
|
+
expect(c.client.get('analytics')).toBe(true);
|
|
305
|
+
spy.mockRestore();
|
|
306
|
+
window.localStorage.setItem = orig;
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
describe('policy versioning', () => {
|
|
310
|
+
beforeEach(clearAllCookies);
|
|
311
|
+
it('changed categories invalidate consent', () => {
|
|
312
|
+
const c1 = createConsentify({ policy: { categories: ['analytics'] } });
|
|
313
|
+
c1.client.set({ analytics: true });
|
|
314
|
+
const c2 = createConsentify({ policy: { categories: ['analytics', 'marketing'] } });
|
|
315
|
+
expect(c2.client.get()).toEqual({ decision: 'unset' });
|
|
316
|
+
});
|
|
317
|
+
it('custom identifier works', () => {
|
|
318
|
+
const c = createConsentify({
|
|
319
|
+
policy: { categories: ['analytics'], identifier: 'v2' },
|
|
320
|
+
});
|
|
321
|
+
expect(c.policy.identifier).toBe('v2');
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
describe('consent expiration', () => {
|
|
325
|
+
beforeEach(clearAllCookies);
|
|
326
|
+
it('fresh consent is valid', () => {
|
|
327
|
+
const c = createConsentify({
|
|
328
|
+
policy: { categories: ['analytics'] },
|
|
329
|
+
consentMaxAgeDays: 365,
|
|
330
|
+
});
|
|
331
|
+
const snapshot = {
|
|
332
|
+
policy: c.policy.identifier,
|
|
333
|
+
givenAt: new Date().toISOString(),
|
|
334
|
+
choices: { necessary: true, analytics: true },
|
|
335
|
+
};
|
|
336
|
+
expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('decided');
|
|
337
|
+
});
|
|
338
|
+
it('old consent is expired', () => {
|
|
339
|
+
const c = createConsentify({
|
|
340
|
+
policy: { categories: ['analytics'] },
|
|
341
|
+
consentMaxAgeDays: 30,
|
|
342
|
+
});
|
|
343
|
+
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
|
344
|
+
const snapshot = {
|
|
345
|
+
policy: c.policy.identifier,
|
|
346
|
+
givenAt: oldDate,
|
|
347
|
+
choices: { necessary: true, analytics: true },
|
|
348
|
+
};
|
|
349
|
+
expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
|
|
350
|
+
});
|
|
351
|
+
it('invalid date treated as expired', () => {
|
|
352
|
+
const c = createConsentify({
|
|
353
|
+
policy: { categories: ['analytics'] },
|
|
354
|
+
consentMaxAgeDays: 365,
|
|
355
|
+
});
|
|
356
|
+
const snapshot = {
|
|
357
|
+
policy: c.policy.identifier,
|
|
358
|
+
givenAt: 'invalid-date',
|
|
359
|
+
choices: { necessary: true, analytics: true },
|
|
360
|
+
};
|
|
361
|
+
expect(c.server.get(`consentify=${enc(snapshot)}`).decision).toBe('unset');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
describe('client.guard()', () => {
|
|
365
|
+
beforeEach(clearAllCookies);
|
|
366
|
+
it('fires immediately when already consented', () => {
|
|
367
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
368
|
+
c.client.set({ analytics: true });
|
|
369
|
+
const onGrant = vi.fn();
|
|
370
|
+
c.client.guard('analytics', onGrant);
|
|
371
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
372
|
+
});
|
|
373
|
+
it('defers until consent is granted', () => {
|
|
374
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
375
|
+
const onGrant = vi.fn();
|
|
376
|
+
c.client.guard('analytics', onGrant);
|
|
377
|
+
expect(onGrant).not.toHaveBeenCalled();
|
|
378
|
+
c.client.set({ analytics: true });
|
|
379
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
380
|
+
});
|
|
381
|
+
it('onRevoke fires when consent is withdrawn', () => {
|
|
382
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
383
|
+
const onGrant = vi.fn();
|
|
384
|
+
const onRevoke = vi.fn();
|
|
385
|
+
c.client.guard('analytics', onGrant, onRevoke);
|
|
386
|
+
c.client.set({ analytics: true });
|
|
387
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
388
|
+
c.client.set({ analytics: false });
|
|
389
|
+
expect(onRevoke).toHaveBeenCalledTimes(1);
|
|
390
|
+
});
|
|
391
|
+
it('does not fire onGrant again after revoke', () => {
|
|
392
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
393
|
+
const onGrant = vi.fn();
|
|
394
|
+
const onRevoke = vi.fn();
|
|
395
|
+
c.client.guard('analytics', onGrant, onRevoke);
|
|
396
|
+
c.client.set({ analytics: true });
|
|
397
|
+
c.client.set({ analytics: false });
|
|
398
|
+
c.client.set({ analytics: true });
|
|
399
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
400
|
+
expect(onRevoke).toHaveBeenCalledTimes(1);
|
|
401
|
+
});
|
|
402
|
+
it('dispose cancels before grant', () => {
|
|
403
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
404
|
+
const onGrant = vi.fn();
|
|
405
|
+
const dispose = c.client.guard('analytics', onGrant);
|
|
406
|
+
dispose();
|
|
407
|
+
c.client.set({ analytics: true });
|
|
408
|
+
expect(onGrant).not.toHaveBeenCalled();
|
|
409
|
+
});
|
|
410
|
+
it('dispose cancels before revoke', () => {
|
|
411
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
412
|
+
const onGrant = vi.fn();
|
|
413
|
+
const onRevoke = vi.fn();
|
|
414
|
+
c.client.guard('analytics', onGrant, onRevoke);
|
|
415
|
+
c.client.set({ analytics: true });
|
|
416
|
+
const dispose = c.client.guard('analytics', vi.fn(), onRevoke);
|
|
417
|
+
dispose();
|
|
418
|
+
c.client.set({ analytics: false });
|
|
419
|
+
expect(onRevoke).toHaveBeenCalledTimes(1);
|
|
420
|
+
});
|
|
421
|
+
it('guard("necessary") fires immediately (always true)', () => {
|
|
422
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
423
|
+
const onGrant = vi.fn();
|
|
424
|
+
c.client.guard('necessary', onGrant);
|
|
425
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
426
|
+
});
|
|
427
|
+
it('without onRevoke stops watching after grant', () => {
|
|
428
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
429
|
+
const onGrant = vi.fn();
|
|
430
|
+
c.client.guard('analytics', onGrant);
|
|
431
|
+
c.client.set({ analytics: true });
|
|
432
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
433
|
+
c.client.set({ analytics: false });
|
|
434
|
+
c.client.set({ analytics: true });
|
|
435
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
describe('unified top-level API', () => {
|
|
439
|
+
beforeEach(clearAllCookies);
|
|
440
|
+
it('get() delegates to client.get()', () => {
|
|
441
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
442
|
+
expect(c.get()).toEqual({ decision: 'unset' });
|
|
443
|
+
c.client.set({ analytics: true });
|
|
444
|
+
expect(c.get().decision).toBe('decided');
|
|
445
|
+
});
|
|
446
|
+
it('get(cookieHeader) delegates to server.get()', () => {
|
|
447
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
448
|
+
const snapshot = {
|
|
449
|
+
policy: c.policy.identifier,
|
|
450
|
+
givenAt: new Date().toISOString(),
|
|
451
|
+
choices: { necessary: true, analytics: true },
|
|
452
|
+
};
|
|
453
|
+
const header = `consentify=${enc(snapshot)}`;
|
|
454
|
+
const state = c.get(header);
|
|
455
|
+
expect(state.decision).toBe('decided');
|
|
456
|
+
});
|
|
457
|
+
it('get(null) falls through to client.get()', () => {
|
|
458
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
459
|
+
expect(c.get(null)).toEqual({ decision: 'unset' });
|
|
460
|
+
});
|
|
461
|
+
it('get("") delegates to server.get() and returns unset', () => {
|
|
462
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
463
|
+
expect(c.get('')).toEqual({ decision: 'unset' });
|
|
464
|
+
});
|
|
465
|
+
it('isGranted("analytics") returns correct boolean', () => {
|
|
466
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
467
|
+
expect(c.isGranted('analytics')).toBe(false);
|
|
468
|
+
c.client.set({ analytics: true });
|
|
469
|
+
expect(c.isGranted('analytics')).toBe(true);
|
|
470
|
+
});
|
|
471
|
+
it('isGranted("necessary") always returns true', () => {
|
|
472
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
473
|
+
expect(c.isGranted('necessary')).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
it('set(choices) delegates to client.set()', () => {
|
|
476
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
477
|
+
c.set({ analytics: true });
|
|
478
|
+
expect(c.client.get('analytics')).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
it('set(choices, cookieHeader) returns Set-Cookie string', () => {
|
|
481
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
482
|
+
const result = c.set({ analytics: true }, '');
|
|
483
|
+
expect(typeof result).toBe('string');
|
|
484
|
+
expect(result).toContain('consentify=');
|
|
485
|
+
});
|
|
486
|
+
it('clear() delegates to client.clear()', () => {
|
|
487
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
488
|
+
c.client.set({ analytics: true });
|
|
489
|
+
expect(c.get().decision).toBe('decided');
|
|
490
|
+
c.clear();
|
|
491
|
+
expect(c.get()).toEqual({ decision: 'unset' });
|
|
492
|
+
});
|
|
493
|
+
it('clear(cookieHeader) returns clearing header', () => {
|
|
494
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
495
|
+
const result = c.clear('somecookie=value');
|
|
496
|
+
expect(typeof result).toBe('string');
|
|
497
|
+
expect(result).toContain('Max-Age=0');
|
|
498
|
+
});
|
|
499
|
+
it('subscribe(cb) works at top level', () => {
|
|
500
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
501
|
+
const cb = vi.fn();
|
|
502
|
+
const unsub = c.subscribe(cb);
|
|
503
|
+
c.set({ analytics: true });
|
|
504
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
505
|
+
unsub();
|
|
506
|
+
c.set({ analytics: false });
|
|
507
|
+
expect(cb).toHaveBeenCalledTimes(1);
|
|
508
|
+
});
|
|
509
|
+
it('guard() works at top level', () => {
|
|
510
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
511
|
+
const onGrant = vi.fn();
|
|
512
|
+
c.guard('analytics', onGrant);
|
|
513
|
+
expect(onGrant).not.toHaveBeenCalled();
|
|
514
|
+
c.set({ analytics: true });
|
|
515
|
+
expect(onGrant).toHaveBeenCalledTimes(1);
|
|
516
|
+
});
|
|
517
|
+
it('getServerSnapshot() returns unset', () => {
|
|
518
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
519
|
+
expect(c.getServerSnapshot()).toEqual({ decision: 'unset' });
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
function findGtagCall(action, type) {
|
|
523
|
+
for (const entry of window.dataLayer) {
|
|
524
|
+
const args = Array.from(entry);
|
|
525
|
+
if (args[0] === action && args[1] === type) {
|
|
526
|
+
return args[2];
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
function countGtagCalls(action, type) {
|
|
532
|
+
let count = 0;
|
|
533
|
+
for (const entry of window.dataLayer) {
|
|
534
|
+
const args = Array.from(entry);
|
|
535
|
+
if (args[0] === action && args[1] === type)
|
|
536
|
+
count++;
|
|
537
|
+
}
|
|
538
|
+
return count;
|
|
539
|
+
}
|
|
540
|
+
describe('enableConsentMode', () => {
|
|
541
|
+
let consent;
|
|
542
|
+
beforeEach(() => {
|
|
543
|
+
delete window.dataLayer;
|
|
544
|
+
delete window.gtag;
|
|
545
|
+
clearAllCookies();
|
|
546
|
+
localStorage.clear();
|
|
547
|
+
consent = createConsentify({
|
|
548
|
+
policy: { categories: ['analytics', 'marketing', 'preferences'] },
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
it('returns no-op dispose and makes no gtag calls in SSR', () => {
|
|
552
|
+
const origWindow = globalThis.window;
|
|
553
|
+
Object.defineProperty(globalThis, 'window', { value: undefined, configurable: true });
|
|
554
|
+
const dispose = enableConsentMode(consent, {
|
|
555
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
556
|
+
});
|
|
557
|
+
expect(dispose).toBeTypeOf('function');
|
|
558
|
+
dispose();
|
|
559
|
+
Object.defineProperty(globalThis, 'window', { value: origWindow, configurable: true });
|
|
560
|
+
});
|
|
561
|
+
it('bootstraps dataLayer and gtag if missing', () => {
|
|
562
|
+
expect(window.dataLayer).toBeUndefined();
|
|
563
|
+
expect(window.gtag).toBeUndefined();
|
|
564
|
+
enableConsentMode(consent, {
|
|
565
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
566
|
+
});
|
|
567
|
+
expect(Array.isArray(window.dataLayer)).toBe(true);
|
|
568
|
+
expect(typeof window.gtag).toBe('function');
|
|
569
|
+
});
|
|
570
|
+
it('preserves existing dataLayer and gtag', () => {
|
|
571
|
+
const existingData = [{ event: 'existing' }];
|
|
572
|
+
window.dataLayer = existingData;
|
|
573
|
+
const customGtag = vi.fn(function gtag() { window.dataLayer.push(arguments); });
|
|
574
|
+
window.gtag = customGtag;
|
|
575
|
+
enableConsentMode(consent, {
|
|
576
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
577
|
+
});
|
|
578
|
+
expect(window.dataLayer[0]).toEqual({ event: 'existing' });
|
|
579
|
+
expect(customGtag).toHaveBeenCalled();
|
|
580
|
+
});
|
|
581
|
+
it('calls gtag consent default on init with mapped types as denied', () => {
|
|
582
|
+
enableConsentMode(consent, {
|
|
583
|
+
mapping: {
|
|
584
|
+
analytics: ['analytics_storage'],
|
|
585
|
+
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
|
|
586
|
+
},
|
|
587
|
+
});
|
|
588
|
+
const defaultCall = findGtagCall('consent', 'default');
|
|
589
|
+
expect(defaultCall).toBeDefined();
|
|
590
|
+
expect(defaultCall.analytics_storage).toBe('denied');
|
|
591
|
+
expect(defaultCall.ad_storage).toBe('denied');
|
|
592
|
+
expect(defaultCall.ad_user_data).toBe('denied');
|
|
593
|
+
expect(defaultCall.ad_personalization).toBe('denied');
|
|
594
|
+
});
|
|
595
|
+
it('passes wait_for_update in default call when provided', () => {
|
|
596
|
+
enableConsentMode(consent, {
|
|
597
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
598
|
+
waitForUpdate: 500,
|
|
599
|
+
});
|
|
600
|
+
const defaultCall = findGtagCall('consent', 'default');
|
|
601
|
+
expect(defaultCall).toBeDefined();
|
|
602
|
+
expect(defaultCall.wait_for_update).toBe(500);
|
|
603
|
+
});
|
|
604
|
+
it('does not include wait_for_update when not provided', () => {
|
|
605
|
+
enableConsentMode(consent, {
|
|
606
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
607
|
+
});
|
|
608
|
+
const defaultCall = findGtagCall('consent', 'default');
|
|
609
|
+
expect(defaultCall).toBeDefined();
|
|
610
|
+
expect(defaultCall).not.toHaveProperty('wait_for_update');
|
|
611
|
+
});
|
|
612
|
+
it('calls both default and update if consent already decided', () => {
|
|
613
|
+
consent.set({ analytics: true, marketing: false });
|
|
614
|
+
enableConsentMode(consent, {
|
|
615
|
+
mapping: {
|
|
616
|
+
analytics: ['analytics_storage'],
|
|
617
|
+
marketing: ['ad_storage'],
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
expect(countGtagCalls('consent', 'default')).toBe(1);
|
|
621
|
+
expect(countGtagCalls('consent', 'update')).toBe(1);
|
|
622
|
+
const updateCall = findGtagCall('consent', 'update');
|
|
623
|
+
expect(updateCall.analytics_storage).toBe('granted');
|
|
624
|
+
expect(updateCall.ad_storage).toBe('denied');
|
|
625
|
+
});
|
|
626
|
+
it('only calls default if consent is unset', () => {
|
|
627
|
+
enableConsentMode(consent, {
|
|
628
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
629
|
+
});
|
|
630
|
+
expect(countGtagCalls('consent', 'default')).toBe(1);
|
|
631
|
+
expect(countGtagCalls('consent', 'update')).toBe(0);
|
|
632
|
+
});
|
|
633
|
+
it('calls gtag consent update on set()', () => {
|
|
634
|
+
enableConsentMode(consent, {
|
|
635
|
+
mapping: {
|
|
636
|
+
analytics: ['analytics_storage'],
|
|
637
|
+
marketing: ['ad_storage', 'ad_user_data'],
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
consent.set({ analytics: true, marketing: false });
|
|
641
|
+
const updateCalls = window.dataLayer.filter(entry => {
|
|
642
|
+
const args = Array.from(entry);
|
|
643
|
+
return args[0] === 'consent' && args[1] === 'update';
|
|
644
|
+
});
|
|
645
|
+
expect(updateCalls.length).toBeGreaterThanOrEqual(1);
|
|
646
|
+
const lastUpdate = Array.from(updateCalls[updateCalls.length - 1]);
|
|
647
|
+
const payload = lastUpdate[2];
|
|
648
|
+
expect(payload.analytics_storage).toBe('granted');
|
|
649
|
+
expect(payload.ad_storage).toBe('denied');
|
|
650
|
+
expect(payload.ad_user_data).toBe('denied');
|
|
651
|
+
});
|
|
652
|
+
it('maps multiple categories correctly', () => {
|
|
653
|
+
enableConsentMode(consent, {
|
|
654
|
+
mapping: {
|
|
655
|
+
analytics: ['analytics_storage'],
|
|
656
|
+
marketing: ['ad_storage'],
|
|
657
|
+
preferences: ['functionality_storage', 'personalization_storage'],
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
consent.set({ analytics: true, marketing: false, preferences: true });
|
|
661
|
+
const updateCalls = window.dataLayer.filter(entry => {
|
|
662
|
+
const args = Array.from(entry);
|
|
663
|
+
return args[0] === 'consent' && args[1] === 'update';
|
|
664
|
+
});
|
|
665
|
+
const lastUpdate = Array.from(updateCalls[updateCalls.length - 1]);
|
|
666
|
+
const payload = lastUpdate[2];
|
|
667
|
+
expect(payload.analytics_storage).toBe('granted');
|
|
668
|
+
expect(payload.ad_storage).toBe('denied');
|
|
669
|
+
expect(payload.functionality_storage).toBe('granted');
|
|
670
|
+
expect(payload.personalization_storage).toBe('granted');
|
|
671
|
+
});
|
|
672
|
+
it('maps necessary to granted always', () => {
|
|
673
|
+
enableConsentMode(consent, {
|
|
674
|
+
mapping: {
|
|
675
|
+
necessary: ['security_storage'],
|
|
676
|
+
analytics: ['analytics_storage'],
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
const defaultCall = findGtagCall('consent', 'default');
|
|
680
|
+
expect(defaultCall.security_storage).toBe('granted');
|
|
681
|
+
expect(defaultCall.analytics_storage).toBe('denied');
|
|
682
|
+
});
|
|
683
|
+
it('dispose stops future updates', () => {
|
|
684
|
+
const dispose = enableConsentMode(consent, {
|
|
685
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
686
|
+
});
|
|
687
|
+
dispose();
|
|
688
|
+
const countBefore = countGtagCalls('consent', 'update');
|
|
689
|
+
consent.set({ analytics: true });
|
|
690
|
+
const countAfter = countGtagCalls('consent', 'update');
|
|
691
|
+
expect(countAfter).toBe(countBefore);
|
|
692
|
+
});
|
|
693
|
+
it('handles clear() (consent revoked)', () => {
|
|
694
|
+
enableConsentMode(consent, {
|
|
695
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
696
|
+
});
|
|
697
|
+
consent.set({ analytics: true });
|
|
698
|
+
const updatesBefore = countGtagCalls('consent', 'update');
|
|
699
|
+
consent.clear();
|
|
700
|
+
const updatesAfter = countGtagCalls('consent', 'update');
|
|
701
|
+
expect(updatesAfter).toBe(updatesBefore);
|
|
702
|
+
});
|
|
703
|
+
it('survives a throwing gtag and still subscribes', () => {
|
|
704
|
+
window.dataLayer = [];
|
|
705
|
+
window.gtag = vi.fn(() => { throw new Error('gtag broke'); });
|
|
706
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
707
|
+
const dispose = enableConsentMode(consent, {
|
|
708
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
709
|
+
});
|
|
710
|
+
expect(spy).toHaveBeenCalledWith('[consentify] gtag call failed:', expect.any(Error));
|
|
711
|
+
window.gtag = function gtag() { window.dataLayer.push(arguments); };
|
|
712
|
+
consent.set({ analytics: true });
|
|
713
|
+
expect(countGtagCalls('consent', 'update')).toBeGreaterThanOrEqual(1);
|
|
714
|
+
dispose();
|
|
715
|
+
spy.mockRestore();
|
|
716
|
+
});
|
|
717
|
+
it('works with a minimal ConsentifySubscribable (not a full instance)', () => {
|
|
718
|
+
let state = { decision: 'unset' };
|
|
719
|
+
const listeners = new Set();
|
|
720
|
+
const subscribable = {
|
|
721
|
+
subscribe: (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
|
|
722
|
+
get: () => state,
|
|
723
|
+
getServerSnapshot: () => ({ decision: 'unset' }),
|
|
724
|
+
};
|
|
725
|
+
const dispose = enableConsentMode(subscribable, {
|
|
726
|
+
mapping: { analytics: ['analytics_storage'] },
|
|
727
|
+
});
|
|
728
|
+
const defaultCall = findGtagCall('consent', 'default');
|
|
729
|
+
expect(defaultCall).toBeDefined();
|
|
730
|
+
expect(defaultCall.analytics_storage).toBe('denied');
|
|
731
|
+
state = {
|
|
732
|
+
decision: 'decided',
|
|
733
|
+
snapshot: {
|
|
734
|
+
policy: 'x',
|
|
735
|
+
givenAt: new Date().toISOString(),
|
|
736
|
+
choices: { necessary: true, analytics: true },
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
listeners.forEach(cb => cb());
|
|
740
|
+
const updateCall = findGtagCall('consent', 'update');
|
|
741
|
+
expect(updateCall).toBeDefined();
|
|
742
|
+
expect(updateCall.analytics_storage).toBe('granted');
|
|
743
|
+
dispose();
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
describe('server API — merge & cookie config', () => {
|
|
747
|
+
it('server.set() merges with existing consent from currentCookieHeader', () => {
|
|
748
|
+
const c = createConsentify({ policy: { categories: ['analytics', 'marketing'] } });
|
|
749
|
+
const header1 = c.server.set({ analytics: true });
|
|
750
|
+
const cookieVal = header1.split(';')[0];
|
|
751
|
+
const header2 = c.server.set({ marketing: true }, cookieVal);
|
|
752
|
+
const val = header2.split(';')[0].split('=').slice(1).join('=');
|
|
753
|
+
const snapshot = JSON.parse(decodeURIComponent(val));
|
|
754
|
+
expect(snapshot.choices.analytics).toBe(true);
|
|
755
|
+
expect(snapshot.choices.marketing).toBe(true);
|
|
756
|
+
});
|
|
757
|
+
it('SameSite=None forces Secure flag in server headers', () => {
|
|
758
|
+
const c = createConsentify({
|
|
759
|
+
policy: { categories: ['analytics'] },
|
|
760
|
+
cookie: { sameSite: 'None', secure: false },
|
|
761
|
+
});
|
|
762
|
+
const header = c.server.set({ analytics: true });
|
|
763
|
+
expect(header).toContain('SameSite=None');
|
|
764
|
+
expect(header).toContain('Secure');
|
|
765
|
+
});
|
|
766
|
+
it('domain option appears in Set-Cookie header', () => {
|
|
767
|
+
const c = createConsentify({
|
|
768
|
+
policy: { categories: ['analytics'] },
|
|
769
|
+
cookie: { domain: '.example.com' },
|
|
770
|
+
});
|
|
771
|
+
const header = c.server.set({ analytics: true });
|
|
772
|
+
expect(header).toContain('Domain=.example.com');
|
|
773
|
+
});
|
|
774
|
+
it('domain option appears in clear header', () => {
|
|
775
|
+
const c = createConsentify({
|
|
776
|
+
policy: { categories: ['analytics'] },
|
|
777
|
+
cookie: { domain: '.example.com' },
|
|
778
|
+
});
|
|
779
|
+
const header = c.server.clear();
|
|
780
|
+
expect(header).toContain('Domain=.example.com');
|
|
781
|
+
});
|
|
782
|
+
it('clear() returns the same header regardless of input', () => {
|
|
783
|
+
const c = createConsentify({ policy: { categories: ['analytics'] } });
|
|
784
|
+
const result1 = c.clear('foo=bar');
|
|
785
|
+
const result2 = c.clear('baz=qux');
|
|
786
|
+
expect(result1).toBe(result2);
|
|
787
|
+
});
|
|
788
|
+
});
|