Backed out changeset 1686b901daa4 (bug 1237983) for bustage
authorCarsten "Tomcat" Book <cbook@mozilla.com>
Thu, 28 Jan 2016 09:36:00 +0100
changeset 326712 7581aa0f335f6787457f1fd3115ae413060cdd33
parent 326711 d359f149bf63826eefba0276ddb6050141af158c
child 326713 2e91c47d5ced479b43d9d46ee8f2a5f43adec60d
push id10166
push users.kaspari@gmail.com
push dateThu, 28 Jan 2016 12:16:34 +0000
bugs1237983
milestone47.0a1
backs out1686b901daa4eb42e6bbae2e9ba23c9754c584da
Backed out changeset 1686b901daa4 (bug 1237983) for bustage
services/common/bagheeraclient.js
services/common/modules-testing/bagheeraserver.js
services/common/moz.build
services/common/tests/mach_commands.py
services/common/tests/run_bagheera_server.js
services/common/tests/unit/test_bagheera_client.js
services/common/tests/unit/test_bagheera_server.js
services/common/tests/unit/test_load_modules.js
services/common/tests/unit/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/services/common/bagheeraclient.js
@@ -0,0 +1,278 @@
+/* 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 a client API for the Bagheera data storage service.
+ *
+ * Information about Bagheera is available at
+ * https://github.com/mozilla-metrics/bagheera
+ */
+
+#ifndef MERGED_COMPARTMENT
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = [
+  "BagheeraClient",
+  "BagheeraClientRequestResult",
+];
+
+var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+#endif
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/rest.js");
+Cu.import("resource://services-common/utils.js");
+
+/**
+ * Represents the result of a Bagheera request.
+ */
+this.BagheeraClientRequestResult = function BagheeraClientRequestResult() {
+  this.transportSuccess = false;
+  this.serverSuccess = false;
+  this.request = null;
+};
+
+Object.freeze(BagheeraClientRequestResult.prototype);
+
+
+/**
+ * Wrapper around RESTRequest so logging is sane.
+ */
+function BagheeraRequest(uri) {
+  RESTRequest.call(this, uri);
+
+  this._log = Log.repository.getLogger("Services.BagheeraClient");
+  this._log.level = Log.Level.Debug;
+}
+
+BagheeraRequest.prototype = Object.freeze({
+  __proto__: RESTRequest.prototype,
+});
+
+
+/**
+ * Create a new Bagheera client instance.
+ *
+ * Each client is associated with a specific Bagheera HTTP URI endpoint.
+ *
+ * @param baseURI
+ *        (string) The base URI of the Bagheera HTTP endpoint.
+ */
+this.BagheeraClient = function BagheeraClient(baseURI) {
+  if (!baseURI) {
+    throw new Error("baseURI argument must be defined.");
+  }
+
+  this._log = Log.repository.getLogger("Services.BagheeraClient");
+  this._log.level = Log.Level.Debug;
+
+  this.baseURI = baseURI;
+
+  if (!baseURI.endsWith("/")) {
+    this.baseURI += "/";
+  }
+};
+
+BagheeraClient.prototype = Object.freeze({
+  /**
+   * Channel load flags for all requests.
+   *
+   * Caching is not applicable, so we bypass and disable it. We also
+   * ignore any cookies that may be present for the domain because
+   * Bagheera does not utilize cookies and the release of cookies may
+   * inadvertantly constitute unncessary information disclosure.
+   */
+  _loadFlags: Ci.nsIRequest.LOAD_BYPASS_CACHE |
+              Ci.nsIRequest.INHIBIT_CACHING |
+              Ci.nsIRequest.LOAD_ANONYMOUS,
+
+  DEFAULT_TIMEOUT_MSEC: 5 * 60 * 1000, // 5 minutes.
+
+  _RE_URI_IDENTIFIER: /^[a-zA-Z0-9_-]+$/,
+
+  /**
+   * Upload a JSON payload to the server.
+   *
+   * The return value is a Promise which will be resolved with a
+   * BagheeraClientRequestResult when the request has finished.
+   *
+   * @param namespace
+   *        (string) The namespace to post this data to.
+   * @param id
+   *        (string) The ID of the document being uploaded. This is typically
+   *        a UUID in hex form.
+   * @param payload
+   *        (string|object) Data to upload. Can be specified as a string (which
+   *        is assumed to be JSON) or an object. If an object, it will be fed into
+   *        JSON.stringify() for serialization.
+   * @param options
+   *        (object) Extra options to control behavior. Recognized properties:
+   *
+   *          deleteIDs -- (array) Old document IDs to delete as part of
+   *            upload. If not specified, no old documents will be deleted as
+   *            part of upload. The array values are typically UUIDs in hex
+   *            form.
+   *
+   *          telemetryCompressed -- (string) Telemetry histogram to record
+   *            compressed size of payload under. If not defined, no telemetry
+   *            data for the compressed size will be recorded.
+   *
+   * @return Promise<BagheeraClientRequestResult>
+   */
+  uploadJSON: function uploadJSON(namespace, id, payload, options={}) {
+    if (!namespace) {
+      throw new Error("namespace argument must be defined.");
+    }
+
+    if (!id) {
+      throw new Error("id argument must be defined.");
+    }
+
+    if (!payload) {
+      throw new Error("payload argument must be defined.");
+    }
+
+    if (options && typeof(options) != "object") {
+      throw new Error("Unexpected type for options argument. Expected object. " +
+                      "Got: " + typeof(options));
+    }
+
+    let uri = this._submitURI(namespace, id);
+
+    let data = payload;
+
+    if (typeof(payload) == "object") {
+      data = JSON.stringify(payload);
+    }
+
+    if (typeof(data) != "string") {
+      throw new Error("Unknown type for payload: " + typeof(data));
+    }
+
+    this._log.info("Uploading data to " + uri);
+
+    let request = new BagheeraRequest(uri);
+    request.loadFlags = this._loadFlags;
+    request.timeout = this.DEFAULT_TIMEOUT_MSEC;
+
+    // Since API changed, throw on old API usage.
+    if ("deleteID" in options) {
+      throw new Error("API has changed, use (array) deleteIDs instead");
+    }
+
+    let deleteIDs;
+    if (options.deleteIDs && options.deleteIDs.length > 0) {
+      deleteIDs = options.deleteIDs;
+      this._log.debug("Will delete " + deleteIDs.join(", "));
+      request.setHeader("X-Obsolete-Document", deleteIDs.join(","));
+    }
+
+    let deferred = Promise.defer();
+
+    // The string converter service used by CommonUtils.convertString()
+    // silently throws away high bytes. We need to convert the string to
+    // consist of only low bytes first.
+    data = CommonUtils.encodeUTF8(data);
+    data = CommonUtils.convertString(data, "uncompressed", "deflate");
+    if (options.telemetryCompressed) {
+      try {
+        let h = Services.telemetry.getHistogramById(options.telemetryCompressed);
+        h.add(data.length);
+      } catch (ex) {
+        this._log.warn("Unable to record telemetry for compressed payload size", ex);
+      }
+    }
+
+    // TODO proper header per bug 807134.
+    request.setHeader("Content-Type", "application/json+zlib; charset=utf-8");
+
+    this._log.info("Request body length: " + data.length);
+
+    let result = new BagheeraClientRequestResult();
+    result.namespace = namespace;
+    result.id = id;
+    result.deleteIDs = deleteIDs ? deleteIDs.slice(0) : null;
+
+    request.onComplete = this._onComplete.bind(this, request, deferred, result);
+    request.post(data);
+
+    return deferred.promise;
+  },
+
+  /**
+   * Delete the specified document.
+   *
+   * @param namespace
+   *        (string) Namespace from which to delete the document.
+   * @param id
+   *        (string) ID of document to delete.
+   *
+   * @return Promise<BagheeraClientRequestResult>
+   */
+  deleteDocument: function deleteDocument(namespace, id) {
+    let uri = this._submitURI(namespace, id);
+
+    let request = new BagheeraRequest(uri);
+    request.loadFlags = this._loadFlags;
+    request.timeout = this.DEFAULT_TIMEOUT_MSEC;
+
+    let result = new BagheeraClientRequestResult();
+    result.namespace = namespace;
+    result.id = id;
+    let deferred = Promise.defer();
+
+    request.onComplete = this._onComplete.bind(this, request, deferred, result);
+    request.delete();
+
+    return deferred.promise;
+  },
+
+  _submitURI: function _submitURI(namespace, id) {
+    if (!this._RE_URI_IDENTIFIER.test(namespace)) {
+      throw new Error("Illegal namespace name. Must be alphanumeric + [_-]: " +
+                      namespace);
+    }
+
+    if (!this._RE_URI_IDENTIFIER.test(id)) {
+      throw new Error("Illegal id value. Must be alphanumeric + [_-]: " + id);
+    }
+
+    return this.baseURI + "1.0/submit/" + namespace + "/" + id;
+  },
+
+  _onComplete: function _onComplete(request, deferred, result, error) {
+    result.request = request;
+
+    if (error) {
+      this._log.info("Transport failure on request", error);
+      result.transportSuccess = false;
+      deferred.resolve(result);
+      return;
+    }
+
+    result.transportSuccess = true;
+
+    let response = request.response;
+
+    switch (response.status) {
+      case 200:
+      case 201:
+        result.serverSuccess = true;
+        break;
+
+      default:
+        result.serverSuccess = false;
+
+        this._log.info("Received unexpected status code: " + response.status);
+        this._log.debug("Response body: " + response.body);
+    }
+
+    deferred.resolve(result);
+  },
+});
+
new file mode 100644
--- /dev/null
+++ b/services/common/modules-testing/bagheeraserver.js
@@ -0,0 +1,296 @@
+/* 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";
+
+var {utils: Cu} = Components;
+
+this.EXPORTED_SYMBOLS = ["BagheeraServer"];
+
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://testing-common/httpd.js");
+
+/**
+ * This is an implementation of the Bagheera server.
+ *
+ * The purpose of the server is to facilitate testing of the Bagheera
+ * client and the Firefox Health report. It is *not* meant to be a
+ * production grade server.
+ *
+ * The Bagheera server is essentially a glorified document store.
+ */
+this.BagheeraServer = function BagheeraServer() {
+  this._log = Log.repository.getLogger("metrics.BagheeraServer");
+
+  this.server = new HttpServer();
+  this.namespaces = {};
+
+  this.allowAllNamespaces = false;
+}
+
+BagheeraServer.prototype = {
+  /**
+   * Whether this server has a namespace defined.
+   *
+   * @param ns
+   *        (string) Namepsace whose existence to query for.
+   * @return bool
+   */
+  hasNamespace: function hasNamespace(ns) {
+    return ns in this.namespaces;
+  },
+
+  /**
+   * Whether this server has an ID in a particular namespace.
+   *
+   * @param ns
+   *        (string) Namespace to look for item in.
+   * @param id
+   *        (string) ID of object to look for.
+   * @return bool
+   */
+  hasDocument: function hasDocument(ns, id) {
+    let namespace = this.namespaces[ns];
+
+    if (!namespace) {
+      return false;
+    }
+
+    return id in namespace;
+  },
+
+  /**
+   * Obtain a document from the server.
+   *
+   * @param ns
+   *        (string) Namespace to retrieve document from.
+   * @param id
+   *        (string) ID of document to retrieve.
+   *
+   * @return string The content of the document or null if the document
+   *                does not exist.
+   */
+  getDocument: function getDocument(ns, id) {
+    let namespace = this.namespaces[ns];
+
+    if (!namespace) {
+      return null;
+    }
+
+    return namespace[id];
+  },
+
+  /**
+   * Set the contents of a document in the server.
+   *
+   * @param ns
+   *        (string) Namespace to add document to.
+   * @param id
+   *        (string) ID of document being added.
+   * @param payload
+   *        (string) The content of the document.
+   */
+  setDocument: function setDocument(ns, id, payload) {
+    let namespace = this.namespaces[ns];
+
+    if (!namespace) {
+      if (!this.allowAllNamespaces) {
+        throw new Error("Namespace does not exist: " + ns);
+      }
+
+      this.createNamespace(ns);
+      namespace = this.namespaces[ns];
+    }
+
+    namespace[id] = payload;
+  },
+
+  /**
+   * Create a namespace in the server.
+   *
+   * The namespace will initially be empty.
+   *
+   * @param ns
+   *        (string) The name of the namespace to create.
+   */
+  createNamespace: function createNamespace(ns) {
+    if (ns in this.namespaces) {
+      throw new Error("Namespace already exists: " + ns);
+    }
+
+    this.namespaces[ns] = {};
+  },
+
+  start: function start(port=-1) {
+    this.server.registerPrefixHandler("/", this._handleRequest.bind(this));
+    this.server.start(port);
+    let i = this.server.identity;
+
+    this.serverURI = i.primaryScheme + "://" + i.primaryHost + ":" +
+                     i.primaryPort + "/";
+    this.port = i.primaryPort;
+  },
+
+  stop: function stop(cb) {
+    let handler = {onStopped: cb};
+
+    this.server.stop(handler);
+  },
+
+  /**
+   * Our root path handler.
+   */
+  _handleRequest: function _handleRequest(request, response) {
+    let path = request.path;
+    this._log.info("Received request: " + request.method + " " + path + " " +
+                   "HTTP/" + request.httpVersion);
+
+    try {
+      if (path.startsWith("/1.0/submit/")) {
+        return this._handleV1Submit(request, response,
+                                    path.substr("/1.0/submit/".length));
+      } else {
+        throw HTTP_404;
+      }
+    } catch (ex) {
+      if (ex instanceof HttpError) {
+        this._log.info("HttpError thrown: " + ex.code + " " + ex.description);
+      } else {
+        this._log.warn("Exception processing request", ex);
+      }
+
+      throw ex;
+    }
+  },
+
+  /**
+   * Handles requests to /submit/*.
+   */
+  _handleV1Submit: function _handleV1Submit(request, response, rest) {
+    if (!rest.length) {
+      throw HTTP_404;
+    }
+
+    let namespace;
+    let index = rest.indexOf("/");
+    if (index == -1) {
+      namespace = rest;
+      rest = "";
+    } else {
+      namespace = rest.substr(0, index);
+      rest = rest.substr(index + 1);
+    }
+
+    this._handleNamespaceSubmit(namespace, rest, request, response);
+  },
+
+  _handleNamespaceSubmit: function _handleNamespaceSubmit(namespace, rest,
+                                                          request, response) {
+    if (!this.hasNamespace(namespace)) {
+      if (!this.allowAllNamespaces) {
+        this._log.info("Request to unknown namespace: " + namespace);
+        throw HTTP_404;
+      }
+
+      this.createNamespace(namespace);
+    }
+
+    if (!rest) {
+      this._log.info("No ID defined.");
+      throw HTTP_404;
+    }
+
+    let id = rest;
+    if (id.includes("/")) {
+      this._log.info("URI has too many components.");
+      throw HTTP_404;
+    }
+
+    if (request.method == "POST") {
+      return this._handleNamespaceSubmitPost(namespace, id, request, response);
+    }
+
+    if (request.method == "DELETE") {
+      return this._handleNamespaceSubmitDelete(namespace, id, request, response);
+    }
+
+    this._log.info("Unsupported HTTP method on namespace handler: " +
+                   request.method);
+    response.setHeader("Allow", "POST,DELETE");
+    throw HTTP_405;
+  },
+
+  _handleNamespaceSubmitPost:
+    function _handleNamespaceSubmitPost(namespace, id, request, response) {
+
+    this._log.info("Handling data upload for " + namespace + ":" + id);
+
+    let requestBody = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+    this._log.info("Raw body length: " + requestBody.length);
+
+    if (!request.hasHeader("Content-Type")) {
+      this._log.info("Request does not have Content-Type header.");
+      throw HTTP_400;
+    }
+
+    const ALLOWED_TYPES = [
+      // TODO proper content types from bug 807134.
+      "application/json; charset=utf-8",
+      "application/json+zlib; charset=utf-8",
+    ];
+
+    let ct = request.getHeader("Content-Type");
+    if (ALLOWED_TYPES.indexOf(ct) == -1) {
+      this._log.info("Unknown media type: " + ct);
+      // Should generate proper HTTP response headers for this error.
+      throw HTTP_415;
+    }
+
+    if (ct.startsWith("application/json+zlib")) {
+      this._log.debug("Uncompressing entity body with deflate.");
+      requestBody = CommonUtils.convertString(requestBody, "deflate",
+                                              "uncompressed");
+    }
+
+    requestBody = CommonUtils.decodeUTF8(requestBody);
+
+    this._log.debug("HTTP request body: " + requestBody);
+
+    let doc;
+    try {
+      doc = JSON.parse(requestBody);
+    } catch(ex) {
+      this._log.info("JSON parse error.");
+      throw HTTP_400;
+    }
+
+    this.namespaces[namespace][id] = doc;
+
+    if (request.hasHeader("X-Obsolete-Document")) {
+      let obsolete = request.getHeader("X-Obsolete-Document");
+      this._log.info("Deleting from X-Obsolete-Document header: " + obsolete);
+      for (let obsolete_id of obsolete.split(",")) {
+        delete this.namespaces[namespace][obsolete_id];
+      }
+    }
+
+    response.setStatusLine(request.httpVersion, 201, "Created");
+    response.setHeader("Content-Type", "text/plain");
+
+    let body = id;
+    response.bodyOutputStream.write(body, body.length);
+  },
+
+  _handleNamespaceSubmitDelete:
+    function _handleNamespaceSubmitDelete(namespace, id, request, response) {
+
+    delete this.namespaces[namespace][id];
+
+    let body = id;
+    response.bodyOutputStream.write(body, body.length);
+  },
+};
+
+Object.freeze(BagheeraServer.prototype);
--- a/services/common/moz.build
+++ b/services/common/moz.build
@@ -26,16 +26,24 @@ if CONFIG['MOZ_WIDGET_TOOLKIT'] != 'andr
         'hawkrequest.js',
         'tokenserverclient.js',
     ]
 
     TESTING_JS_MODULES.services.common += [
         'modules-testing/storageserver.js',
     ]
 
+    if CONFIG['MOZ_SERVICES_HEALTHREPORT']:
+        EXTRA_PP_JS_MODULES['services-common'] += [
+            'bagheeraclient.js',
+        ]
+        TESTING_JS_MODULES.services.common += [
+            'modules-testing/bagheeraserver.js',
+        ]
+
 EXTRA_PP_JS_MODULES['services-common'] += [
     'async.js',
     'observers.js',
     'rest.js',
 ]
 
 TESTING_JS_MODULES.services.common += [
     'modules-testing/logging.js',
--- a/services/common/tests/mach_commands.py
+++ b/services/common/tests/mach_commands.py
@@ -29,16 +29,17 @@ from tempfile import mkdtemp
 
 
 DEFAULT_PORT = 8080
 DEFAULT_HOSTNAME = 'localhost'
 
 SRCDIR = mozpath.abspath(mozpath.dirname(__file__))
 
 STORAGE_SERVER_SCRIPT = mozpath.join(SRCDIR, 'run_storage_server.js')
+BAGHEERA_SERVER_SCRIPT = mozpath.join(SRCDIR, 'run_bagheera_server.js')
 
 def SyncStorageCommand(func):
     """Decorator that adds shared command arguments to services commands."""
 
     port = CommandArgument('--port', metavar='PORT', type=int,
                            default=DEFAULT_PORT, help='Port to run server on.')
     func = port(func)
 
@@ -104,8 +105,13 @@ class SyncTestCommands(MachCommandBase):
             print 'Removing profile directory %s' % profile_dir
             rmtree(profile_dir)
 
     @Command('storage-server', category='services',
              description='Run a storage server.')
     @SyncStorageCommand
     def run_storage_server(self, port=DEFAULT_PORT, address=DEFAULT_HOSTNAME):
         exit(self.run_server(STORAGE_SERVER_SCRIPT, address, port))
+
+    @Command('bagheera-server', category='services',
+             description='Run a bagheera server.')
+    def run_bagheera_server(self, port=DEFAULT_PORT, address=DEFAULT_HOSTNAME):
+        exit(self.run_server(BAGHEERA_SERVER_SCRIPT, address, port))
new file mode 100644
--- /dev/null
+++ b/services/common/tests/run_bagheera_server.js
@@ -0,0 +1,26 @@
+/* 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 runs a stub Bagheera server.
+ *
+ * It is meant to be executed with an xpcshell.
+ *
+ * The Makefile in this directory contains a target to run it:
+ *
+ *   $ make bagheera-server
+ */
+
+Cu.import("resource://testing-common/services/common/bagheeraserver.js");
+
+initTestLogging();
+
+var server = new BagheeraServer();
+server.allowAllNamespaces = true;
+server.start(SERVER_PORT);
+_("Bagheera server started on port " + SERVER_PORT);
+
+// Launch the thread manager.
+_do_main();
+
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_bagheera_client.js
@@ -0,0 +1,161 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-common/bagheeraclient.js");
+Cu.import("resource://services-common/rest.js");
+Cu.import("resource://testing-common/services/common/bagheeraserver.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+function getClientAndServer() {
+  let server = new BagheeraServer();
+  server.start();
+
+  let client = new BagheeraClient(server.serverURI);
+
+  return [client, server];
+}
+
+function run_test() {
+  initTestLogging("Trace");
+  run_next_test();
+}
+
+add_test(function test_constructor() {
+  let client = new BagheeraClient("http://localhost:1234/");
+
+  run_next_test();
+});
+
+add_test(function test_post_json_transport_failure() {
+  let client = new BagheeraClient("http://localhost:1234/");
+
+  client.uploadJSON("foo", "bar", {}).then(function onResult(result) {
+    do_check_false(result.transportSuccess);
+
+    run_next_test();
+  });
+});
+
+add_test(function test_post_json_simple() {
+  let [client, server] = getClientAndServer();
+
+  server.createNamespace("foo");
+  let promise = client.uploadJSON("foo", "bar", {foo: "bar", biz: "baz"});
+
+  promise.then(function onSuccess(result) {
+    do_check_true(result instanceof BagheeraClientRequestResult);
+    do_check_true(result.request instanceof RESTRequest);
+    do_check_true(result.transportSuccess);
+    do_check_true(result.serverSuccess);
+
+    server.stop(run_next_test);
+  }, do_check_null);
+});
+
+add_test(function test_post_json_bad_data() {
+  let [client, server] = getClientAndServer();
+
+  server.createNamespace("foo");
+
+  client.uploadJSON("foo", "bar", "{this is invalid json}").then(
+    function onResult(result) {
+    do_check_true(result.transportSuccess);
+    do_check_false(result.serverSuccess);
+
+    server.stop(run_next_test);
+  });
+});
+
+add_task(function* test_unicode_payload() {
+  let [client, server] = getClientAndServer();
+  server.createNamespace("foo");
+
+  const EXPECTED = "πόλλ' οἶδ' ἀλώπηξ, ἀλλ' ἐχῖνος ἓν μέγα";
+
+  let result = yield client.uploadJSON("foo", "bar", {test: EXPECTED});
+  Assert.ok(result.transportSuccess);
+  Assert.ok(result.serverSuccess);
+
+  let p = server.getDocument("foo", "bar");
+  Assert.equal(p.test, EXPECTED);
+
+  result = yield client.uploadJSON("foo", "baz", JSON.stringify({test: EXPECTED}));
+  Assert.ok(result.transportSuccess);
+  Assert.ok(result.serverSuccess);
+  p = server.getDocument("foo", "baz");
+  Assert.equal(p.test, EXPECTED);
+
+  let deferred = Promise.defer();
+  server.stop(() => deferred.resolve());
+  yield deferred.promise;
+});
+
+add_task(function test_post_delete_multiple_obsolete_documents () {
+  let [client, server] = getClientAndServer();
+  let namespace = "foo";
+  let documents = [
+    [namespace, "one", "{v:1}"],
+    [namespace, "two", "{v:2}"],
+    [namespace, "three", "{v:3}"],
+    [namespace, "four", "{v:4}"],
+  ];
+
+  try {
+    // create initial documents
+    server.createNamespace(namespace);
+    for (let [ns, id, payload] of documents) {
+      server.setDocument(ns, id, payload);
+      do_check_true(server.hasDocument(ns, id));
+    }
+
+    // Test uploading with deleting some documents.
+    let deleteIDs = [0, 1].map((no) => { return documents[no][1]; });
+    let result = yield client.uploadJSON(namespace, "new-1", {foo: "bar"}, {deleteIDs: deleteIDs});
+    do_check_true(result.transportSuccess);
+    do_check_true(result.serverSuccess);
+    do_check_true(server.hasDocument(namespace, "new-1"));
+    for (let id of deleteIDs) {
+      do_check_false(server.hasDocument(namespace, id));
+    }
+    // Check if the documents that were not staged for deletion are still there.
+    for (let [,id,] of documents) {
+      if (deleteIDs.indexOf(id) == -1) {
+        do_check_true(server.hasDocument(namespace, id));
+      }
+    }
+
+    // Test upload without deleting documents.
+    let ids = Object.keys(server.namespaces[namespace]);
+    result = yield client.uploadJSON(namespace, "new-2", {foo: "bar"});
+    do_check_true(result.transportSuccess);
+    do_check_true(result.serverSuccess);
+    do_check_true(server.hasDocument(namespace, "new-2"));
+    // Check to see if all the original documents are still there.
+    for (let id of ids) {
+      do_check_true(deleteIDs.indexOf(id) !== -1 || server.hasDocument(namespace, id));
+    }
+  } finally {
+    let deferred = Promise.defer();
+    server.stop(deferred.resolve.bind(deferred));
+    yield deferred.promise;
+  }
+});
+
+add_test(function test_delete_document() {
+  let [client, server] = getClientAndServer();
+
+  server.createNamespace("foo");
+  server.setDocument("foo", "bar", "{}");
+
+  client.deleteDocument("foo", "bar").then(function onResult(result) {
+    do_check_true(result.transportSuccess);
+    do_check_true(result.serverSuccess);
+
+    do_check_null(server.getDocument("foo", "bar"));
+
+    server.stop(run_next_test);
+  });
+});
new file mode 100644
--- /dev/null
+++ b/services/common/tests/unit/test_bagheera_server.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://testing-common/services/common/bagheeraserver.js");
+
+function run_test() {
+  run_next_test();
+}
+
+add_test(function test_server_empty() {
+  let server = new BagheeraServer();
+
+  do_check_false(server.hasNamespace("foo"));
+  do_check_false(server.hasDocument("foo", "bar"));
+  do_check_null(server.getDocument("foo", "bar"));
+
+  server.createNamespace("foo");
+  do_check_true(server.hasNamespace("foo"));
+
+  run_next_test();
+});
+
+add_test(function test_server_start() {
+  let server = new BagheeraServer();
+  server.start();
+  server.stop(run_next_test);
+});
+
--- a/services/common/tests/unit/test_load_modules.js
+++ b/services/common/tests/unit/test_load_modules.js
@@ -11,25 +11,33 @@ const shared_modules = [
   "stringbundle.js",
   "utils.js",
 ];
 
 const non_android_modules = [
   "tokenserverclient.js",
 ];
 
+const non_android_healthreport_modules = [
+  "bagheeraclient.js",
+];
+
 const TEST_BASE = "resource://testing-common/services/common/";
 const shared_test_modules = [
   "logging.js",
 ];
 
 const non_android_test_modules = [
   "storageserver.js",
 ];
 
+const non_android_healthreport_test_modules = [
+  "bagheeraserver.js",
+];
+
 function expectImportsToSucceed(mm, base=MODULE_BASE) {
   for (let m of mm) {
     let resource = base + m;
     let succeeded = false;
     try {
       Components.utils.import(resource, {});
       succeeded = true;
     } catch (e) {}
@@ -57,13 +65,19 @@ function expectImportsToFail(mm, base=MO
 
 function run_test() {
   expectImportsToSucceed(shared_modules);
   expectImportsToSucceed(shared_test_modules, TEST_BASE);
 
   if (AppConstants.platform != "android") {
     expectImportsToSucceed(non_android_modules);
     expectImportsToSucceed(non_android_test_modules, TEST_BASE);
+    if (AppConstants.MOZ_SERVICES_HEALTHREPORT) {
+      expectImportsToSucceed(non_android_healthreport_modules);
+      expectImportsToSucceed(non_android_healthreport_test_modules, TEST_BASE);
+    }
   } else {
     expectImportsToFail(non_android_modules);
     expectImportsToFail(non_android_test_modules, TEST_BASE);
+    expectImportsToFail(non_android_healthreport_modules);
+    expectImportsToFail(non_android_healthreport_test_modules, TEST_BASE);
   }
 }
--- a/services/common/tests/unit/xpcshell.ini
+++ b/services/common/tests/unit/xpcshell.ini
@@ -24,16 +24,22 @@ support-files =
 [test_utils_namedTimer.js]
 [test_utils_sets.js]
 [test_utils_utf8.js]
 [test_utils_uuid.js]
 
 [test_async_chain.js]
 [test_async_querySpinningly.js]
 
+[test_bagheera_server.js]
+skip-if = (os == "android" || !healthreport)
+
+[test_bagheera_client.js]
+skip-if = (os == "android" || !healthreport)
+
 [test_hawkclient.js]
 skip-if = os == "android"
 [test_hawkrequest.js]
 skip-if = os == "android"
 
 [test_logmanager.js]
 [test_observers.js]
 [test_restrequest.js]