From 5732ec775d6ce1f9d1a6cc28af90c31c58a427db Mon Sep 17 00:00:00 2001 From: Antoine Niek Date: Wed, 18 Dec 2024 13:51:42 -0500 Subject: [PATCH] Passthrough gpp alongside gpp_sid when available --- lib/core/network.ts | 4 + lib/core/regs/consent.test.js | 152 ++++++++++++++++++++-------------- lib/core/regs/consent.ts | 66 +++++++++++---- 3 files changed, 144 insertions(+), 78 deletions(-) diff --git a/lib/core/network.ts b/lib/core/network.ts index a3f6460..a41386d 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -16,6 +16,10 @@ function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): url.searchParams.set("gpp", config.consent.gpp); } + if (config.consent.gpp_sid) { + url.searchParams.set("gpp_sid", config.consent.gpp_sid.join(",")); + } + if (config.consent.tcf) { url.searchParams.set("tcf", config.consent.tcf); } diff --git a/lib/core/regs/consent.test.js b/lib/core/regs/consent.test.js index b5f7dc6..e33ecc7 100644 --- a/lib/core/regs/consent.test.js +++ b/lib/core/regs/consent.test.js @@ -1,6 +1,5 @@ import { getConsent } from "./consent"; -import { SectionID as TCFEuV2SectionID, APIPrefix as TCFEuV2APIPrefix } from "./gpp/tcfeuv2"; -import { SectionID as TCFCaV1SectionID, APIPrefix as TCFCaV1APIPrefix } from "./gpp/tcfcav1"; +import * as gpp from "./gpp"; describe("getConsent", () => { let windowSpy; @@ -61,22 +60,7 @@ describe("getConsent", () => { }); it("updates consent based on tcf signals for gdpr", () => { - let listener; - const tcf = jest.fn((_command, _version, cb) => { - listener = cb; - }); - windowSpy.mockImplementation(() => ({ __tcfapi: tcf })); - - const signal = (overrides) => { - listener( - { - gdprApplies: true, - eventStatus: "tcloaded", - ...overrides, - }, - true - ); - }; + const signal = mockTCFSignal(windowSpy); const consent = getConsent("gdpr"); // By default device access is denied @@ -114,129 +98,173 @@ describe("getConsent", () => { }); it("updates consent based on gpp signals for gdpr", () => { - let listener; - const gpp = jest.fn((_command, cb) => { - listener = cb; - }); - windowSpy.mockImplementation(() => ({ __gpp: gpp })); + const signal = mockGPPSignal(windowSpy); const consent = getConsent("gdpr"); // By default device access is denied expect(consent.deviceAccess).toBe(false); expect(consent.reg).toBe("gdpr"); - const signal = (pingData) => { - listener( - { - data: "ready", - eventName: "signalStatus", - pingData, - }, - true - ); - }; - // Simulate gpp ready event indicating no applicable sections signal({ applicableSections: [-1], gppString: "ignored" }); expect(consent.deviceAccess).toBe(false); expect(consent.gpp).toBeUndefined(); // Section tcfeuv2 applies but nothing in parsed sections - signal({ parsedSections: {}, applicableSections: [TCFEuV2SectionID], gppString: "noparsedsections" }); + signal({ parsedSections: {}, applicableSections: [gpp.tcfeuv2.SectionID], gppString: "noparsedsections" }); expect(consent.deviceAccess).toBe(false); expect(consent.gpp).toBe("noparsedsections"); + expect(consent.gpp_sid).toEqual([gpp.tcfeuv2.SectionID]); // Section tcfeuv2 applies but no publisher segment signal({ - parsedSections: { [TCFEuV2APIPrefix]: [] }, - applicableSections: [TCFEuV2SectionID], + parsedSections: { [gpp.tcfeuv2.APIPrefix]: [] }, + applicableSections: [gpp.tcfeuv2.SectionID], gppString: "nopublishersegment", }); expect(consent.deviceAccess).toBe(false); expect(consent.gpp).toBe("nopublishersegment"); + expect(consent.gpp_sid).toEqual([gpp.tcfeuv2.SectionID]); // Section tcfeuv2 applies but no purpose 1 consent in publisher segment signal({ parsedSections: { - [TCFEuV2APIPrefix]: [ + [gpp.tcfeuv2.APIPrefix]: [ { SegmentType: 3, PubPurposesConsent: [], }, ], }, - applicableSections: [TCFEuV2SectionID], + applicableSections: [gpp.tcfeuv2.SectionID], gppString: "nopurpose1consent", }); expect(consent.deviceAccess).toBe(false); expect(consent.gpp).toBe("nopurpose1consent"); + expect(consent.gpp_sid).toEqual([gpp.tcfeuv2.SectionID]); // Section tcfeuv2 applies and purpose 1 granted to publisher signal({ parsedSections: { - [TCFEuV2APIPrefix]: [ + [gpp.tcfeuv2.APIPrefix]: [ { SegmentType: 3, PubPurposesConsent: [1], }, ], }, - applicableSections: [TCFEuV2SectionID], + applicableSections: [gpp.tcfeuv2.SectionID], gppString: "purpose1", }); expect(consent.deviceAccess).toBe(true); expect(consent.gpp).toBe("purpose1"); + expect(consent.gpp_sid).toEqual([gpp.tcfeuv2.SectionID]); // Section tcfeuv2 applies and purpose 1 revoked // to publisher signal({ parsedSections: { - [TCFEuV2APIPrefix]: [ + [gpp.tcfeuv2.APIPrefix]: [ { SegmentType: 3, PubPurposesConsent: [], }, ], }, - applicableSections: [TCFEuV2SectionID], + applicableSections: [gpp.tcfeuv2.SectionID], gppString: "revoked", }); expect(consent.deviceAccess).toBe(false); expect(consent.gpp).toBe("revoked"); + expect(consent.gpp_sid).toEqual([gpp.tcfeuv2.SectionID]); }); it("updates consent based on gpp signals for can", () => { - let listener; - const gpp = jest.fn((_command, cb) => { - listener = cb; - }); - windowSpy.mockImplementation(() => ({ __gpp: gpp })); + const signal = mockGPPSignal(windowSpy); const consent = getConsent("can"); // Device access is always granted expect(consent.deviceAccess).toBe(true); expect(consent.reg).toBe("can"); - const signal = (pingData) => { - listener( - { - data: "ready", - eventName: "signalStatus", - pingData, - }, - true - ); - }; + // Simulate gpp ready event indicating no applicable sections + signal({ applicableSections: [-1], gppString: "ignored" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBeUndefined(); + expect(consent.gpp_sid).toBeUndefined(); + + // It listens to can section changes and propagates gpp and gpp sid + signal({ parsedSections: {}, applicableSections: [gpp.tcfcav1.SectionID], gppString: "can" }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("can"); + expect(consent.gpp_sid).toEqual([gpp.tcfcav1.SectionID]); + }); + + it("updates consent based on gpp signals for us", () => { + const signal = mockGPPSignal(windowSpy); + + const consent = getConsent("us"); + // Device access is always granted + expect(consent.deviceAccess).toBe(true); + expect(consent.reg).toBe("us"); // Simulate gpp ready event indicating no applicable sections signal({ applicableSections: [-1], gppString: "ignored" }); expect(consent.deviceAccess).toBe(true); expect(consent.gpp).toBeUndefined(); + expect(consent.gpp_sid).toBeUndefined(); - // Section tcfcav1 applies but nothing in parsed sections doesn't impact - // device access - signal({ parsedSections: {}, applicableSections: [TCFCaV1SectionID], gppString: "noparsedsections" }); + // It listens to us sections changes and propagates gpp and gpp sid + signal({ parsedSections: {}, applicableSections: [gpp.usnat.SectionID], gppString: "usnat" }); expect(consent.deviceAccess).toBe(true); - expect(consent.gpp).toBe("noparsedsections"); + expect(consent.gpp).toBe("usnat"); + expect(consent.gpp_sid).toEqual([gpp.usnat.SectionID]); + + signal({ + parsedSections: {}, + applicableSections: [gpp.usnat.SectionID, gpp.usca.SectionID], + gppString: "usnat_and_usca", + }); + expect(consent.deviceAccess).toBe(true); + expect(consent.gpp).toBe("usnat_and_usca"); + expect(consent.gpp_sid).toEqual([gpp.usnat.SectionID, gpp.usca.SectionID]); }); }); + +function mockGPPSignal(windowSpy) { + let listener; + const gpp = jest.fn((_command, cb) => { + listener = cb; + }); + windowSpy.mockImplementation(() => ({ __gpp: gpp })); + + return (pingData) => { + listener( + { + data: "ready", + eventName: "signalStatus", + pingData, + }, + true + ); + }; +} + +function mockTCFSignal(windowSpy) { + let listener; + const tcf = jest.fn((_command, _version, cb) => { + listener = cb; + }); + windowSpy.mockImplementation(() => ({ __tcfapi: tcf })); + + return (overrides) => { + listener( + { + gdprApplies: true, + eventStatus: "tcloaded", + ...overrides, + }, + true + ); + }; +} diff --git a/lib/core/regs/consent.ts b/lib/core/regs/consent.ts index 3c2d283..7cc7d31 100644 --- a/lib/core/regs/consent.ts +++ b/lib/core/regs/consent.ts @@ -1,20 +1,42 @@ import { inferRegulation, Regulation } from "./regulations"; -import { PingReturn as GPPConsentData } from "./gpp/cmpapi"; -import { TCData as TCFConsentData } from "./tcf/cmpapi"; -import { SectionID as TCFEuV2SectionID, APIPrefix as TCFEuV2APIPrefix } from "./gpp/tcfeuv2"; -import { SectionID as TCFCaV1SectionID } from "./gpp/tcfcav1"; +import * as gpp from "./gpp"; +import * as tcf from "./tcf"; type Consent = { deviceAccess: boolean; reg: Regulation | null; tcf?: string; gpp?: string; + gpp_sid?: number[]; }; +const gdprSectionIDs = [gpp.tcfeuv2.SectionID]; + +const canSectionIDs = [gpp.tcfcav1.SectionID]; + +const usSectionIDs = [ + gpp.usnat.SectionID, + gpp.usca.SectionID, + gpp.usco.SectionID, + gpp.usct.SectionID, + gpp.usde.SectionID, + gpp.usfl.SectionID, + gpp.usia.SectionID, + gpp.usmt.SectionID, + gpp.usne.SectionID, + gpp.usnh.SectionID, + gpp.usnj.SectionID, + gpp.usor.SectionID, + gpp.ustn.SectionID, + gpp.ustx.SectionID, + gpp.usut.SectionID, + gpp.usva.SectionID, +]; + function gdprConsent(): Consent { const consent: Consent = { deviceAccess: false, reg: "gdpr" }; - // For use TCF if available, otherwise use GPP, + // Use TCF if available, otherwise use GPP, // if none available assume device access is not allowed if (hasTCF()) { onTCFChange((data) => { @@ -22,9 +44,10 @@ function gdprConsent(): Consent { consent.tcf = data.tcString; }); } else if (hasGPP()) { - onGPPSectionChange(TCFEuV2SectionID, (data) => { + onGPPSectionChange(gdprSectionIDs, (data) => { consent.deviceAccess = gppEUDeviceAccess(data); consent.gpp = data.gppString; + consent.gpp_sid = data.applicableSections; }); } @@ -32,14 +55,20 @@ function gdprConsent(): Consent { } function usConsent(): Consent { - return { deviceAccess: true, reg: "us" }; + const consent: Consent = { deviceAccess: true, reg: "us" }; + onGPPSectionChange(usSectionIDs, (data) => { + consent.gpp = data.gppString; + consent.gpp_sid = data.applicableSections; + }); + + return consent; } function canConsent(): Consent { const consent: Consent = { deviceAccess: true, reg: "can" }; - onGPPSectionChange(TCFCaV1SectionID, (data) => { - // Device access is always granted + onGPPSectionChange(canSectionIDs, (data) => { consent.gpp = data.gppString; + consent.gpp_sid = data.applicableSections; }); return consent; @@ -58,12 +87,12 @@ function getConsent(reg: Regulation | null): Consent { } } -function gppEUDeviceAccess(data: GPPConsentData): boolean { - if (!(TCFEuV2APIPrefix in data.parsedSections)) { +function gppEUDeviceAccess(data: gpp.cmpapi.PingReturn): boolean { + if (!(gpp.tcfeuv2.APIPrefix in data.parsedSections)) { return false; } - const section = data.parsedSections[TCFEuV2APIPrefix] || []; + const section = data.parsedSections[gpp.tcfeuv2.APIPrefix] || []; const publisherSubsection = section.find((s) => { return "SegmentType" in s && s.SegmentType === 3; }); @@ -75,14 +104,14 @@ function gppEUDeviceAccess(data: GPPConsentData): boolean { return publisherSubsection.PubPurposesConsent.includes(1); } -function tcfDeviceAccess(data: TCFConsentData): boolean { +function tcfDeviceAccess(data: tcf.cmpapi.TCData): boolean { if (!data.gdprApplies) { return true; } return !!data.publisher.consents["1"]; } -function onGPPSectionChange(sectionID: number, cb: (_: GPPConsentData) => void): void { +function onGPPSectionChange(sectionIDs: number[], cb: (_: gpp.cmpapi.PingReturn) => void): void { if (!hasGPP()) { return; } @@ -96,14 +125,19 @@ function onGPPSectionChange(sectionID: number, cb: (_: GPPConsentData) => void): if (!ready) { return; } - if (!data.pingData.applicableSections.includes(sectionID)) { + + const anyChanged = sectionIDs.some((sectionID) => { + return data.pingData.applicableSections.includes(sectionID); + }); + + if (!anyChanged) { return; } cb(data.pingData); }); } -function onTCFChange(cb: (_: TCFConsentData) => void): void { +function onTCFChange(cb: (_: tcf.cmpapi.TCData) => void): void { if (!hasTCF()) { return; }