summaryrefslogtreecommitdiffstats
path: root/services/common/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /services/common/tests
parentInitial commit. (diff)
downloadfirefox-upstream/124.0.1.tar.xz
firefox-upstream/124.0.1.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/common/tests')
-rw-r--r--services/common/tests/moz.build9
-rw-r--r--services/common/tests/unit/head_global.js35
-rw-r--r--services/common/tests/unit/head_helpers.js270
-rw-r--r--services/common/tests/unit/head_http.js33
-rw-r--r--services/common/tests/unit/moz.build5
-rw-r--r--services/common/tests/unit/test_async_chain.js40
-rw-r--r--services/common/tests/unit/test_async_foreach.js88
-rw-r--r--services/common/tests/unit/test_hawkclient.js515
-rw-r--r--services/common/tests/unit/test_hawkrequest.js231
-rw-r--r--services/common/tests/unit/test_kinto.js512
-rw-r--r--services/common/tests/unit/test_load_modules.js60
-rw-r--r--services/common/tests/unit/test_logmanager.js330
-rw-r--r--services/common/tests/unit/test_observers.js82
-rw-r--r--services/common/tests/unit/test_restrequest.js860
-rw-r--r--services/common/tests/unit/test_storage_adapter.js307
-rw-r--r--services/common/tests/unit/test_storage_adapter/empty.sqlitebin0 -> 2048 bytes
-rw-r--r--services/common/tests/unit/test_storage_adapter/v1.sqlitebin0 -> 131072 bytes
-rw-r--r--services/common/tests/unit/test_storage_adapter_shutdown.js28
-rw-r--r--services/common/tests/unit/test_tokenauthenticatedrequest.js50
-rw-r--r--services/common/tests/unit/test_tokenserverclient.js382
-rw-r--r--services/common/tests/unit/test_uptake_telemetry.js121
-rw-r--r--services/common/tests/unit/test_utils_atob.js9
-rw-r--r--services/common/tests/unit/test_utils_convert_string.js146
-rw-r--r--services/common/tests/unit/test_utils_dateprefs.js77
-rw-r--r--services/common/tests/unit/test_utils_encodeBase32.js55
-rw-r--r--services/common/tests/unit/test_utils_encodeBase64URL.js21
-rw-r--r--services/common/tests/unit/test_utils_ensureMillisecondsTimestamp.js40
-rw-r--r--services/common/tests/unit/test_utils_makeURI.js62
-rw-r--r--services/common/tests/unit/test_utils_namedTimer.js73
-rw-r--r--services/common/tests/unit/test_utils_sets.js66
-rw-r--r--services/common/tests/unit/test_utils_utf8.js9
-rw-r--r--services/common/tests/unit/test_utils_uuid.js12
-rw-r--r--services/common/tests/unit/xpcshell.toml63
33 files changed, 4591 insertions, 0 deletions
diff --git a/services/common/tests/moz.build b/services/common/tests/moz.build
new file mode 100644
index 0000000000..77d3622318
--- /dev/null
+++ b/services/common/tests/moz.build
@@ -0,0 +1,9 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["unit/xpcshell.toml"]
+
+TEST_DIRS += ["unit"]
diff --git a/services/common/tests/unit/head_global.js b/services/common/tests/unit/head_global.js
new file mode 100644
index 0000000000..bcb1c063a4
--- /dev/null
+++ b/services/common/tests/unit/head_global.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var Cm = Components.manager;
+
+// Required to avoid failures.
+do_get_profile();
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const { updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+updateAppInfo({
+ name: "XPCShell",
+ ID: "xpcshell@tests.mozilla.org",
+ version: "1",
+ platformVersion: "",
+});
+
+function addResourceAlias() {
+ const handler = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+
+ let modules = ["common", "crypto", "settings"];
+ for (let module of modules) {
+ let uri = Services.io.newURI(
+ "resource://gre/modules/services-" + module + "/"
+ );
+ handler.setSubstitution("services-" + module, uri);
+ }
+}
+addResourceAlias();
diff --git a/services/common/tests/unit/head_helpers.js b/services/common/tests/unit/head_helpers.js
new file mode 100644
index 0000000000..bd994ee71a
--- /dev/null
+++ b/services/common/tests/unit/head_helpers.js
@@ -0,0 +1,270 @@
+/* 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/. */
+
+/* import-globals-from head_global.js */
+
+var { Log } = ChromeUtils.importESModule("resource://gre/modules/Log.sys.mjs");
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+var {
+ HTTP_400,
+ HTTP_401,
+ HTTP_402,
+ HTTP_403,
+ HTTP_404,
+ HTTP_405,
+ HTTP_406,
+ HTTP_407,
+ HTTP_408,
+ HTTP_409,
+ HTTP_410,
+ HTTP_411,
+ HTTP_412,
+ HTTP_413,
+ HTTP_414,
+ HTTP_415,
+ HTTP_417,
+ HTTP_500,
+ HTTP_501,
+ HTTP_502,
+ HTTP_503,
+ HTTP_504,
+ HTTP_505,
+ HttpError,
+ HttpServer,
+} = ChromeUtils.importESModule("resource://testing-common/httpd.sys.mjs");
+var { getTestLogger, initTestLogging } = ChromeUtils.importESModule(
+ "resource://testing-common/services/common/logging.sys.mjs"
+);
+var { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+var { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+function do_check_empty(obj) {
+ do_check_attribute_count(obj, 0);
+}
+
+function do_check_attribute_count(obj, c) {
+ Assert.equal(c, Object.keys(obj).length);
+}
+
+function do_check_throws(aFunc, aResult) {
+ try {
+ aFunc();
+ } catch (e) {
+ Assert.equal(e.result, aResult);
+ return;
+ }
+ do_throw("Expected result " + aResult + ", none thrown.");
+}
+
+/**
+ * Test whether specified function throws exception with expected
+ * result.
+ *
+ * @param func
+ * Function to be tested.
+ * @param message
+ * Message of expected exception. <code>null</code> for no throws.
+ */
+function do_check_throws_message(aFunc, aResult) {
+ try {
+ aFunc();
+ } catch (e) {
+ Assert.equal(e.message, aResult);
+ return;
+ }
+ do_throw("Expected an error, none thrown.");
+}
+
+/**
+ * Print some debug message to the console. All arguments will be printed,
+ * separated by spaces.
+ *
+ * @param [arg0, arg1, arg2, ...]
+ * Any number of arguments to print out
+ * @usage _("Hello World") -> prints "Hello World"
+ * @usage _(1, 2, 3) -> prints "1 2 3"
+ */
+var _ = function (some, debug, text, to) {
+ print(Array.from(arguments).join(" "));
+};
+
+function httpd_setup(handlers, port = -1) {
+ let server = new HttpServer();
+ for (let path in handlers) {
+ server.registerPathHandler(path, handlers[path]);
+ }
+ try {
+ server.start(port);
+ } catch (ex) {
+ _("==========================================");
+ _("Got exception starting HTTP server on port " + port);
+ _("Error: " + Log.exceptionStr(ex));
+ _("Is there a process already listening on port " + port + "?");
+ _("==========================================");
+ do_throw(ex);
+ }
+
+ // Set the base URI for convenience.
+ let i = server.identity;
+ server.baseURI =
+ i.primaryScheme + "://" + i.primaryHost + ":" + i.primaryPort;
+
+ return server;
+}
+
+function httpd_handler(statusCode, status, body) {
+ return function handler(request, response) {
+ _("Processing request");
+ // Allow test functions to inspect the request.
+ request.body = readBytesFromInputStream(request.bodyInputStream);
+ handler.request = request;
+
+ response.setStatusLine(request.httpVersion, statusCode, status);
+ if (body) {
+ response.bodyOutputStream.write(body, body.length);
+ }
+ };
+}
+
+function promiseStopServer(server) {
+ return new Promise(resolve => server.stop(resolve));
+}
+
+/*
+ * Read bytes string from an nsIInputStream. If 'count' is omitted,
+ * all available input is read.
+ */
+function readBytesFromInputStream(inputStream, count) {
+ if (!count) {
+ count = inputStream.available();
+ }
+ if (!count) {
+ return "";
+ }
+ return NetUtil.readInputStreamToString(inputStream, count, {
+ charset: "UTF-8",
+ });
+}
+
+function writeBytesToOutputStream(outputStream, string) {
+ if (!string) {
+ return;
+ }
+ let converter = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converter.init(outputStream, "UTF-8");
+ converter.writeString(string);
+ converter.close();
+}
+
+/*
+ * Ensure exceptions from inside callbacks leads to test failures.
+ */
+function ensureThrows(func) {
+ return function () {
+ try {
+ func.apply(this, arguments);
+ } catch (ex) {
+ do_throw(ex);
+ }
+ };
+}
+
+/**
+ * Proxy auth helpers.
+ */
+
+/**
+ * Fake a PAC to prompt a channel replacement.
+ */
+var PACSystemSettings = {
+ QueryInterface: ChromeUtils.generateQI(["nsISystemProxySettings"]),
+
+ // Replace this URI for each test to avoid caching. We want to ensure that
+ // each test gets a completely fresh setup.
+ mainThreadOnly: true,
+ PACURI: null,
+ getProxyForURI: function getProxyForURI(aURI) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+};
+
+var fakePACCID;
+function installFakePAC() {
+ _("Installing fake PAC.");
+ fakePACCID = MockRegistrar.register(
+ "@mozilla.org/system-proxy-settings;1",
+ PACSystemSettings
+ );
+}
+
+function uninstallFakePAC() {
+ _("Uninstalling fake PAC.");
+ MockRegistrar.unregister(fakePACCID);
+}
+
+function getUptakeTelemetrySnapshot(component, source) {
+ const TELEMETRY_CATEGORY_ID = "uptake.remotecontent.result";
+ const snapshot = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_ALL_CHANNELS,
+ true
+ );
+ const parentEvents = snapshot.parent || [];
+ return (
+ parentEvents
+ // Transform raw event data to objects.
+ .map(([i, category, method, object, value, extras]) => {
+ return { category, method, object, value, extras };
+ })
+ // Keep only for the specified component and source.
+ .filter(
+ e =>
+ e.category == TELEMETRY_CATEGORY_ID &&
+ e.object == component &&
+ e.extras.source == source
+ )
+ // Return total number of events received by status, to mimic histograms snapshots.
+ .reduce((acc, e) => {
+ acc[e.value] = (acc[e.value] || 0) + 1;
+ return acc;
+ }, {})
+ );
+}
+
+function checkUptakeTelemetry(snapshot1, snapshot2, expectedIncrements) {
+ const { UptakeTelemetry } = ChromeUtils.importESModule(
+ "resource://services-common/uptake-telemetry.sys.mjs"
+ );
+ const STATUSES = Object.values(UptakeTelemetry.STATUS);
+ for (const status of STATUSES) {
+ const expected = expectedIncrements[status] || 0;
+ const previous = snapshot1[status] || 0;
+ const current = snapshot2[status] || previous;
+ Assert.equal(expected, current - previous, `check events for ${status}`);
+ }
+}
+
+async function withFakeChannel(channel, f) {
+ const { Policy } = ChromeUtils.importESModule(
+ "resource://services-common/uptake-telemetry.sys.mjs"
+ );
+ let oldGetChannel = Policy.getChannel;
+ Policy.getChannel = () => channel;
+ try {
+ return await f();
+ } finally {
+ Policy.getChannel = oldGetChannel;
+ }
+}
+
+function arrayEqual(a, b) {
+ return JSON.stringify(a) == JSON.stringify(b);
+}
diff --git a/services/common/tests/unit/head_http.js b/services/common/tests/unit/head_http.js
new file mode 100644
index 0000000000..6c2a0a3f0f
--- /dev/null
+++ b/services/common/tests/unit/head_http.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from head_global.js */
+
+var { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+
+function basic_auth_header(user, password) {
+ return "Basic " + btoa(user + ":" + CommonUtils.encodeUTF8(password));
+}
+
+function basic_auth_matches(req, user, password) {
+ if (!req.hasHeader("Authorization")) {
+ return false;
+ }
+
+ let expected = basic_auth_header(user, CommonUtils.encodeUTF8(password));
+ return req.getHeader("Authorization") == expected;
+}
+
+function httpd_basic_auth_handler(body, metadata, response) {
+ if (basic_auth_matches(metadata, "guest", "guest")) {
+ response.setStatusLine(metadata.httpVersion, 200, "OK, authorized");
+ response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
+ } else {
+ body = "This path exists and is protected - failed";
+ response.setStatusLine(metadata.httpVersion, 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
+ }
+ response.bodyOutputStream.write(body, body.length);
+}
diff --git a/services/common/tests/unit/moz.build b/services/common/tests/unit/moz.build
new file mode 100644
index 0000000000..568f361a54
--- /dev/null
+++ b/services/common/tests/unit/moz.build
@@ -0,0 +1,5 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
diff --git a/services/common/tests/unit/test_async_chain.js b/services/common/tests/unit/test_async_chain.js
new file mode 100644
index 0000000000..3277c92f81
--- /dev/null
+++ b/services/common/tests/unit/test_async_chain.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+
+function run_test() {
+ _("Chain a few async methods, making sure the 'this' object is correct.");
+
+ let methods = {
+ save(x, callback) {
+ this.x = x;
+ callback(x);
+ },
+ addX(x, callback) {
+ callback(x + this.x);
+ },
+ double(x, callback) {
+ callback(x * 2);
+ },
+ neg(x, callback) {
+ callback(-x);
+ },
+ };
+ methods.chain = Async.chain;
+
+ // ((1 + 1 + 1) * (-1) + 1) * 2 + 1 = -3
+ methods.chain(
+ methods.save,
+ methods.addX,
+ methods.addX,
+ methods.neg,
+ methods.addX,
+ methods.double,
+ methods.addX,
+ methods.save
+ )(1);
+ Assert.equal(methods.x, -3);
+}
diff --git a/services/common/tests/unit/test_async_foreach.js b/services/common/tests/unit/test_async_foreach.js
new file mode 100644
index 0000000000..14a23e7ff7
--- /dev/null
+++ b/services/common/tests/unit/test_async_foreach.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+function makeArray(length) {
+ // Start at 1 so that we can just divide by yieldEvery to get the expected
+ // call count. (we exp)
+ return Array.from({ length }, (v, i) => i + 1);
+}
+
+// Adjust if we ever change the default.
+const DEFAULT_YIELD_EVERY = 50;
+
+add_task(async function testYields() {
+ let spy = sinon.spy(Async, "promiseYield");
+ try {
+ await Async.yieldingForEach(makeArray(DEFAULT_YIELD_EVERY * 2), element => {
+ // The yield will happen *after* this function is ran.
+ Assert.equal(
+ spy.callCount,
+ Math.floor((element - 1) / DEFAULT_YIELD_EVERY)
+ );
+ });
+ } finally {
+ spy.restore();
+ }
+});
+
+add_task(async function testExistingYieldState() {
+ const yieldState = Async.yieldState(DEFAULT_YIELD_EVERY);
+
+ for (let i = 0; i < 15; i++) {
+ Assert.equal(yieldState.shouldYield(), false);
+ }
+
+ let spy = sinon.spy(Async, "promiseYield");
+
+ try {
+ await Async.yieldingForEach(
+ makeArray(DEFAULT_YIELD_EVERY * 2),
+ element => {
+ Assert.equal(
+ spy.callCount,
+ Math.floor((element + 15 - 1) / DEFAULT_YIELD_EVERY)
+ );
+ },
+ yieldState
+ );
+ } finally {
+ spy.restore();
+ }
+});
+
+add_task(async function testEarlyReturn() {
+ let lastElement = 0;
+ await Async.yieldingForEach(makeArray(DEFAULT_YIELD_EVERY), element => {
+ lastElement = element;
+ return element === 10;
+ });
+
+ Assert.equal(lastElement, 10);
+});
+
+add_task(async function testEaryReturnAsync() {
+ let lastElement = 0;
+ await Async.yieldingForEach(makeArray(DEFAULT_YIELD_EVERY), async element => {
+ lastElement = element;
+ return element === 10;
+ });
+
+ Assert.equal(lastElement, 10);
+});
+
+add_task(async function testEarlyReturnPromise() {
+ let lastElement = 0;
+ await Async.yieldingForEach(makeArray(DEFAULT_YIELD_EVERY), element => {
+ lastElement = element;
+ return new Promise(resolve => resolve(element === 10));
+ });
+
+ Assert.equal(lastElement, 10);
+});
diff --git a/services/common/tests/unit/test_hawkclient.js b/services/common/tests/unit/test_hawkclient.js
new file mode 100644
index 0000000000..33d125f42e
--- /dev/null
+++ b/services/common/tests/unit/test_hawkclient.js
@@ -0,0 +1,515 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HawkClient } = ChromeUtils.importESModule(
+ "resource://services-common/hawkclient.sys.mjs"
+);
+
+const SECOND_MS = 1000;
+const MINUTE_MS = SECOND_MS * 60;
+const HOUR_MS = MINUTE_MS * 60;
+
+const TEST_CREDS = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+};
+
+initTestLogging("Trace");
+
+add_task(function test_now() {
+ let client = new HawkClient("https://example.com");
+
+ Assert.ok(client.now() - Date.now() < SECOND_MS);
+});
+
+add_task(function test_updateClockOffset() {
+ let client = new HawkClient("https://example.com");
+
+ let now = new Date();
+ let serverDate = now.toUTCString();
+
+ // Client's clock is off
+ client.now = () => {
+ return now.valueOf() + HOUR_MS;
+ };
+
+ client._updateClockOffset(serverDate);
+
+ // Check that they're close; there will likely be a one-second rounding
+ // error, so checking strict equality will likely fail.
+ //
+ // localtimeOffsetMsec is how many milliseconds to add to the local clock so
+ // that it agrees with the server. We are one hour ahead of the server, so
+ // our offset should be -1 hour.
+ Assert.ok(Math.abs(client.localtimeOffsetMsec + HOUR_MS) <= SECOND_MS);
+});
+
+add_task(async function test_authenticated_get_request() {
+ let message = '{"msg": "Great Success!"}';
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ Assert.ok(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ let response = await client.request("/foo", method, TEST_CREDS);
+ let result = JSON.parse(response.body);
+
+ Assert.equal("Great Success!", result.msg);
+
+ await promiseStopServer(server);
+});
+
+async function check_authenticated_request(method) {
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ Assert.ok(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+ response.bodyOutputStream.writeFrom(
+ request.bodyInputStream,
+ request.bodyInputStream.available()
+ );
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ let response = await client.request("/foo", method, TEST_CREDS, {
+ foo: "bar",
+ });
+ let result = JSON.parse(response.body);
+
+ Assert.equal("bar", result.foo);
+
+ await promiseStopServer(server);
+}
+
+add_task(async function test_authenticated_post_request() {
+ await check_authenticated_request("POST");
+});
+
+add_task(async function test_authenticated_put_request() {
+ await check_authenticated_request("PUT");
+});
+
+add_task(async function test_authenticated_patch_request() {
+ await check_authenticated_request("PATCH");
+});
+
+add_task(async function test_extra_headers() {
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ Assert.ok(request.hasHeader("Authorization"));
+ Assert.ok(request.hasHeader("myHeader"));
+ Assert.equal(request.getHeader("myHeader"), "fake");
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+ response.bodyOutputStream.writeFrom(
+ request.bodyInputStream,
+ request.bodyInputStream.available()
+ );
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ let response = await client.request(
+ "/foo",
+ "POST",
+ TEST_CREDS,
+ { foo: "bar" },
+ { myHeader: "fake" }
+ );
+ let result = JSON.parse(response.body);
+
+ Assert.equal("bar", result.foo);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_credentials_optional() {
+ let method = "GET";
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ Assert.ok(!request.hasHeader("Authorization"));
+
+ let message = JSON.stringify({ msg: "you're in the friend zone" });
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+ let result = await client.request("/foo", method); // credentials undefined
+ Assert.equal(JSON.parse(result.body).msg, "you're in the friend zone");
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_server_error() {
+ let message = "Ohai!";
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ response.setStatusLine(request.httpVersion, 418, "I am a Teapot");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ try {
+ await client.request("/foo", method, TEST_CREDS);
+ do_throw("Expected an error");
+ } catch (err) {
+ Assert.equal(418, err.code);
+ Assert.equal("I am a Teapot", err.message);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_server_error_json() {
+ let message = JSON.stringify({ error: "Cannot get ye flask." });
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ response.setStatusLine(
+ request.httpVersion,
+ 400,
+ "What wouldst thou deau?"
+ );
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ try {
+ await client.request("/foo", method, TEST_CREDS);
+ do_throw("Expected an error");
+ } catch (err) {
+ Assert.equal("Cannot get ye flask.", err.error);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_offset_after_request() {
+ let message = "Ohai!";
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+ let now = Date.now();
+ client.now = () => {
+ return now + HOUR_MS;
+ };
+
+ Assert.equal(client.localtimeOffsetMsec, 0);
+
+ await client.request("/foo", method, TEST_CREDS);
+ // Should be about an hour off
+ Assert.ok(Math.abs(client.localtimeOffsetMsec + HOUR_MS) < SECOND_MS);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_offset_in_hawk_header() {
+ let message = "Ohai!";
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/first": function (request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+
+ "/second": function (request, response) {
+ // We see a better date now in the ts component of the header
+ let delta = getTimestampDelta(request.getHeader("Authorization"));
+
+ // We're now within HAWK's one-minute window.
+ // I hope this isn't a recipe for intermittent oranges ...
+ if (delta < MINUTE_MS) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ } else {
+ response.setStatusLine(request.httpVersion, 400, "Delta: " + delta);
+ }
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ client.now = () => {
+ return Date.now() + 12 * HOUR_MS;
+ };
+
+ // We begin with no offset
+ Assert.equal(client.localtimeOffsetMsec, 0);
+ await client.request("/first", method, TEST_CREDS);
+
+ // After the first server response, our offset is updated to -12 hours.
+ // We should be safely in the window, now.
+ Assert.ok(Math.abs(client.localtimeOffsetMsec + 12 * HOUR_MS) < MINUTE_MS);
+ await client.request("/second", method, TEST_CREDS);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_2xx_success() {
+ // Just to ensure that we're not biased toward 200 OK for success
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/foo": (request, response) => {
+ response.setStatusLine(request.httpVersion, 202, "Accepted");
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ let response = await client.request("/foo", method, credentials);
+
+ // Shouldn't be any content in a 202
+ Assert.equal(response.body, "");
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_retry_request_on_fail() {
+ let attempts = 0;
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/maybe": function (request, response) {
+ // This path should be hit exactly twice; once with a bad timestamp, and
+ // again when the client retries the request with a corrected timestamp.
+ attempts += 1;
+ Assert.ok(attempts <= 2);
+
+ let delta = getTimestampDelta(request.getHeader("Authorization"));
+
+ // First time through, we should have a bad timestamp
+ if (attempts === 1) {
+ Assert.ok(delta > MINUTE_MS);
+ let message = "never!!!";
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ response.bodyOutputStream.write(message, message.length);
+ return;
+ }
+
+ // Second time through, timestamp should be corrected by client
+ Assert.ok(delta < MINUTE_MS);
+ let message = "i love you!!!";
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ client.now = () => {
+ return Date.now() + 12 * HOUR_MS;
+ };
+
+ // We begin with no offset
+ Assert.equal(client.localtimeOffsetMsec, 0);
+
+ // Request will have bad timestamp; client will retry once
+ let response = await client.request("/maybe", method, credentials);
+ Assert.equal(response.body, "i love you!!!");
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_multiple_401_retry_once() {
+ // Like test_retry_request_on_fail, but always return a 401
+ // and ensure that the client only retries once.
+ let attempts = 0;
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/maybe": function (request, response) {
+ // This path should be hit exactly twice; once with a bad timestamp, and
+ // again when the client retries the request with a corrected timestamp.
+ attempts += 1;
+
+ Assert.ok(attempts <= 2);
+
+ let message = "never!!!";
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ client.now = () => {
+ return Date.now() - 12 * HOUR_MS;
+ };
+
+ // We begin with no offset
+ Assert.equal(client.localtimeOffsetMsec, 0);
+
+ // Request will have bad timestamp; client will retry once
+ try {
+ await client.request("/maybe", method, credentials);
+ do_throw("Expected an error");
+ } catch (err) {
+ Assert.equal(err.code, 401);
+ }
+ Assert.equal(attempts, 2);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_500_no_retry() {
+ // If we get a 500 error, the client should not retry (as it would with a
+ // 401)
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/no-shutup": function (request, response) {
+ let message = "Cannot get ye flask.";
+ response.setStatusLine(request.httpVersion, 500, "Internal server error");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ // Throw off the clock so the HawkClient would want to retry the request if
+ // it could
+ client.now = () => {
+ return Date.now() - 12 * HOUR_MS;
+ };
+
+ // Request will 500; no retries
+ try {
+ await client.request("/no-shutup", method, credentials);
+ do_throw("Expected an error");
+ } catch (err) {
+ Assert.equal(err.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_401_then_500() {
+ // Like test_multiple_401_retry_once, but return a 500 to the
+ // second request, ensuring that the promise is properly rejected
+ // in client.request.
+ let attempts = 0;
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "GET";
+
+ let server = httpd_setup({
+ "/maybe": function (request, response) {
+ // This path should be hit exactly twice; once with a bad timestamp, and
+ // again when the client retries the request with a corrected timestamp.
+ attempts += 1;
+ Assert.ok(attempts <= 2);
+
+ let delta = getTimestampDelta(request.getHeader("Authorization"));
+
+ // First time through, we should have a bad timestamp
+ // Client will retry
+ if (attempts === 1) {
+ Assert.ok(delta > MINUTE_MS);
+ let message = "never!!!";
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ response.bodyOutputStream.write(message, message.length);
+ return;
+ }
+
+ // Second time through, timestamp should be corrected by client
+ // And fail on the client
+ Assert.ok(delta < MINUTE_MS);
+ let message = "Cannot get ye flask.";
+ response.setStatusLine(request.httpVersion, 500, "Internal server error");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new HawkClient(server.baseURI);
+
+ client.now = () => {
+ return Date.now() - 12 * HOUR_MS;
+ };
+
+ // We begin with no offset
+ Assert.equal(client.localtimeOffsetMsec, 0);
+
+ // Request will have bad timestamp; client will retry once
+ try {
+ await client.request("/maybe", method, credentials);
+ } catch (err) {
+ Assert.equal(err.code, 500);
+ }
+ Assert.equal(attempts, 2);
+
+ await promiseStopServer(server);
+});
+
+// End of tests.
+// Utility functions follow
+
+function getTimestampDelta(authHeader, now = Date.now()) {
+ let tsMS = new Date(
+ parseInt(/ts="(\d+)"/.exec(authHeader)[1], 10) * SECOND_MS
+ );
+ return Math.abs(tsMS - now);
+}
+
+function run_test() {
+ initTestLogging("Trace");
+ run_next_test();
+}
diff --git a/services/common/tests/unit/test_hawkrequest.js b/services/common/tests/unit/test_hawkrequest.js
new file mode 100644
index 0000000000..13ab6737de
--- /dev/null
+++ b/services/common/tests/unit/test_hawkrequest.js
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HAWKAuthenticatedRESTRequest, deriveHawkCredentials } =
+ ChromeUtils.importESModule("resource://services-common/hawkrequest.sys.mjs");
+const { Async } = ChromeUtils.importESModule(
+ "resource://services-common/async.sys.mjs"
+);
+
+// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-use-session-certificatesign-etc
+var SESSION_KEYS = {
+ sessionToken: h(
+ // eslint-disable-next-line no-useless-concat
+ "a0a1a2a3a4a5a6a7 a8a9aaabacadaeaf" + "b0b1b2b3b4b5b6b7 b8b9babbbcbdbebf"
+ ),
+
+ tokenID: h(
+ // eslint-disable-next-line no-useless-concat
+ "c0a29dcf46174973 da1378696e4c82ae" + "10f723cf4f4d9f75 e39f4ae3851595ab"
+ ),
+
+ reqHMACkey: h(
+ // eslint-disable-next-line no-useless-concat
+ "9d8f22998ee7f579 8b887042466b72d5" + "3e56ab0c094388bf 65831f702d2febc0"
+ ),
+};
+
+function do_register_cleanup() {
+ Services.prefs.clearUserPref("intl.accept_languages");
+ Services.prefs.clearUserPref("services.common.log.logger.rest.request");
+
+ // remove the pref change listener
+ let hawk = new HAWKAuthenticatedRESTRequest("https://example.com");
+ hawk._intl.uninit();
+}
+
+function run_test() {
+ registerCleanupFunction(do_register_cleanup);
+
+ Services.prefs.setStringPref(
+ "services.common.log.logger.rest.request",
+ "Trace"
+ );
+ initTestLogging("Trace");
+
+ run_next_test();
+}
+
+add_test(function test_intl_accept_language() {
+ let testCount = 0;
+ let languages = [
+ "zu-NP;vo", // Nepalese dialect of Zulu, defaulting to Volapük
+ "fa-CG;ik", // Congolese dialect of Farsei, defaulting to Inupiaq
+ ];
+
+ function setLanguagePref(lang) {
+ Services.prefs.setStringPref("intl.accept_languages", lang);
+ }
+
+ let hawk = new HAWKAuthenticatedRESTRequest("https://example.com");
+
+ Services.prefs.addObserver("intl.accept_languages", checkLanguagePref);
+ setLanguagePref(languages[testCount]);
+
+ function checkLanguagePref() {
+ CommonUtils.nextTick(function () {
+ // Ensure we're only called for the number of entries in languages[].
+ Assert.ok(testCount < languages.length);
+
+ Assert.equal(hawk._intl.accept_languages, languages[testCount]);
+
+ testCount++;
+ if (testCount < languages.length) {
+ // Set next language in prefs; Pref service will call checkNextLanguage.
+ setLanguagePref(languages[testCount]);
+ return;
+ }
+
+ // We've checked all the entries in languages[]. Cleanup and move on.
+ info(
+ "Checked " +
+ testCount +
+ " languages. Removing checkLanguagePref as pref observer."
+ );
+ Services.prefs.removeObserver("intl.accept_languages", checkLanguagePref);
+ run_next_test();
+ });
+ }
+});
+
+add_task(async function test_hawk_authenticated_request() {
+ let postData = { your: "data" };
+
+ // An arbitrary date - Feb 2, 1971. It ends in a bunch of zeroes to make our
+ // computation with the hawk timestamp easier, since hawk throws away the
+ // millisecond values.
+ let then = 34329600000;
+
+ let clockSkew = 120000;
+ let timeOffset = -1 * clockSkew;
+ let localTime = then + clockSkew;
+
+ // Set the accept-languages pref to the Nepalese dialect of Zulu.
+ let acceptLanguage = "zu-NP"; // omit trailing ';', which our HTTP libs snip
+ Services.prefs.setStringPref("intl.accept_languages", acceptLanguage);
+
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+
+ let server = httpd_setup({
+ "/elysium": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+
+ // check that the header timestamp is our arbitrary system date, not
+ // today's date. Note that hawk header timestamps are in seconds, not
+ // milliseconds.
+ let authorization = request.getHeader("Authorization");
+ let tsMS = parseInt(/ts="(\d+)"/.exec(authorization)[1], 10) * 1000;
+ Assert.equal(tsMS, then);
+
+ // This testing can be a little wonky. In an environment where
+ // pref("intl.accept_languages") === 'en-US, en'
+ // the header is sent as:
+ // 'en-US,en;q=0.5'
+ // hence our fake value for acceptLanguage.
+ let lang = request.getHeader("Accept-Language");
+ Assert.equal(lang, acceptLanguage);
+
+ let message = "yay";
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let url = server.baseURI + "/elysium";
+ let extra = {
+ now: localTime,
+ localtimeOffsetMsec: timeOffset,
+ };
+
+ let request = new HAWKAuthenticatedRESTRequest(url, credentials, extra);
+
+ // Allow hawk._intl to respond to the language pref change
+ await Async.promiseYield();
+
+ await request.post(postData);
+ Assert.equal(200, request.response.status);
+ Assert.equal(request.response.body, "yay");
+
+ Services.prefs.clearUserPref("intl.accept_languages");
+ let pref = Services.prefs.getComplexValue(
+ "intl.accept_languages",
+ Ci.nsIPrefLocalizedString
+ );
+ Assert.notEqual(acceptLanguage, pref.data);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_hawk_language_pref_changed() {
+ let languages = [
+ "zu-NP", // Nepalese dialect of Zulu
+ "fa-CG", // Congolese dialect of Farsi
+ ];
+
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+
+ function setLanguage(lang) {
+ Services.prefs.setStringPref("intl.accept_languages", lang);
+ }
+
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ Assert.equal(languages[1], request.getHeader("Accept-Language"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ },
+ });
+
+ let url = server.baseURI + "/foo";
+ let request;
+
+ setLanguage(languages[0]);
+
+ // A new request should create the stateful object for tracking the current
+ // language.
+ request = new HAWKAuthenticatedRESTRequest(url, credentials);
+
+ // Wait for change to propagate
+ await Async.promiseYield();
+ Assert.equal(languages[0], request._intl.accept_languages);
+
+ // Change the language pref ...
+ setLanguage(languages[1]);
+
+ await Async.promiseYield();
+
+ request = new HAWKAuthenticatedRESTRequest(url, credentials);
+ let response = await request.post({});
+
+ Assert.equal(200, response.status);
+ Services.prefs.clearUserPref("intl.accept_languages");
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_deriveHawkCredentials() {
+ let credentials = await deriveHawkCredentials(
+ SESSION_KEYS.sessionToken,
+ "sessionToken"
+ );
+ Assert.equal(credentials.id, SESSION_KEYS.tokenID);
+ Assert.equal(
+ CommonUtils.bytesAsHex(credentials.key),
+ SESSION_KEYS.reqHMACkey
+ );
+});
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/common/tests/unit/test_kinto.js b/services/common/tests/unit/test_kinto.js
new file mode 100644
index 0000000000..4b5e8471b1
--- /dev/null
+++ b/services/common/tests/unit/test_kinto.js
@@ -0,0 +1,512 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Kinto } = ChromeUtils.import(
+ "resource://services-common/kinto-offline-client.js"
+);
+const { FirefoxAdapter } = ChromeUtils.importESModule(
+ "resource://services-common/kinto-storage-adapter.sys.mjs"
+);
+
+var server;
+
+// set up what we need to make storage adapters
+const kintoFilename = "kinto.sqlite";
+
+function do_get_kinto_sqliteHandle() {
+ return FirefoxAdapter.openConnection({ path: kintoFilename });
+}
+
+function do_get_kinto_collection(sqliteHandle, collection = "test_collection") {
+ let config = {
+ remote: `http://localhost:${server.identity.primaryPort}/v1/`,
+ headers: { Authorization: "Basic " + btoa("user:pass") },
+ adapter: FirefoxAdapter,
+ adapterOptions: { sqliteHandle },
+ };
+ return new Kinto(config).collection(collection);
+}
+
+async function clear_collection() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ await collection.clear();
+ } finally {
+ await sqliteHandle.close();
+ }
+}
+
+// test some operations on a local collection
+add_task(async function test_kinto_add_get() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+
+ let newRecord = { foo: "bar" };
+ // check a record is created
+ let createResult = await collection.create(newRecord);
+ Assert.equal(createResult.data.foo, newRecord.foo);
+ // check getting the record gets the same info
+ let getResult = await collection.get(createResult.data.id);
+ deepEqual(createResult.data, getResult.data);
+ // check what happens if we create the same item again (it should throw
+ // since you can't create with id)
+ try {
+ await collection.create(createResult.data);
+ do_throw("Creation of a record with an id should fail");
+ } catch (err) {}
+ // try a few creates without waiting for the first few to resolve
+ let promises = [];
+ promises.push(collection.create(newRecord));
+ promises.push(collection.create(newRecord));
+ promises.push(collection.create(newRecord));
+ await collection.create(newRecord);
+ await Promise.all(promises);
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+// test some operations on multiple connections
+add_task(async function test_kinto_add_get() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection1 = do_get_kinto_collection(sqliteHandle);
+ const collection2 = do_get_kinto_collection(
+ sqliteHandle,
+ "test_collection_2"
+ );
+
+ let newRecord = { foo: "bar" };
+
+ // perform several write operations alternately without waiting for promises
+ // to resolve
+ let promises = [];
+ for (let i = 0; i < 10; i++) {
+ promises.push(collection1.create(newRecord));
+ promises.push(collection2.create(newRecord));
+ }
+
+ // ensure subsequent operations still work
+ await Promise.all([
+ collection1.create(newRecord),
+ collection2.create(newRecord),
+ ]);
+ await Promise.all(promises);
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(async function test_kinto_update() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const newRecord = { foo: "bar" };
+ // check a record is created
+ let createResult = await collection.create(newRecord);
+ Assert.equal(createResult.data.foo, newRecord.foo);
+ Assert.equal(createResult.data._status, "created");
+ // check we can update this OK
+ let copiedRecord = Object.assign(createResult.data, {});
+ deepEqual(createResult.data, copiedRecord);
+ copiedRecord.foo = "wibble";
+ let updateResult = await collection.update(copiedRecord);
+ // check the field was updated
+ Assert.equal(updateResult.data.foo, copiedRecord.foo);
+ // check the status is still "created", since we haven't synced
+ // the record
+ Assert.equal(updateResult.data._status, "created");
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(async function test_kinto_clear() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+
+ // create an expected number of records
+ const expected = 10;
+ const newRecord = { foo: "bar" };
+ for (let i = 0; i < expected; i++) {
+ await collection.create(newRecord);
+ }
+ // check the collection contains the correct number
+ let list = await collection.list();
+ Assert.equal(list.data.length, expected);
+ // clear the collection and check again - should be 0
+ await collection.clear();
+ list = await collection.list();
+ Assert.equal(list.data.length, 0);
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(async function test_kinto_delete() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const newRecord = { foo: "bar" };
+ // check a record is created
+ let createResult = await collection.create(newRecord);
+ Assert.equal(createResult.data.foo, newRecord.foo);
+ // check getting the record gets the same info
+ let getResult = await collection.get(createResult.data.id);
+ deepEqual(createResult.data, getResult.data);
+ // delete that record
+ let deleteResult = await collection.delete(createResult.data.id);
+ // check the ID is set on the result
+ Assert.equal(getResult.data.id, deleteResult.data.id);
+ // and check that get no longer returns the record
+ try {
+ getResult = await collection.get(createResult.data.id);
+ do_throw("there should not be a result");
+ } catch (e) {}
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(async function test_kinto_list() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const expected = 10;
+ const created = [];
+ for (let i = 0; i < expected; i++) {
+ let newRecord = { foo: "test " + i };
+ let createResult = await collection.create(newRecord);
+ created.push(createResult.data);
+ }
+ // check the collection contains the correct number
+ let list = await collection.list();
+ Assert.equal(list.data.length, expected);
+
+ // check that all created records exist in the retrieved list
+ for (let createdRecord of created) {
+ let found = false;
+ for (let retrievedRecord of list.data) {
+ if (createdRecord.id == retrievedRecord.id) {
+ deepEqual(createdRecord, retrievedRecord);
+ found = true;
+ }
+ }
+ Assert.ok(found);
+ }
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(async function test_importBulk_ignores_already_imported_records() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const record = {
+ id: "41b71c13-17e9-4ee3-9268-6a41abf9730f",
+ title: "foo",
+ last_modified: 1457896541,
+ };
+ await collection.importBulk([record]);
+ let impactedRecords = await collection.importBulk([record]);
+ Assert.equal(impactedRecords.length, 0);
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(async function test_loadDump_should_overwrite_old_records() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const record = {
+ id: "41b71c13-17e9-4ee3-9268-6a41abf9730f",
+ title: "foo",
+ last_modified: 1457896541,
+ };
+ await collection.loadDump([record]);
+ const updated = Object.assign({}, record, { last_modified: 1457896543 });
+ let impactedRecords = await collection.loadDump([updated]);
+ Assert.equal(impactedRecords.length, 1);
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(async function test_loadDump_should_not_overwrite_unsynced_records() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const recordId = "41b71c13-17e9-4ee3-9268-6a41abf9730f";
+ await collection.create(
+ { id: recordId, title: "foo" },
+ { useRecordId: true }
+ );
+ const record = { id: recordId, title: "bar", last_modified: 1457896541 };
+ let impactedRecords = await collection.loadDump([record]);
+ Assert.equal(impactedRecords.length, 0);
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+add_task(clear_collection);
+
+add_task(
+ async function test_loadDump_should_not_overwrite_records_without_last_modified() {
+ let sqliteHandle;
+ try {
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+ const recordId = "41b71c13-17e9-4ee3-9268-6a41abf9730f";
+ await collection.create({ id: recordId, title: "foo" }, { synced: true });
+ const record = { id: recordId, title: "bar", last_modified: 1457896541 };
+ let impactedRecords = await collection.loadDump([record]);
+ Assert.equal(impactedRecords.length, 0);
+ } finally {
+ await sqliteHandle.close();
+ }
+ }
+);
+
+add_task(clear_collection);
+
+// Now do some sanity checks against a server - we're not looking to test
+// core kinto.js functionality here (there is excellent test coverage in
+// kinto.js), more making sure things are basically working as expected.
+add_task(async function test_kinto_sync() {
+ const configPath = "/v1/";
+ const metadataPath = "/v1/buckets/default/collections/test_collection";
+ const recordsPath = "/v1/buckets/default/collections/test_collection/records";
+ // register a handler
+ function handleResponse(request, response) {
+ try {
+ const sampled = getSampleResponse(request, server.identity.primaryPort);
+ if (!sampled) {
+ do_throw(
+ `unexpected ${request.method} request for ${request.path}?${request.queryString}`
+ );
+ }
+
+ response.setStatusLine(
+ null,
+ sampled.status.status,
+ sampled.status.statusText
+ );
+ // send the headers
+ for (let headerLine of sampled.sampleHeaders) {
+ let headerElements = headerLine.split(":");
+ response.setHeader(headerElements[0], headerElements[1].trimLeft());
+ }
+ response.setHeader("Date", new Date().toUTCString());
+
+ response.write(sampled.responseBody);
+ } catch (e) {
+ dump(`${e}\n`);
+ }
+ }
+ server.registerPathHandler(configPath, handleResponse);
+ server.registerPathHandler(metadataPath, handleResponse);
+ server.registerPathHandler(recordsPath, handleResponse);
+
+ // create an empty collection, sync to populate
+ let sqliteHandle;
+ try {
+ let result;
+ sqliteHandle = await do_get_kinto_sqliteHandle();
+ const collection = do_get_kinto_collection(sqliteHandle);
+
+ result = await collection.sync();
+ Assert.ok(result.ok);
+
+ // our test data has a single record; it should be in the local collection
+ let list = await collection.list();
+ Assert.equal(list.data.length, 1);
+
+ // now sync again; we should now have 2 records
+ result = await collection.sync();
+ Assert.ok(result.ok);
+ list = await collection.list();
+ Assert.equal(list.data.length, 2);
+
+ // sync again; the second records should have been modified
+ const before = list.data[0].title;
+ result = await collection.sync();
+ Assert.ok(result.ok);
+ list = await collection.list();
+ const after = list.data[1].title;
+ Assert.notEqual(before, after);
+
+ const manualID = list.data[0].id;
+ Assert.equal(list.data.length, 3);
+ Assert.equal(manualID, "some-manually-chosen-id");
+ } finally {
+ await sqliteHandle.close();
+ }
+});
+
+function run_test() {
+ // Set up an HTTP Server
+ server = new HttpServer();
+ server.start(-1);
+
+ run_next_test();
+
+ registerCleanupFunction(function () {
+ server.stop(function () {});
+ });
+}
+
+// get a response for a given request from sample data
+function getSampleResponse(req, port) {
+ const responses = {
+ OPTIONS: {
+ sampleHeaders: [
+ "Access-Control-Allow-Headers: Content-Length,Expires,Backoff,Retry-After,Last-Modified,Total-Records,ETag,Pragma,Cache-Control,authorization,content-type,if-none-match,Alert,Next-Page",
+ "Access-Control-Allow-Methods: GET,HEAD,OPTIONS,POST,DELETE,OPTIONS",
+ "Access-Control-Allow-Origin: *",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: "null",
+ },
+ "GET:/v1/?": {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: JSON.stringify({
+ settings: {
+ batch_max_requests: 25,
+ },
+ url: `http://localhost:${port}/v1/`,
+ documentation: "https://kinto.readthedocs.org/",
+ version: "1.5.1",
+ commit: "cbc6f58",
+ hello: "kinto",
+ }),
+ },
+ "GET:/v1/buckets/default/collections/test_collection": {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ 'Etag: "1234"',
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: JSON.stringify({
+ data: {
+ id: "test_collection",
+ last_modified: 1234,
+ },
+ }),
+ },
+ "GET:/v1/buckets/default/collections/test_collection/records?_sort=-last_modified":
+ {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ 'Etag: "1445606341071"',
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: JSON.stringify({
+ data: [
+ {
+ last_modified: 1445606341071,
+ done: false,
+ id: "68db8313-686e-4fff-835e-07d78ad6f2af",
+ title: "New test",
+ },
+ ],
+ }),
+ },
+ "GET:/v1/buckets/default/collections/test_collection/records?_sort=-last_modified&_since=1445606341071":
+ {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ 'Etag: "1445607941223"',
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: JSON.stringify({
+ data: [
+ {
+ last_modified: 1445607941223,
+ done: false,
+ id: "901967b0-f729-4b30-8d8d-499cba7f4b1d",
+ title: "Another new test",
+ },
+ ],
+ }),
+ },
+ "GET:/v1/buckets/default/collections/test_collection/records?_sort=-last_modified&_since=1445607941223":
+ {
+ sampleHeaders: [
+ "Access-Control-Allow-Origin: *",
+ "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff",
+ "Content-Type: application/json; charset=UTF-8",
+ "Server: waitress",
+ 'Etag: "1445607541267"',
+ ],
+ status: { status: 200, statusText: "OK" },
+ responseBody: JSON.stringify({
+ data: [
+ {
+ last_modified: 1445607541265,
+ done: false,
+ id: "901967b0-f729-4b30-8d8d-499cba7f4b1d",
+ title: "Modified title",
+ },
+ {
+ last_modified: 1445607541267,
+ done: true,
+ id: "some-manually-chosen-id",
+ title: "New record with custom ID",
+ },
+ ],
+ }),
+ },
+ };
+ return (
+ responses[`${req.method}:${req.path}?${req.queryString}`] ||
+ responses[`${req.method}:${req.path}`] ||
+ responses[req.method]
+ );
+}
diff --git a/services/common/tests/unit/test_load_modules.js b/services/common/tests/unit/test_load_modules.js
new file mode 100644
index 0000000000..d86165266f
--- /dev/null
+++ b/services/common/tests/unit/test_load_modules.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+const MODULE_BASE = "resource://services-common/";
+const shared_modules = [
+ "async.sys.mjs",
+ "logmanager.sys.mjs",
+ "rest.sys.mjs",
+ "utils.sys.mjs",
+];
+
+const non_android_modules = ["tokenserverclient.sys.mjs"];
+
+const TEST_BASE = "resource://testing-common/services/common/";
+const shared_test_modules = ["logging.sys.mjs"];
+
+function expectImportsToSucceed(mm, base = MODULE_BASE) {
+ for (let m of mm) {
+ let resource = base + m;
+ let succeeded = false;
+ try {
+ ChromeUtils.importESModule(resource);
+ succeeded = true;
+ } catch (e) {}
+
+ if (!succeeded) {
+ throw new Error(`Importing ${resource} should have succeeded!`);
+ }
+ }
+}
+
+function expectImportsToFail(mm, base = MODULE_BASE) {
+ for (let m of mm) {
+ let resource = base + m;
+ let succeeded = false;
+ try {
+ ChromeUtils.importESModule(resource);
+ succeeded = true;
+ } catch (e) {}
+
+ if (succeeded) {
+ throw new Error(`Importing ${resource} should have failed!`);
+ }
+ }
+}
+
+function run_test() {
+ expectImportsToSucceed(shared_modules);
+ expectImportsToSucceed(shared_test_modules, TEST_BASE);
+
+ if (AppConstants.platform != "android") {
+ expectImportsToSucceed(non_android_modules);
+ } else {
+ expectImportsToFail(non_android_modules);
+ }
+}
diff --git a/services/common/tests/unit/test_logmanager.js b/services/common/tests/unit/test_logmanager.js
new file mode 100644
index 0000000000..89ac274e61
--- /dev/null
+++ b/services/common/tests/unit/test_logmanager.js
@@ -0,0 +1,330 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// NOTE: The sync test_errorhandler_* tests have quite good coverage for
+// other aspects of this.
+
+const { LogManager } = ChromeUtils.importESModule(
+ "resource://services-common/logmanager.sys.mjs"
+);
+const { FileUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/FileUtils.sys.mjs"
+);
+
+// Returns an array of [consoleAppender, dumpAppender, [fileAppenders]] for
+// the specified log. Note that fileAppenders will usually have length=1
+function getAppenders(log) {
+ let capps = log.appenders.filter(app => app instanceof Log.ConsoleAppender);
+ equal(capps.length, 1, "should only have one console appender");
+ let dapps = log.appenders.filter(app => app instanceof Log.DumpAppender);
+ equal(dapps.length, 1, "should only have one dump appender");
+ let fapps = log.appenders.filter(
+ app => app instanceof LogManager.StorageStreamAppender
+ );
+ return [capps[0], dapps[0], fapps];
+}
+
+// Test that the correct thing happens when no prefs exist for the log manager.
+add_task(async function test_noPrefs() {
+ // tell the log manager to init with a pref branch that doesn't exist.
+ let lm = new LogManager("no-such-branch.", ["TestLog"], "test");
+
+ let log = Log.repository.getLogger("TestLog");
+ let [capp, dapp, fapps] = getAppenders(log);
+ // The console appender gets "Fatal" while the "dump" appender gets "Error" levels
+ equal(capp.level, Log.Level.Fatal);
+ equal(dapp.level, Log.Level.Error);
+ // and the file (stream) appender gets Debug by default
+ equal(fapps.length, 1, "only 1 file appender");
+ equal(fapps[0].level, Log.Level.Debug);
+ lm.finalize();
+});
+
+// Test that changes to the prefs used by the log manager are updated dynamically.
+add_task(async function test_PrefChanges() {
+ Services.prefs.setStringPref(
+ "log-manager.test.log.appender.console",
+ "Trace"
+ );
+ Services.prefs.setStringPref("log-manager.test.log.appender.dump", "Trace");
+ Services.prefs.setStringPref(
+ "log-manager.test.log.appender.file.level",
+ "Trace"
+ );
+ let lm = new LogManager("log-manager.test.", ["TestLog2"], "test");
+
+ let log = Log.repository.getLogger("TestLog2");
+ let [capp, dapp, [fapp]] = getAppenders(log);
+ equal(capp.level, Log.Level.Trace);
+ equal(dapp.level, Log.Level.Trace);
+ equal(fapp.level, Log.Level.Trace);
+ // adjust the prefs and they should magically be reflected in the appenders.
+ Services.prefs.setStringPref(
+ "log-manager.test.log.appender.console",
+ "Debug"
+ );
+ Services.prefs.setStringPref("log-manager.test.log.appender.dump", "Debug");
+ Services.prefs.setStringPref(
+ "log-manager.test.log.appender.file.level",
+ "Debug"
+ );
+ equal(capp.level, Log.Level.Debug);
+ equal(dapp.level, Log.Level.Debug);
+ equal(fapp.level, Log.Level.Debug);
+ // and invalid values should cause them to fallback to their defaults.
+ Services.prefs.setStringPref("log-manager.test.log.appender.console", "xxx");
+ Services.prefs.setStringPref("log-manager.test.log.appender.dump", "xxx");
+ Services.prefs.setStringPref(
+ "log-manager.test.log.appender.file.level",
+ "xxx"
+ );
+ equal(capp.level, Log.Level.Fatal);
+ equal(dapp.level, Log.Level.Error);
+ equal(fapp.level, Log.Level.Debug);
+ lm.finalize();
+});
+
+// Test that the same log used by multiple log managers does the right thing.
+add_task(async function test_SharedLogs() {
+ // create the prefs for the first instance.
+ Services.prefs.setStringPref(
+ "log-manager-1.test.log.appender.console",
+ "Trace"
+ );
+ Services.prefs.setStringPref("log-manager-1.test.log.appender.dump", "Trace");
+ Services.prefs.setStringPref(
+ "log-manager-1.test.log.appender.file.level",
+ "Trace"
+ );
+ let lm1 = new LogManager("log-manager-1.test.", ["TestLog3"], "test");
+
+ // and the second.
+ Services.prefs.setStringPref(
+ "log-manager-2.test.log.appender.console",
+ "Debug"
+ );
+ Services.prefs.setStringPref("log-manager-2.test.log.appender.dump", "Debug");
+ Services.prefs.setStringPref(
+ "log-manager-2.test.log.appender.file.level",
+ "Debug"
+ );
+ let lm2 = new LogManager("log-manager-2.test.", ["TestLog3"], "test");
+
+ let log = Log.repository.getLogger("TestLog3");
+ let [capp, dapp] = getAppenders(log);
+
+ // console and dump appenders should be "trace" as it is more verbose than
+ // "debug"
+ equal(capp.level, Log.Level.Trace);
+ equal(dapp.level, Log.Level.Trace);
+
+ // Set the prefs on the -1 branch to "Error" - it should then end up with
+ // "Debug" from the -2 branch.
+ Services.prefs.setStringPref(
+ "log-manager-1.test.log.appender.console",
+ "Error"
+ );
+ Services.prefs.setStringPref("log-manager-1.test.log.appender.dump", "Error");
+ Services.prefs.setStringPref(
+ "log-manager-1.test.log.appender.file.level",
+ "Error"
+ );
+
+ equal(capp.level, Log.Level.Debug);
+ equal(dapp.level, Log.Level.Debug);
+
+ lm1.finalize();
+ lm2.finalize();
+});
+
+// A little helper to test what log files exist. We expect exactly zero (if
+// prefix is null) or exactly one with the specified prefix.
+function checkLogFile(prefix) {
+ let logsdir = FileUtils.getDir("ProfD", ["weave", "logs"]);
+ let entries = logsdir.directoryEntries;
+ if (!prefix) {
+ // expecting no files.
+ ok(!entries.hasMoreElements());
+ } else {
+ // expecting 1 file.
+ ok(entries.hasMoreElements());
+ let logfile = entries.getNext().QueryInterface(Ci.nsIFile);
+ equal(logfile.leafName.slice(-4), ".txt");
+ ok(logfile.leafName.startsWith(prefix + "-test-"), logfile.leafName);
+ // and remove it ready for the next check.
+ logfile.remove(false);
+ }
+}
+
+// Test that we correctly write error logs by default
+add_task(async function test_logFileErrorDefault() {
+ let lm = new LogManager("log-manager.test.", ["TestLog2"], "test");
+
+ let log = Log.repository.getLogger("TestLog2");
+ log.error("an error message");
+ await lm.resetFileLog(lm.REASON_ERROR);
+ // One error log file exists.
+ checkLogFile("error");
+
+ lm.finalize();
+});
+
+// Test that we correctly write success logs.
+add_task(async function test_logFileSuccess() {
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnError",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnSuccess",
+ false
+ );
+
+ let lm = new LogManager("log-manager.test.", ["TestLog2"], "test");
+
+ let log = Log.repository.getLogger("TestLog2");
+ log.info("an info message");
+ await lm.resetFileLog();
+ // Zero log files exist.
+ checkLogFile(null);
+
+ // Reset logOnSuccess and do it again - log should appear.
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnSuccess",
+ true
+ );
+ log.info("an info message");
+ await lm.resetFileLog();
+
+ checkLogFile("success");
+
+ // Now test with no "reason" specified and no "error" record.
+ log.info("an info message");
+ await lm.resetFileLog();
+ // should get a "success" entry.
+ checkLogFile("success");
+
+ // With no "reason" and an error record - should get no success log.
+ log.error("an error message");
+ await lm.resetFileLog();
+ // should get no entry
+ checkLogFile(null);
+
+ // And finally now with no error, to ensure that the fact we had an error
+ // previously doesn't persist after the .resetFileLog call.
+ log.info("an info message");
+ await lm.resetFileLog();
+ checkLogFile("success");
+
+ lm.finalize();
+});
+
+// Test that we correctly write error logs.
+add_task(async function test_logFileError() {
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnError",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnSuccess",
+ false
+ );
+
+ let lm = new LogManager("log-manager.test.", ["TestLog2"], "test");
+
+ let log = Log.repository.getLogger("TestLog2");
+ log.info("an info message");
+ let reason = await lm.resetFileLog();
+ Assert.equal(reason, null, "null returned when no file created.");
+ // Zero log files exist.
+ checkLogFile(null);
+
+ // Reset logOnSuccess - success logs should appear if no error records.
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnSuccess",
+ true
+ );
+ log.info("an info message");
+ reason = await lm.resetFileLog();
+ Assert.equal(reason, lm.SUCCESS_LOG_WRITTEN);
+ checkLogFile("success");
+
+ // Set logOnError and unset logOnSuccess - error logs should appear.
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnSuccess",
+ false
+ );
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnError",
+ true
+ );
+ log.error("an error message");
+ reason = await lm.resetFileLog();
+ Assert.equal(reason, lm.ERROR_LOG_WRITTEN);
+ checkLogFile("error");
+
+ // Now test with no "error" record.
+ log.info("an info message");
+ reason = await lm.resetFileLog();
+ // should get no file
+ Assert.equal(reason, null);
+ checkLogFile(null);
+
+ // With an error record we should get an error log.
+ log.error("an error message");
+ reason = await lm.resetFileLog();
+ // should get en error log
+ Assert.equal(reason, lm.ERROR_LOG_WRITTEN);
+ checkLogFile("error");
+
+ // And finally now with success, to ensure that the fact we had an error
+ // previously doesn't persist after the .resetFileLog call.
+ log.info("an info message");
+ await lm.resetFileLog();
+ checkLogFile(null);
+
+ lm.finalize();
+});
+
+function countLogFiles() {
+ let logsdir = FileUtils.getDir("ProfD", ["weave", "logs"]);
+ let count = 0;
+ for (let entry of logsdir.directoryEntries) {
+ void entry;
+ count += 1;
+ }
+ return count;
+}
+
+// Test that removeAllLogs removes all log files.
+add_task(async function test_logFileError() {
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnError",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "log-manager.test.log.appender.file.logOnSuccess",
+ true
+ );
+
+ let lm = new LogManager("log-manager.test.", ["TestLog2"], "test");
+
+ let log = Log.repository.getLogger("TestLog2");
+ log.info("an info message");
+ let reason = await lm.resetFileLog();
+ Assert.equal(reason, lm.SUCCESS_LOG_WRITTEN, "success log was written.");
+
+ log.error("an error message");
+ reason = await lm.resetFileLog();
+ Assert.equal(reason, lm.ERROR_LOG_WRITTEN);
+
+ Assert.equal(countLogFiles(), 2, "expect 2 log files");
+ await lm.removeAllLogs();
+ Assert.equal(
+ countLogFiles(),
+ 0,
+ "should be no log files after removing them"
+ );
+
+ lm.finalize();
+});
diff --git a/services/common/tests/unit/test_observers.js b/services/common/tests/unit/test_observers.js
new file mode 100644
index 0000000000..b0fce95e0b
--- /dev/null
+++ b/services/common/tests/unit/test_observers.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Observers } = ChromeUtils.importESModule(
+ "resource://services-common/observers.sys.mjs"
+);
+
+var gSubject = {};
+
+add_test(function test_function_observer() {
+ let foo = false;
+
+ let onFoo = function (subject, data) {
+ foo = !foo;
+ Assert.equal(subject, gSubject);
+ Assert.equal(data, "some data");
+ };
+
+ Observers.add("foo", onFoo);
+ Observers.notify("foo", gSubject, "some data");
+
+ // The observer was notified after being added.
+ Assert.ok(foo);
+
+ Observers.remove("foo", onFoo);
+ Observers.notify("foo");
+
+ // The observer was not notified after being removed.
+ Assert.ok(foo);
+
+ run_next_test();
+});
+
+add_test(function test_method_observer() {
+ let obj = {
+ foo: false,
+ onFoo(subject, data) {
+ this.foo = !this.foo;
+ Assert.equal(subject, gSubject);
+ Assert.equal(data, "some data");
+ },
+ };
+
+ // The observer is notified after being added.
+ Observers.add("foo", obj.onFoo, obj);
+ Observers.notify("foo", gSubject, "some data");
+ Assert.ok(obj.foo);
+
+ // The observer is not notified after being removed.
+ Observers.remove("foo", obj.onFoo, obj);
+ Observers.notify("foo");
+ Assert.ok(obj.foo);
+
+ run_next_test();
+});
+
+add_test(function test_object_observer() {
+ let obj = {
+ foo: false,
+ observe(subject, topic, data) {
+ this.foo = !this.foo;
+
+ Assert.equal(subject, gSubject);
+ Assert.equal(topic, "foo");
+ Assert.equal(data, "some data");
+ },
+ };
+
+ Observers.add("foo", obj);
+ Observers.notify("foo", gSubject, "some data");
+
+ // The observer is notified after being added.
+ Assert.ok(obj.foo);
+
+ Observers.remove("foo", obj);
+ Observers.notify("foo");
+
+ // The observer is not notified after being removed.
+ Assert.ok(obj.foo);
+
+ run_next_test();
+});
diff --git a/services/common/tests/unit/test_restrequest.js b/services/common/tests/unit/test_restrequest.js
new file mode 100644
index 0000000000..9ae5e9429a
--- /dev/null
+++ b/services/common/tests/unit/test_restrequest.js
@@ -0,0 +1,860 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { RESTRequest } = ChromeUtils.importESModule(
+ "resource://services-common/rest.sys.mjs"
+);
+
+function run_test() {
+ Log.repository.getLogger("Services.Common.RESTRequest").level =
+ Log.Level.Trace;
+ initTestLogging("Trace");
+
+ run_next_test();
+}
+
+/**
+ * Initializing a RESTRequest with an invalid URI throws
+ * NS_ERROR_MALFORMED_URI.
+ */
+add_test(function test_invalid_uri() {
+ do_check_throws(function () {
+ new RESTRequest("an invalid URI");
+ }, Cr.NS_ERROR_MALFORMED_URI);
+ run_next_test();
+});
+
+/**
+ * Verify initial values for attributes.
+ */
+add_test(function test_attributes() {
+ let uri = "http://foo.com/bar/baz";
+ let request = new RESTRequest(uri);
+
+ Assert.ok(request.uri instanceof Ci.nsIURI);
+ Assert.equal(request.uri.spec, uri);
+ Assert.equal(request.response, null);
+ Assert.equal(request.status, request.NOT_SENT);
+ let expectedLoadFlags =
+ Ci.nsIRequest.LOAD_BYPASS_CACHE |
+ Ci.nsIRequest.INHIBIT_CACHING |
+ Ci.nsIRequest.LOAD_ANONYMOUS;
+ Assert.equal(request.loadFlags, expectedLoadFlags);
+
+ run_next_test();
+});
+
+/**
+ * Verify that a proxy auth redirect doesn't break us. This has to be the first
+ * request made in the file!
+ */
+add_task(async function test_proxy_auth_redirect() {
+ let pacFetched = false;
+ function pacHandler(metadata, response) {
+ pacFetched = true;
+ let body = 'function FindProxyForURL(url, host) { return "DIRECT"; }';
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.setHeader(
+ "Content-Type",
+ "application/x-ns-proxy-autoconfig",
+ false
+ );
+ response.bodyOutputStream.write(body, body.length);
+ }
+
+ let fetched = false;
+ function original(metadata, response) {
+ fetched = true;
+ let body = "TADA!";
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(body, body.length);
+ }
+
+ let server = httpd_setup({
+ "/original": original,
+ "/pac3": pacHandler,
+ });
+ PACSystemSettings.PACURI = server.baseURI + "/pac3";
+ installFakePAC();
+
+ let req = new RESTRequest(server.baseURI + "/original");
+ await req.get();
+
+ Assert.ok(pacFetched);
+ Assert.ok(fetched);
+
+ Assert.ok(req.response.success);
+ Assert.equal("TADA!", req.response.body);
+ uninstallFakePAC();
+ await promiseStopServer(server);
+});
+
+/**
+ * Ensure that failures that cause asyncOpen to throw
+ * result in callbacks being invoked.
+ * Bug 826086.
+ */
+add_task(async function test_forbidden_port() {
+ let request = new RESTRequest("http://localhost:6000/");
+
+ await Assert.rejects(
+ request.get(),
+ error => error.result == Cr.NS_ERROR_PORT_ACCESS_NOT_ALLOWED
+ );
+});
+
+/**
+ * Demonstrate API short-hand: create a request and dispatch it immediately.
+ */
+add_task(async function test_simple_get() {
+ let handler = httpd_handler(200, "OK", "Huzzah!");
+ let server = httpd_setup({ "/resource": handler });
+ let request = new RESTRequest(server.baseURI + "/resource");
+ let promiseResponse = request.get();
+
+ Assert.equal(request.status, request.SENT);
+ Assert.equal(request.method, "GET");
+
+ let response = await promiseResponse;
+ Assert.equal(response, request.response);
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(response.success);
+ Assert.equal(response.status, 200);
+ Assert.equal(response.body, "Huzzah!");
+ await promiseStopServer(server);
+});
+
+/**
+ * Test HTTP GET with all bells and whistles.
+ */
+add_task(async function test_get() {
+ let handler = httpd_handler(200, "OK", "Huzzah!");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ Assert.equal(request.status, request.NOT_SENT);
+
+ let promiseResponse = request.get();
+
+ Assert.equal(request.status, request.SENT);
+ Assert.equal(request.method, "GET");
+
+ Assert.ok(!!(request.channel.loadFlags & Ci.nsIRequest.LOAD_BYPASS_CACHE));
+ Assert.ok(!!(request.channel.loadFlags & Ci.nsIRequest.INHIBIT_CACHING));
+
+ let response = await promiseResponse;
+
+ Assert.equal(response, request.response);
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "Huzzah!");
+ Assert.equal(handler.request.method, "GET");
+
+ await Assert.rejects(request.get(), /Request has already been sent/);
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Test HTTP GET with UTF-8 content, and custom Content-Type.
+ */
+add_task(async function test_get_utf8() {
+ let response = "Hello World or Καλημέρα κόσμε or こんにちは 世界 😺";
+
+ let contentType = "text/plain";
+ let charset = true;
+ let charsetSuffix = "; charset=UTF-8";
+
+ let server = httpd_setup({
+ "/resource": function (req, res) {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.setHeader(
+ "Content-Type",
+ contentType + (charset ? charsetSuffix : "")
+ );
+
+ let converter = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converter.init(res.bodyOutputStream, "UTF-8");
+ converter.writeString(response);
+ converter.close();
+ },
+ });
+
+ // Check if charset in Content-Type is propertly interpreted.
+ let request1 = new RESTRequest(server.baseURI + "/resource");
+ await request1.get();
+
+ Assert.equal(request1.response.status, 200);
+ Assert.equal(request1.response.body, response);
+ Assert.equal(
+ request1.response.headers["content-type"],
+ contentType + charsetSuffix
+ );
+
+ // Check that we default to UTF-8 if Content-Type doesn't have a charset
+ charset = false;
+ let request2 = new RESTRequest(server.baseURI + "/resource");
+ await request2.get();
+ Assert.equal(request2.response.status, 200);
+ Assert.equal(request2.response.body, response);
+ Assert.equal(request2.response.headers["content-type"], contentType);
+ Assert.equal(request2.response.charset, "utf-8");
+
+ let request3 = new RESTRequest(server.baseURI + "/resource");
+
+ // With the test server we tend to get onDataAvailable in chunks of 8192 (in
+ // real network requests there doesn't appear to be any pattern to the size of
+ // the data `onDataAvailable` is called with), the smiling cat emoji encodes as
+ // 4 bytes, and so when utf8 encoded, the `"a" + "😺".repeat(2048)` will not be
+ // aligned onto a codepoint.
+ //
+ // Since 8192 isn't guaranteed and could easily change, the following string is
+ // a) very long, and b) misaligned on roughly 3/4 of the bytes, as a safety
+ // measure.
+ response = ("a" + "😺".repeat(2048)).repeat(10);
+
+ await request3.get();
+
+ Assert.equal(request3.response.status, 200);
+
+ // Make sure it came through ok, despite the misalignment.
+ Assert.equal(request3.response.body, response);
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Test HTTP POST data is encoded as UTF-8 by default.
+ */
+add_task(async function test_post_utf8() {
+ // We setup a handler that responds with exactly what it received.
+ // Given we've already tested above that responses are correctly utf-8
+ // decoded we can surmise that the correct response coming back means the
+ // input must also have been encoded.
+ let server = httpd_setup({
+ "/echo": function (req, res) {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.setHeader("Content-Type", req.getHeader("content-type"));
+ // Get the body as bytes and write them back without touching them
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sis.init(req.bodyInputStream);
+ let body = sis.read(sis.available());
+ sis.close();
+ res.write(body);
+ },
+ });
+
+ let data = {
+ copyright: "©",
+ // See the comment in test_get_utf8 about this string.
+ long: ("a" + "😺".repeat(2048)).repeat(10),
+ };
+ let request1 = new RESTRequest(server.baseURI + "/echo");
+ await request1.post(data);
+
+ Assert.equal(request1.response.status, 200);
+ deepEqual(JSON.parse(request1.response.body), data);
+ Assert.equal(
+ request1.response.headers["content-type"],
+ "application/json; charset=utf-8"
+ );
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Test more variations of charset handling.
+ */
+add_task(async function test_charsets() {
+ let response = "Hello World, I can't speak Russian";
+
+ let contentType = "text/plain";
+ let charset = true;
+ let charsetSuffix = "; charset=us-ascii";
+
+ let server = httpd_setup({
+ "/resource": function (req, res) {
+ res.setStatusLine(req.httpVersion, 200, "OK");
+ res.setHeader(
+ "Content-Type",
+ contentType + (charset ? charsetSuffix : "")
+ );
+
+ let converter = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converter.init(res.bodyOutputStream, "us-ascii");
+ converter.writeString(response);
+ converter.close();
+ },
+ });
+
+ // Check that provided charset overrides hint.
+ let request1 = new RESTRequest(server.baseURI + "/resource");
+ request1.charset = "not-a-charset";
+ await request1.get();
+ Assert.equal(request1.response.status, 200);
+ Assert.equal(request1.response.body, response);
+ Assert.equal(
+ request1.response.headers["content-type"],
+ contentType + charsetSuffix
+ );
+ Assert.equal(request1.response.charset, "us-ascii");
+
+ // Check that hint is used if Content-Type doesn't have a charset.
+ charset = false;
+ let request2 = new RESTRequest(server.baseURI + "/resource");
+ request2.charset = "us-ascii";
+ await request2.get();
+
+ Assert.equal(request2.response.status, 200);
+ Assert.equal(request2.response.body, response);
+ Assert.equal(request2.response.headers["content-type"], contentType);
+ Assert.equal(request2.response.charset, "us-ascii");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Used for testing PATCH/PUT/POST methods.
+ */
+async function check_posting_data(method) {
+ let funcName = method.toLowerCase();
+ let handler = httpd_handler(200, "OK", "Got it!");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ Assert.equal(request.status, request.NOT_SENT);
+ let responsePromise = request[funcName]("Hullo?");
+ Assert.equal(request.status, request.SENT);
+ Assert.equal(request.method, method);
+
+ let response = await responsePromise;
+
+ Assert.equal(response, request.response);
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "Got it!");
+
+ Assert.equal(handler.request.method, method);
+ Assert.equal(handler.request.body, "Hullo?");
+ Assert.equal(handler.request.getHeader("Content-Type"), "text/plain");
+
+ await Assert.rejects(
+ request[funcName]("Hai!"),
+ /Request has already been sent/
+ );
+
+ await promiseStopServer(server);
+}
+
+/**
+ * Test HTTP PATCH with a simple string argument and default Content-Type.
+ */
+add_task(async function test_patch() {
+ await check_posting_data("PATCH");
+});
+
+/**
+ * Test HTTP PUT with a simple string argument and default Content-Type.
+ */
+add_task(async function test_put() {
+ await check_posting_data("PUT");
+});
+
+/**
+ * Test HTTP POST with a simple string argument and default Content-Type.
+ */
+add_task(async function test_post() {
+ await check_posting_data("POST");
+});
+
+/**
+ * Test HTTP DELETE.
+ */
+add_task(async function test_delete() {
+ let handler = httpd_handler(200, "OK", "Got it!");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ Assert.equal(request.status, request.NOT_SENT);
+ let responsePromise = request.delete();
+ Assert.equal(request.status, request.SENT);
+ Assert.equal(request.method, "DELETE");
+
+ let response = await responsePromise;
+ Assert.equal(response, request.response);
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "Got it!");
+ Assert.equal(handler.request.method, "DELETE");
+
+ await Assert.rejects(request.delete(), /Request has already been sent/);
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Test an HTTP response with a non-200 status code.
+ */
+add_task(async function test_get_404() {
+ let handler = httpd_handler(404, "Not Found", "Cannae find it!");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.get();
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(!request.response.success);
+ Assert.equal(request.response.status, 404);
+ Assert.equal(request.response.body, "Cannae find it!");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * The 'data' argument to PUT, if not a string already, is automatically
+ * stringified as JSON.
+ */
+add_task(async function test_put_json() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let sample_data = {
+ some: "sample_data",
+ injson: "format",
+ number: 42,
+ };
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.put(sample_data);
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(handler.request.method, "PUT");
+ Assert.equal(handler.request.body, JSON.stringify(sample_data));
+ Assert.equal(
+ handler.request.getHeader("Content-Type"),
+ "application/json; charset=utf-8"
+ );
+
+ await promiseStopServer(server);
+});
+
+/**
+ * The 'data' argument to POST, if not a string already, is automatically
+ * stringified as JSON.
+ */
+add_task(async function test_post_json() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let sample_data = {
+ some: "sample_data",
+ injson: "format",
+ number: 42,
+ };
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.post(sample_data);
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(handler.request.method, "POST");
+ Assert.equal(handler.request.body, JSON.stringify(sample_data));
+ Assert.equal(
+ handler.request.getHeader("Content-Type"),
+ "application/json; charset=utf-8"
+ );
+
+ await promiseStopServer(server);
+});
+
+/**
+ * The content-type will be text/plain without a charset if the 'data' argument
+ * to POST is already a string.
+ */
+add_task(async function test_post_json() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let sample_data = "hello";
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.post(sample_data);
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(handler.request.method, "POST");
+ Assert.equal(handler.request.body, sample_data);
+ Assert.equal(handler.request.getHeader("Content-Type"), "text/plain");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * HTTP PUT with a custom Content-Type header.
+ */
+add_task(async function test_put_override_content_type() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ request.setHeader("Content-Type", "application/lolcat");
+ await request.put("O HAI!!1!");
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(handler.request.method, "PUT");
+ Assert.equal(handler.request.body, "O HAI!!1!");
+ Assert.equal(handler.request.getHeader("Content-Type"), "application/lolcat");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * HTTP POST with a custom Content-Type header.
+ */
+add_task(async function test_post_override_content_type() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ request.setHeader("Content-Type", "application/lolcat");
+ await request.post("O HAI!!1!");
+
+ Assert.equal(request.status, request.COMPLETED);
+ Assert.ok(request.response.success);
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(handler.request.method, "POST");
+ Assert.equal(handler.request.body, "O HAI!!1!");
+ Assert.equal(handler.request.getHeader("Content-Type"), "application/lolcat");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * No special headers are sent by default on a GET request.
+ */
+add_task(async function test_get_no_headers() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let ignore_headers = [
+ "host",
+ "user-agent",
+ "accept",
+ "accept-language",
+ "accept-encoding",
+ "accept-charset",
+ "keep-alive",
+ "connection",
+ "pragma",
+ "cache-control",
+ "content-length",
+ "sec-fetch-dest",
+ "sec-fetch-mode",
+ "sec-fetch-site",
+ "sec-fetch-user",
+ ];
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.get();
+
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ let server_headers = handler.request.headers;
+ while (server_headers.hasMoreElements()) {
+ let header = server_headers.getNext().toString();
+ if (!ignore_headers.includes(header)) {
+ do_throw("Got unexpected header!");
+ }
+ }
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Client includes default Accept header in API requests
+ */
+add_task(async function test_default_accept_headers() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.get();
+
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ let accept_header = handler.request.getHeader("accept");
+
+ Assert.ok(!accept_header.includes("text/html"));
+ Assert.ok(!accept_header.includes("application/xhtml+xml"));
+ Assert.ok(!accept_header.includes("applcation/xml"));
+
+ Assert.ok(
+ accept_header.includes("application/json") ||
+ accept_header.includes("application/newlines")
+ );
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Test changing the URI after having created the request.
+ */
+add_task(async function test_changing_uri() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest("http://localhost:1234/the-wrong-resource");
+ request.uri = CommonUtils.makeURI(server.baseURI + "/resource");
+ let response = await request.get();
+ Assert.equal(response.status, 200);
+ await promiseStopServer(server);
+});
+
+/**
+ * Test setting HTTP request headers.
+ */
+add_task(async function test_request_setHeader() {
+ let handler = httpd_handler(200, "OK");
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+
+ request.setHeader("X-What-Is-Weave", "awesome");
+ request.setHeader("X-WHAT-is-Weave", "more awesomer");
+ request.setHeader("Another-Header", "Hello World");
+ await request.get();
+
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(handler.request.getHeader("X-What-Is-Weave"), "more awesomer");
+ Assert.equal(handler.request.getHeader("another-header"), "Hello World");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * Test receiving HTTP response headers.
+ */
+add_task(async function test_response_headers() {
+ function handler(request, response) {
+ response.setHeader("X-What-Is-Weave", "awesome");
+ response.setHeader("Another-Header", "Hello World");
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ }
+ let server = httpd_setup({ "/resource": handler });
+ let request = new RESTRequest(server.baseURI + "/resource");
+ await request.get();
+
+ Assert.equal(request.response.status, 200);
+ Assert.equal(request.response.body, "");
+
+ Assert.equal(request.response.headers["x-what-is-weave"], "awesome");
+ Assert.equal(request.response.headers["another-header"], "Hello World");
+
+ await promiseStopServer(server);
+});
+
+/**
+ * The onComplete() handler gets called in case of any network errors
+ * (e.g. NS_ERROR_CONNECTION_REFUSED).
+ */
+add_task(async function test_connection_refused() {
+ let request = new RESTRequest("http://localhost:1234/resource");
+
+ // Fail the test if we resolve, return the error if we reject
+ await Assert.rejects(
+ request.get(),
+ error =>
+ error.result == Cr.NS_ERROR_CONNECTION_REFUSED &&
+ error.message == "NS_ERROR_CONNECTION_REFUSED"
+ );
+
+ Assert.equal(request.status, request.COMPLETED);
+});
+
+/**
+ * Abort a request that just sent off.
+ */
+add_task(async function test_abort() {
+ function handler() {
+ do_throw("Shouldn't have gotten here!");
+ }
+ let server = httpd_setup({ "/resource": handler });
+
+ let request = new RESTRequest(server.baseURI + "/resource");
+
+ // Aborting a request that hasn't been sent yet is pointless and will throw.
+ do_check_throws(function () {
+ request.abort();
+ });
+
+ let responsePromise = request.get();
+ request.abort();
+
+ // Aborting an already aborted request is pointless and will throw.
+ do_check_throws(function () {
+ request.abort();
+ });
+
+ Assert.equal(request.status, request.ABORTED);
+
+ await Assert.rejects(responsePromise, /NS_BINDING_ABORTED/);
+
+ await promiseStopServer(server);
+});
+
+/**
+ * A non-zero 'timeout' property specifies the amount of seconds to wait after
+ * channel activity until the request is automatically canceled.
+ */
+add_task(async function test_timeout() {
+ let server = new HttpServer();
+ let server_connection;
+ server._handler.handleResponse = function (connection) {
+ // This is a handler that doesn't do anything, just keeps the connection
+ // open, thereby mimicking a timing out connection. We keep a reference to
+ // the open connection for later so it can be properly disposed of. That's
+ // why you really only want to make one HTTP request to this server ever.
+ server_connection = connection;
+ };
+ server.start();
+ let identity = server.identity;
+ let uri =
+ identity.primaryScheme +
+ "://" +
+ identity.primaryHost +
+ ":" +
+ identity.primaryPort;
+
+ let request = new RESTRequest(uri + "/resource");
+ request.timeout = 0.1; // 100 milliseconds
+
+ await Assert.rejects(
+ request.get(),
+ error => error.result == Cr.NS_ERROR_NET_TIMEOUT
+ );
+
+ Assert.equal(request.status, request.ABORTED);
+
+ // server_connection is undefined on the Android emulator for reasons
+ // unknown. Yet, we still get here. If this test is refactored, we should
+ // investigate the reason why the above callback is behaving differently.
+ if (server_connection) {
+ _("Closing connection.");
+ server_connection.close();
+ }
+ await promiseStopServer(server);
+});
+
+add_task(async function test_new_channel() {
+ _("Ensure a redirect to a new channel is handled properly.");
+
+ function checkUA(metadata) {
+ let ua = metadata.getHeader("User-Agent");
+ _("User-Agent is " + ua);
+ Assert.equal("foo bar", ua);
+ }
+
+ let redirectRequested = false;
+ let redirectURL;
+ function redirectHandler(metadata, response) {
+ checkUA(metadata);
+ redirectRequested = true;
+
+ let body = "Redirecting";
+ response.setStatusLine(metadata.httpVersion, 307, "TEMPORARY REDIRECT");
+ response.setHeader("Location", redirectURL);
+ response.bodyOutputStream.write(body, body.length);
+ }
+
+ let resourceRequested = false;
+ function resourceHandler(metadata, response) {
+ checkUA(metadata);
+ resourceRequested = true;
+
+ let body = "Test";
+ response.setHeader("Content-Type", "text/plain");
+ response.bodyOutputStream.write(body, body.length);
+ }
+
+ let server1 = httpd_setup({ "/redirect": redirectHandler });
+ let server2 = httpd_setup({ "/resource": resourceHandler });
+ redirectURL = server2.baseURI + "/resource";
+
+ let request = new RESTRequest(server1.baseURI + "/redirect");
+ request.setHeader("User-Agent", "foo bar");
+
+ // Swizzle in our own fakery, because this redirect is neither
+ // internal nor URI-preserving. RESTRequest's policy is to only
+ // copy headers under certain circumstances.
+ let protoMethod = request.shouldCopyOnRedirect;
+ request.shouldCopyOnRedirect = function wrapped(o, n, f) {
+ // Check the default policy.
+ Assert.ok(!protoMethod.call(this, o, n, f));
+ return true;
+ };
+
+ let response = await request.get();
+
+ Assert.equal(200, response.status);
+ Assert.equal("Test", response.body);
+ Assert.ok(redirectRequested);
+ Assert.ok(resourceRequested);
+
+ await promiseStopServer(server1);
+ await promiseStopServer(server2);
+});
+
+add_task(async function test_not_sending_cookie() {
+ function handler(metadata, response) {
+ let body = "COOKIE!";
+ response.setStatusLine(metadata.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(body, body.length);
+ Assert.ok(!metadata.hasHeader("Cookie"));
+ }
+ let server = httpd_setup({ "/test": handler });
+
+ let uri = CommonUtils.makeURI(server.baseURI);
+ let channel = NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_DOCUMENT,
+ });
+ Services.cookies.setCookieStringFromHttp(uri, "test=test; path=/;", channel);
+
+ let res = new RESTRequest(server.baseURI + "/test");
+ let response = await res.get();
+
+ Assert.ok(response.success);
+ Assert.equal("COOKIE!", response.body);
+
+ await promiseStopServer(server);
+});
diff --git a/services/common/tests/unit/test_storage_adapter.js b/services/common/tests/unit/test_storage_adapter.js
new file mode 100644
index 0000000000..1dda35ad12
--- /dev/null
+++ b/services/common/tests/unit/test_storage_adapter.js
@@ -0,0 +1,307 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Sqlite } = ChromeUtils.importESModule(
+ "resource://gre/modules/Sqlite.sys.mjs"
+);
+const { FirefoxAdapter } = ChromeUtils.importESModule(
+ "resource://services-common/kinto-storage-adapter.sys.mjs"
+);
+
+// set up what we need to make storage adapters
+const kintoFilename = "kinto.sqlite";
+
+function do_get_kinto_connection() {
+ return FirefoxAdapter.openConnection({ path: kintoFilename });
+}
+
+function do_get_kinto_adapter(sqliteHandle) {
+ return new FirefoxAdapter("test", { sqliteHandle });
+}
+
+function do_get_kinto_db() {
+ let profile = do_get_profile();
+ let kintoDB = profile.clone();
+ kintoDB.append(kintoFilename);
+ return kintoDB;
+}
+
+function cleanup_kinto() {
+ add_test(function cleanup_kinto_files() {
+ let kintoDB = do_get_kinto_db();
+ // clean up the db
+ kintoDB.remove(false);
+ run_next_test();
+ });
+}
+
+function test_collection_operations() {
+ add_task(async function test_kinto_clear() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ await adapter.clear();
+ await sqliteHandle.close();
+ });
+
+ // test creating new records... and getting them again
+ add_task(async function test_kinto_create_new_get_existing() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ let record = { id: "test-id", foo: "bar" };
+ await adapter.execute(transaction => transaction.create(record));
+ let newRecord = await adapter.get("test-id");
+ // ensure the record is the same as when it was added
+ deepEqual(record, newRecord);
+ await sqliteHandle.close();
+ });
+
+ // test removing records
+ add_task(async function test_kinto_can_remove_some_records() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ // create a second record
+ let record = { id: "test-id-2", foo: "baz" };
+ await adapter.execute(transaction => transaction.create(record));
+ let newRecord = await adapter.get("test-id-2");
+ deepEqual(record, newRecord);
+ // delete the record
+ await adapter.execute(transaction => transaction.delete(record.id));
+ newRecord = await adapter.get(record.id);
+ // ... and ensure it's no longer there
+ Assert.equal(newRecord, undefined);
+ // ensure the other record still exists
+ newRecord = await adapter.get("test-id");
+ Assert.notEqual(newRecord, undefined);
+ await sqliteHandle.close();
+ });
+
+ // test getting records that don't exist
+ add_task(async function test_kinto_get_non_existant() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ // Kinto expects adapters to either:
+ let newRecord = await adapter.get("missing-test-id");
+ // resolve with an undefined record
+ Assert.equal(newRecord, undefined);
+ await sqliteHandle.close();
+ });
+
+ // test updating records... and getting them again
+ add_task(async function test_kinto_update_get_existing() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ let originalRecord = { id: "test-id", foo: "bar" };
+ let updatedRecord = { id: "test-id", foo: "baz" };
+ await adapter.clear();
+ await adapter.execute(transaction => transaction.create(originalRecord));
+ await adapter.execute(transaction => transaction.update(updatedRecord));
+ // ensure the record exists
+ let newRecord = await adapter.get("test-id");
+ // ensure the record is the same as when it was added
+ deepEqual(updatedRecord, newRecord);
+ await sqliteHandle.close();
+ });
+
+ // test listing records
+ add_task(async function test_kinto_list() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ let originalRecord = { id: "test-id-1", foo: "bar" };
+ let records = await adapter.list();
+ Assert.equal(records.length, 1);
+ await adapter.execute(transaction => transaction.create(originalRecord));
+ records = await adapter.list();
+ Assert.equal(records.length, 2);
+ await sqliteHandle.close();
+ });
+
+ // test aborting transaction
+ add_task(async function test_kinto_aborting_transaction() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ await adapter.clear();
+ let record = { id: 1, foo: "bar" };
+ let error = null;
+ try {
+ await adapter.execute(transaction => {
+ transaction.create(record);
+ throw new Error("unexpected");
+ });
+ } catch (e) {
+ error = e;
+ }
+ Assert.notEqual(error, null);
+ let records = await adapter.list();
+ Assert.equal(records.length, 0);
+ await sqliteHandle.close();
+ });
+
+ // test save and get last modified
+ add_task(async function test_kinto_last_modified() {
+ const initialValue = 0;
+ const intendedValue = 12345678;
+
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ let lastModified = await adapter.getLastModified();
+ Assert.equal(lastModified, initialValue);
+ let result = await adapter.saveLastModified(intendedValue);
+ Assert.equal(result, intendedValue);
+ lastModified = await adapter.getLastModified();
+ Assert.equal(lastModified, intendedValue);
+
+ // test saveLastModified parses values correctly
+ result = await adapter.saveLastModified(" " + intendedValue + " blah");
+ // should resolve with the parsed int
+ Assert.equal(result, intendedValue);
+ // and should have saved correctly
+ lastModified = await adapter.getLastModified();
+ Assert.equal(lastModified, intendedValue);
+ await sqliteHandle.close();
+ });
+
+ // test loadDump(records)
+ add_task(async function test_kinto_import_records() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ let record1 = { id: 1, foo: "bar" };
+ let record2 = { id: 2, foo: "baz" };
+ let impactedRecords = await adapter.loadDump([record1, record2]);
+ Assert.equal(impactedRecords.length, 2);
+ let newRecord1 = await adapter.get("1");
+ // ensure the record is the same as when it was added
+ deepEqual(record1, newRecord1);
+ let newRecord2 = await adapter.get("2");
+ // ensure the record is the same as when it was added
+ deepEqual(record2, newRecord2);
+ await sqliteHandle.close();
+ });
+
+ add_task(async function test_kinto_import_records_should_override_existing() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ await adapter.clear();
+ let records = await adapter.list();
+ Assert.equal(records.length, 0);
+ let impactedRecords = await adapter.loadDump([
+ { id: 1, foo: "bar" },
+ { id: 2, foo: "baz" },
+ ]);
+ Assert.equal(impactedRecords.length, 2);
+ await adapter.loadDump([
+ { id: 1, foo: "baz" },
+ { id: 3, foo: "bab" },
+ ]);
+ records = await adapter.list();
+ Assert.equal(records.length, 3);
+ let newRecord1 = await adapter.get("1");
+ deepEqual(newRecord1.foo, "baz");
+ await sqliteHandle.close();
+ });
+
+ add_task(async function test_import_updates_lastModified() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ await adapter.loadDump([
+ { id: 1, foo: "bar", last_modified: 1457896541 },
+ { id: 2, foo: "baz", last_modified: 1458796542 },
+ ]);
+ let lastModified = await adapter.getLastModified();
+ Assert.equal(lastModified, 1458796542);
+ await sqliteHandle.close();
+ });
+
+ add_task(async function test_import_preserves_older_lastModified() {
+ let sqliteHandle = await do_get_kinto_connection();
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ await adapter.saveLastModified(1458796543);
+
+ await adapter.loadDump([
+ { id: 1, foo: "bar", last_modified: 1457896541 },
+ { id: 2, foo: "baz", last_modified: 1458796542 },
+ ]);
+ let lastModified = await adapter.getLastModified();
+ Assert.equal(lastModified, 1458796543);
+ await sqliteHandle.close();
+ });
+
+ add_task(async function test_save_metadata_preserves_lastModified() {
+ let sqliteHandle = await do_get_kinto_connection();
+
+ let adapter = do_get_kinto_adapter(sqliteHandle);
+ await adapter.saveLastModified(42);
+
+ await adapter.saveMetadata({ id: "col" });
+
+ let lastModified = await adapter.getLastModified();
+ Assert.equal(lastModified, 42);
+ await sqliteHandle.close();
+ });
+}
+
+// test kinto db setup and operations in various scenarios
+// test from scratch - no current existing database
+add_test(function test_db_creation() {
+ add_test(function test_create_from_scratch() {
+ // ensure the file does not exist in the profile
+ let kintoDB = do_get_kinto_db();
+ Assert.ok(!kintoDB.exists());
+ run_next_test();
+ });
+
+ test_collection_operations();
+
+ cleanup_kinto();
+ run_next_test();
+});
+
+// this is the closest we can get to a schema version upgrade at v1 - test an
+// existing database
+add_test(function test_creation_from_empty_db() {
+ add_test(function test_create_from_empty_db() {
+ // place an empty kinto db file in the profile
+ let profile = do_get_profile();
+
+ let emptyDB = do_get_file("test_storage_adapter/empty.sqlite");
+ emptyDB.copyTo(profile, kintoFilename);
+
+ run_next_test();
+ });
+
+ test_collection_operations();
+
+ cleanup_kinto();
+ run_next_test();
+});
+
+// test schema version upgrade at v2
+add_test(function test_migration_from_v1_to_v2() {
+ add_test(function test_migrate_from_v1_to_v2() {
+ // place an empty kinto db file in the profile
+ let profile = do_get_profile();
+
+ let v1DB = do_get_file("test_storage_adapter/v1.sqlite");
+ v1DB.copyTo(profile, kintoFilename);
+
+ run_next_test();
+ });
+
+ add_test(async function schema_is_update_from_1_to_2() {
+ // The `v1.sqlite` has schema version 1.
+ let sqliteHandle = await Sqlite.openConnection({ path: kintoFilename });
+ Assert.equal(await sqliteHandle.getSchemaVersion(), 1);
+ await sqliteHandle.close();
+
+ // The `.openConnection()` migrates it to version 2.
+ sqliteHandle = await FirefoxAdapter.openConnection({ path: kintoFilename });
+ Assert.equal(await sqliteHandle.getSchemaVersion(), 2);
+ await sqliteHandle.close();
+
+ run_next_test();
+ });
+
+ test_collection_operations();
+
+ cleanup_kinto();
+ run_next_test();
+});
diff --git a/services/common/tests/unit/test_storage_adapter/empty.sqlite b/services/common/tests/unit/test_storage_adapter/empty.sqlite
new file mode 100644
index 0000000000..7f295b4146
--- /dev/null
+++ b/services/common/tests/unit/test_storage_adapter/empty.sqlite
Binary files differ
diff --git a/services/common/tests/unit/test_storage_adapter/v1.sqlite b/services/common/tests/unit/test_storage_adapter/v1.sqlite
new file mode 100644
index 0000000000..8482b8b31d
--- /dev/null
+++ b/services/common/tests/unit/test_storage_adapter/v1.sqlite
Binary files differ
diff --git a/services/common/tests/unit/test_storage_adapter_shutdown.js b/services/common/tests/unit/test_storage_adapter_shutdown.js
new file mode 100644
index 0000000000..dce26ce842
--- /dev/null
+++ b/services/common/tests/unit/test_storage_adapter_shutdown.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { AsyncShutdown } = ChromeUtils.importESModule(
+ "resource://gre/modules/AsyncShutdown.sys.mjs"
+);
+
+const { FirefoxAdapter } = ChromeUtils.importESModule(
+ "resource://services-common/kinto-storage-adapter.sys.mjs"
+);
+
+add_task(async function test_sqlite_shutdown() {
+ const sqliteHandle = await FirefoxAdapter.openConnection({
+ path: "kinto.sqlite",
+ });
+
+ // Shutdown Sqlite.sys.mjs synchronously.
+ Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true);
+ AsyncShutdown.profileBeforeChange._trigger();
+ Services.prefs.clearUserPref("toolkit.asyncshutdown.testing");
+
+ try {
+ sqliteHandle.execute("SELECT 1;");
+ equal("Should not succeed, connection should be closed.", false);
+ } catch (e) {
+ equal(e.message, "Connection is not open.");
+ }
+});
diff --git a/services/common/tests/unit/test_tokenauthenticatedrequest.js b/services/common/tests/unit/test_tokenauthenticatedrequest.js
new file mode 100644
index 0000000000..70735894e4
--- /dev/null
+++ b/services/common/tests/unit/test_tokenauthenticatedrequest.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+const { TokenAuthenticatedRESTRequest } = ChromeUtils.importESModule(
+ "resource://services-common/rest.sys.mjs"
+);
+
+function run_test() {
+ initTestLogging("Trace");
+ run_next_test();
+}
+
+add_task(async function test_authenticated_request() {
+ _("Ensure that sending a MAC authenticated GET request works as expected.");
+
+ let message = "Great Success!";
+
+ let id = "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x";
+ let key = "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=";
+ let method = "GET";
+
+ let nonce = btoa(CryptoUtils.generateRandomBytesLegacy(16));
+ let ts = Math.floor(Date.now() / 1000);
+ let extra = { ts, nonce };
+
+ let auth;
+
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+ Assert.equal(auth, request.getHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+ let uri = CommonUtils.makeURI(server.baseURI + "/foo");
+ let sig = await CryptoUtils.computeHTTPMACSHA1(id, key, method, uri, extra);
+ auth = sig.getHeader();
+
+ let req = new TokenAuthenticatedRESTRequest(uri, { id, key }, extra);
+ await req.get();
+
+ Assert.equal(message, req.response.body);
+
+ await promiseStopServer(server);
+});
diff --git a/services/common/tests/unit/test_tokenserverclient.js b/services/common/tests/unit/test_tokenserverclient.js
new file mode 100644
index 0000000000..c1c47f6e1a
--- /dev/null
+++ b/services/common/tests/unit/test_tokenserverclient.js
@@ -0,0 +1,382 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const {
+ TokenServerClient,
+ TokenServerClientError,
+ TokenServerClientServerError,
+} = ChromeUtils.importESModule(
+ "resource://services-common/tokenserverclient.sys.mjs"
+);
+
+initTestLogging("Trace");
+
+add_task(async function test_working_token_exchange() {
+ _("Ensure that working OAuth token exchange works as expected.");
+
+ let service = "http://example.com/foo";
+ let duration = 300;
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ Assert.ok(request.hasHeader("accept"));
+ Assert.equal("application/json", request.getHeader("accept"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+
+ let body = JSON.stringify({
+ id: "id",
+ key: "key",
+ api_endpoint: service,
+ uid: "uid",
+ duration,
+ });
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+ let result = await client.getTokenUsingOAuth(url, "access_token");
+ Assert.equal("object", typeof result);
+ do_check_attribute_count(result, 7);
+ Assert.equal(service, result.endpoint);
+ Assert.equal("id", result.id);
+ Assert.equal("key", result.key);
+ Assert.equal("uid", result.uid);
+ Assert.equal(duration, result.duration);
+ Assert.deepEqual(undefined, result.node_type);
+ await promiseStopServer(server);
+});
+
+add_task(async function test_working_token_exchange_with_nodetype() {
+ _("Ensure that a token response with a node type as expected.");
+
+ let service = "http://example.com/foo";
+ let duration = 300;
+ let nodeType = "the-node-type";
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ Assert.ok(request.hasHeader("accept"));
+ Assert.equal("application/json", request.getHeader("accept"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+
+ let body = JSON.stringify({
+ id: "id",
+ key: "key",
+ api_endpoint: service,
+ uid: "uid",
+ duration,
+ node_type: nodeType,
+ });
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+ let result = await client.getTokenUsingOAuth(url, "access_token");
+ Assert.equal("object", typeof result);
+ do_check_attribute_count(result, 7);
+ Assert.equal(service, result.endpoint);
+ Assert.equal("id", result.id);
+ Assert.equal("key", result.key);
+ Assert.equal("uid", result.uid);
+ Assert.equal(duration, result.duration);
+ Assert.equal(nodeType, result.node_type);
+ await promiseStopServer(server);
+});
+
+add_task(async function test_invalid_arguments() {
+ _("Ensure invalid arguments to APIs are rejected.");
+
+ let args = [
+ [null, "access_token"],
+ ["http://example.com/", null],
+ ];
+
+ for (let arg of args) {
+ let client = new TokenServerClient();
+ await Assert.rejects(client.getTokenUsingOAuth(arg[0], arg[1]), ex => {
+ Assert.ok(ex instanceof TokenServerClientError);
+ return true;
+ });
+ }
+});
+
+add_task(async function test_invalid_403_no_content_type() {
+ _("Ensure that a 403 without content-type is handled properly.");
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ response.setStatusLine(request.httpVersion, 403, "Forbidden");
+ // No Content-Type header by design.
+
+ let body = JSON.stringify({
+ errors: [{ description: "irrelevant", location: "body", name: "" }],
+ urls: { foo: "http://bar" },
+ });
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.ok(error instanceof TokenServerClientServerError);
+ Assert.equal(error.cause, "malformed-response");
+
+ Assert.equal(null, error.urls);
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_send_extra_headers() {
+ _("Ensures that the condition acceptance header is sent when asked.");
+
+ let duration = 300;
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ Assert.ok(request.hasHeader("x-foo"));
+ Assert.equal(request.getHeader("x-foo"), "42");
+
+ Assert.ok(request.hasHeader("x-bar"));
+ Assert.equal(request.getHeader("x-bar"), "17");
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+
+ let body = JSON.stringify({
+ id: "id",
+ key: "key",
+ api_endpoint: "http://example.com/",
+ uid: "uid",
+ duration,
+ });
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+
+ let extra = {
+ "X-Foo": 42,
+ "X-Bar": 17,
+ };
+
+ await client.getTokenUsingOAuth(url, "access_token", extra);
+ // Other tests validate other things.
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_error_404_empty() {
+ _("Ensure that 404 responses without proper response are handled properly.");
+
+ let server = httpd_setup();
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/foo";
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.ok(error instanceof TokenServerClientServerError);
+ Assert.equal(error.cause, "malformed-response");
+
+ Assert.notEqual(null, error.response);
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_error_404_proper_response() {
+ _("Ensure that a Cornice error report for 404 is handled properly.");
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+
+ let body = JSON.stringify({
+ status: 404,
+ errors: [{ description: "No service", location: "body", name: "" }],
+ });
+
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.ok(error instanceof TokenServerClientServerError);
+ Assert.equal(error.cause, "unknown-service");
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_bad_json() {
+ _("Ensure that malformed JSON is handled properly.");
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+
+ let body = '{"id": "id", baz}';
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.notEqual(null, error);
+ Assert.equal("TokenServerClientServerError", error.name);
+ Assert.equal(error.cause, "malformed-response");
+ Assert.notEqual(null, error.response);
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_400_response() {
+ _("Ensure HTTP 400 is converted to malformed-request.");
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ response.setStatusLine(request.httpVersion, 400, "Bad Request");
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+
+ let body = "{}"; // Actual content may not be used.
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.notEqual(null, error);
+ Assert.equal("TokenServerClientServerError", error.name);
+ Assert.notEqual(null, error.response);
+ Assert.equal(error.cause, "malformed-request");
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_401_with_error_cause() {
+ _("Ensure 401 cause is specified in body.status");
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+
+ let body = JSON.stringify({ status: "no-soup-for-you" });
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let client = new TokenServerClient();
+ let url = server.baseURI + "/1.0/foo/1.0";
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.notEqual(null, error);
+ Assert.equal("TokenServerClientServerError", error.name);
+ Assert.notEqual(null, error.response);
+ Assert.equal(error.cause, "no-soup-for-you");
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_unhandled_media_type() {
+ _("Ensure that unhandled media types throw an error.");
+
+ let server = httpd_setup({
+ "/1.0/foo/1.0": function (request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "text/plain");
+
+ let body = "hello, world";
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let url = server.baseURI + "/1.0/foo/1.0";
+ let client = new TokenServerClient();
+
+ await Assert.rejects(
+ client.getTokenUsingOAuth(url, "access_token"),
+ error => {
+ Assert.notEqual(null, error);
+ Assert.equal("TokenServerClientServerError", error.name);
+ Assert.notEqual(null, error.response);
+ return true;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_rich_media_types() {
+ _("Ensure that extra tokens in the media type aren't rejected.");
+
+ let duration = 300;
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json; foo=bar; bar=foo");
+
+ let body = JSON.stringify({
+ id: "id",
+ key: "key",
+ api_endpoint: "foo",
+ uid: "uid",
+ duration,
+ });
+ response.bodyOutputStream.write(body, body.length);
+ },
+ });
+
+ let url = server.baseURI + "/foo";
+ let client = new TokenServerClient();
+
+ await client.getTokenUsingOAuth(url, "access_token");
+ await promiseStopServer(server);
+});
diff --git a/services/common/tests/unit/test_uptake_telemetry.js b/services/common/tests/unit/test_uptake_telemetry.js
new file mode 100644
index 0000000000..24967b641f
--- /dev/null
+++ b/services/common/tests/unit/test_uptake_telemetry.js
@@ -0,0 +1,121 @@
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+const { UptakeTelemetry } = ChromeUtils.importESModule(
+ "resource://services-common/uptake-telemetry.sys.mjs"
+);
+
+const COMPONENT = "remotesettings";
+
+async function withFakeClientID(uuid, f) {
+ const { Policy } = ChromeUtils.importESModule(
+ "resource://services-common/uptake-telemetry.sys.mjs"
+ );
+ let oldGetClientID = Policy.getClientID;
+ Policy._clientIDHash = null;
+ Policy.getClientID = () => Promise.resolve(uuid);
+ try {
+ return await f();
+ } finally {
+ Policy.getClientID = oldGetClientID;
+ }
+}
+
+add_task(async function test_unknown_status_is_not_reported() {
+ const source = "update-source";
+ const startSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+
+ try {
+ await UptakeTelemetry.report(COMPONENT, "unknown-status", { source });
+ } catch (e) {}
+
+ const endSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ Assert.deepEqual(startSnapshot, endSnapshot);
+});
+
+add_task(async function test_age_is_converted_to_string_and_reported() {
+ const status = UptakeTelemetry.STATUS.SUCCESS;
+ const age = 42;
+
+ await withFakeChannel("nightly", async () => {
+ await UptakeTelemetry.report(COMPONENT, status, { source: "s", age });
+ });
+
+ TelemetryTestUtils.assertEvents([
+ [
+ "uptake.remotecontent.result",
+ "uptake",
+ COMPONENT,
+ status,
+ { source: "s", age: `${age}` },
+ ],
+ ]);
+});
+
+add_task(async function test_each_status_can_be_caught_in_snapshot() {
+ const source = "some-source";
+ const startSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+
+ const expectedIncrements = {};
+ await withFakeChannel("nightly", async () => {
+ for (const status of Object.values(UptakeTelemetry.STATUS)) {
+ expectedIncrements[status] = 1;
+ await UptakeTelemetry.report(COMPONENT, status, { source });
+ }
+ });
+
+ const endSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ checkUptakeTelemetry(startSnapshot, endSnapshot, expectedIncrements);
+});
+
+add_task(async function test_events_are_sent_when_hash_is_mod_0() {
+ const source = "some-source";
+ const startSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ const startSuccess = startSnapshot.success || 0;
+ const uuid = "d81bbfad-d741-41f5-a7e6-29f6bde4972a"; // hash % 100 = 0
+ await withFakeClientID(uuid, async () => {
+ await withFakeChannel("release", async () => {
+ await UptakeTelemetry.report(COMPONENT, UptakeTelemetry.STATUS.SUCCESS, {
+ source,
+ });
+ });
+ });
+ const endSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ Assert.equal(endSnapshot.success, startSuccess + 1);
+});
+
+add_task(
+ async function test_events_are_not_sent_when_hash_is_greater_than_pref() {
+ const source = "some-source";
+ const startSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ const startSuccess = startSnapshot.success || 0;
+ const uuid = "d81bbfad-d741-41f5-a7e6-29f6bde49721"; // hash % 100 = 1
+ await withFakeClientID(uuid, async () => {
+ await withFakeChannel("release", async () => {
+ await UptakeTelemetry.report(
+ COMPONENT,
+ UptakeTelemetry.STATUS.SUCCESS,
+ { source }
+ );
+ });
+ });
+ const endSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ Assert.equal(endSnapshot.success || 0, startSuccess);
+ }
+);
+
+add_task(async function test_events_are_sent_when_nightly() {
+ const source = "some-source";
+ const startSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ const startSuccess = startSnapshot.success || 0;
+ const uuid = "d81bbfad-d741-41f5-a7e6-29f6bde49721"; // hash % 100 = 1
+ await withFakeClientID(uuid, async () => {
+ await withFakeChannel("nightly", async () => {
+ await UptakeTelemetry.report(COMPONENT, UptakeTelemetry.STATUS.SUCCESS, {
+ source,
+ });
+ });
+ });
+ const endSnapshot = getUptakeTelemetrySnapshot(COMPONENT, source);
+ Assert.equal(endSnapshot.success, startSuccess + 1);
+});
diff --git a/services/common/tests/unit/test_utils_atob.js b/services/common/tests/unit/test_utils_atob.js
new file mode 100644
index 0000000000..9e24b56f6f
--- /dev/null
+++ b/services/common/tests/unit/test_utils_atob.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ let data = ["Zm9vYmE=", "Zm9vYmE==", "Zm9vYmE==="];
+ for (let d in data) {
+ Assert.equal(CommonUtils.safeAtoB(data[d]), "fooba");
+ }
+}
diff --git a/services/common/tests/unit/test_utils_convert_string.js b/services/common/tests/unit/test_utils_convert_string.js
new file mode 100644
index 0000000000..fcfd874994
--- /dev/null
+++ b/services/common/tests/unit/test_utils_convert_string.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// A wise line of Greek verse, and the utf-8 byte encoding.
+// N.b., Greek begins at utf-8 ce 91
+const TEST_STR = "πόλλ' οἶδ' ἀλώπηξ, ἀλλ' ἐχῖνος ἓν μέγα";
+const TEST_HEX = h(
+ "cf 80 cf 8c ce bb ce bb 27 20 ce bf e1 bc b6 ce" +
+ "b4 27 20 e1 bc 80 ce bb cf 8e cf 80 ce b7 ce be" +
+ "2c 20 e1 bc 80 ce bb ce bb 27 20 e1 bc 90 cf 87" +
+ "e1 bf 96 ce bd ce bf cf 82 20 e1 bc 93 ce bd 20" +
+ "ce bc ce ad ce b3 ce b1"
+);
+// Integer byte values for the above
+const TEST_BYTES = [
+ 207, 128, 207, 140, 206, 187, 206, 187, 39, 32, 206, 191, 225, 188, 182, 206,
+ 180, 39, 32, 225, 188, 128, 206, 187, 207, 142, 207, 128, 206, 183, 206, 190,
+ 44, 32, 225, 188, 128, 206, 187, 206, 187, 39, 32, 225, 188, 144, 207, 135,
+ 225, 191, 150, 206, 189, 206, 191, 207, 130, 32, 225, 188, 147, 206, 189, 32,
+ 206, 188, 206, 173, 206, 179, 206, 177,
+];
+
+add_test(function test_compress_string() {
+ const INPUT = "hello";
+
+ let result = CommonUtils.convertString(INPUT, "uncompressed", "deflate");
+ Assert.equal(result.length, 13);
+
+ let result2 = CommonUtils.convertString(INPUT, "uncompressed", "deflate");
+ Assert.equal(result, result2);
+
+ let result3 = CommonUtils.convertString(result, "deflate", "uncompressed");
+ Assert.equal(result3, INPUT);
+
+ run_next_test();
+});
+
+add_test(function test_compress_utf8() {
+ const INPUT =
+ "Árvíztűrő tükörfúrógép いろはにほへとちりぬるを Pijamalı hasta, yağız şoföre çabucak güvendi.";
+ let inputUTF8 = CommonUtils.encodeUTF8(INPUT);
+
+ let compressed = CommonUtils.convertString(
+ inputUTF8,
+ "uncompressed",
+ "deflate"
+ );
+ let uncompressed = CommonUtils.convertString(
+ compressed,
+ "deflate",
+ "uncompressed"
+ );
+
+ Assert.equal(uncompressed, inputUTF8);
+
+ let outputUTF8 = CommonUtils.decodeUTF8(uncompressed);
+ Assert.equal(outputUTF8, INPUT);
+
+ run_next_test();
+});
+
+add_test(function test_bad_argument() {
+ let failed = false;
+ try {
+ CommonUtils.convertString(null, "uncompressed", "deflate");
+ } catch (ex) {
+ failed = true;
+ Assert.ok(ex.message.startsWith("Input string must be defined"));
+ } finally {
+ Assert.ok(failed);
+ }
+
+ run_next_test();
+});
+
+add_task(function test_stringAsHex() {
+ Assert.equal(TEST_HEX, CommonUtils.stringAsHex(TEST_STR));
+});
+
+add_task(function test_hexAsString() {
+ Assert.equal(TEST_STR, CommonUtils.hexAsString(TEST_HEX));
+});
+
+add_task(function test_hexToBytes() {
+ let bytes = CommonUtils.hexToBytes(TEST_HEX);
+ Assert.equal(TEST_BYTES.length, bytes.length);
+ // Ensure that the decimal values of each byte are correct
+ Assert.ok(arraysEqual(TEST_BYTES, CommonUtils.stringToByteArray(bytes)));
+});
+
+add_task(function test_bytesToHex() {
+ // Create a list of our character bytes from the reference int values
+ let bytes = CommonUtils.byteArrayToString(TEST_BYTES);
+ Assert.equal(TEST_HEX, CommonUtils.bytesAsHex(bytes));
+});
+
+add_task(function test_stringToBytes() {
+ Assert.ok(
+ arraysEqual(
+ TEST_BYTES,
+ CommonUtils.stringToByteArray(CommonUtils.stringToBytes(TEST_STR))
+ )
+ );
+});
+
+add_task(function test_stringRoundTrip() {
+ Assert.equal(
+ TEST_STR,
+ CommonUtils.hexAsString(CommonUtils.stringAsHex(TEST_STR))
+ );
+});
+
+add_task(function test_hexRoundTrip() {
+ Assert.equal(
+ TEST_HEX,
+ CommonUtils.stringAsHex(CommonUtils.hexAsString(TEST_HEX))
+ );
+});
+
+add_task(function test_byteArrayRoundTrip() {
+ Assert.ok(
+ arraysEqual(
+ TEST_BYTES,
+ CommonUtils.stringToByteArray(CommonUtils.byteArrayToString(TEST_BYTES))
+ )
+ );
+});
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
+
+function arraysEqual(a1, a2) {
+ if (a1.length !== a2.length) {
+ return false;
+ }
+ for (let i = 0; i < a1.length; i++) {
+ if (a1[i] !== a2[i]) {
+ return false;
+ }
+ }
+ return true;
+}
diff --git a/services/common/tests/unit/test_utils_dateprefs.js b/services/common/tests/unit/test_utils_dateprefs.js
new file mode 100644
index 0000000000..35b0c26470
--- /dev/null
+++ b/services/common/tests/unit/test_utils_dateprefs.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var prefs = Services.prefs.getBranch("servicescommon.tests.");
+
+function DummyLogger() {
+ this.messages = [];
+}
+DummyLogger.prototype.warn = function warn(message) {
+ this.messages.push(message);
+};
+
+add_test(function test_set_basic() {
+ let now = new Date();
+
+ CommonUtils.setDatePref(prefs, "test00", now);
+ let value = prefs.getStringPref("test00");
+ Assert.equal(value, "" + now.getTime());
+
+ let now2 = CommonUtils.getDatePref(prefs, "test00");
+
+ Assert.equal(now.getTime(), now2.getTime());
+
+ run_next_test();
+});
+
+add_test(function test_set_bounds_checking() {
+ let d = new Date(2342354);
+
+ let failed = false;
+ try {
+ CommonUtils.setDatePref(prefs, "test01", d);
+ } catch (ex) {
+ Assert.ok(ex.message.startsWith("Trying to set"));
+ failed = true;
+ }
+
+ Assert.ok(failed);
+ run_next_test();
+});
+
+add_test(function test_get_bounds_checking() {
+ prefs.setStringPref("test_bounds_checking", "13241431");
+
+ let log = new DummyLogger();
+ let d = CommonUtils.getDatePref(prefs, "test_bounds_checking", 0, log);
+ Assert.equal(d.getTime(), 0);
+ Assert.equal(log.messages.length, 1);
+
+ run_next_test();
+});
+
+add_test(function test_get_bad_default() {
+ let failed = false;
+ try {
+ CommonUtils.getDatePref(prefs, "get_bad_default", new Date());
+ } catch (ex) {
+ Assert.ok(ex.message.startsWith("Default value is not a number"));
+ failed = true;
+ }
+
+ Assert.ok(failed);
+ run_next_test();
+});
+
+add_test(function test_get_invalid_number() {
+ prefs.setStringPref("get_invalid_number", "hello world");
+
+ let log = new DummyLogger();
+ let d = CommonUtils.getDatePref(prefs, "get_invalid_number", 42, log);
+ Assert.equal(d.getTime(), 42);
+ Assert.equal(log.messages.length, 1);
+
+ run_next_test();
+});
diff --git a/services/common/tests/unit/test_utils_encodeBase32.js b/services/common/tests/unit/test_utils_encodeBase32.js
new file mode 100644
index 0000000000..3299ac8d05
--- /dev/null
+++ b/services/common/tests/unit/test_utils_encodeBase32.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ // Testing byte array manipulation.
+ Assert.equal(
+ "FOOBAR",
+ CommonUtils.byteArrayToString([70, 79, 79, 66, 65, 82])
+ );
+ Assert.equal("", CommonUtils.byteArrayToString([]));
+
+ _("Testing encoding...");
+ // Test vectors from RFC 4648
+ Assert.equal(CommonUtils.encodeBase32(""), "");
+ Assert.equal(CommonUtils.encodeBase32("f"), "MY======");
+ Assert.equal(CommonUtils.encodeBase32("fo"), "MZXQ====");
+ Assert.equal(CommonUtils.encodeBase32("foo"), "MZXW6===");
+ Assert.equal(CommonUtils.encodeBase32("foob"), "MZXW6YQ=");
+ Assert.equal(CommonUtils.encodeBase32("fooba"), "MZXW6YTB");
+ Assert.equal(CommonUtils.encodeBase32("foobar"), "MZXW6YTBOI======");
+
+ Assert.equal(
+ CommonUtils.encodeBase32("Bacon is a vegetable."),
+ "IJQWG33OEBUXGIDBEB3GKZ3FORQWE3DFFY======"
+ );
+
+ _("Checking assumptions...");
+ for (let i = 0; i <= 255; ++i) {
+ Assert.equal(undefined | i, i);
+ }
+
+ _("Testing decoding...");
+ Assert.equal(CommonUtils.decodeBase32(""), "");
+ Assert.equal(CommonUtils.decodeBase32("MY======"), "f");
+ Assert.equal(CommonUtils.decodeBase32("MZXQ===="), "fo");
+ Assert.equal(CommonUtils.decodeBase32("MZXW6YTB"), "fooba");
+ Assert.equal(CommonUtils.decodeBase32("MZXW6YTBOI======"), "foobar");
+
+ // Same with incorrect or missing padding.
+ Assert.equal(CommonUtils.decodeBase32("MZXW6YTBOI=="), "foobar");
+ Assert.equal(CommonUtils.decodeBase32("MZXW6YTBOI"), "foobar");
+
+ let encoded = CommonUtils.encodeBase32("Bacon is a vegetable.");
+ _("Encoded to " + JSON.stringify(encoded));
+ Assert.equal(CommonUtils.decodeBase32(encoded), "Bacon is a vegetable.");
+
+ // Test failure.
+ let err;
+ try {
+ CommonUtils.decodeBase32("000");
+ } catch (ex) {
+ err = ex;
+ }
+ Assert.equal(err.message, "Unknown character in base32: 0");
+}
diff --git a/services/common/tests/unit/test_utils_encodeBase64URL.js b/services/common/tests/unit/test_utils_encodeBase64URL.js
new file mode 100644
index 0000000000..84d98b06f1
--- /dev/null
+++ b/services/common/tests/unit/test_utils_encodeBase64URL.js
@@ -0,0 +1,21 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_test(function test_simple() {
+ let expected = {
+ hello: "aGVsbG8=",
+ "<>?": "PD4_",
+ };
+
+ for (let [k, v] of Object.entries(expected)) {
+ Assert.equal(CommonUtils.encodeBase64URL(k), v);
+ }
+
+ run_next_test();
+});
+
+add_test(function test_no_padding() {
+ Assert.equal(CommonUtils.encodeBase64URL("hello", false), "aGVsbG8");
+
+ run_next_test();
+});
diff --git a/services/common/tests/unit/test_utils_ensureMillisecondsTimestamp.js b/services/common/tests/unit/test_utils_ensureMillisecondsTimestamp.js
new file mode 100644
index 0000000000..f334364b2d
--- /dev/null
+++ b/services/common/tests/unit/test_utils_ensureMillisecondsTimestamp.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ Assert.equal(null, CommonUtils.ensureMillisecondsTimestamp(null));
+ Assert.equal(null, CommonUtils.ensureMillisecondsTimestamp(0));
+ Assert.equal(null, CommonUtils.ensureMillisecondsTimestamp("0"));
+ Assert.equal(null, CommonUtils.ensureMillisecondsTimestamp("000"));
+
+ Assert.equal(
+ null,
+ CommonUtils.ensureMillisecondsTimestamp(999 * 10000000000)
+ );
+
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp(-1);
+ });
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp(1);
+ });
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp(1.5);
+ });
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp(999 * 10000000000 + 0.5);
+ });
+
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp("-1");
+ });
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp("1");
+ });
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp("1.5");
+ });
+ do_check_throws(function err() {
+ CommonUtils.ensureMillisecondsTimestamp("" + (999 * 10000000000 + 0.5));
+ });
+}
diff --git a/services/common/tests/unit/test_utils_makeURI.js b/services/common/tests/unit/test_utils_makeURI.js
new file mode 100644
index 0000000000..d6d7e19db9
--- /dev/null
+++ b/services/common/tests/unit/test_utils_makeURI.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+_("Make sure uri strings are converted to nsIURIs");
+
+function run_test() {
+ _test_makeURI();
+}
+
+function _test_makeURI() {
+ _("Check http uris");
+ let uri1 = "http://mozillalabs.com/";
+ Assert.equal(CommonUtils.makeURI(uri1).spec, uri1);
+ let uri2 = "http://www.mozillalabs.com/";
+ Assert.equal(CommonUtils.makeURI(uri2).spec, uri2);
+ let uri3 = "http://mozillalabs.com/path";
+ Assert.equal(CommonUtils.makeURI(uri3).spec, uri3);
+ let uri4 = "http://mozillalabs.com/multi/path";
+ Assert.equal(CommonUtils.makeURI(uri4).spec, uri4);
+ let uri5 = "http://mozillalabs.com/?query";
+ Assert.equal(CommonUtils.makeURI(uri5).spec, uri5);
+ let uri6 = "http://mozillalabs.com/#hash";
+ Assert.equal(CommonUtils.makeURI(uri6).spec, uri6);
+
+ _("Check https uris");
+ let uris1 = "https://mozillalabs.com/";
+ Assert.equal(CommonUtils.makeURI(uris1).spec, uris1);
+ let uris2 = "https://www.mozillalabs.com/";
+ Assert.equal(CommonUtils.makeURI(uris2).spec, uris2);
+ let uris3 = "https://mozillalabs.com/path";
+ Assert.equal(CommonUtils.makeURI(uris3).spec, uris3);
+ let uris4 = "https://mozillalabs.com/multi/path";
+ Assert.equal(CommonUtils.makeURI(uris4).spec, uris4);
+ let uris5 = "https://mozillalabs.com/?query";
+ Assert.equal(CommonUtils.makeURI(uris5).spec, uris5);
+ let uris6 = "https://mozillalabs.com/#hash";
+ Assert.equal(CommonUtils.makeURI(uris6).spec, uris6);
+
+ _("Check chrome uris");
+ let uric1 = "chrome://browser/content/browser.xhtml";
+ Assert.equal(CommonUtils.makeURI(uric1).spec, uric1);
+ let uric2 = "chrome://browser/skin/browser.css";
+ Assert.equal(CommonUtils.makeURI(uric2).spec, uric2);
+
+ _("Check about uris");
+ let uria1 = "about:weave";
+ Assert.equal(CommonUtils.makeURI(uria1).spec, uria1);
+ let uria2 = "about:weave/";
+ Assert.equal(CommonUtils.makeURI(uria2).spec, uria2);
+ let uria3 = "about:weave/path";
+ Assert.equal(CommonUtils.makeURI(uria3).spec, uria3);
+ let uria4 = "about:weave/multi/path";
+ Assert.equal(CommonUtils.makeURI(uria4).spec, uria4);
+ let uria5 = "about:weave/?query";
+ Assert.equal(CommonUtils.makeURI(uria5).spec, uria5);
+ let uria6 = "about:weave/#hash";
+ Assert.equal(CommonUtils.makeURI(uria6).spec, uria6);
+
+ _("Invalid uris are undefined");
+ Assert.equal(CommonUtils.makeURI("mozillalabs.com"), undefined);
+ Assert.equal(CommonUtils.makeURI("this is a test"), undefined);
+}
diff --git a/services/common/tests/unit/test_utils_namedTimer.js b/services/common/tests/unit/test_utils_namedTimer.js
new file mode 100644
index 0000000000..47ac0b9dc1
--- /dev/null
+++ b/services/common/tests/unit/test_utils_namedTimer.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_test(function test_required_args() {
+ try {
+ CommonUtils.namedTimer(function callback() {
+ do_throw("Shouldn't fire.");
+ }, 0);
+ do_throw("Should have thrown!");
+ } catch (ex) {
+ run_next_test();
+ }
+});
+
+add_test(function test_simple() {
+ _("Test basic properties of CommonUtils.namedTimer.");
+
+ const delay = 200;
+ let that = {};
+ let t0 = Date.now();
+ CommonUtils.namedTimer(
+ function callback(timer) {
+ Assert.equal(this, that);
+ Assert.equal(this._zetimer, null);
+ Assert.ok(timer instanceof Ci.nsITimer);
+ // Difference should be ~delay, but hard to predict on all platforms,
+ // particularly Windows XP.
+ Assert.ok(Date.now() > t0);
+ run_next_test();
+ },
+ delay,
+ that,
+ "_zetimer"
+ );
+});
+
+add_test(function test_delay() {
+ _("Test delaying a timer that hasn't fired yet.");
+
+ const delay = 100;
+ let that = {};
+ let t0 = Date.now();
+ function callback(timer) {
+ // Difference should be ~2*delay, but hard to predict on all platforms,
+ // particularly Windows XP.
+ Assert.ok(Date.now() - t0 > delay);
+ run_next_test();
+ }
+ CommonUtils.namedTimer(callback, delay, that, "_zetimer");
+ CommonUtils.namedTimer(callback, 2 * delay, that, "_zetimer");
+ run_next_test();
+});
+
+add_test(function test_clear() {
+ _("Test clearing a timer that hasn't fired yet.");
+
+ const delay = 0;
+ let that = {};
+ CommonUtils.namedTimer(
+ function callback(timer) {
+ do_throw("Shouldn't fire!");
+ },
+ delay,
+ that,
+ "_zetimer"
+ );
+
+ that._zetimer.clear();
+ Assert.equal(that._zetimer, null);
+ CommonUtils.nextTick(run_next_test);
+
+ run_next_test();
+});
diff --git a/services/common/tests/unit/test_utils_sets.js b/services/common/tests/unit/test_utils_sets.js
new file mode 100644
index 0000000000..c15d48528f
--- /dev/null
+++ b/services/common/tests/unit/test_utils_sets.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const EMPTY = new Set();
+const A = new Set(["a"]);
+const ABC = new Set(["a", "b", "c"]);
+const ABCD = new Set(["a", "b", "c", "d"]);
+const BC = new Set(["b", "c"]);
+const BCD = new Set(["b", "c", "d"]);
+const FGH = new Set(["f", "g", "h"]);
+const BCDFGH = new Set(["b", "c", "d", "f", "g", "h"]);
+
+var union = CommonUtils.union;
+var difference = CommonUtils.difference;
+var intersection = CommonUtils.intersection;
+var setEqual = CommonUtils.setEqual;
+
+function do_check_setEqual(a, b) {
+ Assert.ok(setEqual(a, b));
+}
+
+function do_check_not_setEqual(a, b) {
+ Assert.ok(!setEqual(a, b));
+}
+
+add_test(function test_setEqual() {
+ do_check_setEqual(EMPTY, EMPTY);
+ do_check_setEqual(EMPTY, new Set());
+ do_check_setEqual(A, A);
+ do_check_setEqual(A, new Set(["a"]));
+ do_check_setEqual(new Set(["a"]), A);
+ do_check_not_setEqual(A, EMPTY);
+ do_check_not_setEqual(EMPTY, A);
+ do_check_not_setEqual(ABC, A);
+ run_next_test();
+});
+
+add_test(function test_union() {
+ do_check_setEqual(EMPTY, union(EMPTY, EMPTY));
+ do_check_setEqual(ABC, union(EMPTY, ABC));
+ do_check_setEqual(ABC, union(ABC, ABC));
+ do_check_setEqual(ABCD, union(ABC, BCD));
+ do_check_setEqual(ABCD, union(BCD, ABC));
+ do_check_setEqual(BCDFGH, union(BCD, FGH));
+ run_next_test();
+});
+
+add_test(function test_difference() {
+ do_check_setEqual(EMPTY, difference(EMPTY, EMPTY));
+ do_check_setEqual(EMPTY, difference(EMPTY, A));
+ do_check_setEqual(EMPTY, difference(A, A));
+ do_check_setEqual(ABC, difference(ABC, EMPTY));
+ do_check_setEqual(ABC, difference(ABC, FGH));
+ do_check_setEqual(A, difference(ABC, BCD));
+ run_next_test();
+});
+
+add_test(function test_intersection() {
+ do_check_setEqual(EMPTY, intersection(EMPTY, EMPTY));
+ do_check_setEqual(EMPTY, intersection(ABC, EMPTY));
+ do_check_setEqual(EMPTY, intersection(ABC, FGH));
+ do_check_setEqual(BC, intersection(ABC, BCD));
+ run_next_test();
+});
diff --git a/services/common/tests/unit/test_utils_utf8.js b/services/common/tests/unit/test_utils_utf8.js
new file mode 100644
index 0000000000..aa075873b9
--- /dev/null
+++ b/services/common/tests/unit/test_utils_utf8.js
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+ let str = "Umlaute: \u00FC \u00E4\n"; // Umlaute: ü ä
+ let encoded = CommonUtils.encodeUTF8(str);
+ let decoded = CommonUtils.decodeUTF8(encoded);
+ Assert.equal(decoded, str);
+}
diff --git a/services/common/tests/unit/test_utils_uuid.js b/services/common/tests/unit/test_utils_uuid.js
new file mode 100644
index 0000000000..33b407e92d
--- /dev/null
+++ b/services/common/tests/unit/test_utils_uuid.js
@@ -0,0 +1,12 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ let uuid = CommonUtils.generateUUID();
+ Assert.equal(uuid.length, 36);
+ Assert.equal(uuid[8], "-");
+
+ run_next_test();
+}
diff --git a/services/common/tests/unit/xpcshell.toml b/services/common/tests/unit/xpcshell.toml
new file mode 100644
index 0000000000..e4035f66b2
--- /dev/null
+++ b/services/common/tests/unit/xpcshell.toml
@@ -0,0 +1,63 @@
+[DEFAULT]
+head = "head_global.js head_helpers.js head_http.js"
+firefox-appdir = "browser"
+support-files = ["test_storage_adapter/**"]
+
+# Test load modules first so syntax failures are caught early.
+
+["test_async_chain.js"]
+
+["test_async_foreach.js"]
+
+["test_hawkclient.js"]
+skip-if = ["os == 'android'"]
+
+["test_hawkrequest.js"]
+skip-if = ["os == 'android'"]
+
+["test_kinto.js"]
+tags = "blocklist"
+
+["test_load_modules.js"]
+
+["test_logmanager.js"]
+
+["test_observers.js"]
+
+["test_restrequest.js"]
+
+["test_storage_adapter.js"]
+tags = "remote-settings blocklist"
+
+["test_storage_adapter_shutdown.js"]
+tags = "remote-settings blocklist"
+
+["test_tokenauthenticatedrequest.js"]
+
+["test_tokenserverclient.js"]
+skip-if = ["os == 'android'"]
+
+["test_uptake_telemetry.js"]
+tags = "remote-settings"
+
+["test_utils_atob.js"]
+
+["test_utils_convert_string.js"]
+
+["test_utils_dateprefs.js"]
+
+["test_utils_encodeBase32.js"]
+
+["test_utils_encodeBase64URL.js"]
+
+["test_utils_ensureMillisecondsTimestamp.js"]
+
+["test_utils_makeURI.js"]
+
+["test_utils_namedTimer.js"]
+
+["test_utils_sets.js"]
+
+["test_utils_utf8.js"]
+
+["test_utils_uuid.js"]