summaryrefslogtreecommitdiffstats
path: root/services/common/modules-testing
diff options
context:
space:
mode:
Diffstat (limited to 'services/common/modules-testing')
-rw-r--r--services/common/modules-testing/logging.js54
-rw-r--r--services/common/modules-testing/storageserver.js1677
-rw-r--r--services/common/modules-testing/utils.js42
3 files changed, 1773 insertions, 0 deletions
diff --git a/services/common/modules-testing/logging.js b/services/common/modules-testing/logging.js
new file mode 100644
index 000000000..3ff2c396c
--- /dev/null
+++ b/services/common/modules-testing/logging.js
@@ -0,0 +1,54 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "getTestLogger",
+ "initTestLogging",
+];
+
+var {utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Log.jsm");
+
+this.initTestLogging = function initTestLogging(level) {
+ function LogStats() {
+ this.errorsLogged = 0;
+ }
+ LogStats.prototype = {
+ format: function format(message) {
+ if (message.level == Log.Level.Error) {
+ this.errorsLogged += 1;
+ }
+
+ return message.time + "\t" + message.loggerName + "\t" + message.levelDesc + "\t" +
+ this.formatText(message) + "\n";
+ }
+ };
+ LogStats.prototype.__proto__ = new Log.BasicFormatter();
+
+ let log = Log.repository.rootLogger;
+ let logStats = new LogStats();
+ let appender = new Log.DumpAppender(logStats);
+
+ if (typeof(level) == "undefined") {
+ level = "Debug";
+ }
+ getTestLogger().level = Log.Level[level];
+ Log.repository.getLogger("Services").level = Log.Level[level];
+
+ log.level = Log.Level.Trace;
+ appender.level = Log.Level.Trace;
+ // Overwrite any other appenders (e.g. from previous incarnations)
+ log.ownAppenders = [appender];
+ log.updateAppenders();
+
+ return logStats;
+}
+
+this.getTestLogger = function getTestLogger(component) {
+ return Log.repository.getLogger("Testing");
+}
+
diff --git a/services/common/modules-testing/storageserver.js b/services/common/modules-testing/storageserver.js
new file mode 100644
index 000000000..650ac307f
--- /dev/null
+++ b/services/common/modules-testing/storageserver.js
@@ -0,0 +1,1677 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file contains an implementation of the Storage Server in JavaScript.
+ *
+ * The server should not be used for any production purposes.
+ */
+
+var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+this.EXPORTED_SYMBOLS = [
+ "ServerBSO",
+ "StorageServerCallback",
+ "StorageServerCollection",
+ "StorageServer",
+ "storageServerForUsers",
+];
+
+Cu.import("resource://testing-common/httpd.js");
+Cu.import("resource://services-common/async.js");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/utils.js");
+
+const STORAGE_HTTP_LOGGER = "Services.Common.Test.Server";
+const STORAGE_API_VERSION = "2.0";
+
+// Use the same method that record.js does, which mirrors the server.
+function new_timestamp() {
+ return Math.round(Date.now());
+}
+
+function isInteger(s) {
+ let re = /^[0-9]+$/;
+ return re.test(s);
+}
+
+function writeHttpBody(response, body) {
+ if (!body) {
+ return;
+ }
+
+ response.bodyOutputStream.write(body, body.length);
+}
+
+function sendMozSvcError(request, response, code) {
+ response.setStatusLine(request.httpVersion, 400, "Bad Request");
+ response.setHeader("Content-Type", "text/plain", false);
+ response.bodyOutputStream.write(code, code.length);
+}
+
+/**
+ * Represent a BSO on the server.
+ *
+ * A BSO is constructed from an ID, content, and a modified time.
+ *
+ * @param id
+ * (string) ID of the BSO being created.
+ * @param payload
+ * (strong|object) Payload for the BSO. Should ideally be a string. If
+ * an object is passed, it will be fed into JSON.stringify and that
+ * output will be set as the payload.
+ * @param modified
+ * (number) Milliseconds since UNIX epoch that the BSO was last
+ * modified. If not defined or null, the current time will be used.
+ */
+this.ServerBSO = function ServerBSO(id, payload, modified) {
+ if (!id) {
+ throw new Error("No ID for ServerBSO!");
+ }
+
+ if (!id.match(/^[a-zA-Z0-9_-]{1,64}$/)) {
+ throw new Error("BSO ID is invalid: " + id);
+ }
+
+ this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
+
+ this.id = id;
+ if (!payload) {
+ return;
+ }
+
+ CommonUtils.ensureMillisecondsTimestamp(modified);
+
+ if (typeof payload == "object") {
+ payload = JSON.stringify(payload);
+ }
+
+ this.payload = payload;
+ this.modified = modified || new_timestamp();
+}
+ServerBSO.prototype = {
+ FIELDS: [
+ "id",
+ "modified",
+ "payload",
+ "ttl",
+ "sortindex",
+ ],
+
+ toJSON: function toJSON() {
+ let obj = {};
+
+ for (let key of this.FIELDS) {
+ if (this[key] !== undefined) {
+ obj[key] = this[key];
+ }
+ }
+
+ return obj;
+ },
+
+ delete: function delete_() {
+ this.deleted = true;
+
+ delete this.payload;
+ delete this.modified;
+ },
+
+ /**
+ * Handler for GET requests for this BSO.
+ */
+ getHandler: function getHandler(request, response) {
+ let code = 200;
+ let status = "OK";
+ let body;
+
+ function sendResponse() {
+ response.setStatusLine(request.httpVersion, code, status);
+ writeHttpBody(response, body);
+ }
+
+ if (request.hasHeader("x-if-modified-since")) {
+ let headerModified = parseInt(request.getHeader("x-if-modified-since"),
+ 10);
+ CommonUtils.ensureMillisecondsTimestamp(headerModified);
+
+ if (headerModified >= this.modified) {
+ code = 304;
+ status = "Not Modified";
+
+ sendResponse();
+ return;
+ }
+ } else if (request.hasHeader("x-if-unmodified-since")) {
+ let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
+ 10);
+ let serverModified = this.modified;
+
+ if (serverModified > requestModified) {
+ code = 412;
+ status = "Precondition Failed";
+ sendResponse();
+ return;
+ }
+ }
+
+ if (!this.deleted) {
+ body = JSON.stringify(this.toJSON());
+ response.setHeader("Content-Type", "application/json", false);
+ response.setHeader("X-Last-Modified", "" + this.modified, false);
+ } else {
+ code = 404;
+ status = "Not Found";
+ }
+
+ sendResponse();
+ },
+
+ /**
+ * Handler for PUT requests for this BSO.
+ */
+ putHandler: function putHandler(request, response) {
+ if (request.hasHeader("Content-Type")) {
+ let ct = request.getHeader("Content-Type");
+ if (ct != "application/json") {
+ throw HTTP_415;
+ }
+ }
+
+ let input = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let parsed;
+ try {
+ parsed = JSON.parse(input);
+ } catch (ex) {
+ return sendMozSvcError(request, response, "8");
+ }
+
+ if (typeof(parsed) != "object") {
+ return sendMozSvcError(request, response, "8");
+ }
+
+ // Don't update if a conditional request fails preconditions.
+ if (request.hasHeader("x-if-unmodified-since")) {
+ let reqModified = parseInt(request.getHeader("x-if-unmodified-since"));
+
+ if (reqModified < this.modified) {
+ response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
+ return;
+ }
+ }
+
+ let code, status;
+ if (this.payload) {
+ code = 204;
+ status = "No Content";
+ } else {
+ code = 201;
+ status = "Created";
+ }
+
+ // Alert when we see unrecognized fields.
+ for (let [key, value] of Object.entries(parsed)) {
+ switch (key) {
+ case "payload":
+ if (typeof(value) != "string") {
+ sendMozSvcError(request, response, "8");
+ return true;
+ }
+
+ this.payload = value;
+ break;
+
+ case "ttl":
+ if (!isInteger(value)) {
+ sendMozSvcError(request, response, "8");
+ return true;
+ }
+ this.ttl = parseInt(value, 10);
+ break;
+
+ case "sortindex":
+ if (!isInteger(value) || value.length > 9) {
+ sendMozSvcError(request, response, "8");
+ return true;
+ }
+ this.sortindex = parseInt(value, 10);
+ break;
+
+ case "id":
+ break;
+
+ default:
+ this._log.warn("Unexpected field in BSO record: " + key);
+ sendMozSvcError(request, response, "8");
+ return true;
+ }
+ }
+
+ this.modified = request.timestamp;
+ this.deleted = false;
+ response.setHeader("X-Last-Modified", "" + this.modified, false);
+
+ response.setStatusLine(request.httpVersion, code, status);
+ },
+};
+
+/**
+ * Represent a collection on the server.
+ *
+ * The '_bsos' attribute is a mapping of id -> ServerBSO objects.
+ *
+ * Note that if you want these records to be accessible individually,
+ * you need to register their handlers with the server separately, or use a
+ * containing HTTP server that will do so on your behalf.
+ *
+ * @param bsos
+ * An object mapping BSO IDs to ServerBSOs.
+ * @param acceptNew
+ * If true, POSTs to this collection URI will result in new BSOs being
+ * created and wired in on the fly.
+ * @param timestamp
+ * An optional timestamp value to initialize the modified time of the
+ * collection. This should be in the format returned by new_timestamp().
+ */
+this.StorageServerCollection =
+ function StorageServerCollection(bsos, acceptNew, timestamp=new_timestamp()) {
+ this._bsos = bsos || {};
+ this.acceptNew = acceptNew || false;
+
+ /*
+ * Track modified timestamp.
+ * We can't just use the timestamps of contained BSOs: an empty collection
+ * has a modified time.
+ */
+ CommonUtils.ensureMillisecondsTimestamp(timestamp);
+ this._timestamp = timestamp;
+
+ this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
+}
+StorageServerCollection.prototype = {
+ BATCH_MAX_COUNT: 100, // # of records.
+ BATCH_MAX_SIZE: 1024 * 1024, // # bytes.
+
+ _timestamp: null,
+
+ get timestamp() {
+ return this._timestamp;
+ },
+
+ set timestamp(timestamp) {
+ CommonUtils.ensureMillisecondsTimestamp(timestamp);
+ this._timestamp = timestamp;
+ },
+
+ get totalPayloadSize() {
+ let size = 0;
+ for (let bso of this.bsos()) {
+ size += bso.payload.length;
+ }
+
+ return size;
+ },
+
+ /**
+ * Convenience accessor for our BSO keys.
+ * Excludes deleted items, of course.
+ *
+ * @param filter
+ * A predicate function (applied to the ID and BSO) which dictates
+ * whether to include the BSO's ID in the output.
+ *
+ * @return an array of IDs.
+ */
+ keys: function keys(filter) {
+ let ids = [];
+ for (let [id, bso] of Object.entries(this._bsos)) {
+ if (!bso.deleted && (!filter || filter(id, bso))) {
+ ids.push(id);
+ }
+ }
+ return ids;
+ },
+
+ /**
+ * Convenience method to get an array of BSOs.
+ * Optionally provide a filter function.
+ *
+ * @param filter
+ * A predicate function, applied to the BSO, which dictates whether to
+ * include the BSO in the output.
+ *
+ * @return an array of ServerBSOs.
+ */
+ bsos: function bsos(filter) {
+ let os = [];
+ for (let [id, bso] of Object.entries(this._bsos)) {
+ if (!bso.deleted) {
+ os.push(bso);
+ }
+ }
+
+ if (!filter) {
+ return os;
+ }
+
+ return os.filter(filter);
+ },
+
+ /**
+ * Obtain a BSO by ID.
+ */
+ bso: function bso(id) {
+ return this._bsos[id];
+ },
+
+ /**
+ * Obtain the payload of a specific BSO.
+ *
+ * Raises if the specified BSO does not exist.
+ */
+ payload: function payload(id) {
+ return this.bso(id).payload;
+ },
+
+ /**
+ * Insert the provided BSO under its ID.
+ *
+ * @return the provided BSO.
+ */
+ insertBSO: function insertBSO(bso) {
+ return this._bsos[bso.id] = bso;
+ },
+
+ /**
+ * Insert the provided payload as part of a new ServerBSO with the provided
+ * ID.
+ *
+ * @param id
+ * The GUID for the BSO.
+ * @param payload
+ * The payload, as provided to the ServerBSO constructor.
+ * @param modified
+ * An optional modified time for the ServerBSO. If not specified, the
+ * current time will be used.
+ *
+ * @return the inserted BSO.
+ */
+ insert: function insert(id, payload, modified) {
+ return this.insertBSO(new ServerBSO(id, payload, modified));
+ },
+
+ /**
+ * Removes an object entirely from the collection.
+ *
+ * @param id
+ * (string) ID to remove.
+ */
+ remove: function remove(id) {
+ delete this._bsos[id];
+ },
+
+ _inResultSet: function _inResultSet(bso, options) {
+ if (!bso.payload) {
+ return false;
+ }
+
+ if (options.ids) {
+ if (options.ids.indexOf(bso.id) == -1) {
+ return false;
+ }
+ }
+
+ if (options.newer) {
+ if (bso.modified <= options.newer) {
+ return false;
+ }
+ }
+
+ if (options.older) {
+ if (bso.modified >= options.older) {
+ return false;
+ }
+ }
+
+ return true;
+ },
+
+ count: function count(options) {
+ options = options || {};
+ let c = 0;
+ for (let [id, bso] of Object.entries(this._bsos)) {
+ if (bso.modified && this._inResultSet(bso, options)) {
+ c++;
+ }
+ }
+ return c;
+ },
+
+ get: function get(options) {
+ let data = [];
+ for (let id in this._bsos) {
+ let bso = this._bsos[id];
+ if (!bso.modified) {
+ continue;
+ }
+
+ if (!this._inResultSet(bso, options)) {
+ continue;
+ }
+
+ data.push(bso);
+ }
+
+ if (options.sort) {
+ if (options.sort == "oldest") {
+ data.sort(function sortOldest(a, b) {
+ if (a.modified == b.modified) {
+ return 0;
+ }
+
+ return a.modified < b.modified ? -1 : 1;
+ });
+ } else if (options.sort == "newest") {
+ data.sort(function sortNewest(a, b) {
+ if (a.modified == b.modified) {
+ return 0;
+ }
+
+ return a.modified > b.modified ? -1 : 1;
+ });
+ } else if (options.sort == "index") {
+ data.sort(function sortIndex(a, b) {
+ if (a.sortindex == b.sortindex) {
+ return 0;
+ }
+
+ if (a.sortindex !== undefined && b.sortindex == undefined) {
+ return 1;
+ }
+
+ if (a.sortindex === undefined && b.sortindex !== undefined) {
+ return -1;
+ }
+
+ return a.sortindex > b.sortindex ? -1 : 1;
+ });
+ }
+ }
+
+ if (options.limit) {
+ data = data.slice(0, options.limit);
+ }
+
+ return data;
+ },
+
+ post: function post(input, timestamp) {
+ let success = [];
+ let failed = {};
+ let count = 0;
+ let size = 0;
+
+ // This will count records where we have an existing ServerBSO
+ // registered with us as successful and all other records as failed.
+ for (let record of input) {
+ count += 1;
+ if (count > this.BATCH_MAX_COUNT) {
+ failed[record.id] = "Max record count exceeded.";
+ continue;
+ }
+
+ if (typeof(record.payload) != "string") {
+ failed[record.id] = "Payload is not a string!";
+ continue;
+ }
+
+ size += record.payload.length;
+ if (size > this.BATCH_MAX_SIZE) {
+ failed[record.id] = "Payload max size exceeded!";
+ continue;
+ }
+
+ if (record.sortindex) {
+ if (!isInteger(record.sortindex)) {
+ failed[record.id] = "sortindex is not an integer.";
+ continue;
+ }
+
+ if (record.sortindex.length > 9) {
+ failed[record.id] = "sortindex is too long.";
+ continue;
+ }
+ }
+
+ if ("ttl" in record) {
+ if (!isInteger(record.ttl)) {
+ failed[record.id] = "ttl is not an integer.";
+ continue;
+ }
+ }
+
+ try {
+ let bso = this.bso(record.id);
+ if (!bso && this.acceptNew) {
+ this._log.debug("Creating BSO " + JSON.stringify(record.id) +
+ " on the fly.");
+ bso = new ServerBSO(record.id);
+ this.insertBSO(bso);
+ }
+ if (bso) {
+ bso.payload = record.payload;
+ bso.modified = timestamp;
+ bso.deleted = false;
+ success.push(record.id);
+
+ if (record.sortindex) {
+ bso.sortindex = parseInt(record.sortindex, 10);
+ }
+
+ } else {
+ failed[record.id] = "no bso configured";
+ }
+ } catch (ex) {
+ this._log.info("Exception when processing BSO", ex);
+ failed[record.id] = "Exception when processing.";
+ }
+ }
+ return {success: success, failed: failed};
+ },
+
+ delete: function delete_(options) {
+ options = options || {};
+
+ // Protocol 2.0 only allows the "ids" query string argument.
+ let keys = Object.keys(options).filter(function(k) {
+ return k != "ids";
+ });
+ if (keys.length) {
+ this._log.warn("Invalid query string parameter to collection delete: " +
+ keys.join(", "));
+ throw new Error("Malformed client request.");
+ }
+
+ if (options.ids && options.ids.length > this.BATCH_MAX_COUNT) {
+ throw HTTP_400;
+ }
+
+ let deleted = [];
+ for (let [id, bso] of Object.entries(this._bsos)) {
+ if (this._inResultSet(bso, options)) {
+ this._log.debug("Deleting " + JSON.stringify(bso));
+ deleted.push(bso.id);
+ bso.delete();
+ }
+ }
+ return deleted;
+ },
+
+ parseOptions: function parseOptions(request) {
+ let options = {};
+
+ for (let chunk of request.queryString.split("&")) {
+ if (!chunk) {
+ continue;
+ }
+ chunk = chunk.split("=");
+ let key = decodeURIComponent(chunk[0]);
+ if (chunk.length == 1) {
+ options[key] = "";
+ } else {
+ options[key] = decodeURIComponent(chunk[1]);
+ }
+ }
+
+ if (options.ids) {
+ options.ids = options.ids.split(",");
+ }
+
+ if (options.newer) {
+ if (!isInteger(options.newer)) {
+ throw HTTP_400;
+ }
+
+ CommonUtils.ensureMillisecondsTimestamp(options.newer);
+ options.newer = parseInt(options.newer, 10);
+ }
+
+ if (options.older) {
+ if (!isInteger(options.older)) {
+ throw HTTP_400;
+ }
+
+ CommonUtils.ensureMillisecondsTimestamp(options.older);
+ options.older = parseInt(options.older, 10);
+ }
+
+ if (options.limit) {
+ if (!isInteger(options.limit)) {
+ throw HTTP_400;
+ }
+
+ options.limit = parseInt(options.limit, 10);
+ }
+
+ return options;
+ },
+
+ getHandler: function getHandler(request, response) {
+ let options = this.parseOptions(request);
+ let data = this.get(options);
+
+ if (request.hasHeader("x-if-modified-since")) {
+ let requestModified = parseInt(request.getHeader("x-if-modified-since"),
+ 10);
+ let newestBSO = 0;
+ for (let bso of data) {
+ if (bso.modified > newestBSO) {
+ newestBSO = bso.modified;
+ }
+ }
+
+ if (requestModified >= newestBSO) {
+ response.setHeader("X-Last-Modified", "" + newestBSO);
+ response.setStatusLine(request.httpVersion, 304, "Not Modified");
+ return;
+ }
+ } else if (request.hasHeader("x-if-unmodified-since")) {
+ let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
+ 10);
+ let serverModified = this.timestamp;
+
+ if (serverModified > requestModified) {
+ response.setHeader("X-Last-Modified", "" + serverModified);
+ response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
+ return;
+ }
+ }
+
+ if (options.full) {
+ data = data.map(function map(bso) {
+ return bso.toJSON();
+ });
+ } else {
+ data = data.map(function map(bso) {
+ return bso.id;
+ });
+ }
+
+ // application/json is default media type.
+ let newlines = false;
+ if (request.hasHeader("accept")) {
+ let accept = request.getHeader("accept");
+ if (accept == "application/newlines") {
+ newlines = true;
+ } else if (accept != "application/json") {
+ throw HTTP_406;
+ }
+ }
+
+ let body;
+ if (newlines) {
+ response.setHeader("Content-Type", "application/newlines", false);
+ let normalized = data.map(function map(d) {
+ return JSON.stringify(d);
+ });
+
+ body = normalized.join("\n") + "\n";
+ } else {
+ response.setHeader("Content-Type", "application/json", false);
+ body = JSON.stringify({items: data});
+ }
+
+ this._log.info("Records: " + data.length);
+ response.setHeader("X-Num-Records", "" + data.length, false);
+ response.setHeader("X-Last-Modified", "" + this.timestamp, false);
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(body, body.length);
+ },
+
+ postHandler: function postHandler(request, response) {
+ let options = this.parseOptions(request);
+
+ if (!request.hasHeader("content-type")) {
+ this._log.info("No Content-Type request header!");
+ throw HTTP_400;
+ }
+
+ let inputStream = request.bodyInputStream;
+ let inputBody = CommonUtils.readBytesFromInputStream(inputStream);
+ let input = [];
+
+ let inputMediaType = request.getHeader("content-type");
+ if (inputMediaType == "application/json") {
+ try {
+ input = JSON.parse(inputBody);
+ } catch (ex) {
+ this._log.info("JSON parse error on input body!");
+ throw HTTP_400;
+ }
+
+ if (!Array.isArray(input)) {
+ this._log.info("Input JSON type not an array!");
+ return sendMozSvcError(request, response, "8");
+ }
+ } else if (inputMediaType == "application/newlines") {
+ for (let line of inputBody.split("\n")) {
+ let record;
+ try {
+ record = JSON.parse(line);
+ } catch (ex) {
+ this._log.info("JSON parse error on line!");
+ return sendMozSvcError(request, response, "8");
+ }
+
+ input.push(record);
+ }
+ } else {
+ this._log.info("Unknown media type: " + inputMediaType);
+ throw HTTP_415;
+ }
+
+ if (this._ensureUnmodifiedSince(request, response)) {
+ return;
+ }
+
+ let res = this.post(input, request.timestamp);
+ let body = JSON.stringify(res);
+ response.setHeader("Content-Type", "application/json", false);
+ this.timestamp = request.timestamp;
+ response.setHeader("X-Last-Modified", "" + this.timestamp, false);
+
+ response.setStatusLine(request.httpVersion, "200", "OK");
+ response.bodyOutputStream.write(body, body.length);
+ },
+
+ deleteHandler: function deleteHandler(request, response) {
+ this._log.debug("Invoking StorageServerCollection.DELETE.");
+
+ let options = this.parseOptions(request);
+
+ if (this._ensureUnmodifiedSince(request, response)) {
+ return;
+ }
+
+ let deleted = this.delete(options);
+ response.deleted = deleted;
+ this.timestamp = request.timestamp;
+
+ response.setStatusLine(request.httpVersion, 204, "No Content");
+ },
+
+ handler: function handler() {
+ let self = this;
+
+ return function(request, response) {
+ switch(request.method) {
+ case "GET":
+ return self.getHandler(request, response);
+
+ case "POST":
+ return self.postHandler(request, response);
+
+ case "DELETE":
+ return self.deleteHandler(request, response);
+
+ }
+
+ request.setHeader("Allow", "GET,POST,DELETE");
+ response.setStatusLine(request.httpVersion, 405, "Method Not Allowed");
+ };
+ },
+
+ _ensureUnmodifiedSince: function _ensureUnmodifiedSince(request, response) {
+ if (!request.hasHeader("x-if-unmodified-since")) {
+ return false;
+ }
+
+ let requestModified = parseInt(request.getHeader("x-if-unmodified-since"),
+ 10);
+ let serverModified = this.timestamp;
+
+ this._log.debug("Request modified time: " + requestModified +
+ "; Server modified time: " + serverModified);
+ if (serverModified <= requestModified) {
+ return false;
+ }
+
+ this._log.info("Conditional request rejected because client time older " +
+ "than collection timestamp.");
+ response.setStatusLine(request.httpVersion, 412, "Precondition Failed");
+ return true;
+ },
+};
+
+
+//===========================================================================//
+// httpd.js-based Storage server. //
+//===========================================================================//
+
+/**
+ * In general, the preferred way of using StorageServer is to directly
+ * introspect it. Callbacks are available for operations which are hard to
+ * verify through introspection, such as deletions.
+ *
+ * One of the goals of this server is to provide enough hooks for test code to
+ * find out what it needs without monkeypatching. Use this object as your
+ * prototype, and override as appropriate.
+ */
+this.StorageServerCallback = {
+ onCollectionDeleted: function onCollectionDeleted(user, collection) {},
+ onItemDeleted: function onItemDeleted(user, collection, bsoID) {},
+
+ /**
+ * Called at the top of every request.
+ *
+ * Allows the test to inspect the request. Hooks should be careful not to
+ * modify or change state of the request or they may impact future processing.
+ */
+ onRequest: function onRequest(request) {},
+};
+
+/**
+ * Construct a new test Storage server. Takes a callback object (e.g.,
+ * StorageServerCallback) as input.
+ */
+this.StorageServer = function StorageServer(callback) {
+ this.callback = callback || {__proto__: StorageServerCallback};
+ this.server = new HttpServer();
+ this.started = false;
+ this.users = {};
+ this.requestCount = 0;
+ this._log = Log.repository.getLogger(STORAGE_HTTP_LOGGER);
+
+ // Install our own default handler. This allows us to mess around with the
+ // whole URL space.
+ let handler = this.server._handler;
+ handler._handleDefault = this.handleDefault.bind(this, handler);
+}
+StorageServer.prototype = {
+ DEFAULT_QUOTA: 1024 * 1024, // # bytes.
+
+ server: null, // HttpServer.
+ users: null, // Map of username => {collections, password}.
+
+ /**
+ * If true, the server will allow any arbitrary user to be used.
+ *
+ * No authentication will be performed. Whatever user is detected from the
+ * URL or auth headers will be created (if needed) and used.
+ */
+ allowAllUsers: false,
+
+ /**
+ * Start the StorageServer's underlying HTTP server.
+ *
+ * @param port
+ * The numeric port on which to start. A falsy value implies to
+ * select any available port.
+ * @param cb
+ * A callback function (of no arguments) which is invoked after
+ * startup.
+ */
+ start: function start(port, cb) {
+ if (this.started) {
+ this._log.warn("Warning: server already started on " + this.port);
+ return;
+ }
+ if (!port) {
+ port = -1;
+ }
+ this.port = port;
+
+ try {
+ this.server.start(this.port);
+ this.port = this.server.identity.primaryPort;
+ this.started = true;
+ if (cb) {
+ cb();
+ }
+ } catch (ex) {
+ _("==========================================");
+ _("Got exception starting Storage HTTP server on port " + this.port);
+ _("Error: " + Log.exceptionStr(ex));
+ _("Is there a process already listening on port " + this.port + "?");
+ _("==========================================");
+ do_throw(ex);
+ }
+ },
+
+ /**
+ * Start the server synchronously.
+ *
+ * @param port
+ * The numeric port on which to start. The default is to choose
+ * any available port.
+ */
+ startSynchronous: function startSynchronous(port=-1) {
+ let cb = Async.makeSpinningCallback();
+ this.start(port, cb);
+ cb.wait();
+ },
+
+ /**
+ * Stop the StorageServer's HTTP server.
+ *
+ * @param cb
+ * A callback function. Invoked after the server has been stopped.
+ *
+ */
+ stop: function stop(cb) {
+ if (!this.started) {
+ this._log.warn("StorageServer: Warning: server not running. Can't stop " +
+ "me now!");
+ return;
+ }
+
+ this.server.stop(cb);
+ this.started = false;
+ },
+
+ serverTime: function serverTime() {
+ return new_timestamp();
+ },
+
+ /**
+ * Create a new user, complete with an empty set of collections.
+ *
+ * @param username
+ * The username to use. An Error will be thrown if a user by that name
+ * already exists.
+ * @param password
+ * A password string.
+ *
+ * @return a user object, as would be returned by server.user(username).
+ */
+ registerUser: function registerUser(username, password) {
+ if (username in this.users) {
+ throw new Error("User already exists.");
+ }
+
+ if (!isFinite(parseInt(username))) {
+ throw new Error("Usernames must be numeric: " + username);
+ }
+
+ this._log.info("Registering new user with server: " + username);
+ this.users[username] = {
+ password: password,
+ collections: {},
+ quota: this.DEFAULT_QUOTA,
+ };
+ return this.user(username);
+ },
+
+ userExists: function userExists(username) {
+ return username in this.users;
+ },
+
+ getCollection: function getCollection(username, collection) {
+ return this.users[username].collections[collection];
+ },
+
+ _insertCollection: function _insertCollection(collections, collection, bsos) {
+ let coll = new StorageServerCollection(bsos, true);
+ coll.collectionHandler = coll.handler();
+ collections[collection] = coll;
+ return coll;
+ },
+
+ createCollection: function createCollection(username, collection, bsos) {
+ if (!(username in this.users)) {
+ throw new Error("Unknown user.");
+ }
+ let collections = this.users[username].collections;
+ if (collection in collections) {
+ throw new Error("Collection already exists.");
+ }
+ return this._insertCollection(collections, collection, bsos);
+ },
+
+ deleteCollection: function deleteCollection(username, collection) {
+ if (!(username in this.users)) {
+ throw new Error("Unknown user.");
+ }
+ delete this.users[username].collections[collection];
+ },
+
+ /**
+ * Accept a map like the following:
+ * {
+ * meta: {global: {version: 1, ...}},
+ * crypto: {"keys": {}, foo: {bar: 2}},
+ * bookmarks: {}
+ * }
+ * to cause collections and BSOs to be created.
+ * If a collection already exists, no error is raised.
+ * If a BSO already exists, it will be updated to the new contents.
+ */
+ createContents: function createContents(username, collections) {
+ if (!(username in this.users)) {
+ throw new Error("Unknown user.");
+ }
+ let userCollections = this.users[username].collections;
+ for (let [id, contents] of Object.entries(collections)) {
+ let coll = userCollections[id] ||
+ this._insertCollection(userCollections, id);
+ for (let [bsoID, payload] of Object.entries(contents)) {
+ coll.insert(bsoID, payload);
+ }
+ }
+ },
+
+ /**
+ * Insert a BSO in an existing collection.
+ */
+ insertBSO: function insertBSO(username, collection, bso) {
+ if (!(username in this.users)) {
+ throw new Error("Unknown user.");
+ }
+ let userCollections = this.users[username].collections;
+ if (!(collection in userCollections)) {
+ throw new Error("Unknown collection.");
+ }
+ userCollections[collection].insertBSO(bso);
+ return bso;
+ },
+
+ /**
+ * Delete all of the collections for the named user.
+ *
+ * @param username
+ * The name of the affected user.
+ */
+ deleteCollections: function deleteCollections(username) {
+ if (!(username in this.users)) {
+ throw new Error("Unknown user.");
+ }
+ let userCollections = this.users[username].collections;
+ for (let name in userCollections) {
+ let coll = userCollections[name];
+ this._log.trace("Bulk deleting " + name + " for " + username + "...");
+ coll.delete({});
+ }
+ this.users[username].collections = {};
+ },
+
+ getQuota: function getQuota(username) {
+ if (!(username in this.users)) {
+ throw new Error("Unknown user.");
+ }
+
+ return this.users[username].quota;
+ },
+
+ /**
+ * Obtain the newest timestamp of all collections for a user.
+ */
+ newestCollectionTimestamp: function newestCollectionTimestamp(username) {
+ let collections = this.users[username].collections;
+ let newest = 0;
+ for (let name in collections) {
+ let collection = collections[name];
+ if (collection.timestamp > newest) {
+ newest = collection.timestamp;
+ }
+ }
+
+ return newest;
+ },
+
+ /**
+ * Compute the object that is returned for an info/collections request.
+ */
+ infoCollections: function infoCollections(username) {
+ let responseObject = {};
+ let colls = this.users[username].collections;
+ for (let coll in colls) {
+ responseObject[coll] = colls[coll].timestamp;
+ }
+ this._log.trace("StorageServer: info/collections returning " +
+ JSON.stringify(responseObject));
+ return responseObject;
+ },
+
+ infoCounts: function infoCounts(username) {
+ let data = {};
+ let collections = this.users[username].collections;
+ for (let [k, v] of Object.entries(collections)) {
+ let count = v.count();
+ if (!count) {
+ continue;
+ }
+
+ data[k] = count;
+ }
+
+ return data;
+ },
+
+ infoUsage: function infoUsage(username) {
+ let data = {};
+ let collections = this.users[username].collections;
+ for (let [k, v] of Object.entries(collections)) {
+ data[k] = v.totalPayloadSize;
+ }
+
+ return data;
+ },
+
+ infoQuota: function infoQuota(username) {
+ let total = 0;
+ let usage = this.infoUsage(username);
+ for (let key in usage) {
+ let value = usage[key];
+ total += value;
+ }
+
+ return {
+ quota: this.getQuota(username),
+ usage: total
+ };
+ },
+
+ /**
+ * Simple accessor to allow collective binding and abbreviation of a bunch of
+ * methods. Yay!
+ * Use like this:
+ *
+ * let u = server.user("john");
+ * u.collection("bookmarks").bso("abcdefg").payload; // Etc.
+ *
+ * @return a proxy for the user data stored in this server.
+ */
+ user: function user(username) {
+ let collection = this.getCollection.bind(this, username);
+ let createCollection = this.createCollection.bind(this, username);
+ let createContents = this.createContents.bind(this, username);
+ let modified = function (collectionName) {
+ return collection(collectionName).timestamp;
+ }
+ let deleteCollections = this.deleteCollections.bind(this, username);
+ let quota = this.getQuota.bind(this, username);
+ return {
+ collection: collection,
+ createCollection: createCollection,
+ createContents: createContents,
+ deleteCollections: deleteCollections,
+ modified: modified,
+ quota: quota,
+ };
+ },
+
+ _pruneExpired: function _pruneExpired() {
+ let now = Date.now();
+
+ for (let username in this.users) {
+ let user = this.users[username];
+ for (let name in user.collections) {
+ let collection = user.collections[name];
+ for (let bso of collection.bsos()) {
+ // ttl === 0 is a special case, so we can't simply !ttl.
+ if (typeof(bso.ttl) != "number") {
+ continue;
+ }
+
+ let ttlDate = bso.modified + (bso.ttl * 1000);
+ if (ttlDate < now) {
+ this._log.info("Deleting BSO because TTL expired: " + bso.id);
+ bso.delete();
+ }
+ }
+ }
+ }
+ },
+
+ /*
+ * Regular expressions for splitting up Storage request paths.
+ * Storage URLs are of the form:
+ * /$apipath/$version/$userid/$further
+ * where $further is usually:
+ * storage/$collection/$bso
+ * or
+ * storage/$collection
+ * or
+ * info/$op
+ *
+ * We assume for the sake of simplicity that $apipath is empty.
+ *
+ * N.B., we don't follow any kind of username spec here, because as far as I
+ * can tell there isn't one. See Bug 689671. Instead we follow the Python
+ * server code.
+ *
+ * Path: [all, version, first, rest]
+ * Storage: [all, collection?, id?]
+ */
+ pathRE: /^\/([0-9]+(?:\.[0-9]+)?)(?:\/([0-9]+)\/([^\/]+)(?:\/(.+))?)?$/,
+ storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
+
+ defaultHeaders: {},
+
+ /**
+ * HTTP response utility.
+ */
+ respond: function respond(req, resp, code, status, body, headers, timestamp) {
+ this._log.info("Response: " + code + " " + status);
+ resp.setStatusLine(req.httpVersion, code, status);
+ if (!headers) {
+ headers = this.defaultHeaders;
+ }
+ for (let header in headers) {
+ let value = headers[header];
+ resp.setHeader(header, value, false);
+ }
+
+ if (timestamp) {
+ resp.setHeader("X-Timestamp", "" + timestamp, false);
+ }
+
+ if (body) {
+ resp.bodyOutputStream.write(body, body.length);
+ }
+ },
+
+ /**
+ * This is invoked by the HttpServer. `this` is bound to the StorageServer;
+ * `handler` is the HttpServer's handler.
+ *
+ * TODO: need to use the correct Storage API response codes and errors here.
+ */
+ handleDefault: function handleDefault(handler, req, resp) {
+ this.requestCount++;
+ let timestamp = new_timestamp();
+ try {
+ this._handleDefault(handler, req, resp, timestamp);
+ } catch (e) {
+ if (e instanceof HttpError) {
+ this.respond(req, resp, e.code, e.description, "", {}, timestamp);
+ } else {
+ this._log.warn("StorageServer: handleDefault caught an error", e);
+ throw e;
+ }
+ }
+ },
+
+ _handleDefault: function _handleDefault(handler, req, resp, timestamp) {
+ let path = req.path;
+ if (req.queryString.length) {
+ path += "?" + req.queryString;
+ }
+
+ this._log.debug("StorageServer: Handling request: " + req.method + " " +
+ path);
+
+ if (this.callback.onRequest) {
+ this.callback.onRequest(req);
+ }
+
+ // Prune expired records for all users at top of request. This is the
+ // easiest way to process TTLs since all requests go through here.
+ this._pruneExpired();
+
+ req.timestamp = timestamp;
+ resp.setHeader("X-Timestamp", "" + timestamp, false);
+
+ let parts = this.pathRE.exec(req.path);
+ if (!parts) {
+ this._log.debug("StorageServer: Unexpected request: bad URL " + req.path);
+ throw HTTP_404;
+ }
+
+ let [all, version, userPath, first, rest] = parts;
+ if (version != STORAGE_API_VERSION) {
+ this._log.debug("StorageServer: Unknown version.");
+ throw HTTP_404;
+ }
+
+ let username;
+
+ // By default, the server requires users to be authenticated. When a
+ // request arrives, the user must have been previously configured and
+ // the request must have authentication. In "allow all users" mode, we
+ // take the username from the URL, create the user on the fly, and don't
+ // perform any authentication.
+ if (!this.allowAllUsers) {
+ // Enforce authentication.
+ if (!req.hasHeader("authorization")) {
+ this.respond(req, resp, 401, "Authorization Required", "{}", {
+ "WWW-Authenticate": 'Basic realm="secret"'
+ });
+ return;
+ }
+
+ let ensureUserExists = function ensureUserExists(username) {
+ if (this.userExists(username)) {
+ return;
+ }
+
+ this._log.info("StorageServer: Unknown user: " + username);
+ throw HTTP_401;
+ }.bind(this);
+
+ let auth = req.getHeader("authorization");
+ this._log.debug("Authorization: " + auth);
+
+ if (auth.indexOf("Basic ") == 0) {
+ let decoded = CommonUtils.safeAtoB(auth.substr(6));
+ this._log.debug("Decoded Basic Auth: " + decoded);
+ let [user, password] = decoded.split(":", 2);
+
+ if (!password) {
+ this._log.debug("Malformed HTTP Basic Authorization header: " + auth);
+ throw HTTP_400;
+ }
+
+ this._log.debug("Got HTTP Basic auth for user: " + user);
+ ensureUserExists(user);
+ username = user;
+
+ if (this.users[user].password != password) {
+ this._log.debug("StorageServer: Provided password is not correct.");
+ throw HTTP_401;
+ }
+ // TODO support token auth.
+ } else {
+ this._log.debug("Unsupported HTTP authorization type: " + auth);
+ throw HTTP_500;
+ }
+ // All users mode.
+ } else {
+ // Auto create user with dummy password.
+ if (!this.userExists(userPath)) {
+ this.registerUser(userPath, "DUMMY-PASSWORD-*&%#");
+ }
+
+ username = userPath;
+ }
+
+ // Hand off to the appropriate handler for this path component.
+ if (first in this.toplevelHandlers) {
+ let handler = this.toplevelHandlers[first];
+ try {
+ return handler.call(this, handler, req, resp, version, username, rest);
+ } catch (ex) {
+ this._log.warn("Got exception during request", ex);
+ throw ex;
+ }
+ }
+ this._log.debug("StorageServer: Unknown top-level " + first);
+ throw HTTP_404;
+ },
+
+ /**
+ * Collection of the handler methods we use for top-level path components.
+ */
+ toplevelHandlers: {
+ "storage": function handleStorage(handler, req, resp, version, username,
+ rest) {
+ let respond = this.respond.bind(this, req, resp);
+ if (!rest || !rest.length) {
+ this._log.debug("StorageServer: top-level storage " +
+ req.method + " request.");
+
+ if (req.method != "DELETE") {
+ respond(405, "Method Not Allowed", null, {"Allow": "DELETE"});
+ return;
+ }
+
+ this.user(username).deleteCollections();
+
+ respond(204, "No Content");
+ return;
+ }
+
+ let match = this.storageRE.exec(rest);
+ if (!match) {
+ this._log.warn("StorageServer: Unknown storage operation " + rest);
+ throw HTTP_404;
+ }
+ let [all, collection, bsoID] = match;
+ let coll = this.getCollection(username, collection);
+ let collectionExisted = !!coll;
+
+ switch (req.method) {
+ case "GET":
+ // Tried to GET on a collection that doesn't exist.
+ if (!coll) {
+ respond(404, "Not Found");
+ return;
+ }
+
+ // No BSO URL parameter goes to collection handler.
+ if (!bsoID) {
+ return coll.collectionHandler(req, resp);
+ }
+
+ // Handle non-existent BSO.
+ let bso = coll.bso(bsoID);
+ if (!bso) {
+ respond(404, "Not Found");
+ return;
+ }
+
+ // Proxy to BSO handler.
+ return bso.getHandler(req, resp);
+
+ case "DELETE":
+ // Collection doesn't exist.
+ if (!coll) {
+ respond(404, "Not Found");
+ return;
+ }
+
+ // Deleting a specific BSO.
+ if (bsoID) {
+ let bso = coll.bso(bsoID);
+
+ // BSO does not exist on the server. Nothing to do.
+ if (!bso) {
+ respond(404, "Not Found");
+ return;
+ }
+
+ if (req.hasHeader("x-if-unmodified-since")) {
+ let modified = parseInt(req.getHeader("x-if-unmodified-since"));
+ CommonUtils.ensureMillisecondsTimestamp(modified);
+
+ if (bso.modified > modified) {
+ respond(412, "Precondition Failed");
+ return;
+ }
+ }
+
+ bso.delete();
+ coll.timestamp = req.timestamp;
+ this.callback.onItemDeleted(username, collection, bsoID);
+ respond(204, "No Content");
+ return;
+ }
+
+ // Proxy to collection handler.
+ coll.collectionHandler(req, resp);
+
+ // Spot if this is a DELETE for some IDs, and don't blow away the
+ // whole collection!
+ //
+ // We already handled deleting the BSOs by invoking the deleted
+ // collection's handler. However, in the case of
+ //
+ // DELETE storage/foobar
+ //
+ // we also need to remove foobar from the collections map. This
+ // clause tries to differentiate the above request from
+ //
+ // DELETE storage/foobar?ids=foo,baz
+ //
+ // and do the right thing.
+ // TODO: less hacky method.
+ if (-1 == req.queryString.indexOf("ids=")) {
+ // When you delete the entire collection, we drop it.
+ this._log.debug("Deleting entire collection.");
+ delete this.users[username].collections[collection];
+ this.callback.onCollectionDeleted(username, collection);
+ }
+
+ // Notify of item deletion.
+ let deleted = resp.deleted || [];
+ for (let i = 0; i < deleted.length; ++i) {
+ this.callback.onItemDeleted(username, collection, deleted[i]);
+ }
+ return;
+
+ case "POST":
+ case "PUT":
+ // Auto-create collection if it doesn't exist.
+ if (!coll) {
+ coll = this.createCollection(username, collection);
+ }
+
+ try {
+ if (bsoID) {
+ let bso = coll.bso(bsoID);
+ if (!bso) {
+ this._log.trace("StorageServer: creating BSO " + collection +
+ "/" + bsoID);
+ try {
+ bso = coll.insert(bsoID);
+ } catch (ex) {
+ return sendMozSvcError(req, resp, "8");
+ }
+ }
+
+ bso.putHandler(req, resp);
+
+ coll.timestamp = req.timestamp;
+ return resp;
+ }
+
+ return coll.collectionHandler(req, resp);
+ } catch (ex) {
+ if (ex instanceof HttpError) {
+ if (!collectionExisted) {
+ this.deleteCollection(username, collection);
+ }
+ }
+
+ throw ex;
+ }
+
+ default:
+ throw new Error("Request method " + req.method + " not implemented.");
+ }
+ },
+
+ "info": function handleInfo(handler, req, resp, version, username, rest) {
+ switch (rest) {
+ case "collections":
+ return this.handleInfoCollections(req, resp, username);
+
+ case "collection_counts":
+ return this.handleInfoCounts(req, resp, username);
+
+ case "collection_usage":
+ return this.handleInfoUsage(req, resp, username);
+
+ case "quota":
+ return this.handleInfoQuota(req, resp, username);
+
+ default:
+ this._log.warn("StorageServer: Unknown info operation " + rest);
+ throw HTTP_404;
+ }
+ }
+ },
+
+ handleInfoConditional: function handleInfoConditional(request, response,
+ user) {
+ if (!request.hasHeader("x-if-modified-since")) {
+ return false;
+ }
+
+ let requestModified = request.getHeader("x-if-modified-since");
+ requestModified = parseInt(requestModified, 10);
+
+ let serverModified = this.newestCollectionTimestamp(user);
+
+ this._log.info("Server mtime: " + serverModified + "; Client modified: " +
+ requestModified);
+ if (serverModified > requestModified) {
+ return false;
+ }
+
+ this.respond(request, response, 304, "Not Modified", null, {
+ "X-Last-Modified": "" + serverModified
+ });
+
+ return true;
+ },
+
+ handleInfoCollections: function handleInfoCollections(request, response,
+ user) {
+ if (this.handleInfoConditional(request, response, user)) {
+ return;
+ }
+
+ let info = this.infoCollections(user);
+ let body = JSON.stringify(info);
+ this.respond(request, response, 200, "OK", body, {
+ "Content-Type": "application/json",
+ "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
+ });
+ },
+
+ handleInfoCounts: function handleInfoCounts(request, response, user) {
+ if (this.handleInfoConditional(request, response, user)) {
+ return;
+ }
+
+ let counts = this.infoCounts(user);
+ let body = JSON.stringify(counts);
+
+ this.respond(request, response, 200, "OK", body, {
+ "Content-Type": "application/json",
+ "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
+ });
+ },
+
+ handleInfoUsage: function handleInfoUsage(request, response, user) {
+ if (this.handleInfoConditional(request, response, user)) {
+ return;
+ }
+
+ let body = JSON.stringify(this.infoUsage(user));
+ this.respond(request, response, 200, "OK", body, {
+ "Content-Type": "application/json",
+ "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
+ });
+ },
+
+ handleInfoQuota: function handleInfoQuota(request, response, user) {
+ if (this.handleInfoConditional(request, response, user)) {
+ return;
+ }
+
+ let body = JSON.stringify(this.infoQuota(user));
+ this.respond(request, response, 200, "OK", body, {
+ "Content-Type": "application/json",
+ "X-Last-Modified": "" + this.newestCollectionTimestamp(user),
+ });
+ },
+};
+
+/**
+ * Helper to create a storage server for a set of users.
+ *
+ * Each user is specified by a map of username to password.
+ */
+this.storageServerForUsers =
+ function storageServerForUsers(users, contents, callback) {
+ let server = new StorageServer(callback);
+ for (let [user, pass] of Object.entries(users)) {
+ server.registerUser(user, pass);
+ server.createContents(user, contents);
+ }
+ server.start();
+ return server;
+}
diff --git a/services/common/modules-testing/utils.js b/services/common/modules-testing/utils.js
new file mode 100644
index 000000000..e909afc48
--- /dev/null
+++ b/services/common/modules-testing/utils.js
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+ "TestingUtils",
+];
+
+this.TestingUtils = {
+ /**
+ * Perform a deep copy of an Array or Object.
+ */
+ deepCopy: function deepCopy(thing, noSort) {
+ if (typeof(thing) != "object" || thing == null) {
+ return thing;
+ }
+
+ if (Array.isArray(thing)) {
+ let ret = [];
+ for (let element of thing) {
+ ret.push(this.deepCopy(element, noSort));
+ }
+
+ return ret;
+ }
+
+ let ret = {};
+ let props = Object.keys(thing);
+
+ if (!noSort) {
+ props = props.sort();
+ }
+
+ for (let prop of props) {
+ ret[prop] = this.deepCopy(thing[prop], noSort);
+ }
+
+ return ret;
+ },
+};