diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs | 619 |
1 files changed, 619 insertions, 0 deletions
diff --git a/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs b/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs new file mode 100644 index 0000000000..32b42198c3 --- /dev/null +++ b/browser/components/urlbar/tests/quicksuggest/RemoteSettingsServer.sys.mjs @@ -0,0 +1,619 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* eslint-disable jsdoc/require-param-description */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + HttpError: "resource://testing-common/httpd.sys.mjs", + HttpServer: "resource://testing-common/httpd.sys.mjs", + HTTP_404: "resource://testing-common/httpd.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", +}); + +const SERVER_PREF = "services.settings.server"; + +/** + * A remote settings server. Tested with the desktop and Rust remote settings + * clients. + */ +export class RemoteSettingsServer { + /** + * The server must be started by calling `start()`. + * + * @param {object} options + * @param {number} options.logLevel + * A `Log.Level` value from `Log.sys.mjs`. `Log.Level.Info` logs basic info + * on requests and responses like paths and status codes. Pass + * `Log.Level.Debug` to log more info like headers, response bodies, and + * added and removed records. + */ + constructor({ logLevel = lazy.Log.Level.Info } = {}) { + this.#log = lazy.Log.repository.getLogger("RemoteSettingsServer"); + this.#log.level = logLevel; + + // Use `DumpAppender` instead of `ConsoleAppender`. The xpcshell and browser + // test harnesses buffer console messages and log them later, which makes it + // really hard to debug problems. `DumpAppender` logs to stdout, which the + // harnesses log immediately. + this.#log.addAppender( + new lazy.Log.DumpAppender(new lazy.Log.BasicFormatter()) + ); + } + + /** + * @returns {URL} + * The server's URL. Null when the server is stopped. + */ + get url() { + return this.#url; + } + + /** + * Starts the server and sets the `services.settings.server` pref to its + * URL. The server's `url` property will be non-null on return. + */ + async start() { + this.#log.info("Starting"); + + if (this.#url) { + this.#log.info("Already started at " + this.#url); + return; + } + + if (!this.#server) { + this.#server = new lazy.HttpServer(); + this.#server.registerPrefixHandler("/", this); + } + this.#server.start(-1); + + this.#url = new URL("http://localhost/v1"); + this.#url.port = this.#server.identity.primaryPort; + + this.#originalServerPrefValue = Services.prefs.getCharPref( + SERVER_PREF, + null + ); + Services.prefs.setCharPref(SERVER_PREF, this.#url.toString()); + + this.#log.info("Server is now started at " + this.#url); + } + + /** + * Stops the server and clears the `services.settings.server` pref. The + * server's `url` property will be null on return. + */ + async stop() { + this.#log.info("Stopping"); + + if (!this.#url) { + this.#log.info("Already stopped"); + return; + } + + await this.#server.stop(); + this.#url = null; + + if (this.#originalServerPrefValue === null) { + Services.prefs.clearUserPref(SERVER_PREF); + } else { + Services.prefs.setCharPref(SERVER_PREF, this.#originalServerPrefValue); + } + + this.#log.info("Server is now stopped"); + } + + /** + * Adds remote settings records to the server. Records may have attachments; + * see the param doc below. + * + * @param {object} options + * @param {string} options.bucket + * @param {string} options.collection + * @param {Array} options.records + * Each object in this array should be a realistic remote settings record + * with the following exceptions: + * + * - `record.id` will be generated if it's undefined. + * - `record.last_modified` will be set to the `#lastModified` property of + * the server if it's undefined. + * - `record.attachment`, if defined, should be the attachment itself and + * not its metadata. The server will automatically create some dummy + * metadata. Currently the only supported attachment type is plain + * JSON'able objects that the server will convert to JSON in responses. + */ + async addRecords({ bucket = "main", collection = "test", records }) { + this.#log.debug( + "Adding records: " + + JSON.stringify({ bucket, collection, records }, null, 2) + ); + + this.#lastModified++; + + let key = this.#recordsKey(bucket, collection); + let allRecords = this.#records.get(key); + if (!allRecords) { + allRecords = []; + this.#records.set(key, allRecords); + } + + for (let record of records) { + let copy = { ...record }; + if (!copy.hasOwnProperty("id")) { + copy.id = String(this.#nextRecordId++); + } + if (!copy.hasOwnProperty("last_modified")) { + copy.last_modified = this.#lastModified; + } + if (copy.attachment) { + await this.#addAttachment({ bucket, collection, record: copy }); + } + allRecords.push(copy); + } + + this.#log.debug( + "Done adding records. All records are now: " + + JSON.stringify([...this.#records.entries()], null, 2) + ); + } + + /** + * Marks records as deleted. Deleted records will still be returned in + * responses, but they'll have a `deleted = true` property. Their attachments + * will be deleted immediately, however. + * + * @param {object} filter + * If null, all records will be marked as deleted. Otherwise only records + * that match the filter will be marked as deleted. For a given record, each + * value in the filter object will be compared to the value with the same + * key in the record. If all values are the same, the record will be + * removed. Examples: + * + * To remove remove records whose `type` key has the value "data": + * `{ type: "data" } + * + * To remove remove records whose `type` key has the value "data" and whose + * `last_modified` key has the value 1234: + * `{ type: "data", last_modified: 1234 } + */ + removeRecords(filter = null) { + this.#log.debug("Removing records: " + JSON.stringify({ filter })); + + this.#lastModified++; + + for (let [recordsKey, records] of this.#records.entries()) { + for (let record of records) { + if ( + !filter || + Object.entries(filter).every( + ([filterKey, filterValue]) => + record.hasOwnProperty(filterKey) && + record[filterKey] == filterValue + ) + ) { + if (record.attachment) { + let attachmentKey = `${recordsKey}/${record.attachment.filename}`; + this.#attachments.delete(attachmentKey); + } + record.deleted = true; + record.last_modified = this.#lastModified; + } + } + } + + this.#log.debug( + "Done removing records. All records are now: " + + JSON.stringify([...this.#records.entries()], null, 2) + ); + } + + /** + * Removes all existing records and adds the given records to the server. + * + * @param {object} options + * @param {string} options.bucket + * @param {string} options.collection + * @param {Array} options.records + * See `addRecords()`. + */ + async setRecords({ bucket = "main", collection = "test", records }) { + this.#log.debug("Setting records"); + + this.removeRecords(); + await this.addRecords({ bucket, collection, records }); + + this.#log.debug("Done setting records"); + } + + /** + * `nsIHttpRequestHandler` callback from the backing server. Handles a + * request. + * + * @param {nsIHttpRequest} request + * @param {nsIHttpResponse} response + */ + handle(request, response) { + this.#logRequest(request); + + // Get the route that matches the request path. + let { match, route } = this.#getRoute(request.path) || {}; + if (!route) { + this.#prepareError({ request, response, error: lazy.HTTP_404 }); + return; + } + + let respInfo = route.response(match, request, response); + if (respInfo instanceof lazy.HttpError) { + this.#prepareError({ request, response, error: respInfo }); + } else { + this.#prepareResponse({ ...respInfo, request, response }); + } + } + + /** + * @returns {Array} + * The routes handled by the server. Each item in this array is an object + * with the following properties that describes one or more paths and the + * response that should be sent when a request is made on those paths: + * + * {string} spec + * A path spec. This is required unless `specs` is defined. To determine + * which route should be used for a given request, the server will check + * each route's spec(s) until it finds the first that matches the + * request's path. A spec is just a path whose components can be variables + * that start with "$". When a spec with variables matches a request path, + * the `match` object passed to the route's `response` function will map + * from variable names to the corresponding components in the path. + * {Array} specs + * An array of path spec strings. Use this instead of `spec` if the route + * handles more than one. + * {function} response + * A function that will be called when the route matches a request. It is + * called as: `response(match, request, response)` + * + * {object} match + * An object mapping variable names in the spec to their matched + * components in the path. See `#match()` for details. + * {nsIHttpRequest} request + * {nsIHttpResponse} response + * + * The function must return one of the following: + * + * {object} + * An object that describes the response with the following properties: + * {object} body + * A plain JSON'able object. The server will convert this to JSON and + * set it to the response body. + * {HttpError} + * An `HttpError` instance defined in `httpd.sys.mjs`. + */ + get #routes() { + return [ + { + spec: "/v1", + response: () => ({ + body: { + capabilities: { + attachments: { + base_url: this.#url.toString(), + }, + }, + }, + }), + }, + + { + spec: "/v1/buckets/monitor/collections/changes/changeset", + response: () => ({ + body: { + timestamp: this.#lastModified, + changes: [ + { + last_modified: this.#lastModified, + }, + ], + }, + }), + }, + + { + spec: "/v1/buckets/$bucket/collections/$collection/changeset", + response: ({ bucket, collection }) => { + let records = this.#getRecords(bucket, collection); + return !records + ? lazy.HTTP_404 + : { + body: { + metadata: null, + timestamp: this.#lastModified, + changes: records, + }, + }; + }, + }, + + { + spec: "/v1/buckets/$bucket/collections/$collection/records", + response: ({ bucket, collection }) => { + let records = this.#getRecords(bucket, collection); + return !records + ? lazy.HTTP_404 + : { + body: { + data: records, + }, + }; + }, + }, + + { + specs: [ + // The Rust remote settings client doesn't include "v1" in attachment + // URLs, but the JS client does. + "/attachments/$bucket/$collection/$filename", + "/v1/attachments/$bucket/$collection/$filename", + ], + response: ({ bucket, collection, filename }) => { + return { + body: this.#getAttachment(bucket, collection, filename), + }; + }, + }, + ]; + } + + /** + * @returns {object} + * Default response headers. + */ + get #responseHeaders() { + return { + "Access-Control-Allow-Origin": "*", + "Access-Control-Expose-Headers": + "Retry-After, Content-Length, Alert, Backoff", + Server: "waitress", + Etag: `"${this.#lastModified}"`, + }; + } + + /** + * Returns the route that matches a request path. + * + * @param {string} path + * A request path. + * @returns {object} + * If no route matches the path, returns an empty object. Otherwise returns + * an object with the following properties: + * + * {object} match + * An object describing the matched variables in the route spec. See + * `#match()` for details. + * {object} route + * The matched route. See `#routes` for details. + */ + #getRoute(path) { + for (let route of this.#routes) { + let specs = route.specs || [route.spec]; + for (let spec of specs) { + let match = this.#match(path, spec); + if (match) { + return { match, route }; + } + } + } + return {}; + } + + /** + * Matches a request path to a route spec. + * + * @param {string} path + * A request path. + * @param {string} spec + * A route spec. See `#routes` for details. + * @returns {object|null} + * If the spec doesn't match the path, returns null. Otherwise returns an + * object mapping variable names in the spec to their matched components in + * the path. Example: + * + * path : "/main/myfeature/foo" + * spec : "/$bucket/$collection/foo" + * returns: `{ bucket: "main", collection: "myfeature" }` + */ + #match(path, spec) { + let pathParts = path.split("/"); + let specParts = spec.split("/"); + + if (pathParts.length != specParts.length) { + // If the path has only one more part than the spec and its last part is + // empty, then the path ends in a trailing slash but the spec does not. + // Consider that a match. Otherwise return null for no match. + if ( + pathParts[pathParts.length - 1] || + pathParts.length != specParts.length + 1 + ) { + return null; + } + pathParts.pop(); + } + + let match = {}; + for (let i = 0; i < pathParts.length; i++) { + let pathPart = pathParts[i]; + let specPart = specParts[i]; + if (specPart.startsWith("$")) { + match[specPart.substring(1)] = pathPart; + } else if (pathPart != specPart) { + return null; + } + } + + return match; + } + + #getRecords(bucket, collection) { + return this.#records.get(this.#recordsKey(bucket, collection)); + } + + #recordsKey(bucket, collection) { + return `${bucket}/${collection}`; + } + + /** + * Registers an attachment for a record. + * + * @param {object} options + * @param {string} options.bucket + * @param {string} options.collection + * @param {object} options.record + * The record should have an `attachment` property as described in + * `addRecords()`. + */ + async #addAttachment({ bucket, collection, record }) { + let { attachment } = record; + let filename = record.id; + + this.#attachments.set( + this.#attachmentsKey(bucket, collection, filename), + attachment + ); + + let encoder = new TextEncoder(); + let bytes = encoder.encode(JSON.stringify(attachment)); + + let hashBuffer = await crypto.subtle.digest("SHA-256", bytes); + let hashBytes = new Uint8Array(hashBuffer); + let toHex = b => b.toString(16).padStart(2, "0"); + let hash = Array.from(hashBytes, toHex).join(""); + + // Replace `record.attachment` with appropriate metadata in order to conform + // with the remote settings API. + record.attachment = { + hash, + filename, + mimetype: "application/json; charset=UTF-8", + size: bytes.length, + location: `attachments/${bucket}/${collection}/${filename}`, + }; + } + + #attachmentsKey(bucket, collection, filename) { + return `${bucket}/${collection}/${filename}`; + } + + #getAttachment(bucket, collection, filename) { + return this.#attachments.get( + this.#attachmentsKey(bucket, collection, filename) + ); + } + + /** + * Prepares an HTTP response. + * + * @param {object} options + * @param {nsIHttpRequest} options.request + * @param {nsIHttpResponse} options.response + * @param {object|null} options.body + * Currently only JSON'able objects are supported. They will be converted to + * JSON in the response. + * @param {integer} options.status + * @param {string} options.statusText + */ + #prepareResponse({ + request, + response, + body = null, + status = 200, + statusText = "OK", + }) { + let headers = { ...this.#responseHeaders }; + if (body) { + headers["Content-Type"] = "application/json; charset=UTF-8"; + } + + this.#logResponse({ request, status, statusText, headers, body }); + + for (let [name, value] of Object.entries(headers)) { + response.setHeader(name, value, false); + } + if (body) { + response.write(JSON.stringify(body)); + } + response.setStatusLine(request.httpVersion, status, statusText); + } + + /** + * Prepares an HTTP error response. + * + * @param {object} options + * @param {nsIHttpRequest} options.request + * @param {nsIHttpResponse} options.response + * @param {HttpError} options.error + * An `HttpError` instance defined in `httpd.sys.mjs`. + */ + #prepareError({ request, response, error }) { + this.#prepareResponse({ + request, + response, + status: error.code, + statusText: error.description, + }); + } + + /** + * Logs a request. + * + * @param {nsIHttpRequest} request + */ + #logRequest(request) { + let pathAndQuery = request.path; + if (request.queryString) { + pathAndQuery += "?" + request.queryString; + } + this.#log.info( + `< HTTP ${request.httpVersion} ${request.method} ${pathAndQuery}` + ); + for (let name of request.headers) { + this.#log.debug(`${name}: ${request.getHeader(name.toString())}`); + } + } + + /** + * Logs a response. + * + * @param {object} options + * @param {nsIHttpRequest} options.request + * The associated request. + * @param {integer} options.status + * The HTTP status code of the response. + * @param {string} options.statusText + * The description of the status code. + * @param {object} options.headers + * An object mapping from response header names to values. + * @param {object} options.body + * The response body, if any. + */ + #logResponse({ request, status, statusText, headers, body }) { + this.#log.info(`> ${status} ${request.path}`); + for (let [name, value] of Object.entries(headers)) { + this.#log.debug(`${name}: ${value}`); + } + if (body) { + this.#log.debug("Response body: " + JSON.stringify(body, null, 2)); + } + } + + // records key (see `#recordsKey()`) -> array of record objects + #records = new Map(); + + // attachments key (see `#attachmentsKey()`) -> attachment object + #attachments = new Map(); + + #log; + #server; + #originalServerPrefValue; + #url = null; + #lastModified = 1368273600000; + #nextRecordId = 1; +} |