@slashgear/gdpr-cookie-scanner 2.0.4 → 3.1.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.
Files changed (53) hide show
  1. package/.github/workflows/update-trackers.yml +95 -0
  2. package/CHANGELOG.md +102 -0
  3. package/CLAUDE.md +1 -1
  4. package/README.md +78 -5
  5. package/dist/analyzers/compliance.d.ts.map +1 -1
  6. package/dist/analyzers/compliance.js +52 -9
  7. package/dist/analyzers/compliance.js.map +1 -1
  8. package/dist/classifiers/cookie-classifier.d.ts.map +1 -1
  9. package/dist/classifiers/cookie-classifier.js +2 -1
  10. package/dist/classifiers/cookie-classifier.js.map +1 -1
  11. package/dist/classifiers/network-classifier.d.ts +1 -0
  12. package/dist/classifiers/network-classifier.d.ts.map +1 -1
  13. package/dist/classifiers/network-classifier.js +10 -1
  14. package/dist/classifiers/network-classifier.js.map +1 -1
  15. package/dist/classifiers/tracker-list.d.ts +1 -0
  16. package/dist/classifiers/tracker-list.d.ts.map +1 -1
  17. package/dist/classifiers/tracker-list.js +7 -1
  18. package/dist/classifiers/tracker-list.js.map +1 -1
  19. package/dist/cli.js +1 -1
  20. package/dist/cli.js.map +1 -1
  21. package/dist/index.d.ts +51 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +46 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/report/generator.d.ts.map +1 -1
  26. package/dist/report/generator.js +82 -41
  27. package/dist/report/generator.js.map +1 -1
  28. package/dist/scanner/index.js +4 -4
  29. package/dist/scanner/index.js.map +1 -1
  30. package/dist/scanner/network.d.ts.map +1 -1
  31. package/dist/scanner/network.js +1 -0
  32. package/dist/scanner/network.js.map +1 -1
  33. package/dist/types.d.ts +2 -1
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +11 -2
  36. package/scripts/update-trackers.ts +273 -0
  37. package/src/analyzers/compliance.ts +54 -11
  38. package/src/classifiers/cookie-classifier.ts +2 -1
  39. package/src/classifiers/network-classifier.ts +11 -1
  40. package/src/classifiers/tracker-list.ts +9 -1
  41. package/src/cli.ts +1 -5
  42. package/src/index.ts +87 -0
  43. package/src/report/generator.ts +83 -44
  44. package/src/scanner/index.ts +4 -4
  45. package/src/scanner/network.ts +1 -0
  46. package/src/types.ts +2 -1
  47. package/tests/analyzers/compliance.test.ts +489 -0
  48. package/tests/analyzers/wording.test.ts +160 -0
  49. package/tests/classifiers/cookie-classifier.test.ts +270 -0
  50. package/tests/classifiers/network-classifier.test.ts +140 -0
  51. package/tests/e2e/scanner.test.ts +4 -7
  52. package/tests/unit/compliance.test.ts +99 -13
  53. package/tests/unit/network-classifier.test.ts +27 -0
@@ -0,0 +1,270 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { classifyCookie } from "../../src/classifiers/cookie-classifier.js";
3
+
4
+ describe("classifyCookie", () => {
5
+ // ── Strictly necessary ───────────────────────────────────────────
6
+
7
+ describe("strictly-necessary", () => {
8
+ it.each(["PHPSESSID", "JSESSIONID", "ASP.NET_SessionId", "__session"])(
9
+ "classifies %s as strictly-necessary",
10
+ (name) => {
11
+ const result = classifyCookie(name, "example.com", "abc123");
12
+ expect(result).toEqual({ category: "strictly-necessary", requiresConsent: false });
13
+ },
14
+ );
15
+
16
+ it("classifies session_id as strictly-necessary", () => {
17
+ expect(classifyCookie("session_id", "example.com", "xyz")).toEqual({
18
+ category: "strictly-necessary",
19
+ requiresConsent: false,
20
+ });
21
+ });
22
+
23
+ it.each(["csrf_token", "xsrf_token", "_token", "authenticity_token"])(
24
+ "classifies CSRF cookie %s as strictly-necessary",
25
+ (name) => {
26
+ expect(classifyCookie(name, "example.com", "token")).toEqual({
27
+ category: "strictly-necessary",
28
+ requiresConsent: false,
29
+ });
30
+ },
31
+ );
32
+
33
+ it.each(["auth_token", "authenticated", "login_session", "logged_in"])(
34
+ "classifies auth cookie %s as strictly-necessary",
35
+ (name) => {
36
+ expect(classifyCookie(name, "example.com", "1")).toEqual({
37
+ category: "strictly-necessary",
38
+ requiresConsent: false,
39
+ });
40
+ },
41
+ );
42
+
43
+ it.each(["cart_id", "basket", "checkout_token"])(
44
+ "classifies cart/checkout cookie %s as strictly-necessary",
45
+ (name) => {
46
+ expect(classifyCookie(name, "example.com", "abc")).toEqual({
47
+ category: "strictly-necessary",
48
+ requiresConsent: false,
49
+ });
50
+ },
51
+ );
52
+
53
+ it.each(["lang", "locale", "language", "country", "currency"])(
54
+ "classifies preference cookie %s as strictly-necessary",
55
+ (name) => {
56
+ expect(classifyCookie(name, "example.com", "fr")).toEqual({
57
+ category: "strictly-necessary",
58
+ requiresConsent: false,
59
+ });
60
+ },
61
+ );
62
+
63
+ it.each(["cookieconsent_status", "cookie_consent", "cc_cookie"])(
64
+ "classifies CMP storage cookie %s as strictly-necessary",
65
+ (name) => {
66
+ expect(classifyCookie(name, "example.com", "granted")).toEqual({
67
+ category: "strictly-necessary",
68
+ requiresConsent: false,
69
+ });
70
+ },
71
+ );
72
+
73
+ it.each(["axeptio_cookies", "didomi_token", "CookieConsent", "tarteaucitron"])(
74
+ "classifies known CMP cookie %s as strictly-necessary",
75
+ (name) => {
76
+ expect(classifyCookie(name, "example.com", "{}")).toEqual({
77
+ category: "strictly-necessary",
78
+ requiresConsent: false,
79
+ });
80
+ },
81
+ );
82
+ });
83
+
84
+ // ── Analytics ────────────────────────────────────────────────────
85
+
86
+ describe("analytics", () => {
87
+ it.each(["_ga", "_gid"])(
88
+ "classifies Google Analytics cookie %s as analytics requiring consent",
89
+ (name) => {
90
+ expect(classifyCookie(name, "example.com", "GA1.2.xxx")).toEqual({
91
+ category: "analytics",
92
+ requiresConsent: true,
93
+ });
94
+ },
95
+ );
96
+
97
+ it("classifies _ga_XXXXX (GA4 stream) as analytics", () => {
98
+ expect(classifyCookie("_ga_K1A2B3C4D5", "example.com", "GS1.1.xxx")).toEqual({
99
+ category: "analytics",
100
+ requiresConsent: true,
101
+ });
102
+ });
103
+
104
+ it.each(["_gat", "_gat_UA-123456"])(
105
+ "classifies GA rate-limiter cookie %s as analytics",
106
+ (name) => {
107
+ expect(classifyCookie(name, "example.com", "1")).toEqual({
108
+ category: "analytics",
109
+ requiresConsent: true,
110
+ });
111
+ },
112
+ );
113
+
114
+ it.each(["_utma", "_utmb", "_utmz"])("classifies legacy UTM cookie %s as analytics", (name) => {
115
+ expect(classifyCookie(name, "example.com", "xxx")).toEqual({
116
+ category: "analytics",
117
+ requiresConsent: true,
118
+ });
119
+ });
120
+
121
+ it("classifies __utmz as analytics", () => {
122
+ expect(classifyCookie("__utmz", "example.com", "xxx")).toEqual({
123
+ category: "analytics",
124
+ requiresConsent: true,
125
+ });
126
+ });
127
+
128
+ it("classifies Matomo _pk_ cookie as analytics", () => {
129
+ expect(classifyCookie("_pk_id.1.1fff", "example.com", "xxx")).toEqual({
130
+ category: "analytics",
131
+ requiresConsent: true,
132
+ });
133
+ });
134
+
135
+ it("classifies Amplitude amp_ cookie as analytics", () => {
136
+ expect(classifyCookie("amp_abc123", "example.com", "xxx")).toEqual({
137
+ category: "analytics",
138
+ requiresConsent: true,
139
+ });
140
+ });
141
+
142
+ it("classifies Hotjar _hj cookie as analytics", () => {
143
+ expect(classifyCookie("_hjSessionUser_123", "example.com", "xxx")).toEqual({
144
+ category: "analytics",
145
+ requiresConsent: true,
146
+ });
147
+ });
148
+
149
+ it("classifies Microsoft Clarity CLID as analytics", () => {
150
+ expect(classifyCookie("CLID", "example.com", "xxx")).toEqual({
151
+ category: "analytics",
152
+ requiresConsent: true,
153
+ });
154
+ });
155
+ });
156
+
157
+ // ── Advertising ──────────────────────────────────────────────────
158
+
159
+ describe("advertising", () => {
160
+ it.each(["_fbp", "_fbc", "fb_id"])(
161
+ "classifies Meta/Facebook cookie %s as advertising",
162
+ (name) => {
163
+ expect(classifyCookie(name, "example.com", "xxx")).toEqual({
164
+ category: "advertising",
165
+ requiresConsent: true,
166
+ });
167
+ },
168
+ );
169
+
170
+ it.each(["IDE", "NID", "DSID", "ANID", "__gads", "__gpi", "FCNEC"])(
171
+ "classifies Google Ads cookie %s as advertising",
172
+ (name) => {
173
+ expect(classifyCookie(name, "example.com", "xxx")).toEqual({
174
+ category: "advertising",
175
+ requiresConsent: true,
176
+ });
177
+ },
178
+ );
179
+
180
+ it("classifies MUID (Microsoft) as advertising", () => {
181
+ expect(classifyCookie("MUID", "example.com", "xxx")).toEqual({
182
+ category: "advertising",
183
+ requiresConsent: true,
184
+ });
185
+ });
186
+
187
+ it("classifies li_fat_id (LinkedIn) as advertising", () => {
188
+ expect(classifyCookie("li_fat_id", "example.com", "xxx")).toEqual({
189
+ category: "advertising",
190
+ requiresConsent: true,
191
+ });
192
+ });
193
+
194
+ it("classifies _ttp (TikTok) as advertising", () => {
195
+ expect(classifyCookie("_ttp", "example.com", "xxx")).toEqual({
196
+ category: "advertising",
197
+ requiresConsent: true,
198
+ });
199
+ });
200
+ });
201
+
202
+ // ── Social ───────────────────────────────────────────────────────
203
+
204
+ describe("social", () => {
205
+ it("classifies fbsr_ (Facebook login) as social", () => {
206
+ expect(classifyCookie("fbsr_123456", "example.com", "xxx")).toEqual({
207
+ category: "social",
208
+ requiresConsent: true,
209
+ });
210
+ });
211
+
212
+ it.each(["YSC", "VISITOR_INFO1_LIVE", "GPS"])(
213
+ "classifies YouTube cookie %s as social",
214
+ (name) => {
215
+ expect(classifyCookie(name, "example.com", "xxx")).toEqual({
216
+ category: "social",
217
+ requiresConsent: true,
218
+ });
219
+ },
220
+ );
221
+ });
222
+
223
+ // ── Personalization ──────────────────────────────────────────────
224
+
225
+ describe("personalization", () => {
226
+ it.each(["ab_test", "abt_variant", "abtest_bucket"])(
227
+ "classifies A/B test cookie %s as personalization",
228
+ (name) => {
229
+ expect(classifyCookie(name, "example.com", "variant_b")).toEqual({
230
+ category: "personalization",
231
+ requiresConsent: true,
232
+ });
233
+ },
234
+ );
235
+
236
+ it("classifies optimizely cookie as personalization", () => {
237
+ expect(classifyCookie("optimizely_data", "example.com", "xxx")).toEqual({
238
+ category: "personalization",
239
+ requiresConsent: true,
240
+ });
241
+ });
242
+ });
243
+
244
+ // ── Unknown / heuristics ─────────────────────────────────────────
245
+
246
+ describe("unknown", () => {
247
+ it("classifies unrecognised long-name cookie as unknown without consent", () => {
248
+ expect(classifyCookie("my_custom_app_cookie", "example.com", "somevalue=abc")).toEqual({
249
+ category: "unknown",
250
+ requiresConsent: false,
251
+ });
252
+ });
253
+
254
+ it("classifies short-name cookie (≤4 chars, no = in value) as unknown requiring consent", () => {
255
+ // Heuristic: suspicious session-like short cookie
256
+ expect(classifyCookie("sid", "example.com", "abc123")).toEqual({
257
+ category: "unknown",
258
+ requiresConsent: true,
259
+ });
260
+ });
261
+
262
+ it("does NOT flag short-name cookie as suspicious when value contains =", () => {
263
+ // Base64-encoded values contain = — should not be flagged
264
+ expect(classifyCookie("tok", "example.com", "abc=def")).toEqual({
265
+ category: "unknown",
266
+ requiresConsent: false,
267
+ });
268
+ });
269
+ });
270
+ });
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { classifyNetworkRequest } from "../../src/classifiers/network-classifier.js";
3
+
4
+ describe("classifyNetworkRequest", () => {
5
+ // ── Known trackers (exact hostname match) ────────────────────────
6
+
7
+ describe("tracker database — exact match", () => {
8
+ it("identifies Google Analytics", () => {
9
+ const result = classifyNetworkRequest("https://google-analytics.com/collect", "xhr");
10
+ expect(result.trackerCategory).toBe("analytics");
11
+ expect(result.trackerName).toBe("Google Analytics");
12
+ expect(result.requiresConsent).toBe(true);
13
+ expect(result.isThirdParty).toBe(true);
14
+ });
15
+
16
+ it("identifies Google Tag Manager", () => {
17
+ const result = classifyNetworkRequest(
18
+ "https://googletagmanager.com/gtm.js?id=GTM-XXXX",
19
+ "script",
20
+ );
21
+ expect(result.trackerCategory).toBe("analytics");
22
+ expect(result.trackerName).toBe("Google Tag Manager");
23
+ expect(result.requiresConsent).toBe(true);
24
+ });
25
+
26
+ it("identifies Meta Pixel (pixel.facebook.com)", () => {
27
+ const result = classifyNetworkRequest("https://pixel.facebook.com/tr?id=123", "xhr");
28
+ expect(result.trackerCategory).toBe("advertising");
29
+ expect(result.trackerName).toBe("Meta Pixel");
30
+ expect(result.requiresConsent).toBe(true);
31
+ });
32
+
33
+ it("identifies LinkedIn Insight Tag", () => {
34
+ const result = classifyNetworkRequest(
35
+ "https://snap.licdn.com/li.lms-analytics/insight.min.js",
36
+ "script",
37
+ );
38
+ expect(result.trackerCategory).toBe("advertising");
39
+ expect(result.requiresConsent).toBe(true);
40
+ });
41
+
42
+ it("identifies Hotjar", () => {
43
+ const result = classifyNetworkRequest("https://hotjar.com/api/v2/event", "xhr");
44
+ expect(result.trackerCategory).toBe("analytics");
45
+ expect(result.trackerName).toBe("Hotjar");
46
+ });
47
+
48
+ it("identifies Microsoft Clarity", () => {
49
+ const result = classifyNetworkRequest("https://clarity.ms/collect", "xhr");
50
+ expect(result.trackerCategory).toBe("analytics");
51
+ expect(result.requiresConsent).toBe(true);
52
+ });
53
+ });
54
+
55
+ // ── Subdomain suffix match ────────────────────────────────────────
56
+
57
+ describe("tracker database — subdomain match", () => {
58
+ it("matches subdomain of google-analytics.com", () => {
59
+ const result = classifyNetworkRequest("https://stats.google-analytics.com/r/collect", "xhr");
60
+ expect(result.trackerName).toBe("Google Analytics");
61
+ expect(result.isThirdParty).toBe(true);
62
+ });
63
+
64
+ it("strips www. before matching", () => {
65
+ const result = classifyNetworkRequest("https://www.google-analytics.com/collect", "xhr");
66
+ expect(result.trackerCategory).toBe("analytics");
67
+ });
68
+ });
69
+
70
+ // ── Pixel pattern matching ────────────────────────────────────────
71
+
72
+ describe("pixel patterns", () => {
73
+ it("identifies tracking pixels by URL pattern", () => {
74
+ const result = classifyNetworkRequest(
75
+ "https://track.example.com/pixel.gif?uid=123&ev=pageview",
76
+ "image",
77
+ );
78
+ // Either caught by pixel pattern or image heuristic
79
+ expect(result.trackerCategory).toBe("pixel");
80
+ expect(result.requiresConsent).toBe(true);
81
+ });
82
+ });
83
+
84
+ // ── Image heuristic ───────────────────────────────────────────────
85
+
86
+ describe("image resource heuristic", () => {
87
+ it("flags 1×1 gif with tracking params as pixel", () => {
88
+ const result = classifyNetworkRequest(
89
+ "https://metrics.unknown-vendor.com/track.gif?uid=abc&ts=1234",
90
+ "image",
91
+ );
92
+ expect(result.trackerCategory).toBe("pixel");
93
+ expect(result.requiresConsent).toBe(true);
94
+ });
95
+
96
+ it("does NOT flag regular image without tracking params", () => {
97
+ const result = classifyNetworkRequest("https://cdn.unknown-vendor.com/hero.png", "image");
98
+ expect(result.trackerCategory).toBeNull();
99
+ expect(result.requiresConsent).toBe(false);
100
+ });
101
+ });
102
+
103
+ // ── CDN — consent not required ────────────────────────────────────
104
+
105
+ describe("CDN entries", () => {
106
+ it("marks CDN requests as not requiring consent", () => {
107
+ // fbcdn.net is category 'social' not cdn, but let's test the cdn logic
108
+ // by checking that consentRequired=false entries in TRACKER_DB are respected
109
+ // Google fonts is not in the DB, so test a generic unknown
110
+ const result = classifyNetworkRequest("https://totally-unknown-cdn.io/lib.js", "script");
111
+ expect(result.requiresConsent).toBe(false);
112
+ expect(result.trackerCategory).toBeNull();
113
+ });
114
+ });
115
+
116
+ // ── Unknown / safe requests ───────────────────────────────────────
117
+
118
+ describe("unknown requests", () => {
119
+ it("returns null classification for an unknown domain", () => {
120
+ const result = classifyNetworkRequest("https://api.my-own-backend.com/data", "xhr");
121
+ expect(result.trackerCategory).toBeNull();
122
+ expect(result.trackerName).toBeNull();
123
+ expect(result.requiresConsent).toBe(false);
124
+ expect(result.isThirdParty).toBe(false);
125
+ });
126
+
127
+ it("returns safe fallback for an invalid URL", () => {
128
+ const result = classifyNetworkRequest("not-a-url", "xhr");
129
+ expect(result.trackerCategory).toBeNull();
130
+ expect(result.requiresConsent).toBe(false);
131
+ expect(result.isThirdParty).toBe(false);
132
+ });
133
+
134
+ it("returns safe fallback for an empty string", () => {
135
+ const result = classifyNetworkRequest("", "xhr");
136
+ expect(result.trackerCategory).toBeNull();
137
+ expect(result.requiresConsent).toBe(false);
138
+ });
139
+ });
140
+ });
@@ -117,19 +117,16 @@ describe("Scanner E2E", { timeout: E2E_TIMEOUT }, () => {
117
117
  expect(result.privacyPolicyUrl).toBeTruthy();
118
118
  });
119
119
 
120
- it("assigns grade F when no modal is detected", async () => {
120
+ it("assigns grade A no consent mechanism needed for a tracking-free site", async () => {
121
121
  const scanner = new Scanner(makeOptions("no-modal-site.html"));
122
122
  const result = await scanner.run();
123
- expect(result.compliance.grade).toBe("F");
123
+ expect(result.compliance.grade).toBe("A");
124
124
  });
125
125
 
126
- it("raises a critical no-reject-button issue", async () => {
126
+ it("raises no compliance issues for a tracking-free site without a modal", async () => {
127
127
  const scanner = new Scanner(makeOptions("no-modal-site.html"));
128
128
  const result = await scanner.run();
129
- const issue = result.compliance.issues.find(
130
- (i) => i.type === "no-reject-button" && i.severity === "critical",
131
- );
132
- expect(issue).toBeDefined();
129
+ expect(result.compliance.issues).toHaveLength(0);
133
130
  });
134
131
  });
135
132
  });
@@ -74,6 +74,7 @@ function makeRequest(
74
74
  url: string,
75
75
  trackerCategory: NetworkRequest["trackerCategory"],
76
76
  capturedAt: NetworkRequest["capturedAt"],
77
+ requiresConsent?: boolean,
77
78
  ): NetworkRequest {
78
79
  return {
79
80
  url,
@@ -83,6 +84,7 @@ function makeRequest(
83
84
  isThirdParty: trackerCategory !== null,
84
85
  trackerCategory,
85
86
  trackerName: trackerCategory ? "Tracker" : null,
87
+ requiresConsent: requiresConsent ?? (trackerCategory !== null && trackerCategory !== "cdn"),
86
88
  capturedAt,
87
89
  responseStatus: 200,
88
90
  contentType: null,
@@ -103,25 +105,52 @@ const emptyInputBase = {
103
105
 
104
106
  describe("analyzeCompliance", () => {
105
107
  describe("no consent modal detected", () => {
106
- it("scores 0 on consentValidity, easyRefusal, and transparency", () => {
107
- const result = analyzeCompliance({
108
+ describe("when non-essential cookies are present (consent required)", () => {
109
+ const inputWithCookies = {
108
110
  ...emptyInputBase,
111
+ cookiesAfterAccept: [makeCookie("_ga", "analytics", true, "after-accept")],
109
112
  modal: makeModal({ detected: false }),
113
+ };
114
+
115
+ it("scores 0 on consentValidity, easyRefusal, and transparency", () => {
116
+ const result = analyzeCompliance(inputWithCookies);
117
+
118
+ expect(result.breakdown.consentValidity).toBe(0);
119
+ expect(result.breakdown.easyRefusal).toBe(0);
120
+ expect(result.breakdown.transparency).toBe(0);
110
121
  });
111
122
 
112
- expect(result.breakdown.consentValidity).toBe(0);
113
- expect(result.breakdown.easyRefusal).toBe(0);
114
- expect(result.breakdown.transparency).toBe(0);
123
+ it("issues a critical no-reject-button issue", () => {
124
+ const result = analyzeCompliance(inputWithCookies);
125
+ const issue = result.issues.find((i) => i.type === "no-reject-button");
126
+ expect(issue).toBeDefined();
127
+ expect(issue?.severity).toBe("critical");
128
+ });
115
129
  });
116
130
 
117
- it("issues a critical no-reject-button issue", () => {
118
- const result = analyzeCompliance({
119
- ...emptyInputBase,
120
- modal: makeModal({ detected: false }),
131
+ describe("when no non-essential cookies or trackers are present (consent not required)", () => {
132
+ it("scores full marks — no consent mechanism needed", () => {
133
+ const result = analyzeCompliance({
134
+ ...emptyInputBase,
135
+ modal: makeModal({ detected: false }),
136
+ });
137
+
138
+ expect(result.breakdown.consentValidity).toBe(25);
139
+ expect(result.breakdown.easyRefusal).toBe(25);
140
+ expect(result.breakdown.transparency).toBe(25);
141
+ expect(result.breakdown.cookieBehavior).toBe(25);
142
+ expect(result.total).toBe(100);
143
+ expect(result.grade).toBe("A");
144
+ });
145
+
146
+ it("raises no compliance issues", () => {
147
+ const result = analyzeCompliance({
148
+ ...emptyInputBase,
149
+ modal: makeModal({ detected: false }),
150
+ });
151
+
152
+ expect(result.issues).toHaveLength(0);
121
153
  });
122
- const issue = result.issues.find((i) => i.type === "no-reject-button");
123
- expect(issue).toBeDefined();
124
- expect(issue?.severity).toBe("critical");
125
154
  });
126
155
  });
127
156
 
@@ -292,12 +321,14 @@ describe("analyzeCompliance", () => {
292
321
  });
293
322
 
294
323
  describe("missing privacy policy link on page", () => {
295
- it("deducts 3 from transparency and raises a missing-info warning", () => {
324
+ it("deducts 3 from transparency and raises a missing-info warning when consent is required", () => {
296
325
  // hasGranularControls: true so only -3 deduction (no -10 for granular)
326
+ // A non-essential cookie is present so consentRequired = true → deduction applies
297
327
  const result = analyzeCompliance({
298
328
  ...emptyInputBase,
299
329
  privacyPolicyUrl: null,
300
330
  modal: makeModal({ hasGranularControls: true }),
331
+ cookiesAfterAccept: [makeCookie("_ga", "analytics", true, "after-accept")],
301
332
  });
302
333
 
303
334
  // 25 - 3 (no page privacy link) = 22
@@ -307,6 +338,21 @@ describe("analyzeCompliance", () => {
307
338
  );
308
339
  expect(issue).toBeDefined();
309
340
  });
341
+
342
+ it("does not deduct when no consent is required (tracking-free site)", () => {
343
+ const result = analyzeCompliance({
344
+ ...emptyInputBase,
345
+ privacyPolicyUrl: null,
346
+ modal: makeModal({ hasGranularControls: true }),
347
+ });
348
+
349
+ // No non-essential cookies/trackers → consentRequired = false → no deduction
350
+ expect(result.breakdown.transparency).toBe(25);
351
+ const issue = result.issues.find(
352
+ (i) => i.type === "missing-info" && i.description.includes("page"),
353
+ );
354
+ expect(issue).toBeUndefined();
355
+ });
310
356
  });
311
357
 
312
358
  describe("non-essential cookies before consent", () => {
@@ -457,4 +503,44 @@ describe("analyzeCompliance", () => {
457
503
  expect(result.grade).toBe("F");
458
504
  });
459
505
  });
506
+
507
+ describe("consent-exempt tracker (Plausible-like)", () => {
508
+ const plausibleRequest = makeRequest(
509
+ "https://plausible.io/api/event",
510
+ "analytics",
511
+ "before-interaction",
512
+ false, // requiresConsent: false — CNIL ePrivacy exemption
513
+ );
514
+
515
+ it("does not deduct from cookieBehavior when a pre-interaction tracker is consent-exempt", () => {
516
+ const result = analyzeCompliance({
517
+ ...emptyInputBase,
518
+ modal: makeModal({ hasGranularControls: true }),
519
+ networkBeforeInteraction: [plausibleRequest],
520
+ });
521
+
522
+ expect(result.breakdown.cookieBehavior).toBe(25);
523
+ const autoConsentIssue = result.issues.find((i) => i.type === "auto-consent");
524
+ expect(autoConsentIssue).toBeUndefined();
525
+ });
526
+
527
+ it("consentRequired is false when only a consent-exempt tracker is present", () => {
528
+ // No modal, only Plausible → should score full marks (no consent needed)
529
+ const result = analyzeCompliance({
530
+ ...emptyInputBase,
531
+ modal: makeModal({ detected: false }),
532
+ networkBeforeInteraction: [plausibleRequest],
533
+ networkAfterAccept: [
534
+ makeRequest("https://plausible.io/api/event", "analytics", "after-accept", false),
535
+ ],
536
+ });
537
+
538
+ expect(result.breakdown.consentValidity).toBe(25);
539
+ expect(result.breakdown.easyRefusal).toBe(25);
540
+ expect(result.breakdown.transparency).toBe(25);
541
+ expect(result.breakdown.cookieBehavior).toBe(25);
542
+ expect(result.total).toBe(100);
543
+ expect(result.grade).toBe("A");
544
+ });
545
+ });
460
546
  });
@@ -61,6 +61,7 @@ describe("classifyNetworkRequest", () => {
61
61
  expect(result.isThirdParty).toBe(false);
62
62
  expect(result.trackerCategory).toBeNull();
63
63
  expect(result.trackerName).toBeNull();
64
+ expect(result.requiresConsent).toBe(false);
64
65
  });
65
66
 
66
67
  it("does not flag CDN requests for known CDN domains", () => {
@@ -78,6 +79,7 @@ describe("classifyNetworkRequest", () => {
78
79
  expect(result.isThirdParty).toBe(false);
79
80
  expect(result.trackerCategory).toBeNull();
80
81
  expect(result.trackerName).toBeNull();
82
+ expect(result.requiresConsent).toBe(false);
81
83
  });
82
84
  });
83
85
 
@@ -88,4 +90,29 @@ describe("classifyNetworkRequest", () => {
88
90
  expect(result.trackerCategory).toBe("analytics");
89
91
  });
90
92
  });
93
+
94
+ describe("requiresConsent field", () => {
95
+ it("Plausible Analytics (/api/event) is classified as analytics but does not require consent", () => {
96
+ const result = classifyNetworkRequest("https://plausible.io/api/event", "xhr");
97
+ expect(result.isThirdParty).toBe(true);
98
+ expect(result.trackerCategory).toBe("analytics");
99
+ expect(result.trackerName).toBe("Plausible Analytics");
100
+ expect(result.requiresConsent).toBe(false);
101
+ });
102
+
103
+ it("Google Analytics requires consent", () => {
104
+ const result = classifyNetworkRequest(
105
+ "https://www.google-analytics.com/j/collect?v=1&t=event",
106
+ "xhr",
107
+ );
108
+ expect(result.requiresConsent).toBe(true);
109
+ });
110
+
111
+ it("CDN domain (akamaized.net) does not require consent", () => {
112
+ const result = classifyNetworkRequest("https://assets.akamaized.net/bundle.js", "script");
113
+ expect(result.isThirdParty).toBe(true);
114
+ expect(result.trackerCategory).toBe("cdn");
115
+ expect(result.requiresConsent).toBe(false);
116
+ });
117
+ });
91
118
  });