349 lines
10 KiB
JavaScript
349 lines
10 KiB
JavaScript
/**
|
|
* Helper functions for attribution reporting API tests.
|
|
*/
|
|
|
|
const blankURL = (base = location.origin) => new URL('/attribution-reporting/resources/reporting_origin.py', base);
|
|
|
|
const attribution_reporting_promise_test = (f, name) =>
|
|
promise_test(async t => {
|
|
await resetWptServer();
|
|
return f(t);
|
|
}, name);
|
|
|
|
const resetWptServer = () =>
|
|
Promise
|
|
.all([
|
|
resetAttributionReports(eventLevelReportsUrl),
|
|
resetAttributionReports(aggregatableReportsUrl),
|
|
resetAttributionReports(eventLevelDebugReportsUrl),
|
|
resetAttributionReports(attributionSuccessDebugAggregatableReportsUrl),
|
|
resetAttributionReports(verboseDebugReportsUrl),
|
|
resetAttributionReports(aggregatableDebugReportsUrl),
|
|
resetRegisteredSources(),
|
|
]);
|
|
|
|
const eventLevelReportsUrl =
|
|
'/.well-known/attribution-reporting/report-event-attribution';
|
|
const eventLevelDebugReportsUrl =
|
|
'/.well-known/attribution-reporting/debug/report-event-attribution';
|
|
const aggregatableReportsUrl =
|
|
'/.well-known/attribution-reporting/report-aggregate-attribution';
|
|
const attributionSuccessDebugAggregatableReportsUrl =
|
|
'/.well-known/attribution-reporting/debug/report-aggregate-attribution';
|
|
const verboseDebugReportsUrl =
|
|
'/.well-known/attribution-reporting/debug/verbose';
|
|
const aggregatableDebugReportsUrl =
|
|
'/.well-known/attribution-reporting/debug/report-aggregate-debug';
|
|
|
|
const pipeHeaderPattern = /[,)]/g;
|
|
|
|
// , and ) in pipe values must be escaped with \
|
|
const encodeForPipe = urlString => urlString.replace(pipeHeaderPattern, '\\$&');
|
|
|
|
const blankURLWithHeaders = (headers, origin, status) => {
|
|
const url = blankURL(origin);
|
|
|
|
const parts = headers.map(h => `header(${h.name},${encodeForPipe(h.value)})`);
|
|
|
|
if (status !== undefined) {
|
|
parts.push(`status(${encodeForPipe(status)})`);
|
|
}
|
|
|
|
if (parts.length > 0) {
|
|
url.searchParams.set('pipe', parts.join('|'));
|
|
}
|
|
|
|
return url;
|
|
};
|
|
|
|
/**
|
|
* Clears the source registration stash.
|
|
*/
|
|
const resetRegisteredSources = () => {
|
|
return fetch(`${blankURL()}?clear-stash=true`);
|
|
}
|
|
|
|
function prepareAnchorOrArea(tag, referrerPolicy, eligible, url) {
|
|
const el = document.createElement(tag);
|
|
el.referrerPolicy = referrerPolicy;
|
|
el.target = '_blank';
|
|
el.textContent = 'link';
|
|
if (eligible === null) {
|
|
el.attributionSrc = url;
|
|
el.href = blankURL();
|
|
} else {
|
|
el.attributionSrc = '';
|
|
el.href = url;
|
|
}
|
|
return el;
|
|
}
|
|
|
|
let nextMapId = 0;
|
|
|
|
/**
|
|
* Method to clear the stash. Takes the URL as parameter. This could be for
|
|
* event-level or aggregatable reports.
|
|
*/
|
|
const resetAttributionReports = url => {
|
|
// The view of the stash is path-specific (https://web-platform-tests.org/tools/wptserve/docs/stash.html),
|
|
// therefore the origin doesn't need to be specified.
|
|
url = `${url}?clear_stash=true`;
|
|
const options = {
|
|
method: 'POST',
|
|
};
|
|
return fetch(url, options);
|
|
};
|
|
|
|
const redirectReportsTo = origin => {
|
|
return Promise.all([
|
|
fetch(`${eventLevelReportsUrl}?redirect_to=${origin}`, {method: 'POST'}),
|
|
fetch(`${aggregatableReportsUrl}?redirect_to=${origin}`, {method: 'POST'})
|
|
]);
|
|
};
|
|
|
|
const getFetchParams = (origin) => {
|
|
let credentials;
|
|
const headers = [];
|
|
|
|
if (!origin || origin === location.origin) {
|
|
return {credentials, headers};
|
|
}
|
|
|
|
// https://fetch.spec.whatwg.org/#http-cors-protocol
|
|
headers.push({
|
|
name: 'Access-Control-Allow-Origin',
|
|
value: '*',
|
|
});
|
|
return {credentials, headers};
|
|
};
|
|
|
|
const getDefaultReportingOrigin = () => {
|
|
// cross-origin means that the reporting origin differs from the source/destination origin.
|
|
const crossOrigin = new URLSearchParams(location.search).get('cross-origin');
|
|
return crossOrigin === null ? location.origin : get_host_info().HTTPS_REMOTE_ORIGIN;
|
|
};
|
|
|
|
const createRedirectChain = (redirects) => {
|
|
let redirectTo;
|
|
|
|
for (let i = redirects.length - 1; i >= 0; i--) {
|
|
const {source, trigger, reportingOrigin} = redirects[i];
|
|
const headers = [];
|
|
|
|
if (source) {
|
|
headers.push({
|
|
name: 'Attribution-Reporting-Register-Source',
|
|
value: JSON.stringify(source),
|
|
});
|
|
}
|
|
|
|
if (trigger) {
|
|
headers.push({
|
|
name: 'Attribution-Reporting-Register-Trigger',
|
|
value: JSON.stringify(trigger),
|
|
});
|
|
}
|
|
|
|
let status;
|
|
if (redirectTo) {
|
|
headers.push({name: 'Location', value: redirectTo.toString()});
|
|
status = '302';
|
|
}
|
|
|
|
redirectTo = blankURLWithHeaders(
|
|
headers, reportingOrigin || getDefaultReportingOrigin(), status);
|
|
}
|
|
|
|
return redirectTo;
|
|
};
|
|
|
|
const registerAttributionSrcByImg = (attributionSrc) => {
|
|
const element = document.createElement('img');
|
|
element.attributionSrc = attributionSrc;
|
|
};
|
|
|
|
const registerAttributionSrc = ({
|
|
source,
|
|
trigger,
|
|
method = 'img',
|
|
extraQueryParams = {},
|
|
reportingOrigin,
|
|
extraHeaders = [],
|
|
referrerPolicy = '',
|
|
}) => {
|
|
const searchParams = new URLSearchParams(location.search);
|
|
|
|
if (method === 'variant') {
|
|
method = searchParams.get('method');
|
|
}
|
|
|
|
const eligible = searchParams.get('eligible');
|
|
|
|
let headers = [];
|
|
|
|
if (source) {
|
|
headers.push({
|
|
name: 'Attribution-Reporting-Register-Source',
|
|
value: JSON.stringify(source),
|
|
});
|
|
}
|
|
|
|
if (trigger) {
|
|
headers.push({
|
|
name: 'Attribution-Reporting-Register-Trigger',
|
|
value: JSON.stringify(trigger),
|
|
});
|
|
}
|
|
|
|
let credentials;
|
|
if (method === 'fetch') {
|
|
const params = getFetchParams(reportingOrigin);
|
|
credentials = params.credentials;
|
|
headers = headers.concat(params.headers);
|
|
}
|
|
|
|
headers = headers.concat(extraHeaders);
|
|
|
|
const url = blankURLWithHeaders(headers, reportingOrigin);
|
|
|
|
Object.entries(extraQueryParams)
|
|
.forEach(([key, value]) => url.searchParams.set(key, value));
|
|
|
|
switch (method) {
|
|
case 'img': {
|
|
const img = document.createElement('img');
|
|
img.referrerPolicy = referrerPolicy;
|
|
if (eligible === null) {
|
|
img.attributionSrc = url;
|
|
} else {
|
|
img.attributionSrc = '';
|
|
img.src = url;
|
|
}
|
|
return 'event';
|
|
}
|
|
case 'script':
|
|
const script = document.createElement('script');
|
|
script.referrerPolicy = referrerPolicy;
|
|
if (eligible === null) {
|
|
script.attributionSrc = url;
|
|
} else {
|
|
script.attributionSrc = '';
|
|
script.src = url;
|
|
document.body.appendChild(script);
|
|
}
|
|
return 'event';
|
|
case 'a':
|
|
const a = prepareAnchorOrArea('a', referrerPolicy, eligible, url);
|
|
document.body.appendChild(a);
|
|
test_driver.click(a);
|
|
return 'navigation';
|
|
case 'area': {
|
|
const area = prepareAnchorOrArea('area', referrerPolicy, eligible, url);
|
|
const size = 100;
|
|
area.coords = `0,0,${size},${size}`;
|
|
area.shape = 'rect';
|
|
const map = document.createElement('map');
|
|
map.name = `map-${nextMapId++}`;
|
|
map.append(area);
|
|
const img = document.createElement('img');
|
|
img.width = size;
|
|
img.height = size;
|
|
img.useMap = `#${map.name}`;
|
|
document.body.append(map, img);
|
|
test_driver.click(area);
|
|
return 'navigation';
|
|
}
|
|
case 'open':
|
|
test_driver.bless('open window', () => {
|
|
const feature = referrerPolicy === 'no-referrer' ? 'noreferrer' : '';
|
|
if (eligible === null) {
|
|
open(
|
|
blankURL(), '_blank',
|
|
`attributionsrc=${encodeURIComponent(url)} ${feature}`);
|
|
} else {
|
|
open(url, '_blank', `attributionsrc ${feature}`);
|
|
}
|
|
});
|
|
return 'navigation';
|
|
case 'fetch': {
|
|
let attributionReporting;
|
|
if (eligible !== null) {
|
|
attributionReporting = JSON.parse(eligible);
|
|
}
|
|
fetch(url, {credentials, attributionReporting, referrerPolicy});
|
|
return 'event';
|
|
}
|
|
case 'xhr':
|
|
const req = new XMLHttpRequest();
|
|
req.open('GET', url);
|
|
if (eligible !== null) {
|
|
req.setAttributionReporting(JSON.parse(eligible));
|
|
}
|
|
req.send();
|
|
return 'event';
|
|
default:
|
|
throw `unknown method "${method}"`;
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* Generates a random pseudo-unique source event id.
|
|
*/
|
|
const generateSourceEventId = () => {
|
|
return `${Math.round(Math.random() * 10000000000000)}`;
|
|
}
|
|
|
|
/**
|
|
* Delay method that waits for prescribed number of milliseconds.
|
|
*/
|
|
const delay = ms => new Promise(resolve => step_timeout(resolve, ms));
|
|
|
|
/**
|
|
* Method that polls a particular URL for reports. Once reports
|
|
* are received, returns the payload as promise. Returns null if the
|
|
* timeout is reached before a report is available.
|
|
*/
|
|
const pollAttributionReports = async (url, origin = location.origin, timeout = 60 * 1000 /*ms*/) => {
|
|
let startTime = performance.now();
|
|
while (performance.now() - startTime < timeout) {
|
|
const resp = await fetch(new URL(url, origin));
|
|
const payload = await resp.json();
|
|
if (payload.reports.length > 0) {
|
|
return payload;
|
|
}
|
|
await delay(/*ms=*/ 100);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Verbose debug reporting must have been enabled on the source registration for this to work.
|
|
const waitForSourceToBeRegistered = async (sourceId, reportingOrigin) => {
|
|
const debugReportPayload = await pollVerboseDebugReports(reportingOrigin);
|
|
assert_equals(debugReportPayload.reports.length, 1);
|
|
const debugReport = JSON.parse(debugReportPayload.reports[0].body);
|
|
assert_equals(debugReport.length, 1);
|
|
assert_equals(debugReport[0].type, 'source-success');
|
|
assert_equals(debugReport[0].body.source_event_id, sourceId);
|
|
};
|
|
|
|
const pollEventLevelReports = (origin) =>
|
|
pollAttributionReports(eventLevelReportsUrl, origin);
|
|
const pollEventLevelDebugReports = (origin) =>
|
|
pollAttributionReports(eventLevelDebugReportsUrl, origin);
|
|
const pollAggregatableReports = (origin) =>
|
|
pollAttributionReports(aggregatableReportsUrl, origin);
|
|
const pollAttributionSuccessDebugAggregatableReports = (origin) =>
|
|
pollAttributionReports(attributionSuccessDebugAggregatableReportsUrl, origin);
|
|
const pollVerboseDebugReports = (origin) =>
|
|
pollAttributionReports(verboseDebugReportsUrl, origin);
|
|
const pollAggregatableDebugReports = (origin) =>
|
|
pollAttributionReports(aggregatableDebugReportsUrl, origin);
|
|
|
|
const validateReportHeaders = headers => {
|
|
assert_array_equals(headers['content-type'], ['application/json']);
|
|
assert_array_equals(headers['cache-control'], ['no-cache']);
|
|
assert_own_property(headers, 'user-agent');
|
|
assert_not_own_property(headers, 'cookie');
|
|
assert_not_own_property(headers, 'referer');
|
|
};
|