@slashgear/gdpr-cookie-scanner 1.2.1 → 1.4.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/.github/workflows/ci.yml +6 -0
- package/CHANGELOG.md +40 -0
- package/README.md +22 -7
- package/dist/analyzers/compliance.d.ts +1 -0
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +20 -0
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +23 -0
- package/dist/report/generator.js.map +1 -1
- package/dist/scanner/consent-modal.d.ts +5 -0
- package/dist/scanner/consent-modal.d.ts.map +1 -1
- package/dist/scanner/consent-modal.js +56 -0
- package/dist/scanner/consent-modal.js.map +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +5 -1
- package/dist/scanner/index.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/analyzers/compliance.ts +23 -0
- package/src/report/generator.ts +25 -0
- package/src/scanner/consent-modal.ts +64 -0
- package/src/scanner/index.ts +6 -1
- package/src/types.ts +2 -0
- package/tests/e2e/fixtures/compliant-site.html +80 -0
- package/tests/e2e/fixtures/no-modal-site.html +17 -0
- package/tests/e2e/fixtures/non-compliant-site.html +54 -0
- package/tests/e2e/scanner.test.ts +135 -0
- package/tests/helpers/test-server.ts +57 -0
- package/tests/unit/compliance.test.ts +460 -0
- package/tests/unit/cookie-classifier.test.ts +192 -0
- package/tests/unit/network-classifier.test.ts +91 -0
- package/tests/unit/wording.test.ts +162 -0
- package/vitest.config.ts +9 -0
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { analyzeCompliance } from "../../src/analyzers/compliance.js";
|
|
3
|
+
import type {
|
|
4
|
+
ConsentModal,
|
|
5
|
+
ConsentButton,
|
|
6
|
+
ScannedCookie,
|
|
7
|
+
NetworkRequest,
|
|
8
|
+
} from "../../src/types.js";
|
|
9
|
+
|
|
10
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeButton(
|
|
13
|
+
type: ConsentButton["type"],
|
|
14
|
+
text: string,
|
|
15
|
+
overrides?: Partial<ConsentButton>,
|
|
16
|
+
): ConsentButton {
|
|
17
|
+
return {
|
|
18
|
+
type,
|
|
19
|
+
text,
|
|
20
|
+
selector: `button:has-text("${text}")`,
|
|
21
|
+
isVisible: true,
|
|
22
|
+
boundingBox: { x: 0, y: 0, width: 120, height: 40 },
|
|
23
|
+
fontSize: 14,
|
|
24
|
+
backgroundColor: "rgb(0, 128, 0)",
|
|
25
|
+
textColor: "rgb(255, 255, 255)",
|
|
26
|
+
contrastRatio: 8.59,
|
|
27
|
+
clickDepth: 1,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeModal(overrides?: Partial<ConsentModal>): ConsentModal {
|
|
33
|
+
return {
|
|
34
|
+
detected: true,
|
|
35
|
+
selector: "#cookie-banner",
|
|
36
|
+
// Text carefully matches all 4 REQUIRED_INFO_PATTERNS:
|
|
37
|
+
// purposes → "purposes" (matches /purpose/)
|
|
38
|
+
// third-parties → "third-party vendors" (matches /third.part|vendor/)
|
|
39
|
+
// duration → "13 months" (matches /month/)
|
|
40
|
+
// withdrawal → "withdraw" (matches /withdraw/)
|
|
41
|
+
text: "We use cookies for analytics purposes with third-party vendors. Cookies expire after 13 months. You may withdraw your consent at any time.",
|
|
42
|
+
buttons: [makeButton("accept", "Accept all"), makeButton("reject", "Reject all")],
|
|
43
|
+
checkboxes: [],
|
|
44
|
+
hasGranularControls: false,
|
|
45
|
+
layerCount: 1,
|
|
46
|
+
screenshotPath: null,
|
|
47
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
48
|
+
...overrides,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeCookie(
|
|
53
|
+
name: string,
|
|
54
|
+
category: ScannedCookie["category"],
|
|
55
|
+
requiresConsent: boolean,
|
|
56
|
+
capturedAt: ScannedCookie["capturedAt"],
|
|
57
|
+
): ScannedCookie {
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
domain: "example.com",
|
|
61
|
+
path: "/",
|
|
62
|
+
value: "abc123",
|
|
63
|
+
expires: null,
|
|
64
|
+
httpOnly: false,
|
|
65
|
+
secure: false,
|
|
66
|
+
sameSite: null,
|
|
67
|
+
category,
|
|
68
|
+
requiresConsent,
|
|
69
|
+
capturedAt,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeRequest(
|
|
74
|
+
url: string,
|
|
75
|
+
trackerCategory: NetworkRequest["trackerCategory"],
|
|
76
|
+
capturedAt: NetworkRequest["capturedAt"],
|
|
77
|
+
): NetworkRequest {
|
|
78
|
+
return {
|
|
79
|
+
url,
|
|
80
|
+
method: "GET",
|
|
81
|
+
resourceType: "xhr",
|
|
82
|
+
initiator: null,
|
|
83
|
+
isThirdParty: trackerCategory !== null,
|
|
84
|
+
trackerCategory,
|
|
85
|
+
trackerName: trackerCategory ? "Tracker" : null,
|
|
86
|
+
capturedAt,
|
|
87
|
+
responseStatus: 200,
|
|
88
|
+
contentType: null,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const emptyInputBase = {
|
|
93
|
+
cookiesBeforeInteraction: [],
|
|
94
|
+
cookiesAfterAccept: [],
|
|
95
|
+
cookiesAfterReject: [],
|
|
96
|
+
networkBeforeInteraction: [],
|
|
97
|
+
networkAfterAccept: [],
|
|
98
|
+
networkAfterReject: [],
|
|
99
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
describe("analyzeCompliance", () => {
|
|
105
|
+
describe("no consent modal detected", () => {
|
|
106
|
+
it("scores 0 on consentValidity, easyRefusal, and transparency", () => {
|
|
107
|
+
const result = analyzeCompliance({
|
|
108
|
+
...emptyInputBase,
|
|
109
|
+
modal: makeModal({ detected: false }),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(result.breakdown.consentValidity).toBe(0);
|
|
113
|
+
expect(result.breakdown.easyRefusal).toBe(0);
|
|
114
|
+
expect(result.breakdown.transparency).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("issues a critical no-reject-button issue", () => {
|
|
118
|
+
const result = analyzeCompliance({
|
|
119
|
+
...emptyInputBase,
|
|
120
|
+
modal: makeModal({ detected: false }),
|
|
121
|
+
});
|
|
122
|
+
const issue = result.issues.find((i) => i.type === "no-reject-button");
|
|
123
|
+
expect(issue).toBeDefined();
|
|
124
|
+
expect(issue?.severity).toBe("critical");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("perfect modal", () => {
|
|
129
|
+
it("scores 100 when all dimensions are maximised (granular controls present)", () => {
|
|
130
|
+
const result = analyzeCompliance({
|
|
131
|
+
...emptyInputBase,
|
|
132
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(result.breakdown.consentValidity).toBe(25);
|
|
136
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
137
|
+
expect(result.breakdown.transparency).toBe(25);
|
|
138
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
139
|
+
expect(result.total).toBe(100);
|
|
140
|
+
expect(result.grade).toBe("A");
|
|
141
|
+
expect(result.issues).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("scores 90 (grade A) when modal is correct but lacks granular controls", () => {
|
|
145
|
+
const result = analyzeCompliance({
|
|
146
|
+
...emptyInputBase,
|
|
147
|
+
modal: makeModal({ hasGranularControls: false }),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(result.breakdown.consentValidity).toBe(25);
|
|
151
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
152
|
+
// -10 for no granular controls
|
|
153
|
+
expect(result.breakdown.transparency).toBe(15);
|
|
154
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
155
|
+
expect(result.total).toBe(90);
|
|
156
|
+
expect(result.grade).toBe("A");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("pre-ticked checkboxes", () => {
|
|
161
|
+
it("deducts 10 from consentValidity and raises a critical pre-ticked issue", () => {
|
|
162
|
+
const modal = makeModal({
|
|
163
|
+
hasGranularControls: true, // ensure no other transparency deductions
|
|
164
|
+
checkboxes: [
|
|
165
|
+
{
|
|
166
|
+
name: "analytics",
|
|
167
|
+
label: "Analytics",
|
|
168
|
+
isCheckedByDefault: true,
|
|
169
|
+
category: "analytics",
|
|
170
|
+
selector: "#analytics-cb",
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const result = analyzeCompliance({ ...emptyInputBase, modal });
|
|
176
|
+
|
|
177
|
+
// 25 - 10 (pre-ticked) = 15
|
|
178
|
+
expect(result.breakdown.consentValidity).toBe(15);
|
|
179
|
+
const issue = result.issues.find((i) => i.type === "pre-ticked");
|
|
180
|
+
expect(issue).toBeDefined();
|
|
181
|
+
expect(issue?.severity).toBe("critical");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("missing reject button", () => {
|
|
186
|
+
it("deducts 15 from easyRefusal and raises a buried-reject issue", () => {
|
|
187
|
+
const modal = makeModal({ buttons: [makeButton("accept", "Accept all")] });
|
|
188
|
+
const result = analyzeCompliance({ ...emptyInputBase, modal });
|
|
189
|
+
|
|
190
|
+
expect(result.breakdown.easyRefusal).toBe(10);
|
|
191
|
+
const issue = result.issues.find((i) => i.type === "buried-reject");
|
|
192
|
+
expect(issue).toBeDefined();
|
|
193
|
+
expect(issue?.severity).toBe("critical");
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe("reject requires more clicks than accept", () => {
|
|
198
|
+
it("deducts 15 from easyRefusal for click asymmetry", () => {
|
|
199
|
+
const modal = makeModal({
|
|
200
|
+
buttons: [
|
|
201
|
+
makeButton("accept", "Accept", { clickDepth: 1 }),
|
|
202
|
+
makeButton("reject", "Reject", { clickDepth: 2 }),
|
|
203
|
+
],
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const result = analyzeCompliance({ ...emptyInputBase, modal });
|
|
207
|
+
|
|
208
|
+
expect(result.breakdown.easyRefusal).toBe(10);
|
|
209
|
+
const issue = result.issues.find((i) => i.type === "click-asymmetry");
|
|
210
|
+
expect(issue).toBeDefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("large accept button compared to reject", () => {
|
|
215
|
+
it("deducts 5 from easyRefusal for visual asymmetry (accept >3x reject area)", () => {
|
|
216
|
+
const modal = makeModal({
|
|
217
|
+
buttons: [
|
|
218
|
+
makeButton("accept", "Accept", {
|
|
219
|
+
boundingBox: { x: 0, y: 0, width: 300, height: 60 }, // 18000 px²
|
|
220
|
+
}),
|
|
221
|
+
makeButton("reject", "Reject", {
|
|
222
|
+
boundingBox: { x: 0, y: 60, width: 60, height: 30 }, // 1800 px²
|
|
223
|
+
}),
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const result = analyzeCompliance({ ...emptyInputBase, modal });
|
|
228
|
+
|
|
229
|
+
expect(result.breakdown.easyRefusal).toBe(20);
|
|
230
|
+
const issue = result.issues.find((i) => i.type === "asymmetric-prominence");
|
|
231
|
+
expect(issue).toBeDefined();
|
|
232
|
+
expect(issue?.severity).toBe("warning");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("font size asymmetry", () => {
|
|
237
|
+
it("deducts 5 from easyRefusal when accept font is 30%+ larger than reject", () => {
|
|
238
|
+
const modal = makeModal({
|
|
239
|
+
buttons: [
|
|
240
|
+
makeButton("accept", "Accept", { fontSize: 20 }),
|
|
241
|
+
makeButton("reject", "Reject", { fontSize: 12 }),
|
|
242
|
+
],
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const result = analyzeCompliance({ ...emptyInputBase, modal });
|
|
246
|
+
|
|
247
|
+
expect(result.breakdown.easyRefusal).toBe(20);
|
|
248
|
+
const issue = result.issues.find((i) => i.type === "nudging");
|
|
249
|
+
expect(issue).toBeDefined();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("no granular controls", () => {
|
|
254
|
+
it("deducts 10 from transparency when hasGranularControls is false", () => {
|
|
255
|
+
const result = analyzeCompliance({
|
|
256
|
+
...emptyInputBase,
|
|
257
|
+
// Page-level privacy link present, modal link present, good text → only -10 for no granular
|
|
258
|
+
modal: makeModal({ hasGranularControls: false }),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// 25 - 10 (no granular) = 15
|
|
262
|
+
expect(result.breakdown.transparency).toBe(15);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps full transparency (25) when hasGranularControls is true", () => {
|
|
266
|
+
const result = analyzeCompliance({
|
|
267
|
+
...emptyInputBase,
|
|
268
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(result.breakdown.transparency).toBe(25);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("missing privacy policy link in modal", () => {
|
|
276
|
+
it("deducts 5 from transparency and raises a missing-info warning", () => {
|
|
277
|
+
// hasGranularControls: true so only -5 deduction (no -10 for granular)
|
|
278
|
+
const modal = makeModal({ hasGranularControls: true, privacyPolicyUrl: null });
|
|
279
|
+
const result = analyzeCompliance({ ...emptyInputBase, modal });
|
|
280
|
+
|
|
281
|
+
// 25 - 5 (no modal privacy link) = 20
|
|
282
|
+
expect(result.breakdown.transparency).toBe(20);
|
|
283
|
+
const issue = result.issues.find(
|
|
284
|
+
(i) =>
|
|
285
|
+
i.type === "missing-info" &&
|
|
286
|
+
i.description.includes("privacy policy link") &&
|
|
287
|
+
i.description.includes("modal"),
|
|
288
|
+
);
|
|
289
|
+
expect(issue).toBeDefined();
|
|
290
|
+
expect(issue?.severity).toBe("warning");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("missing privacy policy link on page", () => {
|
|
295
|
+
it("deducts 3 from transparency and raises a missing-info warning", () => {
|
|
296
|
+
// hasGranularControls: true so only -3 deduction (no -10 for granular)
|
|
297
|
+
const result = analyzeCompliance({
|
|
298
|
+
...emptyInputBase,
|
|
299
|
+
privacyPolicyUrl: null,
|
|
300
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// 25 - 3 (no page privacy link) = 22
|
|
304
|
+
expect(result.breakdown.transparency).toBe(22);
|
|
305
|
+
const issue = result.issues.find(
|
|
306
|
+
(i) => i.type === "missing-info" && i.description.includes("page"),
|
|
307
|
+
);
|
|
308
|
+
expect(issue).toBeDefined();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("non-essential cookies before consent", () => {
|
|
313
|
+
it("deducts from cookieBehavior for each illegal pre-consent cookie (max -20)", () => {
|
|
314
|
+
const illegalCookies = Array.from({ length: 3 }, (_, i) =>
|
|
315
|
+
makeCookie(`_ga_${i}`, "analytics", true, "before-interaction"),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const result = analyzeCompliance({
|
|
319
|
+
...emptyInputBase,
|
|
320
|
+
modal: makeModal(),
|
|
321
|
+
cookiesBeforeInteraction: illegalCookies,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// 3 cookies × 4 = -12
|
|
325
|
+
expect(result.breakdown.cookieBehavior).toBe(13);
|
|
326
|
+
const issue = result.issues.find(
|
|
327
|
+
(i) => i.type === "auto-consent" && i.description.includes("before any interaction"),
|
|
328
|
+
);
|
|
329
|
+
expect(issue).toBeDefined();
|
|
330
|
+
expect(issue?.severity).toBe("critical");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("deduction is capped at -20 for many pre-consent cookies", () => {
|
|
334
|
+
const illegalCookies = Array.from({ length: 10 }, (_, i) =>
|
|
335
|
+
makeCookie(`_ga_${i}`, "analytics", true, "before-interaction"),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
const result = analyzeCompliance({
|
|
339
|
+
...emptyInputBase,
|
|
340
|
+
modal: makeModal(),
|
|
341
|
+
cookiesBeforeInteraction: illegalCookies,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(result.breakdown.cookieBehavior).toBe(5); // 25 - 20
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("non-essential cookies persisting after reject", () => {
|
|
349
|
+
it("deducts from cookieBehavior for cookies persisting after rejection", () => {
|
|
350
|
+
const cookiesAfterReject = [
|
|
351
|
+
makeCookie("_fbp", "advertising", true, "after-reject"),
|
|
352
|
+
makeCookie("_ga", "analytics", true, "after-reject"),
|
|
353
|
+
];
|
|
354
|
+
|
|
355
|
+
const result = analyzeCompliance({
|
|
356
|
+
...emptyInputBase,
|
|
357
|
+
modal: makeModal(),
|
|
358
|
+
cookiesAfterReject,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// 2 cookies × 3 = -6
|
|
362
|
+
expect(result.breakdown.cookieBehavior).toBe(19);
|
|
363
|
+
const issue = result.issues.find(
|
|
364
|
+
(i) => i.type === "auto-consent" && i.description.includes("after rejection"),
|
|
365
|
+
);
|
|
366
|
+
expect(issue).toBeDefined();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe("trackers firing before consent", () => {
|
|
371
|
+
it("deducts from cookieBehavior for pre-interaction tracker requests", () => {
|
|
372
|
+
const trackerRequests = [
|
|
373
|
+
makeRequest("https://www.google-analytics.com/collect", "analytics", "before-interaction"),
|
|
374
|
+
makeRequest(
|
|
375
|
+
"https://connect.facebook.net/fbevents.js",
|
|
376
|
+
"advertising",
|
|
377
|
+
"before-interaction",
|
|
378
|
+
),
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
const result = analyzeCompliance({
|
|
382
|
+
...emptyInputBase,
|
|
383
|
+
modal: makeModal(),
|
|
384
|
+
networkBeforeInteraction: trackerRequests,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// 2 trackers × 2 = -4
|
|
388
|
+
expect(result.breakdown.cookieBehavior).toBe(21);
|
|
389
|
+
const issue = result.issues.find(
|
|
390
|
+
(i) => i.type === "auto-consent" && i.description.includes("tracker request"),
|
|
391
|
+
);
|
|
392
|
+
expect(issue).toBeDefined();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("does not flag CDN requests as pre-consent trackers", () => {
|
|
396
|
+
const cdnRequest = makeRequest(
|
|
397
|
+
"https://cdn.example.com/font.woff2",
|
|
398
|
+
"cdn",
|
|
399
|
+
"before-interaction",
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
const result = analyzeCompliance({
|
|
403
|
+
...emptyInputBase,
|
|
404
|
+
modal: makeModal(),
|
|
405
|
+
networkBeforeInteraction: [cdnRequest],
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
expect(result.breakdown.cookieBehavior).toBe(25);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("score clamping", () => {
|
|
413
|
+
it("clamps each dimension to [0, 25]", () => {
|
|
414
|
+
// Trigger many deductions at once
|
|
415
|
+
const illegalCookies = Array.from({ length: 10 }, (_, i) =>
|
|
416
|
+
makeCookie(`_ga_${i}`, "analytics", true, "before-interaction"),
|
|
417
|
+
);
|
|
418
|
+
const trackers = Array.from({ length: 10 }, (_, i) =>
|
|
419
|
+
makeRequest(`https://tracker${i}.example.com/collect`, "analytics", "before-interaction"),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
const result = analyzeCompliance({
|
|
423
|
+
...emptyInputBase,
|
|
424
|
+
privacyPolicyUrl: null,
|
|
425
|
+
modal: makeModal({ detected: false }),
|
|
426
|
+
cookiesBeforeInteraction: illegalCookies,
|
|
427
|
+
networkBeforeInteraction: trackers,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const score of Object.values(result.breakdown)) {
|
|
431
|
+
expect(score).toBeGreaterThanOrEqual(0);
|
|
432
|
+
expect(score).toBeLessThanOrEqual(25);
|
|
433
|
+
}
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
describe("grade assignment", () => {
|
|
438
|
+
it("assigns grade A for score >= 90", () => {
|
|
439
|
+
const result = analyzeCompliance({
|
|
440
|
+
...emptyInputBase,
|
|
441
|
+
modal: makeModal({ hasGranularControls: true }),
|
|
442
|
+
});
|
|
443
|
+
expect(result.total).toBe(100);
|
|
444
|
+
expect(result.grade).toBe("A");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("assigns grade F for very low score", () => {
|
|
448
|
+
const illegalCookies = Array.from({ length: 10 }, (_, i) =>
|
|
449
|
+
makeCookie(`_ga_${i}`, "analytics", true, "before-interaction"),
|
|
450
|
+
);
|
|
451
|
+
const result = analyzeCompliance({
|
|
452
|
+
...emptyInputBase,
|
|
453
|
+
privacyPolicyUrl: null,
|
|
454
|
+
modal: makeModal({ detected: false }),
|
|
455
|
+
cookiesBeforeInteraction: illegalCookies,
|
|
456
|
+
});
|
|
457
|
+
expect(result.grade).toBe("F");
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { classifyCookie } from "../../src/classifiers/cookie-classifier.js";
|
|
3
|
+
|
|
4
|
+
describe("classifyCookie", () => {
|
|
5
|
+
describe("strictly-necessary cookies", () => {
|
|
6
|
+
it("classifies PHPSESSID as strictly-necessary", () => {
|
|
7
|
+
const result = classifyCookie("PHPSESSID", "example.com", "abc123");
|
|
8
|
+
expect(result.category).toBe("strictly-necessary");
|
|
9
|
+
expect(result.requiresConsent).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("classifies JSESSIONID as strictly-necessary", () => {
|
|
13
|
+
const result = classifyCookie("JSESSIONID", "example.com", "xyz");
|
|
14
|
+
expect(result.category).toBe("strictly-necessary");
|
|
15
|
+
expect(result.requiresConsent).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("classifies csrf_token as strictly-necessary", () => {
|
|
19
|
+
const result = classifyCookie("csrf_token", "example.com", "token123");
|
|
20
|
+
expect(result.category).toBe("strictly-necessary");
|
|
21
|
+
expect(result.requiresConsent).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("classifies session_id as strictly-necessary", () => {
|
|
25
|
+
const result = classifyCookie("session_id", "example.com", "abc");
|
|
26
|
+
expect(result.category).toBe("strictly-necessary");
|
|
27
|
+
expect(result.requiresConsent).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("classifies auth_token as strictly-necessary", () => {
|
|
31
|
+
const result = classifyCookie("auth_token", "example.com", "tok");
|
|
32
|
+
expect(result.category).toBe("strictly-necessary");
|
|
33
|
+
expect(result.requiresConsent).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("classifies cart as strictly-necessary", () => {
|
|
37
|
+
const result = classifyCookie("cart_id", "example.com", "123");
|
|
38
|
+
expect(result.category).toBe("strictly-necessary");
|
|
39
|
+
expect(result.requiresConsent).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("classifies lang as strictly-necessary", () => {
|
|
43
|
+
const result = classifyCookie("lang", "example.com", "fr");
|
|
44
|
+
expect(result.category).toBe("strictly-necessary");
|
|
45
|
+
expect(result.requiresConsent).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("classifies consent cookie as strictly-necessary", () => {
|
|
49
|
+
const result = classifyCookie("cookieconsent_status", "example.com", "allow");
|
|
50
|
+
expect(result.category).toBe("strictly-necessary");
|
|
51
|
+
expect(result.requiresConsent).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("classifies axeptio cookie as strictly-necessary", () => {
|
|
55
|
+
const result = classifyCookie("axeptio_cookies", "example.com", "{}");
|
|
56
|
+
expect(result.category).toBe("strictly-necessary");
|
|
57
|
+
expect(result.requiresConsent).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("classifies didomi cookie as strictly-necessary", () => {
|
|
61
|
+
const result = classifyCookie("didomi_token", "example.com", "token");
|
|
62
|
+
expect(result.category).toBe("strictly-necessary");
|
|
63
|
+
expect(result.requiresConsent).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("analytics cookies", () => {
|
|
68
|
+
it("classifies _ga as analytics", () => {
|
|
69
|
+
const result = classifyCookie("_ga", "example.com", "GA1.2.123456789.1234567890");
|
|
70
|
+
expect(result.category).toBe("analytics");
|
|
71
|
+
expect(result.requiresConsent).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("classifies _ga_XXXX as analytics", () => {
|
|
75
|
+
const result = classifyCookie("_ga_ABC123", "example.com", "GS1.1.1234567890.1.1.1234567890");
|
|
76
|
+
expect(result.category).toBe("analytics");
|
|
77
|
+
expect(result.requiresConsent).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("classifies _gid as analytics", () => {
|
|
81
|
+
const result = classifyCookie("_gid", "example.com", "GA1.2.1234567890.1234567890");
|
|
82
|
+
expect(result.category).toBe("analytics");
|
|
83
|
+
expect(result.requiresConsent).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("classifies _utm cookie as analytics", () => {
|
|
87
|
+
const result = classifyCookie("_utma", "example.com", "123456.1234567890");
|
|
88
|
+
expect(result.category).toBe("analytics");
|
|
89
|
+
expect(result.requiresConsent).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("classifies _pk_ (Matomo) as analytics", () => {
|
|
93
|
+
const result = classifyCookie("_pk_id.1.2db1", "example.com", "abc.1234567890");
|
|
94
|
+
expect(result.category).toBe("analytics");
|
|
95
|
+
expect(result.requiresConsent).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("classifies _hj (Hotjar) as analytics", () => {
|
|
99
|
+
const result = classifyCookie("_hjSessionUser_1234567", "example.com", "abc");
|
|
100
|
+
expect(result.category).toBe("analytics");
|
|
101
|
+
expect(result.requiresConsent).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("classifies mixpanel as analytics", () => {
|
|
105
|
+
const result = classifyCookie("mixpanel_session", "example.com", "abc");
|
|
106
|
+
expect(result.category).toBe("analytics");
|
|
107
|
+
expect(result.requiresConsent).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("advertising cookies", () => {
|
|
112
|
+
it("classifies _fbp as advertising", () => {
|
|
113
|
+
const result = classifyCookie("_fbp", "example.com", "fb.1.1234567890.123456789");
|
|
114
|
+
expect(result.category).toBe("advertising");
|
|
115
|
+
expect(result.requiresConsent).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("classifies IDE (Google Ads) as advertising", () => {
|
|
119
|
+
const result = classifyCookie("IDE", "doubleclick.net", "abc123");
|
|
120
|
+
expect(result.category).toBe("advertising");
|
|
121
|
+
expect(result.requiresConsent).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("classifies linkedin cookie as advertising", () => {
|
|
125
|
+
const result = classifyCookie("li_fat_id", "linkedin.com", "abc");
|
|
126
|
+
expect(result.category).toBe("advertising");
|
|
127
|
+
expect(result.requiresConsent).toBe(true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("classifies _ttp (TikTok) as advertising", () => {
|
|
131
|
+
const result = classifyCookie("_ttp", "example.com", "abc");
|
|
132
|
+
expect(result.category).toBe("advertising");
|
|
133
|
+
expect(result.requiresConsent).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("social cookies", () => {
|
|
138
|
+
it("classifies YSC (YouTube) as social", () => {
|
|
139
|
+
// VISITOR_INFO pattern matches exactly — real YouTube cookie is VISITOR_INFO1_LIVE
|
|
140
|
+
// which does NOT match the exact-match pattern. YSC is the reliable test case.
|
|
141
|
+
const result = classifyCookie("YSC", "youtube.com", "abc123");
|
|
142
|
+
expect(result.category).toBe("social");
|
|
143
|
+
expect(result.requiresConsent).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("classifies GPS (YouTube) as social", () => {
|
|
147
|
+
const result = classifyCookie("GPS", "youtube.com", "abc");
|
|
148
|
+
expect(result.category).toBe("social");
|
|
149
|
+
expect(result.requiresConsent).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("classifies fbsr_ as social", () => {
|
|
153
|
+
const result = classifyCookie("fbsr_123456", "facebook.com", "abc");
|
|
154
|
+
expect(result.category).toBe("social");
|
|
155
|
+
expect(result.requiresConsent).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("personalization cookies", () => {
|
|
160
|
+
it("classifies ab_test as personalization", () => {
|
|
161
|
+
const result = classifyCookie("ab_test_variant", "example.com", "B");
|
|
162
|
+
expect(result.category).toBe("personalization");
|
|
163
|
+
expect(result.requiresConsent).toBe(true);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("classifies optimizely cookie as personalization", () => {
|
|
167
|
+
const result = classifyCookie("optimizely_session", "example.com", "abc");
|
|
168
|
+
expect(result.category).toBe("personalization");
|
|
169
|
+
expect(result.requiresConsent).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("unknown cookies", () => {
|
|
174
|
+
it("classifies unknown long cookie as unknown without consent", () => {
|
|
175
|
+
const result = classifyCookie("my_custom_preference", "example.com", "some_long_value");
|
|
176
|
+
expect(result.category).toBe("unknown");
|
|
177
|
+
expect(result.requiresConsent).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("classifies short cookie (<=4 chars) without '=' in value as unknown requiring consent", () => {
|
|
181
|
+
const result = classifyCookie("abc", "example.com", "xyz");
|
|
182
|
+
expect(result.category).toBe("unknown");
|
|
183
|
+
expect(result.requiresConsent).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does NOT flag short cookie with '=' in value as requiring consent", () => {
|
|
187
|
+
const result = classifyCookie("uid", "example.com", "dGVzdA==");
|
|
188
|
+
// uid matches Criteo pattern → advertising
|
|
189
|
+
expect(result.requiresConsent).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|