Skip to content

Commit ad07652

Browse files
authored
Added consent gating for the Advertising component (#1435)
* fix functional tests updated the skipped tests for helper.js in componentRegistered hook , for viewthru : return promise for click thru advertising call , for viewthru case fire and forget Add timestamp in payload of enrichment calls * click thru check -> either one of ef_id or s_kwcid param present * build trigger * append timestamp to clickthru and viewthru payload * timestamp fromat * build trigger * functional tests fix * build trigger * build trigger * consent case insensitive comparison and reduce shortTimeouts for wait scenerio , wait on only surfer_id on chrome browsers * consent bug fix : advertising component * handling the consent module loading delays * Remove sandbox file changes from branch * Remove unrelated files from branch - keep only consent changes * feat(Advertising): gate click cookie write on consent Move LAST_CLICK_COOKIE_KEY cookie write from clickThroughHandler into createAdConversionHandler.trackAdConversion(), after consent.awaitConsent() resolves. This ensures no ad-tracking cookies (skwcid/efid) are set without user consent. Changes: - clickThroughHandler: removed pre-consent LAST_CLICK_COOKIE_KEY write, pass skwcid/efid through to trackAdConversion() - createAdConversionHandler: accept cookieManager, write click cookie only after consent is granted - index.js: pass cookieManager to createAdConversionHandler (1-line change) * changes to wait for consent before initilizing advertising component * move consent gating inside try catch , IT for consent scenerio
1 parent b3767ce commit ad07652

File tree

6 files changed

+343
-30
lines changed

6 files changed

+343
-30
lines changed

packages/core/src/components/Advertising/createComponent.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default ({
2020
cookieManager,
2121
adConversionHandler,
2222
getBrowser,
23+
consent,
2324
}) => {
2425
const componentConfig = config.advertising;
2526

@@ -30,12 +31,15 @@ export default ({
3031
logger,
3132
componentConfig,
3233
getBrowser,
34+
consent,
3335
});
3436

3537
return {
3638
lifecycle: {
3739
onComponentsRegistered() {
38-
return sendAdConversionHandler();
40+
// Fire-and-forget: don't return the promise so we don't block
41+
// the configure command from resolving while waiting for consent.
42+
sendAdConversionHandler();
3943
},
4044
onBeforeEvent: ({ event, advertising = {} }) => {
4145
return handleOnBeforeSendEvent({

packages/core/src/components/Advertising/handlers/sendAdConversion.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,22 @@ export default ({
2626
logger,
2727
componentConfig,
2828
getBrowser,
29+
consent,
2930
}) => {
3031
const activeAdvertiserIds = componentConfig?.advertiserSettings
3132
? normalizeAdvertiser(componentConfig.advertiserSettings)
3233
: "";
3334

3435
return async () => {
35-
const { skwcid, efid } = getUrlParams();
36-
const isClickThru = !!(skwcid || efid);
37-
3836
try {
37+
// Wait for consent before any ad conversion processing.
38+
// This ensures no advertising cookies are set without user consent.
39+
// If consent is declined, awaitConsent() rejects and we exit gracefully.
40+
await consent.awaitConsent();
41+
42+
const { skwcid, efid } = getUrlParams();
43+
const isClickThru = !!(skwcid || efid);
44+
3945
if (isClickThru) {
4046
// wait for click through to complete
4147
return handleClickThrough({

packages/core/src/components/Advertising/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const createAdvertising = ({
4747
cookieManager,
4848
adConversionHandler,
4949
getBrowser,
50+
consent,
5051
});
5152
};
5253

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
/*
2+
Copyright 2025 Adobe. All rights reserved.
3+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
*/
5+
import { http, HttpResponse } from "msw";
6+
import { test, describe, expect } from "../../helpers/testsSetup/extend.js";
7+
import { sendEventHandler } from "../../helpers/mswjs/handlers.js";
8+
import alloyConfig from "../../helpers/alloy/config.js";
9+
import { createAdvertisingConfig } from "../../helpers/advertising.js";
10+
import waitFor from "../../helpers/utils/waitFor.js";
11+
12+
const getNamespacedCookieName = (key) => {
13+
const sanitizedOrg = alloyConfig.orgId.replace("@", "_");
14+
return `kndctr_${sanitizedOrg}_${key}`;
15+
};
16+
17+
const getAdvertisingCookie = () => {
18+
const name = getNamespacedCookieName("advertising");
19+
const match = document.cookie
20+
.split("; ")
21+
.find((row) => row.startsWith(`${name}=`));
22+
return match ? match.split("=").slice(1).join("=") : null;
23+
};
24+
25+
const clearCookie = (key) => {
26+
const name = getNamespacedCookieName(key);
27+
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
28+
};
29+
30+
const clearUrlParams = () => {
31+
const url = new URL(window.location.href);
32+
url.searchParams.delete("s_kwcid");
33+
url.searchParams.delete("ef_id");
34+
window.history.replaceState({}, "", url.toString());
35+
};
36+
37+
const cleanupAll = () => {
38+
clearUrlParams();
39+
clearCookie("advertising");
40+
clearCookie("consent");
41+
};
42+
43+
/**
44+
* A setConsent handler that returns a state:store handle to set the consent cookie,
45+
* matching what the real Edge Network returns.
46+
*/
47+
const setConsentAcceptHandler = http.post(
48+
/https:\/\/edge\.adobedc\.net\/ee\/v1\/privacy\/set-consent/,
49+
async (req) => {
50+
const url = new URL(req.request.url);
51+
const configId = url.searchParams.get("configId");
52+
53+
if (configId === "bc1a10e0-aee4-4e0e-ac5b-cdbb9abbec83") {
54+
return HttpResponse.json({
55+
requestId: "consent-request-id",
56+
handle: [
57+
{
58+
type: "state:store",
59+
payload: [
60+
{
61+
key: getNamespacedCookieName("consent"),
62+
value: "general=in",
63+
maxAge: 15552000,
64+
},
65+
],
66+
},
67+
],
68+
});
69+
}
70+
71+
throw new Error("Handler not configured properly");
72+
},
73+
);
74+
75+
describe("Advertising - Consent gate", () => {
76+
test("should not write advertising cookies while consent is pending (click-through)", async ({
77+
alloy,
78+
worker,
79+
}) => {
80+
cleanupAll();
81+
worker.use(sendEventHandler, setConsentAcceptHandler);
82+
83+
// Set URL params for click-through BEFORE configure
84+
const url = new URL(window.location.href);
85+
url.searchParams.set("s_kwcid", "AL!test_kwcid_123");
86+
url.searchParams.set("ef_id", "test_efid_456");
87+
window.history.replaceState({}, "", url.toString());
88+
89+
// Configure with consent pending — advertising component should NOT
90+
// write any cookies until consent is granted.
91+
await alloy("configure", {
92+
...alloyConfig,
93+
...createAdvertisingConfig(),
94+
defaultConsent: "pending",
95+
});
96+
97+
// Wait enough time for the component to have set cookies if it were going to.
98+
await waitFor(500);
99+
100+
// Verify: NO advertising cookie should exist while consent is pending.
101+
expect(getAdvertisingCookie()).toBeNull();
102+
103+
cleanupAll();
104+
});
105+
106+
test("should not write advertising cookies while consent is pending (view-through)", async ({
107+
alloy,
108+
worker,
109+
}) => {
110+
cleanupAll();
111+
worker.use(sendEventHandler, setConsentAcceptHandler);
112+
113+
// Configure with consent pending and advertiser settings for view-through
114+
await alloy("configure", {
115+
...alloyConfig,
116+
...createAdvertisingConfig(),
117+
defaultConsent: "pending",
118+
});
119+
120+
// Wait for view-through flow to have resolved IDs if it were running
121+
await waitFor(500);
122+
123+
// Verify: NO advertising cookie while consent is pending
124+
expect(getAdvertisingCookie()).toBeNull();
125+
126+
cleanupAll();
127+
});
128+
129+
test("should write cookies and send conversion after consent is accepted (click-through)", async ({
130+
alloy,
131+
worker,
132+
networkRecorder,
133+
}) => {
134+
cleanupAll();
135+
worker.use(sendEventHandler, setConsentAcceptHandler);
136+
137+
// Set URL params for click-through
138+
const url = new URL(window.location.href);
139+
url.searchParams.set("s_kwcid", "AL!test_kwcid_789");
140+
url.searchParams.set("ef_id", "test_efid_012");
141+
window.history.replaceState({}, "", url.toString());
142+
143+
await alloy("configure", {
144+
...alloyConfig,
145+
...createAdvertisingConfig(),
146+
defaultConsent: "pending",
147+
});
148+
149+
// Verify no cookies yet
150+
await waitFor(300);
151+
expect(getAdvertisingCookie()).toBeNull();
152+
153+
// Grant consent — the mock returns a state:store handle that sets the
154+
// consent cookie, which makes the SDK transition consent to "in".
155+
await alloy("setConsent", {
156+
consent: [
157+
{
158+
standard: "Adobe",
159+
version: "1.0",
160+
value: { general: "in" },
161+
},
162+
],
163+
});
164+
165+
// Wait for the fire-and-forget conversion flow to complete.
166+
// After consent transitions to "in", sendAdConversion resumes,
167+
// writes cookies, and sends the conversion network call.
168+
await waitFor(3000);
169+
170+
// Verify: advertising cookie should now exist (written during click-through)
171+
const cookieValue = getAdvertisingCookie();
172+
expect(cookieValue).not.toBeNull();
173+
174+
// Verify a click-through conversion call was made
175+
const calls = await networkRecorder.findCalls(/edge\.adobedc\.net/, {
176+
retries: 10,
177+
delayMs: 200,
178+
});
179+
const conversionCall = calls.find((call) => {
180+
const body =
181+
typeof call.request.body === "string"
182+
? JSON.parse(call.request.body)
183+
: call.request.body;
184+
return (
185+
body?.events?.[0]?.xdm?.eventType === "advertising.enrichment_ct"
186+
);
187+
});
188+
expect(conversionCall).toBeTruthy();
189+
190+
cleanupAll();
191+
});
192+
193+
// This test is last because declining consent triggers internal SDK
194+
// promise rejections that can contaminate subsequent tests.
195+
// The SDK's consent state machine rejects all pending deferreds when consent
196+
// is declined. Some of these come from internal lifecycle hooks that don't
197+
// explicitly handle the rejection — this is expected SDK behavior.
198+
test("should not write cookies or send conversion when consent is rejected", async ({
199+
alloy,
200+
worker,
201+
networkRecorder,
202+
}) => {
203+
// Suppress expected "declined consent" unhandled rejections from the SDK
204+
const suppressDeclined = (event) => {
205+
if (event?.reason?.message?.includes("declined consent")) {
206+
event.preventDefault();
207+
}
208+
};
209+
window.addEventListener("unhandledrejection", suppressDeclined);
210+
211+
cleanupAll();
212+
213+
const setConsentDeclineHandler = http.post(
214+
/https:\/\/edge\.adobedc\.net\/ee\/v1\/privacy\/set-consent/,
215+
async (req) => {
216+
const url = new URL(req.request.url);
217+
const configId = url.searchParams.get("configId");
218+
219+
if (configId === "bc1a10e0-aee4-4e0e-ac5b-cdbb9abbec83") {
220+
return HttpResponse.json({
221+
requestId: "consent-request-id",
222+
handle: [
223+
{
224+
type: "state:store",
225+
payload: [
226+
{
227+
key: getNamespacedCookieName("consent"),
228+
value: "general=out",
229+
maxAge: 15552000,
230+
},
231+
],
232+
},
233+
],
234+
});
235+
}
236+
237+
throw new Error("Handler not configured properly");
238+
},
239+
);
240+
241+
worker.use(sendEventHandler, setConsentDeclineHandler);
242+
243+
// Set URL params for click-through
244+
const url = new URL(window.location.href);
245+
url.searchParams.set("s_kwcid", "AL!test_kwcid_reject");
246+
url.searchParams.set("ef_id", "test_efid_reject");
247+
window.history.replaceState({}, "", url.toString());
248+
249+
await alloy("configure", {
250+
...alloyConfig,
251+
...createAdvertisingConfig(),
252+
defaultConsent: "pending",
253+
});
254+
255+
// Verify no cookies yet
256+
await waitFor(300);
257+
expect(getAdvertisingCookie()).toBeNull();
258+
259+
// Decline consent
260+
await alloy("setConsent", {
261+
consent: [
262+
{
263+
standard: "Adobe",
264+
version: "1.0",
265+
value: { general: "out" },
266+
},
267+
],
268+
});
269+
270+
// Wait to ensure nothing fires after decline
271+
await waitFor(500);
272+
273+
// Verify: NO advertising cookie should exist after decline
274+
expect(getAdvertisingCookie()).toBeNull();
275+
276+
// Verify no conversion calls were made (only consent call should exist)
277+
const calls = await networkRecorder.findCalls(/edge\.adobedc\.net/, {
278+
retries: 3,
279+
delayMs: 100,
280+
});
281+
const conversionCall = calls.find((call) => {
282+
const body =
283+
typeof call.request.body === "string"
284+
? JSON.parse(call.request.body)
285+
: call.request.body;
286+
return (
287+
body?.events?.[0]?.xdm?.eventType === "advertising.enrichment_ct"
288+
);
289+
});
290+
expect(conversionCall).toBeFalsy();
291+
292+
cleanupAll();
293+
window.removeEventListener("unhandledrejection", suppressDeclined);
294+
});
295+
});

packages/core/test/unit/specs/components/Advertising/createComponent.spec.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe("Advertising::createComponent", () => {
3737
let eventManager;
3838
let cookieManager;
3939
let adConversionHandler;
40+
let consent;
4041
let component;
4142

4243
beforeEach(() => {
@@ -71,6 +72,10 @@ describe("Advertising::createComponent", () => {
7172
trackAdConversion: vi.fn(),
7273
};
7374

75+
consent = {
76+
awaitConsent: vi.fn().mockResolvedValue(),
77+
};
78+
7479
// Reset mocks
7580
vi.mocked(handleOnBeforeSendEvent).mockReset();
7681

@@ -80,6 +85,7 @@ describe("Advertising::createComponent", () => {
8085
eventManager,
8186
cookieManager,
8287
adConversionHandler,
88+
consent,
8389
});
8490
});
8591

0 commit comments

Comments
 (0)