Skip to content

Commit 5bd3c88

Browse files
committed
fix: accept shared consent broker readiness
Signed-off-by: Jeremi Joslin <jeremi@joslin.fr>
1 parent db02cc8 commit 5bd3c88

3 files changed

Lines changed: 94 additions & 4 deletions

File tree

‎docs/site/scripts/shared-tracking-client.test.mjs‎

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ function fixture(options = {}) {
4949
createElement: tag => {
5050
assert.equal(tag, 'iframe');
5151
const frameListeners = new Map();
52+
const attributes = new Map();
5253
frame = {
5354
contentWindow: { postMessage: (data, origin) => {
5455
if (remainingSendFailures > 0) {
@@ -57,7 +58,8 @@ function fixture(options = {}) {
5758
}
5859
sent.push({ data, origin });
5960
} },
60-
setAttribute() {},
61+
setAttribute: (name, value) => attributes.set(name, value),
62+
getAttribute: name => attributes.get(name) ?? null,
6163
addEventListener: (type, listener) => frameListeners.set(type, listener),
6264
fireLoad: () => frameListeners.get('load')?.(),
6365
fireError: () => frameListeners.get('error')?.(),
@@ -108,20 +110,88 @@ async function nextRequest(run) {
108110
return { promise };
109111
}
110112

111-
test('reads shared consent through one fixed iframe without a parent tracking script', async () => {
113+
test('reads shared consent through one fixed iframe without a parent tracking script', async (t) => {
112114
const f = fixture();
115+
t.after(() => f.client.close());
113116
const { promise } = await nextRequest(() => f.client.read());
117+
void promise.catch(() => {});
114118
assert.equal(f.frame.src, bridgeUrl);
115119
assert.equal(f.frame.hidden, true);
116120
assert.equal(f.frame.referrerPolicy, 'no-referrer');
117121
assert.equal(f.sent[0].origin, 'https://registrystack.org');
118122
assert.equal(f.sent[0].data.type, 'read');
123+
assert.equal(f.frame.getAttribute('data-tracking-ready'), 'load');
119124
f.reply(unknown);
120125
assert.deepEqual(await promise, unknown);
121126
assert.equal(f.values.size, 0);
122127
f.client.close();
123128
});
124129

130+
test('broker readiness permits a consent read before the iframe load event', async (t) => {
131+
const f = fixture({ autoLoad: false });
132+
t.after(() => f.client.close());
133+
const { promise } = await nextRequest(() => f.client.read());
134+
void promise.catch(() => {});
135+
assert.equal(f.sent.length, 0);
136+
137+
f.message({ protocol: envelope.protocol, version: envelope.version, type: 'ready' });
138+
await new Promise(resolve => setImmediate(resolve));
139+
assert.equal(f.sent.length, 1);
140+
assert.equal(f.sent[0].data.type, 'read');
141+
assert.equal(f.frame.getAttribute('data-tracking-ready'), 'broker');
142+
assert.deepEqual(f.client.state, unknown);
143+
f.reply(rejected);
144+
assert.deepEqual(await promise, rejected);
145+
146+
f.frame.fireLoad();
147+
await new Promise(resolve => setImmediate(resolve));
148+
assert.equal(f.sent.length, 1);
149+
assert.equal(f.failures.length, 0);
150+
});
151+
152+
test('readiness requires the exact envelope from the current bridge origin and frame', async (t) => {
153+
const f = fixture({ autoLoad: false });
154+
t.after(() => f.client.close());
155+
const { promise } = await nextRequest(() => f.client.read());
156+
void promise.catch(() => {});
157+
const ready = { protocol: envelope.protocol, version: envelope.version, type: 'ready' };
158+
f.message(ready, { origin: 'https://attacker.example' });
159+
f.message(ready, { source: {} });
160+
f.message({ ...ready, state: rejected });
161+
f.message({ ...ready, version: 1 });
162+
await new Promise(resolve => setImmediate(resolve));
163+
assert.equal(f.sent.length, 0);
164+
assert.deepEqual(f.client.state, unknown);
165+
166+
f.message(ready);
167+
await new Promise(resolve => setImmediate(resolve));
168+
assert.equal(f.sent.length, 1);
169+
f.reply(rejected);
170+
assert.deepEqual(await promise, rejected);
171+
});
172+
173+
test('readiness from a removed failed frame cannot unlock its replacement', async (t) => {
174+
const f = fixture({ autoLoad: false });
175+
t.after(() => f.client.close());
176+
const { promise } = await nextRequest(() => f.client.read());
177+
void promise.catch(() => {});
178+
const oldFrame = f.frame;
179+
oldFrame.fireError();
180+
await new Promise(resolve => setImmediate(resolve));
181+
assert.equal(f.frames.length, 2);
182+
const ready = { protocol: envelope.protocol, version: envelope.version, type: 'ready' };
183+
f.message(ready, { source: oldFrame.contentWindow });
184+
await new Promise(resolve => setImmediate(resolve));
185+
assert.equal(f.sent.length, 0);
186+
187+
f.message(ready);
188+
await new Promise(resolve => setImmediate(resolve));
189+
assert.equal(f.sent.length, 1);
190+
f.reply(rejected);
191+
assert.deepEqual(await promise, rejected);
192+
assert.equal(f.failures.length, 0);
193+
});
194+
125195
test('concurrent startup and resume reads share one request and confirm an unchanged remote state', async (t) => {
126196
const f = fixture();
127197
t.after(() => f.client.close());

‎docs/site/src/lib/shared-tracking-client.mjs‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
SHARED_TRACKING_BRIDGE_URL,
33
createTrackingRequest,
44
parseTrackingChanged,
5+
parseTrackingReady,
56
parseTrackingResponse,
67
} from './tracking-protocol.mjs';
78

@@ -89,6 +90,7 @@ export function createSharedTrackingClient(options = {}) {
8990
let frame;
9091
let frameReady;
9192
let frameReadyCancel;
93+
let frameReadySignal;
9294
let closed = false;
9395
let suspended = false;
9496
let localRefusal = false;
@@ -194,6 +196,10 @@ export function createSharedTrackingClient(options = {}) {
194196

195197
const receive = event => {
196198
if (closed || event.origin !== bridge.origin || event.source !== frame?.contentWindow) return;
199+
if (parseTrackingReady(event.data)) {
200+
frameReadySignal?.('broker');
201+
return;
202+
}
197203
const changed = parseTrackingChanged(event.data);
198204
if (changed) {
199205
update(changed.state);
@@ -255,12 +261,13 @@ export function createSharedTrackingClient(options = {}) {
255261
if (frame === candidate) frame = undefined;
256262
if (frameReady === ready) frameReady = undefined;
257263
if (frameReadyCancel === reset) frameReadyCancel = undefined;
264+
if (frameReadySignal === markReady) frameReadySignal = undefined;
258265
candidate.remove();
259266
rejectReady(trackingError(code));
260267
};
261268
const timer = setTimeout(() => reset('frame-load-timeout'), frameLoadTimeoutMs);
262269
frameReadyCancel = reset;
263-
candidate.addEventListener('load', () => {
270+
const markReady = via => {
264271
if (settled) return;
265272
if (closed || frame !== candidate || frameReady !== ready) {
266273
reset();
@@ -269,8 +276,14 @@ export function createSharedTrackingClient(options = {}) {
269276
settled = true;
270277
clearTimeout(timer);
271278
if (frameReadyCancel === reset) frameReadyCancel = undefined;
279+
if (frameReadySignal === markReady) frameReadySignal = undefined;
280+
candidate.setAttribute('data-tracking-ready', via);
272281
resolveReady(candidate);
273-
}, { once: true });
282+
};
283+
frameReadySignal = markReady;
284+
// The broker can be usable before unrelated iframe resources finish loading.
285+
// Retain load for cached bridges that do not emit application readiness.
286+
candidate.addEventListener('load', () => markReady('load'), { once: true });
274287
candidate.addEventListener('error', () => reset('frame-load-failed'), { once: true });
275288
try {
276289
currentDocument.body.append(candidate);

‎docs/site/src/lib/tracking-protocol.mjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,3 +183,10 @@ export function parseTrackingChanged(value) {
183183
) return null;
184184
return { ...value, state: { ...value.state } };
185185
}
186+
187+
export function parseTrackingReady(value) {
188+
if (!exactKeys(value, ['protocol', 'version', 'type']) ||
189+
value.protocol !== TRACKING_PROTOCOL ||
190+
value.version !== TRACKING_PROTOCOL_VERSION || value.type !== 'ready') return null;
191+
return { ...value };
192+
}

0 commit comments

Comments
 (0)