helpers.js (10557B)
1 /** 2 * Helper functions for attribution reporting API tests. 3 */ 4 5 const blankURL = (base = location.origin) => new URL('/attribution-reporting/resources/reporting_origin.py', base); 6 7 const attribution_reporting_promise_test = (f, name) => 8 promise_test(async t => { 9 await resetWptServer(); 10 return f(t); 11 }, name); 12 13 const resetWptServer = () => 14 Promise 15 .all([ 16 resetAttributionReports(eventLevelReportsUrl), 17 resetAttributionReports(aggregatableReportsUrl), 18 resetAttributionReports(eventLevelDebugReportsUrl), 19 resetAttributionReports(attributionSuccessDebugAggregatableReportsUrl), 20 resetAttributionReports(verboseDebugReportsUrl), 21 resetAttributionReports(aggregatableDebugReportsUrl), 22 resetRegisteredSources(), 23 ]); 24 25 const eventLevelReportsUrl = 26 '/.well-known/attribution-reporting/report-event-attribution'; 27 const eventLevelDebugReportsUrl = 28 '/.well-known/attribution-reporting/debug/report-event-attribution'; 29 const aggregatableReportsUrl = 30 '/.well-known/attribution-reporting/report-aggregate-attribution'; 31 const attributionSuccessDebugAggregatableReportsUrl = 32 '/.well-known/attribution-reporting/debug/report-aggregate-attribution'; 33 const verboseDebugReportsUrl = 34 '/.well-known/attribution-reporting/debug/verbose'; 35 const aggregatableDebugReportsUrl = 36 '/.well-known/attribution-reporting/debug/report-aggregate-debug'; 37 38 const pipeHeaderPattern = /[,)]/g; 39 40 // , and ) in pipe values must be escaped with \ 41 const encodeForPipe = urlString => urlString.replace(pipeHeaderPattern, '\\$&'); 42 43 const blankURLWithHeaders = (headers, origin, status) => { 44 const url = blankURL(origin); 45 46 const parts = headers.map(h => `header(${h.name},${encodeForPipe(h.value)})`); 47 48 if (status !== undefined) { 49 parts.push(`status(${encodeForPipe(status)})`); 50 } 51 52 if (parts.length > 0) { 53 url.searchParams.set('pipe', parts.join('|')); 54 } 55 56 return url; 57 }; 58 59 /** 60 * Clears the source registration stash. 61 */ 62 const resetRegisteredSources = () => { 63 return fetch(`${blankURL()}?clear-stash=true`); 64 } 65 66 function prepareAnchorOrArea(tag, referrerPolicy, eligible, url) { 67 const el = document.createElement(tag); 68 el.referrerPolicy = referrerPolicy; 69 el.target = '_blank'; 70 el.textContent = 'link'; 71 if (eligible === null) { 72 el.attributionSrc = url; 73 el.href = blankURL(); 74 } else { 75 el.attributionSrc = ''; 76 el.href = url; 77 } 78 return el; 79 } 80 81 let nextMapId = 0; 82 83 /** 84 * Method to clear the stash. Takes the URL as parameter. This could be for 85 * event-level or aggregatable reports. 86 */ 87 const resetAttributionReports = url => { 88 // The view of the stash is path-specific (https://web-platform-tests.org/tools/wptserve/docs/stash.html), 89 // therefore the origin doesn't need to be specified. 90 url = `${url}?clear_stash=true`; 91 const options = { 92 method: 'POST', 93 }; 94 return fetch(url, options); 95 }; 96 97 const redirectReportsTo = origin => { 98 return Promise.all([ 99 fetch(`${eventLevelReportsUrl}?redirect_to=${origin}`, {method: 'POST'}), 100 fetch(`${aggregatableReportsUrl}?redirect_to=${origin}`, {method: 'POST'}) 101 ]); 102 }; 103 104 const getFetchParams = (origin) => { 105 let credentials; 106 const headers = []; 107 108 if (!origin || origin === location.origin) { 109 return {credentials, headers}; 110 } 111 112 // https://fetch.spec.whatwg.org/#http-cors-protocol 113 headers.push({ 114 name: 'Access-Control-Allow-Origin', 115 value: '*', 116 }); 117 return {credentials, headers}; 118 }; 119 120 const getDefaultReportingOrigin = () => { 121 // cross-origin means that the reporting origin differs from the source/destination origin. 122 const crossOrigin = new URLSearchParams(location.search).get('cross-origin'); 123 return crossOrigin === null ? location.origin : get_host_info().HTTPS_REMOTE_ORIGIN; 124 }; 125 126 const createRedirectChain = (redirects) => { 127 let redirectTo; 128 129 for (let i = redirects.length - 1; i >= 0; i--) { 130 const {source, trigger, reportingOrigin} = redirects[i]; 131 const headers = []; 132 133 if (source) { 134 headers.push({ 135 name: 'Attribution-Reporting-Register-Source', 136 value: JSON.stringify(source), 137 }); 138 } 139 140 if (trigger) { 141 headers.push({ 142 name: 'Attribution-Reporting-Register-Trigger', 143 value: JSON.stringify(trigger), 144 }); 145 } 146 147 let status; 148 if (redirectTo) { 149 headers.push({name: 'Location', value: redirectTo.toString()}); 150 status = '302'; 151 } 152 153 redirectTo = blankURLWithHeaders( 154 headers, reportingOrigin || getDefaultReportingOrigin(), status); 155 } 156 157 return redirectTo; 158 }; 159 160 const registerAttributionSrcByImg = (attributionSrc) => { 161 const element = document.createElement('img'); 162 element.attributionSrc = attributionSrc; 163 }; 164 165 const registerAttributionSrc = ({ 166 source, 167 trigger, 168 method = 'img', 169 extraQueryParams = {}, 170 reportingOrigin, 171 extraHeaders = [], 172 referrerPolicy = '', 173 }) => { 174 const searchParams = new URLSearchParams(location.search); 175 176 if (method === 'variant') { 177 method = searchParams.get('method'); 178 } 179 180 const eligible = searchParams.get('eligible'); 181 182 let headers = []; 183 184 if (source) { 185 headers.push({ 186 name: 'Attribution-Reporting-Register-Source', 187 value: JSON.stringify(source), 188 }); 189 } 190 191 if (trigger) { 192 headers.push({ 193 name: 'Attribution-Reporting-Register-Trigger', 194 value: JSON.stringify(trigger), 195 }); 196 } 197 198 let credentials; 199 if (method === 'fetch') { 200 const params = getFetchParams(reportingOrigin); 201 credentials = params.credentials; 202 headers = headers.concat(params.headers); 203 } 204 205 headers = headers.concat(extraHeaders); 206 207 const url = blankURLWithHeaders(headers, reportingOrigin); 208 209 Object.entries(extraQueryParams) 210 .forEach(([key, value]) => url.searchParams.set(key, value)); 211 212 switch (method) { 213 case 'img': { 214 const img = document.createElement('img'); 215 img.referrerPolicy = referrerPolicy; 216 if (eligible === null) { 217 img.attributionSrc = url; 218 } else { 219 img.attributionSrc = ''; 220 img.src = url; 221 } 222 return 'event'; 223 } 224 case 'script': 225 const script = document.createElement('script'); 226 script.referrerPolicy = referrerPolicy; 227 if (eligible === null) { 228 script.attributionSrc = url; 229 } else { 230 script.attributionSrc = ''; 231 script.src = url; 232 document.body.appendChild(script); 233 } 234 return 'event'; 235 case 'a': 236 const a = prepareAnchorOrArea('a', referrerPolicy, eligible, url); 237 document.body.appendChild(a); 238 test_driver.click(a); 239 return 'navigation'; 240 case 'area': { 241 const area = prepareAnchorOrArea('area', referrerPolicy, eligible, url); 242 const size = 100; 243 area.coords = `0,0,${size},${size}`; 244 area.shape = 'rect'; 245 const map = document.createElement('map'); 246 map.name = `map-${nextMapId++}`; 247 map.append(area); 248 const img = document.createElement('img'); 249 img.width = size; 250 img.height = size; 251 img.useMap = `#${map.name}`; 252 document.body.append(map, img); 253 test_driver.click(area); 254 return 'navigation'; 255 } 256 case 'open': 257 test_driver.bless('open window', () => { 258 const feature = referrerPolicy === 'no-referrer' ? 'noreferrer' : ''; 259 if (eligible === null) { 260 open( 261 blankURL(), '_blank', 262 `attributionsrc=${encodeURIComponent(url)} ${feature}`); 263 } else { 264 open(url, '_blank', `attributionsrc ${feature}`); 265 } 266 }); 267 return 'navigation'; 268 case 'fetch': { 269 let attributionReporting; 270 if (eligible !== null) { 271 attributionReporting = JSON.parse(eligible); 272 } 273 fetch(url, {credentials, attributionReporting, referrerPolicy}); 274 return 'event'; 275 } 276 case 'xhr': 277 const req = new XMLHttpRequest(); 278 req.open('GET', url); 279 if (eligible !== null) { 280 req.setAttributionReporting(JSON.parse(eligible)); 281 } 282 req.send(); 283 return 'event'; 284 default: 285 throw `unknown method "${method}"`; 286 } 287 }; 288 289 290 /** 291 * Generates a random pseudo-unique source event id. 292 */ 293 const generateSourceEventId = () => { 294 return `${Math.round(Math.random() * 10000000000000)}`; 295 } 296 297 /** 298 * Delay method that waits for prescribed number of milliseconds. 299 */ 300 const delay = ms => new Promise(resolve => step_timeout(resolve, ms)); 301 302 /** 303 * Method that polls a particular URL for reports. Once reports 304 * are received, returns the payload as promise. Returns null if the 305 * timeout is reached before a report is available. 306 */ 307 const pollAttributionReports = async (url, origin = location.origin, timeout = 60 * 1000 /*ms*/) => { 308 let startTime = performance.now(); 309 while (performance.now() - startTime < timeout) { 310 const resp = await fetch(new URL(url, origin)); 311 const payload = await resp.json(); 312 if (payload.reports.length > 0) { 313 return payload; 314 } 315 await delay(/*ms=*/ 100); 316 } 317 return null; 318 }; 319 320 // Verbose debug reporting must have been enabled on the source registration for this to work. 321 const waitForSourceToBeRegistered = async (sourceId, reportingOrigin) => { 322 const debugReportPayload = await pollVerboseDebugReports(reportingOrigin); 323 assert_equals(debugReportPayload.reports.length, 1); 324 const debugReport = JSON.parse(debugReportPayload.reports[0].body); 325 assert_equals(debugReport.length, 1); 326 assert_equals(debugReport[0].type, 'source-success'); 327 assert_equals(debugReport[0].body.source_event_id, sourceId); 328 }; 329 330 const pollEventLevelReports = (origin) => 331 pollAttributionReports(eventLevelReportsUrl, origin); 332 const pollEventLevelDebugReports = (origin) => 333 pollAttributionReports(eventLevelDebugReportsUrl, origin); 334 const pollAggregatableReports = (origin) => 335 pollAttributionReports(aggregatableReportsUrl, origin); 336 const pollAttributionSuccessDebugAggregatableReports = (origin) => 337 pollAttributionReports(attributionSuccessDebugAggregatableReportsUrl, origin); 338 const pollVerboseDebugReports = (origin) => 339 pollAttributionReports(verboseDebugReportsUrl, origin); 340 const pollAggregatableDebugReports = (origin) => 341 pollAttributionReports(aggregatableDebugReportsUrl, origin); 342 343 const validateReportHeaders = headers => { 344 assert_array_equals(headers['content-type'], ['application/json']); 345 assert_array_equals(headers['cache-control'], ['no-cache']); 346 assert_own_property(headers, 'user-agent'); 347 assert_not_own_property(headers, 'cookie'); 348 assert_not_own_property(headers, 'referer'); 349 };