@slashgear/gdpr-cookie-scanner 3.5.1 → 3.7.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/CHANGELOG.md +106 -0
- package/CLAUDE.md +12 -1
- package/NEXT_STEPS.md +37 -3
- package/README.md +23 -0
- package/dist/analyzers/colour.d.ts +36 -0
- package/dist/analyzers/colour.d.ts.map +1 -0
- package/dist/analyzers/colour.js +75 -0
- package/dist/analyzers/colour.js.map +1 -0
- package/dist/analyzers/compliance.d.ts.map +1 -1
- package/dist/analyzers/compliance.js +24 -6
- package/dist/analyzers/compliance.js.map +1 -1
- package/dist/analyzers/tcf-decoder.d.ts +9 -0
- package/dist/analyzers/tcf-decoder.d.ts.map +1 -0
- package/dist/analyzers/tcf-decoder.js +123 -0
- package/dist/analyzers/tcf-decoder.js.map +1 -0
- package/dist/analyzers/wording.d.ts +1 -0
- package/dist/analyzers/wording.d.ts.map +1 -1
- package/dist/analyzers/wording.js +39 -0
- package/dist/analyzers/wording.js.map +1 -1
- package/dist/report/generator.d.ts +1 -2
- package/dist/report/generator.d.ts.map +1 -1
- package/dist/report/generator.js +80 -108
- package/dist/report/generator.js.map +1 -1
- package/dist/report/html.d.ts.map +1 -1
- package/dist/report/html.js +173 -4
- package/dist/report/html.js.map +1 -1
- package/dist/scanner/consent-modal.d.ts.map +1 -1
- package/dist/scanner/consent-modal.js +57 -39
- package/dist/scanner/consent-modal.js.map +1 -1
- package/dist/scanner/index.d.ts.map +1 -1
- package/dist/scanner/index.js +4 -0
- package/dist/scanner/index.js.map +1 -1
- package/dist/scanner/tcf.d.ts +9 -0
- package/dist/scanner/tcf.d.ts.map +1 -0
- package/dist/scanner/tcf.js +72 -0
- package/dist/scanner/tcf.js.map +1 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/index.html +37 -49
- package/docs/reports/www.arte.tv/after-accept.png +0 -0
- package/docs/reports/www.arte.tv/after-reject.png +0 -0
- package/docs/reports/www.arte.tv/gdpr-report-arte.tv-2026-02-24.html +997 -0
- package/docs/reports/www.deezer.com/after-accept.png +0 -0
- package/docs/reports/www.deezer.com/after-reject.png +0 -0
- package/docs/reports/www.deezer.com/gdpr-report-deezer.com-2026-02-22.html +1667 -0
- package/docs/reports/www.impots.gouv.fr/after-accept.png +0 -0
- package/docs/reports/www.impots.gouv.fr/after-reject.png +0 -0
- package/docs/reports/www.impots.gouv.fr/gdpr-report-impots.gouv.fr-2026-02-22.html +751 -0
- package/docs/reports/www.leboncoin.fr/after-accept.png +0 -0
- package/docs/reports/www.leboncoin.fr/after-reject.png +0 -0
- package/docs/reports/www.leboncoin.fr/gdpr-report-leboncoin.fr-2026-02-22.html +764 -0
- package/docs/reports/www.netflix.com/after-accept.png +0 -0
- package/docs/reports/www.netflix.com/after-reject.png +0 -0
- package/docs/reports/www.netflix.com/gdpr-report-netflix.com-2026-02-23.html +1050 -0
- package/docs/reports/www.radiofrance.fr/after-accept.png +0 -0
- package/docs/reports/www.radiofrance.fr/after-reject.png +0 -0
- package/docs/reports/www.radiofrance.fr/gdpr-report-radiofrance.fr-2026-02-24.html +1145 -0
- package/package.json +1 -2
- package/src/analyzers/colour.ts +89 -0
- package/src/analyzers/compliance.ts +35 -10
- package/src/analyzers/tcf-decoder.ts +130 -0
- package/src/analyzers/wording.ts +44 -0
- package/src/report/generator.ts +92 -119
- package/src/report/html.ts +197 -4
- package/src/scanner/consent-modal.ts +64 -38
- package/src/scanner/index.ts +5 -0
- package/src/scanner/tcf.ts +80 -0
- package/src/types.ts +29 -0
- package/tests/analyzers/colour.test.ts +187 -0
- package/tests/analyzers/compliance.test.ts +102 -0
- package/tests/analyzers/tcf-decoder.test.ts +292 -0
- package/tests/analyzers/wording.test.ts +38 -0
- package/tests/scanner/button-classification.test.ts +32 -0
- package/docs/reports/github.com/after-accept.png +0 -0
- package/docs/reports/github.com/after-reject.png +0 -0
- package/docs/reports/github.com/gdpr-checklist-github.com-2026-02-22.md +0 -44
- package/docs/reports/github.com/gdpr-cookies-github.com-2026-02-22.md +0 -29
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.md +0 -102
- package/docs/reports/github.com/gdpr-report-github.com-2026-02-22.pdf +0 -0
- package/docs/reports/gitlab.com/after-accept.png +0 -0
- package/docs/reports/gitlab.com/after-reject.png +0 -0
- package/docs/reports/gitlab.com/gdpr-checklist-gitlab.com-2026-02-22.md +0 -44
- package/docs/reports/gitlab.com/gdpr-cookies-gitlab.com-2026-02-22.md +0 -55
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.md +0 -200
- package/docs/reports/gitlab.com/gdpr-report-gitlab.com-2026-02-22.pdf +0 -0
- package/docs/reports/gitlab.com/modal-initial.png +0 -0
- package/docs/reports/npmjs.com/after-accept.png +0 -0
- package/docs/reports/npmjs.com/after-reject.png +0 -0
- package/docs/reports/npmjs.com/gdpr-checklist-npmjs.com-2026-02-22.md +0 -44
- package/docs/reports/npmjs.com/gdpr-cookies-npmjs.com-2026-02-22.md +0 -25
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.md +0 -88
- package/docs/reports/npmjs.com/gdpr-report-npmjs.com-2026-02-22.pdf +0 -0
- package/docs/reports/reddit.com/after-accept.png +0 -0
- package/docs/reports/reddit.com/after-reject.png +0 -0
- package/docs/reports/reddit.com/gdpr-checklist-reddit.com-2026-02-22.md +0 -44
- package/docs/reports/reddit.com/gdpr-cookies-reddit.com-2026-02-22.md +0 -33
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.md +0 -148
- package/docs/reports/reddit.com/gdpr-report-reddit.com-2026-02-22.pdf +0 -0
- package/docs/reports/reddit.com/modal-initial.png +0 -0
- package/docs/reports/stackoverflow.com/after-accept.png +0 -0
- package/docs/reports/stackoverflow.com/after-reject.png +0 -0
- package/docs/reports/stackoverflow.com/gdpr-checklist-stackoverflow.com-2026-02-22.md +0 -44
- package/docs/reports/stackoverflow.com/gdpr-cookies-stackoverflow.com-2026-02-22.md +0 -67
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.md +0 -206
- package/docs/reports/stackoverflow.com/gdpr-report-stackoverflow.com-2026-02-22.pdf +0 -0
- package/docs/reports/stackoverflow.com/modal-initial.png +0 -0
- package/docs/reports/www.afp.com/after-accept.png +0 -0
- package/docs/reports/www.afp.com/after-reject.png +0 -0
- package/docs/reports/www.afp.com/gdpr-checklist-afp.com-2026-02-22.md +0 -44
- package/docs/reports/www.afp.com/gdpr-cookies-afp.com-2026-02-22.md +0 -42
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.md +0 -202
- package/docs/reports/www.afp.com/gdpr-report-afp.com-2026-02-22.pdf +0 -0
- package/docs/reports/www.afp.com/modal-initial.png +0 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { rgbToHsl, classifyHue, detectColourNudging } from "../../src/analyzers/colour.js";
|
|
3
|
+
|
|
4
|
+
// ── rgbToHsl ──────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
describe("rgbToHsl", () => {
|
|
7
|
+
it("converts pure red", () => {
|
|
8
|
+
const [h, s, l] = rgbToHsl(255, 0, 0);
|
|
9
|
+
expect(h).toBe(0);
|
|
10
|
+
expect(s).toBe(100);
|
|
11
|
+
expect(l).toBe(50);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("converts pure green", () => {
|
|
15
|
+
const [h, s, l] = rgbToHsl(0, 255, 0);
|
|
16
|
+
expect(h).toBe(120);
|
|
17
|
+
expect(s).toBe(100);
|
|
18
|
+
expect(l).toBe(50);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("converts pure blue", () => {
|
|
22
|
+
const [h, s, l] = rgbToHsl(0, 0, 255);
|
|
23
|
+
expect(h).toBe(240);
|
|
24
|
+
expect(s).toBe(100);
|
|
25
|
+
expect(l).toBe(50);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("converts white", () => {
|
|
29
|
+
const [_h, s, l] = rgbToHsl(255, 255, 255);
|
|
30
|
+
expect(s).toBe(0);
|
|
31
|
+
expect(l).toBe(100);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("converts black", () => {
|
|
35
|
+
const [_h, s, l] = rgbToHsl(0, 0, 0);
|
|
36
|
+
expect(s).toBe(0);
|
|
37
|
+
expect(l).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("converts medium grey", () => {
|
|
41
|
+
const [, s, l] = rgbToHsl(128, 128, 128);
|
|
42
|
+
expect(s).toBe(0);
|
|
43
|
+
expect(l).toBeCloseTo(50, 0);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── classifyHue ───────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("classifyHue", () => {
|
|
50
|
+
describe("green", () => {
|
|
51
|
+
it("classifies a vivid green accept button colour", () => {
|
|
52
|
+
// rgb(34, 197, 94) — typical Tailwind green-500
|
|
53
|
+
expect(classifyHue(34, 197, 94)).toBe("green");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("classifies a darker CMP green", () => {
|
|
57
|
+
// rgb(22, 163, 74) — green-600
|
|
58
|
+
expect(classifyHue(22, 163, 74)).toBe("green");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("classifies a yellow-green as green", () => {
|
|
62
|
+
// h ≈ 100 — still in the green range
|
|
63
|
+
expect(classifyHue(100, 200, 50)).toBe("green");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("grey", () => {
|
|
68
|
+
it("classifies a mid-grey reject button", () => {
|
|
69
|
+
expect(classifyHue(160, 160, 160)).toBe("grey");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("classifies a light grey (s very low)", () => {
|
|
73
|
+
expect(classifyHue(220, 222, 220)).toBe("grey");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("classifies a dark grey", () => {
|
|
77
|
+
// l ≈ 25%, s ≈ 0
|
|
78
|
+
expect(classifyHue(60, 60, 60)).toBe("grey");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("red", () => {
|
|
83
|
+
it("classifies pure red", () => {
|
|
84
|
+
expect(classifyHue(255, 0, 0)).toBe("red");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("classifies a muted red / crimson", () => {
|
|
88
|
+
// rgb(185, 28, 28) — red-700
|
|
89
|
+
expect(classifyHue(185, 28, 28)).toBe("red");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("classifies a high-hue pink/magenta near 340° as red", () => {
|
|
93
|
+
// h ≈ 348
|
|
94
|
+
expect(classifyHue(220, 38, 100)).toBe("red");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("blue", () => {
|
|
99
|
+
it("classifies a standard blue CTA button", () => {
|
|
100
|
+
// rgb(59, 130, 246) — blue-500
|
|
101
|
+
expect(classifyHue(59, 130, 246)).toBe("blue");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("classifies a darker navy blue", () => {
|
|
105
|
+
expect(classifyHue(30, 64, 175)).toBe("blue");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("neutral", () => {
|
|
110
|
+
it("returns neutral for white (l > 93)", () => {
|
|
111
|
+
expect(classifyHue(255, 255, 255)).toBe("neutral");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns neutral for near-black (l < 10)", () => {
|
|
115
|
+
expect(classifyHue(10, 10, 10)).toBe("neutral");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns neutral for an orange hue (not a defined category)", () => {
|
|
119
|
+
// h ≈ 30, s ≈ 90% — orange
|
|
120
|
+
expect(classifyHue(255, 140, 0)).toBe("neutral");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ── detectColourNudging ───────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
describe("detectColourNudging", () => {
|
|
128
|
+
const GREEN = "rgb(34, 197, 94)";
|
|
129
|
+
const GREY = "rgb(160, 160, 160)";
|
|
130
|
+
const RED = "rgb(185, 28, 28)";
|
|
131
|
+
const BLUE = "rgb(59, 130, 246)";
|
|
132
|
+
|
|
133
|
+
describe("nudging detected", () => {
|
|
134
|
+
it("flags green accept + grey reject", () => {
|
|
135
|
+
const { isNudging, acceptHue, rejectHue } = detectColourNudging(GREEN, GREY);
|
|
136
|
+
expect(isNudging).toBe(true);
|
|
137
|
+
expect(acceptHue).toBe("green");
|
|
138
|
+
expect(rejectHue).toBe("grey");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("flags green accept + red reject", () => {
|
|
142
|
+
const { isNudging, acceptHue, rejectHue } = detectColourNudging(GREEN, RED);
|
|
143
|
+
expect(isNudging).toBe(true);
|
|
144
|
+
expect(acceptHue).toBe("green");
|
|
145
|
+
expect(rejectHue).toBe("red");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("no nudging", () => {
|
|
150
|
+
it("does not flag blue accept + grey reject (blue ≠ green)", () => {
|
|
151
|
+
const { isNudging } = detectColourNudging(BLUE, GREY);
|
|
152
|
+
expect(isNudging).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not flag green accept + green reject (same hue, no asymmetry)", () => {
|
|
156
|
+
const { isNudging } = detectColourNudging(GREEN, GREEN);
|
|
157
|
+
expect(isNudging).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("does not flag green accept + blue reject", () => {
|
|
161
|
+
const { isNudging } = detectColourNudging(GREEN, BLUE);
|
|
162
|
+
expect(isNudging).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("returns isNudging false when acceptBg is null", () => {
|
|
166
|
+
const { isNudging, acceptHue } = detectColourNudging(null, GREY);
|
|
167
|
+
expect(isNudging).toBe(false);
|
|
168
|
+
expect(acceptHue).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("returns isNudging false when rejectBg is null", () => {
|
|
172
|
+
const { isNudging, rejectHue } = detectColourNudging(GREEN, null);
|
|
173
|
+
expect(isNudging).toBe(false);
|
|
174
|
+
expect(rejectHue).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns isNudging false when both are null", () => {
|
|
178
|
+
const { isNudging } = detectColourNudging(null, null);
|
|
179
|
+
expect(isNudging).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("does not flag unparseable CSS strings", () => {
|
|
183
|
+
const { isNudging } = detectColourNudging("transparent", "inherit");
|
|
184
|
+
expect(isNudging).toBe(false);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -287,6 +287,108 @@ describe("easyRefusal dimension", () => {
|
|
|
287
287
|
});
|
|
288
288
|
expect(result.issues.some((i) => i.type === "nudging")).toBe(true);
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
it("deducts 5 for indirect reject label ('continuer sans accepter' dark pattern)", () => {
|
|
292
|
+
const modal = makeModal({
|
|
293
|
+
buttons: [
|
|
294
|
+
makeButton("accept", "Tout accepter", 1),
|
|
295
|
+
makeButton("reject", "Continuer sans accepter", 1),
|
|
296
|
+
],
|
|
297
|
+
});
|
|
298
|
+
const result = analyzeCompliance({
|
|
299
|
+
modal,
|
|
300
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
301
|
+
cookiesBeforeInteraction: [],
|
|
302
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
303
|
+
cookiesAfterReject: [],
|
|
304
|
+
networkBeforeInteraction: [],
|
|
305
|
+
networkAfterAccept: [],
|
|
306
|
+
networkAfterReject: [],
|
|
307
|
+
});
|
|
308
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
309
|
+
expect(
|
|
310
|
+
result.issues.some((i) => i.type === "misleading-wording" && i.severity === "warning"),
|
|
311
|
+
).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("does NOT deduct for an explicit reject label", () => {
|
|
315
|
+
const modal = makeModal({
|
|
316
|
+
buttons: [makeButton("accept", "Tout accepter", 1), makeButton("reject", "Tout refuser", 1)],
|
|
317
|
+
});
|
|
318
|
+
const result = analyzeCompliance({
|
|
319
|
+
modal,
|
|
320
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
321
|
+
cookiesBeforeInteraction: [],
|
|
322
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
323
|
+
cookiesAfterReject: [],
|
|
324
|
+
networkBeforeInteraction: [],
|
|
325
|
+
networkAfterAccept: [],
|
|
326
|
+
networkAfterReject: [],
|
|
327
|
+
});
|
|
328
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("deducts 5 for colour nudging (green accept + grey reject)", () => {
|
|
332
|
+
const modal = makeModal({
|
|
333
|
+
buttons: [
|
|
334
|
+
makeButton("accept", "Accept all", 1, { backgroundColor: "rgb(34, 197, 94)" }),
|
|
335
|
+
makeButton("reject", "Reject all", 1, { backgroundColor: "rgb(160, 160, 160)" }),
|
|
336
|
+
],
|
|
337
|
+
});
|
|
338
|
+
const result = analyzeCompliance({
|
|
339
|
+
modal,
|
|
340
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
341
|
+
cookiesBeforeInteraction: [],
|
|
342
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
343
|
+
cookiesAfterReject: [],
|
|
344
|
+
networkBeforeInteraction: [],
|
|
345
|
+
networkAfterAccept: [],
|
|
346
|
+
networkAfterReject: [],
|
|
347
|
+
});
|
|
348
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
349
|
+
expect(result.issues.some((i) => i.type === "nudging" && i.severity === "warning")).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("deducts 5 for colour nudging (green accept + red reject)", () => {
|
|
353
|
+
const modal = makeModal({
|
|
354
|
+
buttons: [
|
|
355
|
+
makeButton("accept", "Accept all", 1, { backgroundColor: "rgb(34, 197, 94)" }),
|
|
356
|
+
makeButton("reject", "Reject all", 1, { backgroundColor: "rgb(185, 28, 28)" }),
|
|
357
|
+
],
|
|
358
|
+
});
|
|
359
|
+
const result = analyzeCompliance({
|
|
360
|
+
modal,
|
|
361
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
362
|
+
cookiesBeforeInteraction: [],
|
|
363
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
364
|
+
cookiesAfterReject: [],
|
|
365
|
+
networkBeforeInteraction: [],
|
|
366
|
+
networkAfterAccept: [],
|
|
367
|
+
networkAfterReject: [],
|
|
368
|
+
});
|
|
369
|
+
expect(result.breakdown.easyRefusal).toBeLessThanOrEqual(20);
|
|
370
|
+
expect(result.issues.some((i) => i.type === "nudging")).toBe(true);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("does NOT flag colour nudging when accept is blue (not green)", () => {
|
|
374
|
+
const modal = makeModal({
|
|
375
|
+
buttons: [
|
|
376
|
+
makeButton("accept", "Accept all", 1, { backgroundColor: "rgb(59, 130, 246)" }),
|
|
377
|
+
makeButton("reject", "Reject all", 1, { backgroundColor: "rgb(160, 160, 160)" }),
|
|
378
|
+
],
|
|
379
|
+
});
|
|
380
|
+
const result = analyzeCompliance({
|
|
381
|
+
modal,
|
|
382
|
+
privacyPolicyUrl: "https://example.com/privacy",
|
|
383
|
+
cookiesBeforeInteraction: [],
|
|
384
|
+
cookiesAfterAccept: [makeCookie("_ga", true, "after-accept")],
|
|
385
|
+
cookiesAfterReject: [],
|
|
386
|
+
networkBeforeInteraction: [],
|
|
387
|
+
networkAfterAccept: [],
|
|
388
|
+
networkAfterReject: [],
|
|
389
|
+
});
|
|
390
|
+
expect(result.breakdown.easyRefusal).toBe(25);
|
|
391
|
+
});
|
|
290
392
|
});
|
|
291
393
|
|
|
292
394
|
// ── C. Transparency ───────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
decodeTcfConsentString,
|
|
4
|
+
IAB_PURPOSES,
|
|
5
|
+
IAB_SPECIAL_FEATURES,
|
|
6
|
+
} from "../../src/analyzers/tcf-decoder.js";
|
|
7
|
+
|
|
8
|
+
// ── Fixture builder ───────────────────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Encodes an array of [bitCount, value] pairs into a base64url string
|
|
11
|
+
// suitable for passing to decodeTcfConsentString().
|
|
12
|
+
// Uses modulo + Math.floor to avoid 32-bit overflow on large timestamps.
|
|
13
|
+
|
|
14
|
+
function makeTcfString(fields: Array<[bits: number, value: number]>): string {
|
|
15
|
+
const totalBits = fields.reduce((s, [b]) => s + b, 0);
|
|
16
|
+
const buf = Buffer.alloc(Math.ceil(totalBits / 8), 0);
|
|
17
|
+
let pos = 0;
|
|
18
|
+
for (const [bits, value] of fields) {
|
|
19
|
+
const arr: number[] = [];
|
|
20
|
+
let v = value;
|
|
21
|
+
for (let i = 0; i < bits; i++) {
|
|
22
|
+
arr.unshift(v % 2);
|
|
23
|
+
v = Math.floor(v / 2);
|
|
24
|
+
}
|
|
25
|
+
for (const bit of arr) {
|
|
26
|
+
if (bit) buf[Math.floor(pos / 8)] |= 1 << (7 - (pos % 8));
|
|
27
|
+
pos++;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Encode a 2-char ISO language/country code into 12 bits (A=0). */
|
|
34
|
+
function langBits(code: string): number {
|
|
35
|
+
return ((code.charCodeAt(0) - 65) << 6) | (code.charCodeAt(1) - 65);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Encode a list of purpose IDs (1-based) into a 24-bit bitfield. */
|
|
39
|
+
function purposeBits(ids: number[]): number {
|
|
40
|
+
return ids.reduce((acc, id) => acc | (1 << (24 - id)), 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Encode a list of special feature IDs (1-based) into a 12-bit bitfield. */
|
|
44
|
+
function specialFeatureBits(ids: number[]): number {
|
|
45
|
+
return ids.reduce((acc, id) => acc | (1 << (12 - id)), 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2020-01-01T00:00:00.000Z in deciseconds
|
|
49
|
+
const EPOCH_2020_DS = 15778368000;
|
|
50
|
+
|
|
51
|
+
function makeTcfV2(
|
|
52
|
+
opts: {
|
|
53
|
+
cmpId?: number;
|
|
54
|
+
cmpVersion?: number;
|
|
55
|
+
consentLanguage?: string;
|
|
56
|
+
vendorListVersion?: number;
|
|
57
|
+
tcfPolicyVersion?: number;
|
|
58
|
+
isServiceSpecific?: boolean;
|
|
59
|
+
specialFeatures?: number[];
|
|
60
|
+
purposesConsent?: number[];
|
|
61
|
+
purposesLI?: number[];
|
|
62
|
+
publisherCC?: string;
|
|
63
|
+
} = {},
|
|
64
|
+
): string {
|
|
65
|
+
const {
|
|
66
|
+
cmpId = 28,
|
|
67
|
+
cmpVersion = 1,
|
|
68
|
+
consentLanguage = "EN",
|
|
69
|
+
vendorListVersion = 65,
|
|
70
|
+
tcfPolicyVersion = 2,
|
|
71
|
+
isServiceSpecific = false,
|
|
72
|
+
specialFeatures = [],
|
|
73
|
+
purposesConsent = [],
|
|
74
|
+
purposesLI = [],
|
|
75
|
+
publisherCC = "DE",
|
|
76
|
+
} = opts;
|
|
77
|
+
|
|
78
|
+
return makeTcfString([
|
|
79
|
+
[6, 2], // version = 2
|
|
80
|
+
[36, EPOCH_2020_DS], // created
|
|
81
|
+
[36, EPOCH_2020_DS], // lastUpdated
|
|
82
|
+
[12, cmpId],
|
|
83
|
+
[12, cmpVersion],
|
|
84
|
+
[6, 0], // consentScreen
|
|
85
|
+
[12, langBits(consentLanguage)],
|
|
86
|
+
[12, vendorListVersion],
|
|
87
|
+
[6, tcfPolicyVersion],
|
|
88
|
+
[1, isServiceSpecific ? 1 : 0],
|
|
89
|
+
[1, 0], // useNonStandardStacks
|
|
90
|
+
[12, specialFeatureBits(specialFeatures)],
|
|
91
|
+
[24, purposeBits(purposesConsent)],
|
|
92
|
+
[24, purposeBits(purposesLI)],
|
|
93
|
+
[1, 0], // purposeOneTreatment
|
|
94
|
+
[12, langBits(publisherCC)],
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeTcfV1(
|
|
99
|
+
opts: {
|
|
100
|
+
cmpId?: number;
|
|
101
|
+
cmpVersion?: number;
|
|
102
|
+
consentLanguage?: string;
|
|
103
|
+
vendorListVersion?: number;
|
|
104
|
+
purposesAllowed?: number[];
|
|
105
|
+
} = {},
|
|
106
|
+
): string {
|
|
107
|
+
const {
|
|
108
|
+
cmpId = 10,
|
|
109
|
+
cmpVersion = 1,
|
|
110
|
+
consentLanguage = "FR",
|
|
111
|
+
vendorListVersion = 12,
|
|
112
|
+
purposesAllowed = [],
|
|
113
|
+
} = opts;
|
|
114
|
+
|
|
115
|
+
return makeTcfString([
|
|
116
|
+
[6, 1], // version = 1
|
|
117
|
+
[36, EPOCH_2020_DS], // created
|
|
118
|
+
[36, EPOCH_2020_DS], // lastUpdated
|
|
119
|
+
[12, cmpId],
|
|
120
|
+
[12, cmpVersion],
|
|
121
|
+
[6, 0], // consentScreen
|
|
122
|
+
[12, langBits(consentLanguage)],
|
|
123
|
+
[12, vendorListVersion],
|
|
124
|
+
[24, purposeBits(purposesAllowed)],
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── TCF v2 ────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("decodeTcfConsentString — TCF v2", () => {
|
|
131
|
+
it("decodes version 2", () => {
|
|
132
|
+
expect(decodeTcfConsentString(makeTcfV2())?.version).toBe(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("decodes CMP ID and version", () => {
|
|
136
|
+
const result = decodeTcfConsentString(makeTcfV2({ cmpId: 28, cmpVersion: 3 }));
|
|
137
|
+
expect(result?.cmpId).toBe(28);
|
|
138
|
+
expect(result?.cmpVersion).toBe(3);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("decodes consent language", () => {
|
|
142
|
+
expect(decodeTcfConsentString(makeTcfV2({ consentLanguage: "FR" }))?.consentLanguage).toBe(
|
|
143
|
+
"FR",
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("decodes timestamps as Date objects", () => {
|
|
148
|
+
const result = decodeTcfConsentString(makeTcfV2());
|
|
149
|
+
expect(result?.created).toEqual(new Date(EPOCH_2020_DS * 100));
|
|
150
|
+
expect(result?.lastUpdated).toEqual(new Date(EPOCH_2020_DS * 100));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("decodes purposesConsent", () => {
|
|
154
|
+
const result = decodeTcfConsentString(makeTcfV2({ purposesConsent: [1, 2, 7] }));
|
|
155
|
+
expect(result?.purposesConsent).toEqual([1, 2, 7]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("decodes purposesLegitimateInterest", () => {
|
|
159
|
+
const result = decodeTcfConsentString(makeTcfV2({ purposesLI: [2, 4, 9] }));
|
|
160
|
+
expect(result?.purposesLegitimateInterest).toEqual([2, 4, 9]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("decodes specialFeatureOptins", () => {
|
|
164
|
+
const result = decodeTcfConsentString(makeTcfV2({ specialFeatures: [1, 2] }));
|
|
165
|
+
expect(result?.specialFeatureOptins).toEqual([1, 2]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns empty arrays when nothing is set", () => {
|
|
169
|
+
const result = decodeTcfConsentString(
|
|
170
|
+
makeTcfV2({ purposesConsent: [], purposesLI: [], specialFeatures: [] }),
|
|
171
|
+
);
|
|
172
|
+
expect(result?.purposesConsent).toEqual([]);
|
|
173
|
+
expect(result?.purposesLegitimateInterest).toEqual([]);
|
|
174
|
+
expect(result?.specialFeatureOptins).toEqual([]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("decodes publisherCC", () => {
|
|
178
|
+
expect(decodeTcfConsentString(makeTcfV2({ publisherCC: "FR" }))?.publisherCC).toBe("FR");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("decodes isServiceSpecific", () => {
|
|
182
|
+
expect(decodeTcfConsentString(makeTcfV2({ isServiceSpecific: true }))?.isServiceSpecific).toBe(
|
|
183
|
+
true,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("decodes tcfPolicyVersion", () => {
|
|
188
|
+
expect(decodeTcfConsentString(makeTcfV2({ tcfPolicyVersion: 4 }))?.tcfPolicyVersion).toBe(4);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("decodes vendorListVersion", () => {
|
|
192
|
+
expect(decodeTcfConsentString(makeTcfV2({ vendorListVersion: 130 }))?.vendorListVersion).toBe(
|
|
193
|
+
130,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("preserves the raw string", () => {
|
|
198
|
+
const str = makeTcfV2();
|
|
199
|
+
expect(decodeTcfConsentString(str)?.raw).toBe(str);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("ignores vendor segments after '~'", () => {
|
|
203
|
+
const str = makeTcfV2({ cmpId: 5 });
|
|
204
|
+
const result = decodeTcfConsentString(`${str}~someVendorSegment~anotherSegment`);
|
|
205
|
+
expect(result?.version).toBe(2);
|
|
206
|
+
expect(result?.cmpId).toBe(5);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── TCF v1 ────────────────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("decodeTcfConsentString — TCF v1", () => {
|
|
213
|
+
it("decodes version 1", () => {
|
|
214
|
+
expect(decodeTcfConsentString(makeTcfV1())?.version).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("decodes purposesAllowed as purposesConsent", () => {
|
|
218
|
+
const result = decodeTcfConsentString(makeTcfV1({ purposesAllowed: [1, 3, 5] }));
|
|
219
|
+
expect(result?.purposesConsent).toEqual([1, 3, 5]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("returns empty legitimateInterest and specialFeatures for v1", () => {
|
|
223
|
+
const result = decodeTcfConsentString(makeTcfV1());
|
|
224
|
+
expect(result?.purposesLegitimateInterest).toEqual([]);
|
|
225
|
+
expect(result?.specialFeatureOptins).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("decodes CMP ID", () => {
|
|
229
|
+
expect(decodeTcfConsentString(makeTcfV1({ cmpId: 42 }))?.cmpId).toBe(42);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("decodes consent language", () => {
|
|
233
|
+
expect(decodeTcfConsentString(makeTcfV1({ consentLanguage: "DE" }))?.consentLanguage).toBe(
|
|
234
|
+
"DE",
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("decodes timestamps as Date objects", () => {
|
|
239
|
+
const result = decodeTcfConsentString(makeTcfV1());
|
|
240
|
+
expect(result?.created).toEqual(new Date(EPOCH_2020_DS * 100));
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// ── Edge cases ────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe("decodeTcfConsentString — edge cases", () => {
|
|
247
|
+
it("returns null for an empty string", () => {
|
|
248
|
+
expect(decodeTcfConsentString("")).toBeNull();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("returns null for a garbage base64 string", () => {
|
|
252
|
+
expect(decodeTcfConsentString("aaaa")).toBeNull();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("returns null for an unsupported version (v3)", () => {
|
|
256
|
+
const str = makeTcfString([
|
|
257
|
+
[6, 3], // version = 3, unsupported
|
|
258
|
+
[36, 0],
|
|
259
|
+
[36, 0],
|
|
260
|
+
[12, 1],
|
|
261
|
+
[12, 1],
|
|
262
|
+
[6, 0],
|
|
263
|
+
[12, langBits("EN")],
|
|
264
|
+
[12, 1],
|
|
265
|
+
]);
|
|
266
|
+
expect(decodeTcfConsentString(str)).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── IAB constants ─────────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
describe("IAB_PURPOSES", () => {
|
|
273
|
+
it("contains all 11 standard purposes", () => {
|
|
274
|
+
for (let i = 1; i <= 11; i++) {
|
|
275
|
+
expect(IAB_PURPOSES[i], `purpose ${i}`).toBeDefined();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("purpose 1 is about device storage", () => {
|
|
280
|
+
expect(IAB_PURPOSES[1]).toMatch(/store/i);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("IAB_SPECIAL_FEATURES", () => {
|
|
285
|
+
it("contains exactly 2 special features", () => {
|
|
286
|
+
expect(Object.keys(IAB_SPECIAL_FEATURES)).toHaveLength(2);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("feature 1 is about geolocation", () => {
|
|
290
|
+
expect(IAB_SPECIAL_FEATURES[1]).toMatch(/geolocation/i);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -84,6 +84,44 @@ describe("analyzeButtonWording", () => {
|
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
describe("indirect reject — refusal implied, not stated", () => {
|
|
88
|
+
it.each([
|
|
89
|
+
"Continuer sans accepter",
|
|
90
|
+
"Continue without accepting",
|
|
91
|
+
"Continue without consent",
|
|
92
|
+
"Proceed without accepting",
|
|
93
|
+
"Continuar sin aceptar",
|
|
94
|
+
"Continua senza accettare",
|
|
95
|
+
"Weiter ohne akzeptieren",
|
|
96
|
+
])('flags "%s" as indirect reject (warning)', (text) => {
|
|
97
|
+
const buttons = [makeButton("accept", "Accept all"), makeButton("reject", text)];
|
|
98
|
+
const result = analyzeButtonWording(buttons);
|
|
99
|
+
expect(result.hasIndirectRejectLabel).toBe(true);
|
|
100
|
+
const issue = result.issues.find(
|
|
101
|
+
(i) => i.type === "misleading-wording" && i.severity === "warning",
|
|
102
|
+
);
|
|
103
|
+
expect(issue).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does NOT flag an explicit Reject button as indirect", () => {
|
|
107
|
+
const buttons = [makeButton("accept", "Accept all"), makeButton("reject", "Reject all")];
|
|
108
|
+
const result = analyzeButtonWording(buttons);
|
|
109
|
+
expect(result.hasIndirectRejectLabel).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("does NOT flag 'Tout refuser' as indirect", () => {
|
|
113
|
+
const buttons = [makeButton("accept", "Tout accepter"), makeButton("reject", "Tout refuser")];
|
|
114
|
+
const result = analyzeButtonWording(buttons);
|
|
115
|
+
expect(result.hasIndirectRejectLabel).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns hasIndirectRejectLabel: false when there is no reject button", () => {
|
|
119
|
+
const buttons = [makeButton("accept", "Accept all")];
|
|
120
|
+
const result = analyzeButtonWording(buttons);
|
|
121
|
+
expect(result.hasIndirectRejectLabel).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
87
125
|
describe("fake reject — close/dismiss button disguised as reject", () => {
|
|
88
126
|
it.each(["×", "✕", "close", "Fermer", "dismiss", "skip"])(
|
|
89
127
|
'flags "%s" reject button as misleading (fake reject)',
|
|
@@ -191,6 +191,38 @@ describe("classifyButtonText — null (unknown language)", () => {
|
|
|
191
191
|
});
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
+
// ── "Continue without accepting" dark pattern ─────────────────────────────────
|
|
195
|
+
//
|
|
196
|
+
// Some sites hide their reject action behind a phrase that contains the word
|
|
197
|
+
// "accept" or an equivalent, making automated classifiers misread it as an
|
|
198
|
+
// accept button. These must be classified as "reject".
|
|
199
|
+
|
|
200
|
+
describe('classifyButtonText — "continue without accepting" dark pattern', () => {
|
|
201
|
+
it('FR: "Continuer sans accepter" → reject', () => {
|
|
202
|
+
expect(classifyButtonText("Continuer sans accepter", "fr")).toBe("reject");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('EN: "Continue without accepting" → reject', () => {
|
|
206
|
+
expect(classifyButtonText("Continue without accepting", "en")).toBe("reject");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('EN: "Continue without consent" → reject', () => {
|
|
210
|
+
expect(classifyButtonText("Continue without consent", "en")).toBe("reject");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('ES: "Continuar sin aceptar" → reject', () => {
|
|
214
|
+
expect(classifyButtonText("Continuar sin aceptar", "es")).toBe("reject");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('FR: "Accepter" alone → still accept (no regression)', () => {
|
|
218
|
+
expect(classifyButtonText("Accepter", "fr")).toBe("accept");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('FR: "Tout accepter" → still accept (no regression)', () => {
|
|
222
|
+
expect(classifyButtonText("Tout accepter", "fr")).toBe("accept");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
194
226
|
// ── BCP 47 subtag normalisation (sanity) ─────────────────────────────────────
|
|
195
227
|
|
|
196
228
|
describe("classifyButtonText — full BCP 47 tags should be pre-normalised", () => {
|
|
Binary file
|
|
Binary file
|