From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../tests/attribution-reporting/__init__.py | 0 ...egatable-report-no-contributions.sub.https.html | 51 +++ .../request-format.sub.https.html | 66 ++++ .../attribution-reporting/resources/__init__.py | 0 .../attribution-reporting/resources/helpers.js | 355 +++++++++++++++++++++ .../resources/reporting_origin.py | 65 ++++ .../attribution-reporting/resources/reports.py | 147 +++++++++ .../simple-verbose-debug-report.sub.https.html | 32 ++ 8 files changed, 716 insertions(+) create mode 100644 testing/web-platform/tests/attribution-reporting/__init__.py create mode 100644 testing/web-platform/tests/attribution-reporting/aggregatable-report-no-contributions.sub.https.html create mode 100644 testing/web-platform/tests/attribution-reporting/request-format.sub.https.html create mode 100644 testing/web-platform/tests/attribution-reporting/resources/__init__.py create mode 100644 testing/web-platform/tests/attribution-reporting/resources/helpers.js create mode 100644 testing/web-platform/tests/attribution-reporting/resources/reporting_origin.py create mode 100644 testing/web-platform/tests/attribution-reporting/resources/reports.py create mode 100644 testing/web-platform/tests/attribution-reporting/simple-verbose-debug-report.sub.https.html (limited to 'testing/web-platform/tests/attribution-reporting') diff --git a/testing/web-platform/tests/attribution-reporting/__init__.py b/testing/web-platform/tests/attribution-reporting/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/web-platform/tests/attribution-reporting/aggregatable-report-no-contributions.sub.https.html b/testing/web-platform/tests/attribution-reporting/aggregatable-report-no-contributions.sub.https.html new file mode 100644 index 0000000000..b42a61b7bd --- /dev/null +++ b/testing/web-platform/tests/attribution-reporting/aggregatable-report-no-contributions.sub.https.html @@ -0,0 +1,51 @@ + + + + + + + diff --git a/testing/web-platform/tests/attribution-reporting/request-format.sub.https.html b/testing/web-platform/tests/attribution-reporting/request-format.sub.https.html new file mode 100644 index 0000000000..a9e36dd126 --- /dev/null +++ b/testing/web-platform/tests/attribution-reporting/request-format.sub.https.html @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/testing/web-platform/tests/attribution-reporting/resources/__init__.py b/testing/web-platform/tests/attribution-reporting/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/testing/web-platform/tests/attribution-reporting/resources/helpers.js b/testing/web-platform/tests/attribution-reporting/resources/helpers.js new file mode 100644 index 0000000000..5778159d4c --- /dev/null +++ b/testing/web-platform/tests/attribution-reporting/resources/helpers.js @@ -0,0 +1,355 @@ +/** + * 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(aggregatableDebugReportsUrl), + resetAttributionReports(verboseDebugReportsUrl), + 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 aggregatableDebugReportsUrl = + '/.well-known/attribution-reporting/debug/report-aggregate-attribution'; +const verboseDebugReportsUrl = + '/.well-known/attribution-reporting/debug/verbose'; + +const attributionDebugCookie = 'ar_debug=1;Secure;HttpOnly;SameSite=None;Path=/'; + +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`); +} + +/** + * 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, cookie) => { + let credentials; + const headers = []; + + if (!origin || origin === location.origin) { + return {credentials, headers}; + } + + // https://fetch.spec.whatwg.org/#http-cors-protocol + + const allowOriginHeader = 'Access-Control-Allow-Origin'; + + if (cookie) { + credentials = 'include'; + headers.push({ + name: 'Access-Control-Allow-Credentials', + value: 'true', + }); + headers.push({ + name: allowOriginHeader, + value: `${location.origin}`, + }); + } else { + headers.push({ + name: allowOriginHeader, + 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, cookie, 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), + }); + } + + if (cookie) { + headers.push({name: 'Set-Cookie', value: cookie}); + } + + 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 = async ({ + source, + trigger, + cookie, + method = 'img', + extraQueryParams = {}, + reportingOrigin, +}) => { + 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), + }); + } + + if (cookie) { + const name = 'Set-Cookie'; + headers.push({name, value: cookie}); + } + + + let credentials; + if (method === 'fetch') { + const params = getFetchParams(reportingOrigin, cookie); + credentials = params.credentials; + headers = headers.concat(params.headers); + } + + 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'); + if (eligible === null) { + img.attributionSrc = url; + } else { + await new Promise(resolve => { + img.onload = resolve; + // Since the resource being fetched isn't a valid image, onerror will + // be fired, but the browser will still process the + // attribution-related headers, so resolve the promise instead of + // rejecting. + img.onerror = resolve; + img.attributionSrc = ''; + img.src = url; + }); + } + return 'event'; + case 'script': + const script = document.createElement('script'); + if (eligible === null) { + script.attributionSrc = url; + } else { + await new Promise(resolve => { + script.onload = resolve; + script.attributionSrc = ''; + script.src = url; + document.body.appendChild(script); + }); + } + return 'event'; + case 'a': + const a = document.createElement('a'); + a.target = '_blank'; + a.textContent = 'link'; + if (eligible === null) { + a.attributionSrc = url; + a.href = blankURL(); + } else { + a.attributionSrc = ''; + a.href = url; + } + document.body.appendChild(a); + await test_driver.click(a); + return 'navigation'; + case 'open': + await test_driver.bless('open window', () => { + if (eligible === null) { + open( + blankURL(), '_blank', + `attributionsrc=${encodeURIComponent(url)}`); + } else { + open(url, '_blank', 'attributionsrc'); + } + }); + return 'navigation'; + case 'fetch': { + let attributionReporting; + if (eligible !== null) { + attributionReporting = JSON.parse(eligible); + } + await fetch(url, {credentials, attributionReporting}); + return 'event'; + } + case 'xhr': + await new Promise((resolve, reject) => { + const req = new XMLHttpRequest(); + req.open('GET', url); + if (eligible !== null) { + req.setAttributionReporting(JSON.parse(eligible)); + } + req.onload = resolve; + req.onerror = () => reject(req.statusText); + 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 pollAggregatableDebugReports = (origin) => + pollAttributionReports(aggregatableDebugReportsUrl, origin); +const pollVerboseDebugReports = (origin) => + pollAttributionReports(verboseDebugReportsUrl, 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'); +}; diff --git a/testing/web-platform/tests/attribution-reporting/resources/reporting_origin.py b/testing/web-platform/tests/attribution-reporting/resources/reporting_origin.py new file mode 100644 index 0000000000..4a5877035e --- /dev/null +++ b/testing/web-platform/tests/attribution-reporting/resources/reporting_origin.py @@ -0,0 +1,65 @@ +"""Test reporting origin server used for two reasons: + + 1. It is a workaround for lack of preflight support in the test server. + 2. Stashes requests so they can be inspected by tests. +""" + +from wptserve.stash import Stash +import json + +REQUESTS = "9250f93f-2c05-4aae-83b9-2817b0e18b4d" + + +headers = [ + b"attribution-reporting-eligible", + b"attribution-reporting-support", + b"referer", +] + + +def store_request(request) -> None: + obj = { + "method": request.method, + "url": request.url, + } + for header in headers: + value = request.headers.get(header) + if value is not None: + obj[str(header, "utf-8")] = str(value, "utf-8") + with request.server.stash.lock: + requests = request.server.stash.take(REQUESTS) + if not requests: + requests = [] + requests.append(obj) + request.server.stash.put(REQUESTS, requests) + return None + + +def get_requests(request) -> str: + with request.server.stash.lock: + return json.dumps(request.server.stash.take(REQUESTS)) + + +def main(request, response): + """ + For most requests, simply returns a 200. Actual source/trigger registration + headers are piped using the `pipe` query param. + + If a `clear-stash` param is set, it will clear the stash. + """ + if request.GET.get(b"clear-stash"): + request.stash.take(REQUESTS) + return + + # We dont want to redirect preflight requests. The cors headers are piped + # so we can simply return a 200 and redirect the following request + if request.method == "OPTIONS": + response.status = 200 + return + + if request.GET.get(b"get-requests"): + return get_requests(request) + + if request.GET.get(b"store-request"): + store_request(request) + return "" diff --git a/testing/web-platform/tests/attribution-reporting/resources/reports.py b/testing/web-platform/tests/attribution-reporting/resources/reports.py new file mode 100644 index 0000000000..b71743b0fc --- /dev/null +++ b/testing/web-platform/tests/attribution-reporting/resources/reports.py @@ -0,0 +1,147 @@ +"""Methods for the report-event-attribution and report-aggregate-attribution endpoints""" +import json +from typing import List, Optional, Tuple +import urllib.parse + +from wptserve.request import Request +from wptserve.stash import Stash +from wptserve.utils import isomorphic_decode, isomorphic_encode + +# Key used to access the reports in the stash. +REPORTS = "4691a2d7fca5430fb0f33b1bd8a9d388" +REDIRECT = "9250f93f-2c05-4aae-83b9-2817b0e18b4e" + +CLEAR_STASH = isomorphic_encode("clear_stash") +CONFIG_REDIRECT = isomorphic_encode("redirect_to") + +Header = Tuple[str, str] +Status = Tuple[int, str] +Response = Tuple[Status, List[Header], str] + +def decode_headers(headers: dict) -> dict: + """Decodes the headers from wptserve. + + wptserve headers are encoded like + { + encoded(key): [encoded(value1), encoded(value2),...] + } + This method decodes the above using the wptserve.utils.isomorphic_decode + method + """ + return { + isomorphic_decode(key): [isomorphic_decode(el) for el in value + ] for key, value in headers.items() + } + +def get_request_origin(request: Request) -> str: + return "%s://%s" % (request.url_parts.scheme, + request.url_parts.netloc) + +def configure_redirect(request, origin) -> None: + with request.server.stash.lock: + request.server.stash.put(REDIRECT, origin) + return None + +def get_report_redirect_url(request): + with request.server.stash.lock: + origin = request.server.stash.take(REDIRECT) + if origin is None: + return None + origin_parts = urllib.parse.urlsplit(origin) + parts = request.url_parts + new_parts = origin_parts._replace(path=bytes(parts.path, 'utf-8')) + return urllib.parse.urlunsplit(new_parts) + +def handle_post_report(request: Request, headers: List[Header]) -> Response: + """Handles POST request for reports. + + Retrieves the report from the request body and stores the report in the + stash. If clear_stash is specified in the query params, clears the stash. + """ + if request.GET.get(CLEAR_STASH): + clear_stash(request.server.stash) + return (200, "OK"), headers, json.dumps({ + "code": 200, + "message": "Stash successfully cleared.", + }) + + redirect_origin = request.GET.get(CONFIG_REDIRECT) + if redirect_origin: + configure_redirect(request, redirect_origin) + return (200, "OK"), headers, json.dumps({ + "code": 200, + "message": "Redirect successfully configured.", + }) + + redirect_url = get_report_redirect_url(request) + if redirect_url is not None: + headers.append(("Location", redirect_url)) + return (308, "Permanent Redirect"), headers, json.dumps({ + "code": 308 + }) + + store_report( + request.server.stash, get_request_origin(request), { + "body": request.body.decode("utf-8"), + "headers": decode_headers(request.headers) + }) + return (201, "OK"), headers, json.dumps({ + "code": 201, + "message": "Report successfully stored." + }) + + +def handle_get_reports(request: Request, headers: List[Header]) -> Response: + """Handles GET request for reports. + + Retrieves and returns all reports from the stash. + """ + reports = take_reports(request.server.stash, get_request_origin(request)) + headers.append(("Access-Control-Allow-Origin", "*")) + return (200, "OK"), headers, json.dumps({ + "code": 200, + "reports": reports, + }) + + +def store_report(stash: Stash, origin: str, report: str) -> None: + """Stores the report in the stash. Report here is a JSON.""" + with stash.lock: + reports_dict = stash.take(REPORTS) + if not reports_dict: + reports_dict = {} + reports = reports_dict.get(origin, []) + reports.append(report) + reports_dict[origin] = reports + stash.put(REPORTS, reports_dict) + return None + +def clear_stash(stash: Stash) -> None: + "Clears the stash." + stash.take(REPORTS) + stash.take(REDIRECT) + return None + +def take_reports(stash: Stash, origin: str) -> List[str]: + """Takes all the reports from the stash and returns them.""" + with stash.lock: + reports_dict = stash.take(REPORTS) + if not reports_dict: + reports_dict = {} + + reports = reports_dict.pop(origin, []) + stash.put(REPORTS, reports_dict) + return reports + + +def handle_reports(request: Request) -> Response: + """Handles request to get or store reports.""" + headers = [("Content-Type", "application/json")] + if request.method == "POST": + return handle_post_report(request, headers) + if request.method == "GET": + return handle_get_reports(request, headers) + return (405, "Method Not Allowed"), headers, json.dumps({ + "code": 405, + "message": "Only GET or POST methods are supported." + }) diff --git a/testing/web-platform/tests/attribution-reporting/simple-verbose-debug-report.sub.https.html b/testing/web-platform/tests/attribution-reporting/simple-verbose-debug-report.sub.https.html new file mode 100644 index 0000000000..8a477f732f --- /dev/null +++ b/testing/web-platform/tests/attribution-reporting/simple-verbose-debug-report.sub.https.html @@ -0,0 +1,32 @@ + + + + + + + + -- cgit v1.2.3