@consentify/core 1.1.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/dist/index.d.ts +42 -38
- package/dist/index.js +159 -50
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +788 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,43 +1,15 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Literal type for the non-optional category that is always enabled.
|
|
3
|
-
*/
|
|
4
1
|
export type Necessary = 'necessary';
|
|
5
|
-
/**
|
|
6
|
-
* User-defined category identifier (e.g., 'analytics', 'marketing').
|
|
7
|
-
*/
|
|
8
2
|
export type UserCategory = string;
|
|
9
|
-
/**
|
|
10
|
-
* Map of consent choices for all categories, including the 'necessary' category.
|
|
11
|
-
* A value of `true` means the user granted consent for the category.
|
|
12
|
-
*/
|
|
13
3
|
export type Choices<T extends UserCategory> = Record<Necessary | T, boolean>;
|
|
14
|
-
/**
|
|
15
|
-
* Describes a cookie policy and its consent categories.
|
|
16
|
-
* @template T Category string union used by this policy.
|
|
17
|
-
*/
|
|
18
4
|
export interface Policy<T extends UserCategory> {
|
|
19
|
-
/**
|
|
20
|
-
* Optional stable identifier for your policy. Prefer supplying a value derived from
|
|
21
|
-
* your actual policy content/version (e.g., a hash of the policy document).
|
|
22
|
-
* If omitted, a deterministic hash of the provided categories (and this identifier when present)
|
|
23
|
-
* will be used to key snapshots.
|
|
24
|
-
*/
|
|
25
5
|
identifier?: string;
|
|
26
6
|
categories: readonly T[];
|
|
27
7
|
}
|
|
28
|
-
/**
|
|
29
|
-
* Immutable snapshot of a user's consent decision for a specific policy version.
|
|
30
|
-
* @template T Category string union captured in the snapshot.
|
|
31
|
-
*/
|
|
32
8
|
export interface Snapshot<T extends UserCategory> {
|
|
33
9
|
policy: string;
|
|
34
10
|
givenAt: string;
|
|
35
11
|
choices: Choices<T>;
|
|
36
12
|
}
|
|
37
|
-
/**
|
|
38
|
-
* High-level consent state derived from the presence of a valid snapshot.
|
|
39
|
-
* When no valid snapshot exists for the current policy version, the state is `unset`.
|
|
40
|
-
*/
|
|
41
13
|
export type ConsentState<T extends UserCategory> = {
|
|
42
14
|
decision: 'unset';
|
|
43
15
|
} | {
|
|
@@ -59,18 +31,14 @@ export interface CreateConsentifyInit<Cs extends readonly string[]> {
|
|
|
59
31
|
path?: string;
|
|
60
32
|
domain?: string;
|
|
61
33
|
};
|
|
62
|
-
/**
|
|
63
|
-
* Maximum age of consent in days. If set, consent older than this
|
|
64
|
-
* will be treated as expired, requiring re-consent.
|
|
65
|
-
*/
|
|
66
34
|
consentMaxAgeDays?: number;
|
|
67
|
-
/**
|
|
68
|
-
* Client-side storage priority. Server-side access is cookie-only.
|
|
69
|
-
* Supported: 'cookie' (canonical), 'localStorage' (optional mirror for fast reads)
|
|
70
|
-
* Default: ['cookie']
|
|
71
|
-
*/
|
|
72
35
|
storage?: StorageKind[];
|
|
73
36
|
}
|
|
37
|
+
export interface ConsentifySubscribable<T extends UserCategory> {
|
|
38
|
+
subscribe: (callback: () => void) => () => void;
|
|
39
|
+
get: () => ConsentState<T>;
|
|
40
|
+
getServerSnapshot: () => ConsentState<T>;
|
|
41
|
+
}
|
|
74
42
|
export declare function createConsentify<Cs extends readonly string[]>(init: CreateConsentifyInit<Cs>): {
|
|
75
43
|
readonly policy: {
|
|
76
44
|
readonly categories: Cs;
|
|
@@ -84,14 +52,50 @@ export declare function createConsentify<Cs extends readonly string[]>(init: Cre
|
|
|
84
52
|
readonly client: {
|
|
85
53
|
get: {
|
|
86
54
|
(): ConsentState<ArrToUnion<Cs>>;
|
|
87
|
-
(category:
|
|
55
|
+
(category: Necessary | ArrToUnion<Cs>): boolean;
|
|
88
56
|
};
|
|
89
57
|
set: (choices: Partial<Choices<ArrToUnion<Cs>>>) => void;
|
|
90
58
|
clear: () => void;
|
|
91
59
|
subscribe: (callback: () => void) => (() => void);
|
|
92
60
|
getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
|
|
61
|
+
guard: (category: Necessary | ArrToUnion<Cs>, onGrant: () => void, onRevoke?: () => void) => (() => void);
|
|
62
|
+
};
|
|
63
|
+
readonly get: {
|
|
64
|
+
(): ConsentState<ArrToUnion<Cs>>;
|
|
65
|
+
(cookieHeader: string): ConsentState<ArrToUnion<Cs>>;
|
|
66
|
+
(cookieHeader: null): ConsentState<ArrToUnion<Cs>>;
|
|
67
|
+
};
|
|
68
|
+
readonly isGranted: (category: Necessary | ArrToUnion<Cs>) => boolean;
|
|
69
|
+
readonly set: {
|
|
70
|
+
(choices: Partial<Choices<ArrToUnion<Cs>>>): void;
|
|
71
|
+
(choices: Partial<Choices<ArrToUnion<Cs>>>, cookieHeader: string): string;
|
|
72
|
+
};
|
|
73
|
+
readonly clear: {
|
|
74
|
+
(): void;
|
|
75
|
+
(serverMode: string): string;
|
|
93
76
|
};
|
|
77
|
+
readonly subscribe: (callback: () => void) => (() => void);
|
|
78
|
+
readonly getServerSnapshot: () => ConsentState<ArrToUnion<Cs>>;
|
|
79
|
+
readonly guard: (category: Necessary | ArrToUnion<Cs>, onGrant: () => void, onRevoke?: () => void) => (() => void);
|
|
94
80
|
};
|
|
95
81
|
export declare const defaultCategories: readonly ["preferences", "analytics", "marketing", "functional", "unclassified"];
|
|
96
82
|
export type DefaultCategory = typeof defaultCategories[number];
|
|
83
|
+
export type GoogleConsentType = 'ad_storage' | 'ad_user_data' | 'ad_personalization' | 'analytics_storage' | 'functionality_storage' | 'personalization_storage' | 'security_storage';
|
|
84
|
+
export interface ConsentModeOptions<T extends string> {
|
|
85
|
+
mapping: Partial<Record<'necessary' | T, GoogleConsentType[]>>;
|
|
86
|
+
waitForUpdate?: number;
|
|
87
|
+
}
|
|
88
|
+
export declare const defaultConsentModeMapping: {
|
|
89
|
+
readonly necessary: readonly ["security_storage"];
|
|
90
|
+
readonly analytics: readonly ["analytics_storage"];
|
|
91
|
+
readonly marketing: readonly ["ad_storage", "ad_user_data", "ad_personalization"];
|
|
92
|
+
readonly preferences: readonly ["functionality_storage", "personalization_storage"];
|
|
93
|
+
};
|
|
94
|
+
declare global {
|
|
95
|
+
interface Window {
|
|
96
|
+
dataLayer: unknown[];
|
|
97
|
+
gtag: (...args: unknown[]) => void;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export declare function enableConsentMode<T extends string>(instance: ConsentifySubscribable<T>, options: ConsentModeOptions<T>): () => void;
|
|
97
101
|
export {};
|
package/dist/index.js
CHANGED
|
@@ -15,11 +15,8 @@ function fnv1a(str) {
|
|
|
15
15
|
return ('00000000' + h.toString(16)).slice(-8);
|
|
16
16
|
}
|
|
17
17
|
function hashPolicy(categories, identifier) {
|
|
18
|
-
// Deterministic identity for the policy. If you provide `identifier`, it is folded into the hash,
|
|
19
|
-
// but consider using `identifier` itself as the canonical version key for clarity.
|
|
20
18
|
return fnv1a(stableStringify({ categories: [...categories].sort(), identifier: identifier ?? null }));
|
|
21
19
|
}
|
|
22
|
-
// --- Internals ---
|
|
23
20
|
const DEFAULT_COOKIE = 'consentify';
|
|
24
21
|
const enc = (o) => encodeURIComponent(JSON.stringify(o));
|
|
25
22
|
const dec = (s) => { try {
|
|
@@ -30,11 +27,26 @@ catch {
|
|
|
30
27
|
} };
|
|
31
28
|
const toISO = () => new Date().toISOString();
|
|
32
29
|
function isValidSnapshot(s) {
|
|
33
|
-
|
|
34
|
-
typeof s.policy
|
|
35
|
-
typeof s.givenAt
|
|
36
|
-
typeof s.choices
|
|
37
|
-
|
|
30
|
+
if (typeof s !== 'object' || s === null ||
|
|
31
|
+
typeof s.policy !== 'string' || s.policy === '' ||
|
|
32
|
+
typeof s.givenAt !== 'string' ||
|
|
33
|
+
typeof s.choices !== 'object' || s.choices === null)
|
|
34
|
+
return false;
|
|
35
|
+
if (isNaN(new Date(s.givenAt).getTime()))
|
|
36
|
+
return false;
|
|
37
|
+
for (const v of Object.values(s.choices)) {
|
|
38
|
+
if (typeof v !== 'boolean')
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function buildSetCookieHeader(name, value, opt) {
|
|
44
|
+
let h = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
|
|
45
|
+
if (opt.domain)
|
|
46
|
+
h += `; Domain=${opt.domain}`;
|
|
47
|
+
if (opt.secure)
|
|
48
|
+
h += `; Secure`;
|
|
49
|
+
return h;
|
|
38
50
|
}
|
|
39
51
|
function readCookie(name, cookieStr) {
|
|
40
52
|
const src = cookieStr ?? (typeof document !== 'undefined' ? document.cookie : '');
|
|
@@ -46,14 +58,8 @@ function readCookie(name, cookieStr) {
|
|
|
46
58
|
function writeCookie(name, value, opt) {
|
|
47
59
|
if (typeof document === 'undefined')
|
|
48
60
|
return;
|
|
49
|
-
|
|
50
|
-
if (opt.domain)
|
|
51
|
-
c += `; Domain=${opt.domain}`;
|
|
52
|
-
if (opt.secure)
|
|
53
|
-
c += `; Secure`;
|
|
54
|
-
document.cookie = c;
|
|
61
|
+
document.cookie = buildSetCookieHeader(name, value, opt);
|
|
55
62
|
}
|
|
56
|
-
// --- Unified Factory (single entry point) ---
|
|
57
63
|
export function createConsentify(init) {
|
|
58
64
|
const policyHash = init.policy.identifier ?? hashPolicy(init.policy.categories);
|
|
59
65
|
const cookieName = init.cookie?.name ?? DEFAULT_COOKIE;
|
|
@@ -72,7 +78,7 @@ export function createConsentify(init) {
|
|
|
72
78
|
return false;
|
|
73
79
|
const givenTime = new Date(givenAt).getTime();
|
|
74
80
|
if (isNaN(givenTime))
|
|
75
|
-
return true;
|
|
81
|
+
return true;
|
|
76
82
|
const maxAgeMs = consentMaxAgeDays * 24 * 60 * 60 * 1000;
|
|
77
83
|
return Date.now() - givenTime > maxAgeMs;
|
|
78
84
|
};
|
|
@@ -90,7 +96,6 @@ export function createConsentify(init) {
|
|
|
90
96
|
base.necessary = true;
|
|
91
97
|
return base;
|
|
92
98
|
};
|
|
93
|
-
// --- client-side storage helpers ---
|
|
94
99
|
const isBrowser = () => typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
95
100
|
const canLocal = () => { try {
|
|
96
101
|
return isBrowser() && !!window.localStorage;
|
|
@@ -101,7 +106,13 @@ export function createConsentify(init) {
|
|
|
101
106
|
const readFromStore = (kind) => {
|
|
102
107
|
switch (kind) {
|
|
103
108
|
case 'cookie': return readCookie(cookieName);
|
|
104
|
-
case 'localStorage':
|
|
109
|
+
case 'localStorage': try {
|
|
110
|
+
return canLocal() ? window.localStorage.getItem(cookieName) : null;
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
console.warn('[consentify] localStorage read failed:', err);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
105
116
|
default: return null;
|
|
106
117
|
}
|
|
107
118
|
};
|
|
@@ -111,20 +122,31 @@ export function createConsentify(init) {
|
|
|
111
122
|
writeCookie(cookieName, value, cookieCfg);
|
|
112
123
|
break;
|
|
113
124
|
case 'localStorage':
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
try {
|
|
126
|
+
if (canLocal())
|
|
127
|
+
window.localStorage.setItem(cookieName, value);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
console.warn('[consentify] localStorage write failed:', err);
|
|
131
|
+
}
|
|
116
132
|
break;
|
|
117
133
|
}
|
|
118
134
|
};
|
|
135
|
+
const clearCookieHeader = () => buildSetCookieHeader(cookieName, '', { ...cookieCfg, maxAgeSec: 0 });
|
|
119
136
|
const clearStore = (kind) => {
|
|
120
137
|
switch (kind) {
|
|
121
138
|
case 'cookie':
|
|
122
139
|
if (isBrowser())
|
|
123
|
-
document.cookie =
|
|
140
|
+
document.cookie = clearCookieHeader();
|
|
124
141
|
break;
|
|
125
142
|
case 'localStorage':
|
|
126
|
-
|
|
127
|
-
|
|
143
|
+
try {
|
|
144
|
+
if (canLocal())
|
|
145
|
+
window.localStorage.removeItem(cookieName);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.warn('[consentify] localStorage clear failed:', err);
|
|
149
|
+
}
|
|
128
150
|
break;
|
|
129
151
|
}
|
|
130
152
|
};
|
|
@@ -151,7 +173,6 @@ export function createConsentify(init) {
|
|
|
151
173
|
if (primary !== 'cookie' && storageOrder.includes('cookie'))
|
|
152
174
|
writeToStore('cookie', value);
|
|
153
175
|
};
|
|
154
|
-
// --- read helpers ---
|
|
155
176
|
const readClient = () => {
|
|
156
177
|
const raw = readClientRaw();
|
|
157
178
|
const s = raw ? dec(raw) : null;
|
|
@@ -170,15 +191,6 @@ export function createConsentify(init) {
|
|
|
170
191
|
writeClientRaw(enc(next));
|
|
171
192
|
return !same;
|
|
172
193
|
};
|
|
173
|
-
function buildSetCookieHeader(name, value, opt) {
|
|
174
|
-
let header = `${name}=${value}; Path=${opt.path}; Max-Age=${opt.maxAgeSec}; SameSite=${opt.sameSite}`;
|
|
175
|
-
if (opt.domain)
|
|
176
|
-
header += `; Domain=${opt.domain}`;
|
|
177
|
-
if (opt.secure)
|
|
178
|
-
header += `; Secure`;
|
|
179
|
-
return header;
|
|
180
|
-
}
|
|
181
|
-
// ---- server API
|
|
182
194
|
const server = {
|
|
183
195
|
get: (cookieHeader) => {
|
|
184
196
|
const raw = cookieHeader ? readCookie(cookieName, cookieHeader) : null;
|
|
@@ -201,16 +213,8 @@ export function createConsentify(init) {
|
|
|
201
213
|
};
|
|
202
214
|
return buildSetCookieHeader(cookieName, enc(snapshot), cookieCfg);
|
|
203
215
|
},
|
|
204
|
-
clear: () =>
|
|
205
|
-
let h = `${cookieName}=; Path=${cookieCfg.path}; Max-Age=0; SameSite=${cookieCfg.sameSite}`;
|
|
206
|
-
if (cookieCfg.domain)
|
|
207
|
-
h += `; Domain=${cookieCfg.domain}`;
|
|
208
|
-
if (cookieCfg.secure)
|
|
209
|
-
h += `; Secure`;
|
|
210
|
-
return h;
|
|
211
|
-
}
|
|
216
|
+
clear: () => clearCookieHeader()
|
|
212
217
|
};
|
|
213
|
-
// ========== NEW: Subscribe pattern for React ==========
|
|
214
218
|
const listeners = new Set();
|
|
215
219
|
const unsetState = { decision: 'unset' };
|
|
216
220
|
let cachedState = unsetState;
|
|
@@ -224,14 +228,19 @@ export function createConsentify(init) {
|
|
|
224
228
|
}
|
|
225
229
|
};
|
|
226
230
|
const notifyListeners = () => {
|
|
227
|
-
listeners.forEach(cb =>
|
|
231
|
+
listeners.forEach(cb => {
|
|
232
|
+
try {
|
|
233
|
+
cb();
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
console.error('[consentify] Listener callback threw:', err);
|
|
237
|
+
}
|
|
238
|
+
});
|
|
228
239
|
};
|
|
229
|
-
// Init cache on browser
|
|
230
240
|
if (isBrowser()) {
|
|
231
241
|
syncState();
|
|
232
242
|
}
|
|
233
243
|
function clientGet(category) {
|
|
234
|
-
// Return cached state for React compatibility
|
|
235
244
|
if (typeof category === 'undefined')
|
|
236
245
|
return cachedState;
|
|
237
246
|
if (category === 'necessary')
|
|
@@ -243,8 +252,8 @@ export function createConsentify(init) {
|
|
|
243
252
|
const client = {
|
|
244
253
|
get: clientGet,
|
|
245
254
|
set: (choices) => {
|
|
246
|
-
const
|
|
247
|
-
const base =
|
|
255
|
+
const fresh = readClient();
|
|
256
|
+
const base = fresh ? fresh.choices : normalize();
|
|
248
257
|
const next = {
|
|
249
258
|
policy: policyHash,
|
|
250
259
|
givenAt: toISO(),
|
|
@@ -262,14 +271,47 @@ export function createConsentify(init) {
|
|
|
262
271
|
syncState();
|
|
263
272
|
notifyListeners();
|
|
264
273
|
},
|
|
265
|
-
// NEW: Subscribe for React useSyncExternalStore
|
|
266
274
|
subscribe: (callback) => {
|
|
267
275
|
listeners.add(callback);
|
|
268
276
|
return () => listeners.delete(callback);
|
|
269
277
|
},
|
|
270
|
-
// NEW: Server snapshot for SSR (always unset)
|
|
271
278
|
getServerSnapshot: () => unsetState,
|
|
279
|
+
guard: (category, onGrant, onRevoke) => {
|
|
280
|
+
let phase = 'waiting';
|
|
281
|
+
const check = () => clientGet(category) === true;
|
|
282
|
+
const tick = () => {
|
|
283
|
+
if (phase === 'waiting' && check()) {
|
|
284
|
+
onGrant();
|
|
285
|
+
phase = onRevoke ? 'granted' : 'done';
|
|
286
|
+
if (phase === 'done')
|
|
287
|
+
unsub();
|
|
288
|
+
}
|
|
289
|
+
else if (phase === 'granted' && !check()) {
|
|
290
|
+
onRevoke();
|
|
291
|
+
phase = 'done';
|
|
292
|
+
unsub();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const unsub = client.subscribe(tick);
|
|
296
|
+
tick();
|
|
297
|
+
return () => { phase = 'done'; unsub(); };
|
|
298
|
+
},
|
|
272
299
|
};
|
|
300
|
+
function flatGet(cookieHeader) {
|
|
301
|
+
return typeof cookieHeader === 'string'
|
|
302
|
+
? server.get(cookieHeader)
|
|
303
|
+
: client.get();
|
|
304
|
+
}
|
|
305
|
+
function flatSet(choices, cookieHeader) {
|
|
306
|
+
if (typeof cookieHeader === 'string')
|
|
307
|
+
return server.set(choices, cookieHeader);
|
|
308
|
+
client.set(choices);
|
|
309
|
+
}
|
|
310
|
+
function flatClear(serverMode) {
|
|
311
|
+
if (typeof serverMode === 'string')
|
|
312
|
+
return server.clear();
|
|
313
|
+
client.clear();
|
|
314
|
+
}
|
|
273
315
|
return {
|
|
274
316
|
policy: {
|
|
275
317
|
categories: init.policy.categories,
|
|
@@ -277,7 +319,74 @@ export function createConsentify(init) {
|
|
|
277
319
|
},
|
|
278
320
|
server,
|
|
279
321
|
client,
|
|
322
|
+
get: flatGet,
|
|
323
|
+
isGranted: (category) => {
|
|
324
|
+
return clientGet(category);
|
|
325
|
+
},
|
|
326
|
+
set: flatSet,
|
|
327
|
+
clear: flatClear,
|
|
328
|
+
subscribe: client.subscribe,
|
|
329
|
+
getServerSnapshot: client.getServerSnapshot,
|
|
330
|
+
guard: client.guard,
|
|
280
331
|
};
|
|
281
332
|
}
|
|
282
|
-
// Common predefined category names you can reuse in your policy.
|
|
283
333
|
export const defaultCategories = ['preferences', 'analytics', 'marketing', 'functional', 'unclassified'];
|
|
334
|
+
export const defaultConsentModeMapping = {
|
|
335
|
+
necessary: ['security_storage'],
|
|
336
|
+
analytics: ['analytics_storage'],
|
|
337
|
+
marketing: ['ad_storage', 'ad_user_data', 'ad_personalization'],
|
|
338
|
+
preferences: ['functionality_storage', 'personalization_storage'],
|
|
339
|
+
};
|
|
340
|
+
function safeGtag(...args) {
|
|
341
|
+
try {
|
|
342
|
+
window.gtag(...args);
|
|
343
|
+
}
|
|
344
|
+
catch (err) {
|
|
345
|
+
console.error('[consentify] gtag call failed:', err);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
export function enableConsentMode(instance, options) {
|
|
349
|
+
if (typeof window === 'undefined')
|
|
350
|
+
return () => { };
|
|
351
|
+
window.dataLayer = window.dataLayer || [];
|
|
352
|
+
if (typeof window.gtag !== 'function') {
|
|
353
|
+
window.gtag = function gtag() {
|
|
354
|
+
window.dataLayer.push(arguments);
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
const resolve = () => {
|
|
358
|
+
const state = instance.get();
|
|
359
|
+
const result = {};
|
|
360
|
+
for (const [category, gTypes] of Object.entries(options.mapping)) {
|
|
361
|
+
if (!gTypes)
|
|
362
|
+
continue;
|
|
363
|
+
let granted = false;
|
|
364
|
+
if (category === 'necessary') {
|
|
365
|
+
granted = true;
|
|
366
|
+
}
|
|
367
|
+
else if (state.decision === 'decided') {
|
|
368
|
+
granted = !!state.snapshot.choices[category];
|
|
369
|
+
}
|
|
370
|
+
for (const gType of gTypes) {
|
|
371
|
+
result[gType] = granted ? 'granted' : 'denied';
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
};
|
|
376
|
+
const defaultPayload = { ...resolve() };
|
|
377
|
+
if (options.waitForUpdate != null) {
|
|
378
|
+
defaultPayload.wait_for_update = options.waitForUpdate;
|
|
379
|
+
}
|
|
380
|
+
safeGtag('consent', 'default', defaultPayload);
|
|
381
|
+
const state = instance.get();
|
|
382
|
+
if (state.decision === 'decided') {
|
|
383
|
+
safeGtag('consent', 'update', resolve());
|
|
384
|
+
}
|
|
385
|
+
const unsubscribe = instance.subscribe(() => {
|
|
386
|
+
const current = instance.get();
|
|
387
|
+
if (current.decision === 'decided') {
|
|
388
|
+
safeGtag('consent', 'update', resolve());
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
return unsubscribe;
|
|
392
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
+
});
|