summaryrefslogtreecommitdiffstats
path: root/dom/push/test
diff options
context:
space:
mode:
Diffstat (limited to 'dom/push/test')
-rw-r--r--dom/push/test/error_worker.js9
-rw-r--r--dom/push/test/frame.html24
-rw-r--r--dom/push/test/lifetime_worker.js90
-rw-r--r--dom/push/test/mochitest.ini38
-rw-r--r--dom/push/test/mockpushserviceparent.js201
-rw-r--r--dom/push/test/test_data.html191
-rw-r--r--dom/push/test/test_error_reporting.html112
-rw-r--r--dom/push/test/test_has_permissions.html53
-rw-r--r--dom/push/test/test_multiple_register.html132
-rw-r--r--dom/push/test/test_multiple_register_different_scope.html123
-rw-r--r--dom/push/test/test_multiple_register_during_service_activation.html110
-rw-r--r--dom/push/test/test_permissions.html104
-rw-r--r--dom/push/test/test_register.html107
-rw-r--r--dom/push/test/test_register_key.html308
-rw-r--r--dom/push/test/test_serviceworker_lifetime.html364
-rw-r--r--dom/push/test/test_subscription_change.html67
-rw-r--r--dom/push/test/test_try_registering_offline_disabled.html307
-rw-r--r--dom/push/test/test_unregister.html78
-rw-r--r--dom/push/test/test_utils.js304
-rw-r--r--dom/push/test/webpush.js228
-rw-r--r--dom/push/test/worker.js174
-rw-r--r--dom/push/test/xpcshell/broadcast_handler.sys.mjs12
-rw-r--r--dom/push/test/xpcshell/head-http2.js42
-rw-r--r--dom/push/test/xpcshell/head.js499
-rw-r--r--dom/push/test/xpcshell/moz.build3
-rw-r--r--dom/push/test/xpcshell/test_broadcast_success.js428
-rw-r--r--dom/push/test/xpcshell/test_clearAll_successful.js130
-rw-r--r--dom/push/test/xpcshell/test_clear_forgetAboutSite.js225
-rw-r--r--dom/push/test/xpcshell/test_clear_origin_data.js132
-rw-r--r--dom/push/test/xpcshell/test_crypto.js666
-rw-r--r--dom/push/test/xpcshell/test_crypto_encrypt.js201
-rw-r--r--dom/push/test/xpcshell/test_drop_expired.js164
-rw-r--r--dom/push/test/xpcshell/test_handler_service.js74
-rw-r--r--dom/push/test/xpcshell/test_notification_ack.js163
-rw-r--r--dom/push/test/xpcshell/test_notification_data.js310
-rw-r--r--dom/push/test/xpcshell/test_notification_duplicate.js175
-rw-r--r--dom/push/test/xpcshell/test_notification_error.js151
-rw-r--r--dom/push/test/xpcshell/test_notification_incomplete.js151
-rw-r--r--dom/push/test/xpcshell/test_notification_version_string.js79
-rw-r--r--dom/push/test/xpcshell/test_observer_data.js61
-rw-r--r--dom/push/test/xpcshell/test_observer_remoting.js139
-rw-r--r--dom/push/test/xpcshell/test_permissions.js332
-rw-r--r--dom/push/test/xpcshell/test_quota_exceeded.js156
-rw-r--r--dom/push/test/xpcshell/test_quota_observer.js210
-rw-r--r--dom/push/test/xpcshell/test_quota_with_notification.js129
-rw-r--r--dom/push/test/xpcshell/test_reconnect_retry.js90
-rw-r--r--dom/push/test/xpcshell/test_record.js132
-rw-r--r--dom/push/test/xpcshell/test_register_5xxCode_http2.js127
-rw-r--r--dom/push/test/xpcshell/test_register_case.js67
-rw-r--r--dom/push/test/xpcshell/test_register_flush.js116
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_channel.js63
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_endpoint.js64
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_json.js60
-rw-r--r--dom/push/test/xpcshell/test_register_no_id.js66
-rw-r--r--dom/push/test/xpcshell/test_register_request_queue.js78
-rw-r--r--dom/push/test/xpcshell/test_register_rollback.js102
-rw-r--r--dom/push/test/xpcshell/test_register_success.js93
-rw-r--r--dom/push/test/xpcshell/test_register_timeout.js102
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_id.js75
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_type.js66
-rw-r--r--dom/push/test/xpcshell/test_registration_error.js44
-rw-r--r--dom/push/test/xpcshell/test_registration_error_http2.js38
-rw-r--r--dom/push/test/xpcshell/test_registration_missing_scope.js24
-rw-r--r--dom/push/test/xpcshell/test_registration_none.js30
-rw-r--r--dom/push/test/xpcshell/test_registration_success.js66
-rw-r--r--dom/push/test/xpcshell/test_registration_success_http2.js77
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js111
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js114
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js114
-rw-r--r--dom/push/test/xpcshell/test_retry_ws.js73
-rw-r--r--dom/push/test/xpcshell/test_service_child.js354
-rw-r--r--dom/push/test/xpcshell/test_service_parent.js35
-rw-r--r--dom/push/test/xpcshell/test_unregister_empty_scope.js44
-rw-r--r--dom/push/test/xpcshell/test_unregister_error.js72
-rw-r--r--dom/push/test/xpcshell/test_unregister_invalid_json.js102
-rw-r--r--dom/push/test/xpcshell/test_unregister_not_found.js40
-rw-r--r--dom/push/test/xpcshell/test_unregister_success.js84
-rw-r--r--dom/push/test/xpcshell/test_unregister_success_http2.js78
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js70
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js102
-rw-r--r--dom/push/test/xpcshell/xpcshell.ini73
81 files changed, 10802 insertions, 0 deletions
diff --git a/dom/push/test/error_worker.js b/dom/push/test/error_worker.js
new file mode 100644
index 0000000000..f421118d79
--- /dev/null
+++ b/dom/push/test/error_worker.js
@@ -0,0 +1,9 @@
+this.onpush = function (event) {
+ var request = event.data.json();
+ if (request.type == "exception") {
+ throw new Error("Uncaught exception");
+ }
+ if (request.type == "rejection") {
+ event.waitUntil(Promise.reject(new Error("Unhandled rejection")));
+ }
+};
diff --git a/dom/push/test/frame.html b/dom/push/test/frame.html
new file mode 100644
index 0000000000..50036db15e
--- /dev/null
+++ b/dom/push/test/frame.html
@@ -0,0 +1,24 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+ <script>
+
+ function waitOnWorkerMessage(type) {
+ return new Promise(function(res, rej) {
+ function onMessage(e) {
+ if (e.data.type == type) {
+ navigator.serviceWorker.removeEventListener("message", onMessage);
+ (e.data.okay == "yes" ? res : rej)(e.data);
+ }
+ }
+ navigator.serviceWorker.addEventListener("message", onMessage);
+ });
+ }
+
+ </script>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/dom/push/test/lifetime_worker.js b/dom/push/test/lifetime_worker.js
new file mode 100644
index 0000000000..02c09d966e
--- /dev/null
+++ b/dom/push/test/lifetime_worker.js
@@ -0,0 +1,90 @@
+var state = "from_scope";
+var resolvePromiseCallback;
+
+self.onfetch = function (event) {
+ if (event.request.url.includes("lifetime_frame.html")) {
+ event.respondWith(new Response("iframe_lifetime"));
+ return;
+ }
+
+ var currentState = state;
+ event.waitUntil(
+ self.clients.matchAll().then(clients => {
+ clients.forEach(client => {
+ client.postMessage({ type: "fetch", state: currentState });
+ });
+ })
+ );
+
+ if (event.request.url.includes("update")) {
+ state = "update";
+ } else if (event.request.url.includes("wait")) {
+ event.respondWith(
+ new Promise(function (res, rej) {
+ if (resolvePromiseCallback) {
+ dump("ERROR: service worker was already waiting on a promise.\n");
+ }
+ resolvePromiseCallback = function () {
+ res(new Response("resolve_respondWithPromise"));
+ };
+ })
+ );
+ state = "wait";
+ } else if (event.request.url.includes("release")) {
+ state = "release";
+ resolvePromise();
+ }
+};
+
+function resolvePromise() {
+ if (resolvePromiseCallback === undefined || resolvePromiseCallback == null) {
+ dump("ERROR: wait promise was not set.\n");
+ return;
+ }
+ resolvePromiseCallback();
+ resolvePromiseCallback = null;
+}
+
+self.onmessage = function (event) {
+ var lastState = state;
+ state = event.data;
+ if (state === "wait") {
+ event.waitUntil(
+ new Promise(function (res, rej) {
+ if (resolvePromiseCallback) {
+ dump("ERROR: service worker was already waiting on a promise.\n");
+ }
+ resolvePromiseCallback = res;
+ })
+ );
+ } else if (state === "release") {
+ resolvePromise();
+ }
+ event.source.postMessage({ type: "message", state: lastState });
+};
+
+self.onpush = function (event) {
+ var pushResolve;
+ event.waitUntil(
+ new Promise(function (resolve) {
+ pushResolve = resolve;
+ })
+ );
+
+ // FIXME(catalinb): push message carry no data. So we assume the only
+ // push message we get is "wait"
+ self.clients.matchAll().then(function (client) {
+ if (!client.length) {
+ dump("ERROR: no clients to send the response to.\n");
+ }
+
+ client[0].postMessage({ type: "push", state });
+
+ state = "wait";
+ if (resolvePromiseCallback) {
+ dump("ERROR: service worker was already waiting on a promise.\n");
+ } else {
+ resolvePromiseCallback = pushResolve;
+ }
+ });
+};
diff --git a/dom/push/test/mochitest.ini b/dom/push/test/mochitest.ini
new file mode 100644
index 0000000000..2014d2afbb
--- /dev/null
+++ b/dom/push/test/mochitest.ini
@@ -0,0 +1,38 @@
+[DEFAULT]
+skip-if = os == "android"
+tags = condprof
+support-files =
+ worker.js
+ frame.html
+ webpush.js
+ lifetime_worker.js
+ test_utils.js
+ mockpushserviceparent.js
+ error_worker.js
+
+
+[test_has_permissions.html]
+[test_permissions.html]
+[test_register.html]
+skip-if = os == "win" # Bug 1373346
+[test_register_key.html]
+scheme = https
+[test_multiple_register.html]
+[test_multiple_register_during_service_activation.html]
+skip-if = (os == "win") || (os == "linux") || (os == "mac") #Bug 1274773
+[test_unregister.html]
+[test_multiple_register_different_scope.html]
+[test_subscription_change.html]
+skip-if = os == "win" # Bug 1373346
+[test_data.html]
+skip-if = os == "win" # Bug 1373346
+scheme = https
+[test_try_registering_offline_disabled.html]
+skip-if = os == "win" # Bug 1373346
+[test_serviceworker_lifetime.html]
+skip-if = serviceworker_e10s
+ os == "win"
+ os =="linux" && bits == 64
+ os =="mac" # Windows: Bug 1373346, Bug 1578333, Bug 1578374
+[test_error_reporting.html]
+skip-if = serviceworker_e10s
diff --git a/dom/push/test/mockpushserviceparent.js b/dom/push/test/mockpushserviceparent.js
new file mode 100644
index 0000000000..a6089f6dad
--- /dev/null
+++ b/dom/push/test/mockpushserviceparent.js
@@ -0,0 +1,201 @@
+/* eslint-env mozilla/chrome-script */
+
+"use strict";
+
+/**
+ * Defers one or more callbacks until the next turn of the event loop. Multiple
+ * callbacks are executed in order.
+ *
+ * @param {Function[]} callbacks The callbacks to execute. One callback will be
+ * executed per tick.
+ */
+function waterfall(...callbacks) {
+ callbacks
+ .reduce(
+ (promise, callback) =>
+ promise.then(() => {
+ callback();
+ }),
+ Promise.resolve()
+ )
+ .catch(Cu.reportError);
+}
+
+/**
+ * Minimal implementation of a mock WebSocket connect to be used with
+ * PushService. Forwards and receive messages from the implementation
+ * that lives in the content process.
+ */
+function MockWebSocketParent(originalURI) {
+ this._originalURI = originalURI;
+}
+
+MockWebSocketParent.prototype = {
+ _originalURI: null,
+
+ _listener: null,
+ _context: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, originAttributes, windowId, listener, context) {
+ this._listener = listener;
+ this._context = context;
+ waterfall(() => this._listener.onStart(this._context));
+ },
+
+ sendMsg(msg) {
+ sendAsyncMessage("socket-client-msg", msg);
+ },
+
+ close() {
+ waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
+ },
+
+ serverSendMsg(msg) {
+ waterfall(
+ () => this._listener.onMessageAvailable(this._context, msg),
+ () => this._listener.onAcknowledge(this._context, 0)
+ );
+ },
+};
+
+var pushService = Cc["@mozilla.org/push/Service;1"].getService(
+ Ci.nsIPushService
+).wrappedJSObject;
+
+var mockSocket;
+var serverMsgs = [];
+
+addMessageListener("socket-setup", function () {
+ pushService.replaceServiceBackend({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ mockSocket = new MockWebSocketParent(uri);
+ while (serverMsgs.length) {
+ let msg = serverMsgs.shift();
+ mockSocket.serverSendMsg(msg);
+ }
+ return mockSocket;
+ },
+ });
+});
+
+addMessageListener("socket-teardown", function (msg) {
+ pushService
+ .restoreServiceBackend()
+ .then(_ => {
+ serverMsgs.length = 0;
+ if (mockSocket) {
+ mockSocket.close();
+ mockSocket = null;
+ }
+ sendAsyncMessage("socket-server-teardown");
+ })
+ .catch(error => {
+ Cu.reportError(`Error restoring service backend: ${error}`);
+ });
+});
+
+addMessageListener("socket-server-msg", function (msg) {
+ if (mockSocket) {
+ mockSocket.serverSendMsg(msg);
+ } else {
+ serverMsgs.push(msg);
+ }
+});
+
+var MockService = {
+ requestID: 1,
+ resolvers: new Map(),
+
+ sendRequest(name, params) {
+ return new Promise((resolve, reject) => {
+ let id = this.requestID++;
+ this.resolvers.set(id, { resolve, reject });
+ sendAsyncMessage("service-request", {
+ name,
+ id,
+ // The request params from the real push service may contain a
+ // principal, which cannot be passed to the unprivileged
+ // mochitest scope, and will cause the message to be dropped if
+ // present. The mochitest scope fortunately does not need the
+ // principal, though, so set it to null before sending.
+ params: Object.assign({}, params, { principal: null }),
+ });
+ });
+ },
+
+ handleResponse(response) {
+ if (!this.resolvers.has(response.id)) {
+ Cu.reportError(`Unexpected response for request ${response.id}`);
+ return;
+ }
+ let resolver = this.resolvers.get(response.id);
+ this.resolvers.delete(response.id);
+ if (response.error) {
+ resolver.reject(response.error);
+ } else {
+ resolver.resolve(response.result);
+ }
+ },
+
+ init() {},
+
+ register(pageRecord) {
+ return this.sendRequest("register", pageRecord);
+ },
+
+ registration(pageRecord) {
+ return this.sendRequest("registration", pageRecord);
+ },
+
+ unregister(pageRecord) {
+ return this.sendRequest("unregister", pageRecord);
+ },
+
+ reportDeliveryError(messageId, reason) {
+ sendAsyncMessage("service-delivery-error", {
+ messageId,
+ reason,
+ });
+ },
+
+ uninit() {
+ return Promise.resolve();
+ },
+};
+
+async function replaceService(service) {
+ await pushService.service.uninit();
+ pushService.service = service;
+ await pushService.service.init();
+}
+
+addMessageListener("service-replace", function () {
+ replaceService(MockService)
+ .then(_ => {
+ sendAsyncMessage("service-replaced");
+ })
+ .catch(error => {
+ Cu.reportError(`Error replacing service: ${error}`);
+ });
+});
+
+addMessageListener("service-restore", function () {
+ replaceService(null)
+ .then(_ => {
+ sendAsyncMessage("service-restored");
+ })
+ .catch(error => {
+ Cu.reportError(`Error restoring service: ${error}`);
+ });
+});
+
+addMessageListener("service-response", function (response) {
+ MockService.handleResponse(response);
+});
diff --git a/dom/push/test/test_data.html b/dom/push/test/test_data.html
new file mode 100644
index 0000000000..a2f043b7d9
--- /dev/null
+++ b/dom/push/test/test_data.html
@@ -0,0 +1,191 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1185544: Add data delivery to the WebSocket backend.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1185544</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/webpush.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1185544">Mozilla Bug 1185544</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ /* globals webPushEncrypt */
+
+ var userAgentID = "ac44402c-85fc-41e4-a0d0-483316d15351";
+ var channelID = null;
+
+ var mockSocket = new MockWebSocket();
+ mockSocket.onRegister = function(request) {
+ channelID = request.channelID;
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: userAgentID,
+ channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/1",
+ }));
+ };
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(mockSocket);
+ await setPushPermission(true);
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ var pushSubscription;
+ add_task(async function subscribe() {
+ pushSubscription = await registration.pushManager.subscribe();
+ });
+
+ add_task(async function compareJSONSubscription() {
+ var json = pushSubscription.toJSON();
+ is(json.endpoint, pushSubscription.endpoint, "Wrong endpoint");
+
+ ["p256dh", "auth"].forEach(keyName => {
+ isDeeply(
+ base64UrlDecode(json.keys[keyName]),
+ new Uint8Array(pushSubscription.getKey(keyName)),
+ "Mismatched Base64-encoded key: " + keyName
+ );
+ });
+ });
+
+ add_task(async function comparePublicKey() {
+ var data = await sendRequestToWorker({ type: "publicKey" });
+ var p256dhKey = new Uint8Array(pushSubscription.getKey("p256dh"));
+ is(p256dhKey.length, 65, "Key share should be 65 octets");
+ isDeeply(
+ p256dhKey,
+ new Uint8Array(data.p256dh),
+ "Mismatched key share"
+ );
+ var authSecret = new Uint8Array(pushSubscription.getKey("auth"));
+ is(authSecret.length, 16, "Auth secret should be 16 octets");
+ isDeeply(
+ authSecret,
+ new Uint8Array(data.auth),
+ "Mismatched auth secret"
+ );
+ });
+
+ var version = 0;
+ function sendEncryptedMsg(pushSub, message) {
+ return webPushEncrypt(pushSub, message)
+ .then((encryptedData) => {
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: version++,
+ channelID,
+ data: encryptedData.data,
+ headers: {
+ encryption: encryptedData.encryption,
+ encryption_key: encryptedData.encryption_key,
+ encoding: encryptedData.encoding,
+ },
+ }));
+ });
+ }
+
+ function waitForMessage(pushSub, message) {
+ return Promise.all([
+ controlledFrame.waitOnWorkerMessage("finished"),
+ sendEncryptedMsg(pushSub, message),
+ ]).then(([msg]) => msg);
+ }
+
+ add_task(async function sendPushMessageFromPage() {
+ var typedArray = new Uint8Array([226, 130, 40, 240, 40, 140, 188]);
+ var json = { hello: "world" };
+
+ var message = await waitForMessage(pushSubscription, "Text message from page");
+ is(message.data.text, "Text message from page", "Wrong text message data");
+
+ message = await waitForMessage(
+ pushSubscription,
+ typedArray
+ );
+ isDeeply(new Uint8Array(message.data.arrayBuffer), typedArray,
+ "Wrong array buffer message data");
+
+ message = await waitForMessage(
+ pushSubscription,
+ JSON.stringify(json)
+ );
+ ok(message.data.json.ok, "Unexpected error parsing JSON");
+ isDeeply(message.data.json.value, json, "Wrong JSON message data");
+
+ message = await waitForMessage(
+ pushSubscription,
+ ""
+ );
+ ok(message, "Should include data for empty messages");
+ is(message.data.text, "", "Wrong text for empty message");
+ is(message.data.arrayBuffer.byteLength, 0, "Wrong buffer length for empty message");
+ ok(!message.data.json.ok, "Expected JSON parse error for empty message");
+
+ message = await waitForMessage(
+ pushSubscription,
+ new Uint8Array([0x48, 0x69, 0x21, 0x20, 0xf0, 0x9f, 0x91, 0x80])
+ );
+ is(message.data.text, "Hi! \ud83d\udc40", "Wrong text for message with emoji");
+ var text = await new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.onloadend = event => {
+ if (reader.error) {
+ reject(reader.error);
+ } else {
+ resolve(reader.result);
+ }
+ };
+ reader.readAsText(message.data.blob);
+ });
+ is(text, "Hi! \ud83d\udc40", "Wrong blob data for message with emoji");
+
+ var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
+ // Send a blank message.
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID,
+ }));
+
+ var msg = await finishedPromise;
+ ok(!msg.data, "Should exclude data for blank messages");
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ await pushSubscription.unsubscribe();
+ });
+
+ add_task(async function unregister() {
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_error_reporting.html b/dom/push/test/test_error_reporting.html
new file mode 100644
index 0000000000..c180a1153d
--- /dev/null
+++ b/dom/push/test/test_error_reporting.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1246341: Report message delivery failures to the Push server.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1246341</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1246341">Mozilla Bug 1246341</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var pushNotifier = SpecialPowers.Cc["@mozilla.org/push/Notifier;1"]
+ .getService(SpecialPowers.Ci.nsIPushNotifier);
+
+ var reporters = new Map();
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndReplaceService({
+ reportDeliveryError(messageId, reason) {
+ ok(reporters.has(messageId),
+ "Unexpected error reported for message " + messageId);
+ var resolve = reporters.get(messageId);
+ reporters.delete(messageId);
+ resolve(reason);
+ },
+ });
+ await setPushPermission(true);
+
+ var url = "error_worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ var idCounter = 1;
+ function waitForDeliveryError(request) {
+ return new Promise(resolve => {
+ var data = new TextEncoder().encode(JSON.stringify(request));
+ var principal = SpecialPowers.wrap(window).clientPrincipal;
+
+ let messageId = "message-" + (idCounter++);
+ reporters.set(messageId, resolve);
+ pushNotifier.notifyPushWithData(registration.scope, principal, messageId,
+ data);
+ });
+ }
+
+ add_task(async function reportDeliveryErrors() {
+ var reason = await waitForDeliveryError({ type: "exception" });
+ is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION,
+ "Should report uncaught exceptions");
+
+ reason = await waitForDeliveryError({ type: "rejection" });
+ is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION,
+ "Should report unhandled rejections");
+ });
+
+ add_task(async function reportDecryptionError() {
+ var message = await new Promise(resolve => {
+ SpecialPowers.registerConsoleListener(msg => {
+ if (!msg.isScriptError && !msg.isConsoleEvent) {
+ return;
+ }
+ const scope = "http://mochi.test:8888/tests/dom/push/test/";
+ if (msg.innerWindowID === "ServiceWorker" &&
+ msg.windowID === scope) {
+ SpecialPowers.postConsoleSentinel();
+ resolve(msg);
+ }
+ });
+
+ var principal = SpecialPowers.wrap(window).clientPrincipal;
+ pushNotifier.notifyError(registration.scope, principal, "Push error",
+ SpecialPowers.Ci.nsIScriptError.errorFlag);
+ });
+
+ is(message.sourceName, registration.scope,
+ "Should use the qualified scope URL as the source");
+ is(message.errorMessage, "Push error",
+ "Should report the given error string");
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ });
+
+ add_task(async function unregister() {
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_has_permissions.html b/dom/push/test/test_has_permissions.html
new file mode 100644
index 0000000000..0bef8fe19f
--- /dev/null
+++ b/dom/push/test/test_has_permissions.html
@@ -0,0 +1,53 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var registration;
+
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+
+ var url = "worker.js?" + Math.random();
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ add_task(async function hasPermission() {
+ var state = await registration.pushManager.permissionState();
+ debug("state: " + state);
+ ok(["granted", "denied", "prompt"].includes(state), "permissionState() returned a valid state.");
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_multiple_register.html b/dom/push/test/test_multiple_register.html
new file mode 100644
index 0000000000..3a963b7cd4
--- /dev/null
+++ b/dom/push/test/test_multiple_register.html
@@ -0,0 +1,132 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var registration;
+
+ function start() {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."})
+ .then((swr) => {
+ registration = swr;
+ return waitForActive(registration);
+ });
+ }
+
+ function unregister() {
+ return registration.unregister().then(function(result) {
+ ok(result, "Unregister should return true.");
+ }, function(e) {
+ dump("Unregistering the SW failed with " + e + "\n");
+ });
+ }
+
+ function setupPushNotification(swr) {
+ var p = new Promise(function(res, rej) {
+ swr.pushManager.subscribe().then(
+ function(pushSubscription) {
+ ok(true, "successful registered for push notification");
+ res({swr, pushSubscription});
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function setupSecondEndpoint(result) {
+ var p = new Promise(function(res, rej) {
+ result.swr.pushManager.subscribe().then(
+ function(pushSubscription) {
+ ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "setupSecondEndpoint - Got the same endpoint back.");
+ res(result);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function getEndpointExpectNull(swr) {
+ var p = new Promise(function(res, rej) {
+ swr.pushManager.getSubscription().then(
+ function(pushSubscription) {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ res(swr);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function getEndpoint(result) {
+ var p = new Promise(function(res, rej) {
+ result.swr.pushManager.getSubscription().then(
+ function(pushSubscription) {
+ ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "getEndpoint - Got the same endpoint back.");
+
+ res(pushSubscription);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function unregisterPushNotification(pushSubscription) {
+ return pushSubscription.unsubscribe();
+ }
+
+ function runTest() {
+ start()
+ .then(getEndpointExpectNull)
+ .then(setupPushNotification)
+ .then(setupSecondEndpoint)
+ .then(getEndpoint)
+ .then(unregisterPushNotification)
+ .then(unregister)
+ .catch(function(e) {
+ ok(false, "Some test failed with error " + e);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket())
+ .then(_ => setPushPermission(true))
+ .then(_ => runTest());
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_multiple_register_different_scope.html b/dom/push/test/test_multiple_register_different_scope.html
new file mode 100644
index 0000000000..b7c5bf1414
--- /dev/null
+++ b/dom/push/test/test_multiple_register_different_scope.html
@@ -0,0 +1,123 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1150812: Test registering for two different scopes.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1150812</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var scopeA = "./a/";
+ var scopeB = "./b/";
+
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ function registerServiceWorker(scope) {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope})
+ .then(swr => waitForActive(swr));
+ }
+
+ function unregister(swr) {
+ return swr.unregister()
+ .then(result => {
+ ok(result, "Unregister should return true.");
+ }, err => {
+ ok(false, "Unregistering the SW failed with " + err + "\n");
+ throw err;
+ });
+ }
+
+ function subscribe(swr) {
+ return swr.pushManager.subscribe()
+ .then(sub => {
+ ok(true, "successful registered for push notification");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+
+ function setupMultipleSubscriptions(swr1, swr2) {
+ return Promise.all([
+ subscribe(swr1),
+ subscribe(swr2),
+ ]).then(a => {
+ ok(a[0].endpoint != a[1].endpoint, "setupMultipleSubscriptions - Got different endpoints.");
+ return a;
+ });
+ }
+
+ function getEndpointExpectNull(swr) {
+ return swr.pushManager.getSubscription()
+ .then(pushSubscription => {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function getEndpoint(swr, results) {
+ return swr.pushManager.getSubscription()
+ .then(sub => {
+ ok((results[0].endpoint == sub.endpoint) ||
+ (results[1].endpoint == sub.endpoint), "getEndpoint - Got the same endpoint back.");
+ return results;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function unsubscribe(result) {
+ return result[0].unsubscribe()
+ .then(_ => result[1].unsubscribe());
+ }
+
+ function runTest() {
+ registerServiceWorker(scopeA)
+ .then(swrA =>
+ registerServiceWorker(scopeB)
+ .then(swrB =>
+ getEndpointExpectNull(swrA)
+ .then(_ => getEndpointExpectNull(swrB))
+ .then(_ => setupMultipleSubscriptions(swrA, swrB))
+ .then(results => getEndpoint(swrA, results))
+ .then(results => getEndpoint(swrB, results))
+ .then(results => unsubscribe(results))
+ .then(_ => unregister(swrA))
+ .then(_ => unregister(swrB))
+ )
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket())
+ .then(_ => setPushPermission(true))
+ .then(_ => runTest());
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_multiple_register_during_service_activation.html b/dom/push/test/test_multiple_register_during_service_activation.html
new file mode 100644
index 0000000000..be043a523e
--- /dev/null
+++ b/dom/push/test/test_multiple_register_during_service_activation.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1150812: If service is in activating or no connection state it can not send
+request immediately, but the requests are queued. This test test the case of
+multiple subscription for the same scope during activation.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1150812</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ function registerServiceWorker() {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."});
+ }
+
+ function unregister(swr) {
+ return swr.unregister()
+ .then(result => {
+ ok(result, "Unregister should return true.");
+ }, err => {
+ dump("Unregistering the SW failed with " + err + "\n");
+ throw err;
+ });
+ }
+
+ function subscribe(swr) {
+ return swr.pushManager.subscribe()
+ .then(sub => {
+ ok(true, "successful registered for push notification");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function setupMultipleSubscriptions(swr) {
+ // We need to do this to restart service so that a queue will be formed.
+ let promiseTeardown = teardownMockPushSocket();
+ setupMockPushSocket(new MockWebSocket());
+
+ var pushSubscription;
+ return Promise.all([
+ subscribe(swr),
+ subscribe(swr),
+ ]).then(a => {
+ ok(a[0].endpoint == a[1].endpoint, "setupMultipleSubscriptions - Got the same endpoint back.");
+ pushSubscription = a[0];
+ return promiseTeardown;
+ }).then(_ => {
+ return pushSubscription;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function getEndpointExpectNull(swr) {
+ return swr.pushManager.getSubscription()
+ .then(pushSubscription => {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function unsubscribe(sub) {
+ return sub.unsubscribe();
+ }
+
+ function runTest() {
+ registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => setupMultipleSubscriptions(swr))
+ .then(sub => unsubscribe(sub))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_permissions.html b/dom/push/test/test_permissions.html
new file mode 100644
index 0000000000..442cfe4a09
--- /dev/null
+++ b/dom/push/test/test_permissions.html
@@ -0,0 +1,104 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+ await setPushPermission(false);
+
+ var url = "worker.js?" + Math.random();
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ add_task(async function denySubscribe() {
+ try {
+ await registration.pushManager.subscribe();
+ ok(false, "subscribe() should fail because no permission for push");
+ } catch (error) {
+ ok(error instanceof DOMException, "Wrong exception type");
+ is(error.name, "NotAllowedError", "Wrong exception name");
+ }
+ });
+
+ add_task(async function denySubscribeInWorker() {
+ // If permission is revoked, `getSubscription()` should return `null`, and
+ // `subscribe()` should reject immediately. Calling these from the worker
+ // should not deadlock the main thread (see bug 1228723).
+ var errorInfo = await sendRequestToWorker({
+ type: "denySubscribe",
+ });
+ ok(errorInfo.isDOMException, "Wrong exception type");
+ is(errorInfo.name, "NotAllowedError", "Wrong exception name");
+ });
+
+ add_task(async function getEndpoint() {
+ var pushSubscription = await registration.pushManager.getSubscription();
+ is(pushSubscription, null, "getSubscription() should return null because no permission for push");
+ });
+
+ add_task(async function checkPermissionState() {
+ var permissionManager = SpecialPowers.Ci.nsIPermissionManager;
+ var tests = [{
+ action: permissionManager.ALLOW_ACTION,
+ state: "granted",
+ }, {
+ action: permissionManager.DENY_ACTION,
+ state: "denied",
+ }, {
+ action: permissionManager.PROMPT_ACTION,
+ state: "prompt",
+ }, {
+ action: permissionManager.UNKNOWN_ACTION,
+ state: "prompt",
+ }];
+ for (var test of tests) {
+ await setPushPermission(test.action);
+ var state = await registration.pushManager.permissionState();
+ is(state, test.state, JSON.stringify(test));
+ try {
+ await SpecialPowers.pushPrefEnv({ set: [
+ ["dom.push.testing.ignorePermission", true]] });
+ state = await registration.pushManager.permissionState();
+ is(state, "granted", `Should ignore ${
+ test.action} if the override pref is set`);
+ } finally {
+ await SpecialPowers.flushPrefEnv();
+ }
+ }
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_register.html b/dom/push/test/test_register.html
new file mode 100644
index 0000000000..541e4a2d8d
--- /dev/null
+++ b/dom/push/test/test_register.html
@@ -0,0 +1,107 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var mockSocket = new MockWebSocket();
+
+ var channelID = null;
+
+ mockSocket.onRegister = function(request) {
+ channelID = request.channelID;
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: "c69e2014-9e15-438d-b253-d79cc2df60a8",
+ channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/1",
+ }));
+ };
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(mockSocket);
+ await setPushPermission(true);
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ add_task(async function checkPermissionState() {
+ var state = await registration.pushManager.permissionState();
+ is(state, "granted", "permissionState() should resolve to granted.");
+ });
+
+ var pushSubscription;
+ add_task(async function subscribe() {
+ pushSubscription = await registration.pushManager.subscribe();
+ is(pushSubscription.options.applicationServerKey, null,
+ "Subscription should not have an app server key");
+ });
+
+ add_task(async function resubscribe() {
+ var data = await sendRequestToWorker({
+ type: "resubscribe",
+ endpoint: pushSubscription.endpoint,
+ });
+ pushSubscription = await registration.pushManager.getSubscription();
+ is(data.endpoint, pushSubscription.endpoint,
+ "Subscription endpoints should match after resubscribing in worker");
+ });
+
+ add_task(async function waitForPushNotification() {
+ var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
+
+ // Send a blank message.
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID,
+ }));
+
+ await finishedPromise;
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ await pushSubscription.unsubscribe();
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_register_key.html b/dom/push/test/test_register_key.html
new file mode 100644
index 0000000000..ff80b1afda
--- /dev/null
+++ b/dom/push/test/test_register_key.html
@@ -0,0 +1,308 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1247685: Implement `applicationServerKey` for subscription association.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1247685</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1247685">Mozilla Bug 1247685</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var isTestingMismatchedKey = false;
+ var subscriptions = 0;
+ var testKey; // Generated in `start`.
+
+ function generateKey() {
+ return crypto.subtle.generateKey({
+ name: "ECDSA",
+ namedCurve: "P-256",
+ }, true, ["sign", "verify"]).then(cryptoKey =>
+ crypto.subtle.exportKey("raw", cryptoKey.publicKey)
+ ).then(publicKey => new Uint8Array(publicKey));
+ }
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndReplaceService({
+ register(pageRecord) {
+ ok(pageRecord.appServerKey.length,
+ "App server key should not be empty");
+ if (pageRecord.appServerKey.length != 65) {
+ // eslint-disable-next-line no-throw-literal
+ throw { result:
+ SpecialPowers.Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR };
+ }
+ if (isTestingMismatchedKey) {
+ // eslint-disable-next-line no-throw-literal
+ throw { result:
+ SpecialPowers.Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR };
+ }
+ return {
+ endpoint: "https://example.com/push/" + (++subscriptions),
+ appServerKey: pageRecord.appServerKey,
+ };
+ },
+
+ registration(pageRecord) {
+ return {
+ endpoint: "https://example.com/push/subWithKey",
+ appServerKey: testKey,
+ };
+ },
+ });
+ await setPushPermission(true);
+ testKey = await generateKey();
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ add_task(async function emptyKey() {
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: new ArrayBuffer(0),
+ });
+ ok(false, "Should reject for empty app server keys");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for empty key");
+ is(error.name, "InvalidAccessError",
+ "Wrong exception name for empty key");
+ }
+ });
+
+ add_task(async function invalidKey() {
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: new Uint8Array([0]),
+ });
+ ok(false, "Should reject for invalid app server keys");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for invalid key");
+ is(error.name, "InvalidAccessError",
+ "Wrong exception name for invalid key");
+ }
+ });
+
+ add_task(async function validKey() {
+ var pushSubscription = await registration.pushManager.subscribe({
+ applicationServerKey: await generateKey(),
+ });
+ is(pushSubscription.endpoint, "https://example.com/push/1",
+ "Wrong endpoint for subscription with key");
+ is(pushSubscription.options.applicationServerKey,
+ pushSubscription.options.applicationServerKey,
+ "App server key getter should return the same object");
+ });
+
+ add_task(async function retrieveKey() {
+ var pushSubscription = await registration.pushManager.getSubscription();
+ is(pushSubscription.endpoint, "https://example.com/push/subWithKey",
+ "Got wrong endpoint for subscription with key");
+ isDeeply(
+ new Uint8Array(pushSubscription.options.applicationServerKey),
+ testKey,
+ "Got wrong app server key"
+ );
+ });
+
+ add_task(async function mismatchedKey() {
+ isTestingMismatchedKey = true;
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: await generateKey(),
+ });
+ ok(false, "Should reject for mismatched app server keys");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for mismatched key");
+ is(error.name, "InvalidStateError",
+ "Wrong exception name for mismatched key");
+ } finally {
+ isTestingMismatchedKey = false;
+ }
+ });
+
+ add_task(async function emptyKeyInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: new ArrayBuffer(0),
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for empty key");
+ is(errorInfo.name, "InvalidAccessError",
+ "Wrong exception name in worker for empty key");
+ });
+
+ add_task(async function invalidKeyInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: new Uint8Array([1]),
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for invalid key");
+ is(errorInfo.name, "InvalidAccessError",
+ "Wrong exception name in worker for invalid key");
+ });
+
+ add_task(async function validKeyInWorker() {
+ var key = await generateKey();
+ var data = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key,
+ });
+ is(data.endpoint, "https://example.com/push/2",
+ "Wrong endpoint for subscription with key created in worker");
+ isDeeply(new Uint8Array(data.key), key,
+ "Wrong app server key for subscription created in worker");
+ });
+
+ add_task(async function mismatchedKeyInWorker() {
+ isTestingMismatchedKey = true;
+ try {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: await generateKey(),
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for mismatched key");
+ is(errorInfo.name, "InvalidStateError",
+ "Wrong exception name in worker for mismatched key");
+ } finally {
+ isTestingMismatchedKey = false;
+ }
+ });
+
+ add_task(async function validKeyBuffer() {
+ var key = await generateKey();
+ var pushSubscription = await registration.pushManager.subscribe({
+ applicationServerKey: key.buffer,
+ });
+ is(pushSubscription.endpoint, "https://example.com/push/3",
+ "Wrong endpoint for subscription created with key buffer");
+ var subscriptionKey = pushSubscription.options.applicationServerKey;
+ isDeeply(new Uint8Array(subscriptionKey), key,
+ "App server key getter should match given key");
+ });
+
+ add_task(async function validKeyBufferInWorker() {
+ var key = await generateKey();
+ var data = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: key.buffer,
+ });
+ is(data.endpoint, "https://example.com/push/4",
+ "Wrong endpoint for subscription with key buffer created in worker");
+ isDeeply(new Uint8Array(data.key), key,
+ "App server key getter should match given key for subscription created in worker");
+ });
+
+ add_task(async function validKeyString() {
+ var base64Key = "BOp8kf30nj6mKFFSPw_w3JAMS99Bac8zneMJ6B6lmKixUO5XTf4AtdPgYUgWke-XE25JHdcooyLgJML1R57jhKY";
+ var key = base64UrlDecode(base64Key);
+ var pushSubscription = await registration.pushManager.subscribe({
+ applicationServerKey: base64Key,
+ });
+ is(pushSubscription.endpoint, "https://example.com/push/5",
+ "Wrong endpoint for subscription created with Base64-encoded key");
+ isDeeply(new Uint8Array(pushSubscription.options.applicationServerKey), key,
+ "App server key getter should match Base64-decoded key");
+ });
+
+ add_task(async function validKeyStringInWorker() {
+ var base64Key = "BOp8kf30nj6mKFFSPw_w3JAMS99Bac8zneMJ6B6lmKixUO5XTf4AtdPgYUgWke-XE25JHdcooyLgJML1R57jhKY";
+ var key = base64UrlDecode(base64Key);
+ var data = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: base64Key,
+ });
+ is(data.endpoint, "https://example.com/push/6",
+ "Wrong endpoint for subscription created with Base64-encoded key in worker");
+ isDeeply(new Uint8Array(data.key), key,
+ "App server key getter should match decoded key for subscription created in worker");
+ });
+
+ add_task(async function invalidKeyString() {
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: "!@#$^&*",
+ });
+ ok(false, "Should reject for invalid Base64-encoded keys");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for invalid Base64-encoded key");
+ is(error.name, "InvalidCharacterError",
+ "Wrong exception name for invalid Base64-encoded key");
+ }
+ });
+
+ add_task(async function invalidKeyStringInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: "!@#$^&*",
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for invalid Base64-encoded key");
+ is(errorInfo.name, "InvalidCharacterError",
+ "Wrong exception name in worker for invalid Base64-encoded key");
+ });
+
+ add_task(async function emptyKeyString() {
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: "",
+ });
+ ok(false, "Should reject for empty key strings");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for empty key string");
+ is(error.name, "InvalidAccessError",
+ "Wrong exception name for empty key string");
+ }
+ });
+
+ add_task(async function emptyKeyStringInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: "",
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for empty key string");
+ is(errorInfo.name, "InvalidAccessError",
+ "Wrong exception name in worker for empty key string");
+ });
+
+ add_task(async function unsubscribe() {
+ is(subscriptions, 6, "Wrong subscription count");
+ controlledFrame.remove();
+ });
+
+ add_task(async function unregister() {
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_serviceworker_lifetime.html b/dom/push/test/test_serviceworker_lifetime.html
new file mode 100644
index 0000000000..30f191a119
--- /dev/null
+++ b/dom/push/test/test_serviceworker_lifetime.html
@@ -0,0 +1,364 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test the lifetime management of service workers. We keep this test in
+ dom/push/tests to pass the external network check when connecting to
+ the mozilla push service.
+
+ How this test works:
+ - the service worker maintains a state variable and a promise used for
+ extending its lifetime. Note that the terminating the worker will reset
+ these variables to their default values.
+ - we send 3 types of requests to the service worker:
+ |update|, |wait| and |release|. All three requests will cause the sw to update
+ its state to the new value and reply with a message containing
+ its previous state. Furthermore, |wait| will set a waitUntil or a respondWith
+ promise that's not resolved until the next |release| message.
+ - Each subtest will use a combination of values for the timeouts and check
+ if the service worker is in the correct state as we send it different
+ events.
+ - We also wait and assert for service worker termination using an event dispatched
+ through nsIObserverService.
+ -->
+<head>
+ <title>Test for Bug 1188545</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1188545">Mozilla Bug 118845</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function start() {
+ return navigator.serviceWorker.register("lifetime_worker.js", {scope: "./"})
+ .then((swr) => ({registration: swr}));
+ }
+
+ function waitForActiveServiceWorker(ctx) {
+ return waitForActive(ctx.registration).then(function(result) {
+ ok(ctx.registration.active, "Service Worker is active");
+ return ctx;
+ });
+ }
+
+ function unregister(ctx) {
+ return ctx.registration.unregister().then(function(result) {
+ ok(result, "Unregister should return true.");
+ }, function(e) {
+ dump("Unregistering the SW failed with " + e + "\n");
+ });
+ }
+
+ function registerPushNotification(ctx) {
+ var p = new Promise(function(res, rej) {
+ ctx.registration.pushManager.subscribe().then(
+ function(pushSubscription) {
+ ok(true, "successful registered for push notification");
+ ctx.subscription = pushSubscription;
+ res(ctx);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(ctx);
+ });
+ });
+ return p;
+ }
+
+ var mockSocket = new MockWebSocket();
+ var endpoint = "https://example.com/endpoint/1";
+ var channelID = null;
+ mockSocket.onRegister = function(request) {
+ channelID = request.channelID;
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: "fa8f2e4b-5ddc-4408-b1e3-5f25a02abff0",
+ channelID,
+ status: 200,
+ pushEndpoint: endpoint,
+ }));
+ };
+
+ function sendPushToPushServer(pushEndpoint) {
+ is(pushEndpoint, endpoint, "Got unexpected endpoint");
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID,
+ }));
+ }
+
+ function unregisterPushNotification(ctx) {
+ return ctx.subscription.unsubscribe().then(function(result) {
+ ok(result, "unsubscribe should succeed.");
+ ctx.subscription = null;
+ return ctx;
+ });
+ }
+
+ function createIframe(ctx) {
+ var p = new Promise(function(res, rej) {
+ var iframe = document.createElement("iframe");
+ // This file doesn't exist, the service worker will give us an empty
+ // document.
+ iframe.src = "http://mochi.test:8888/tests/dom/push/test/lifetime_frame.html";
+
+ iframe.onload = function() {
+ ctx.iframe = iframe;
+ res(ctx);
+ };
+ document.body.appendChild(iframe);
+ });
+ return p;
+ }
+
+ function closeIframe(ctx) {
+ ctx.iframe.remove();
+ return new Promise(function(res, rej) {
+ // XXXcatalinb: give the worker more time to "notice" it stopped
+ // controlling documents
+ ctx.iframe = null;
+ setTimeout(res, 0);
+ }).then(() => ctx);
+ }
+
+ function waitAndCheckMessage(contentWindow, expected) {
+ function checkMessage({ type, state }, resolve, event) {
+ ok(event.data.type == type, "Received correct message type: " + type);
+ ok(event.data.state == state, "Service worker is in the correct state: " + state);
+ this.navigator.serviceWorker.onmessage = null;
+ resolve();
+ }
+ return new Promise(function(res, rej) {
+ contentWindow.navigator.serviceWorker.onmessage =
+ checkMessage.bind(contentWindow, expected, res);
+ });
+ }
+
+ function fetchEvent(ctx, expected_state, new_state) {
+ var expected = { type: "fetch", state: expected_state };
+ var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
+ ctx.iframe.contentWindow.fetch(new_state);
+ return p;
+ }
+
+ function pushEvent(ctx, expected_state, new_state) {
+ var expected = {type: "push", state: expected_state};
+ var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
+ sendPushToPushServer(ctx.subscription.endpoint);
+ return p;
+ }
+
+ function messageEventIframe(ctx, expected_state, new_state) {
+ var expected = {type: "message", state: expected_state};
+ var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
+ ctx.iframe.contentWindow.navigator.serviceWorker.controller.postMessage(new_state);
+ return p;
+ }
+
+ function messageEvent(ctx, expected_state, new_state) {
+ var expected = {type: "message", state: expected_state};
+ var p = waitAndCheckMessage(window, expected);
+ ctx.registration.active.postMessage(new_state);
+ return p;
+ }
+
+ function checkStateAndUpdate(eventFunction, expected_state, new_state) {
+ return function(ctx) {
+ return eventFunction(ctx, expected_state, new_state)
+ .then(() => ctx);
+ };
+ }
+
+ let shutdownTopic = "specialpowers-service-worker-shutdown";
+ SpecialPowers.registerObservers("service-worker-shutdown");
+
+ function setShutdownObserver(expectingEvent) {
+ info("Setting shutdown observer: expectingEvent=" + expectingEvent);
+ return function(ctx) {
+ cancelShutdownObserver(ctx);
+
+ ctx.observer_promise = new Promise(function(res, rej) {
+ ctx.observer = {
+ observe(subject, topic, data) {
+ ok((topic == shutdownTopic) && expectingEvent, "Service worker was terminated.");
+ this.remove(ctx);
+ },
+ remove(context) {
+ SpecialPowers.removeObserver(this, shutdownTopic);
+ context.observer = null;
+ res(context);
+ },
+ };
+ SpecialPowers.addObserver(ctx.observer, shutdownTopic);
+ });
+
+ return ctx;
+ };
+ }
+
+ function waitOnShutdownObserver(ctx) {
+ info("Waiting on worker to shutdown.");
+ return ctx.observer_promise;
+ }
+
+ function cancelShutdownObserver(ctx) {
+ if (ctx.observer) {
+ ctx.observer.remove(ctx);
+ }
+ return ctx.observer_promise;
+ }
+
+ function subTest(test) {
+ return function(ctx) {
+ return new Promise(function(res, rej) {
+ function run() {
+ test.steps(ctx).catch(function(e) {
+ ok(false, "Some test failed with error: " + e);
+ }).then(res);
+ }
+
+ SpecialPowers.pushPrefEnv({"set": test.prefs}, run);
+ });
+ };
+ }
+
+ var test1 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 0],
+ ["dom.serviceWorkers.idle_extended_timeout", 2999999],
+ ],
+ // Test that service workers are terminated after the grace period expires
+ // when there are no pending waitUntil or respondWith promises.
+ steps(ctx) {
+ // Test with fetch events and respondWith promises
+ return createIframe(ctx)
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(fetchEvent, "from_scope", "update"))
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(fetchEvent, "from_scope", "wait"))
+ .then(checkStateAndUpdate(fetchEvent, "wait", "update"))
+ .then(checkStateAndUpdate(fetchEvent, "update", "update"))
+ .then(setShutdownObserver(true))
+ // The service worker should be terminated when the promise is resolved.
+ .then(checkStateAndUpdate(fetchEvent, "update", "release"))
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(closeIframe)
+ .then(cancelShutdownObserver)
+
+ // Test with push events and message events
+ .then(setShutdownObserver(true))
+ .then(createIframe)
+ // Make sure we are shutdown before entering our "no shutdown" sequence
+ // to avoid races.
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(pushEvent, "from_scope", "wait"))
+ .then(checkStateAndUpdate(messageEventIframe, "wait", "update"))
+ .then(checkStateAndUpdate(messageEventIframe, "update", "update"))
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(messageEventIframe, "update", "release"))
+ .then(waitOnShutdownObserver)
+ .then(closeIframe);
+ },
+ };
+
+ var test2 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 0],
+ ["dom.serviceWorkers.idle_extended_timeout", 2999999],
+ ],
+ steps(ctx) {
+ // Older versions used to terminate workers when the last controlled
+ // window was closed. This should no longer happen, though. Verify
+ // the new behavior.
+ setShutdownObserver(true)(ctx);
+ return createIframe(ctx)
+ // Make sure we are shutdown before entering our "no shutdown" sequence
+ // to avoid races.
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(fetchEvent, "from_scope", "wait"))
+ .then(closeIframe)
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(messageEvent, "wait", "release"))
+ .then(waitOnShutdownObserver)
+
+ // Push workers were exempt from the old rule and should continue to
+ // survive past the closing of the last controlled window.
+ .then(setShutdownObserver(true))
+ .then(createIframe)
+ // Make sure we are shutdown before entering our "no shutdown" sequence
+ // to avoid races.
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(pushEvent, "from_scope", "wait"))
+ .then(closeIframe)
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(messageEvent, "wait", "release"))
+ .then(waitOnShutdownObserver);
+ },
+ };
+
+ var test3 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 2999999],
+ ["dom.serviceWorkers.idle_extended_timeout", 0],
+ ],
+ steps(ctx) {
+ // set the grace period to 0 and dispatch a message which will reset
+ // the internal sw timer to the new value.
+ var test3_1 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 0],
+ ["dom.serviceWorkers.idle_extended_timeout", 0],
+ ],
+ steps(context) {
+ return new Promise(function(res, rej) {
+ context.iframe.contentWindow.navigator.serviceWorker.controller.postMessage("ping");
+ res(context);
+ });
+ },
+ };
+
+ // Test that service worker is closed when the extended timeout expired
+ return createIframe(ctx)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(messageEvent, "from_scope", "update"))
+ .then(checkStateAndUpdate(messageEventIframe, "update", "update"))
+ .then(checkStateAndUpdate(fetchEvent, "update", "wait"))
+ .then(setShutdownObserver(true))
+ .then(subTest(test3_1)) // This should cause the internal timer to expire.
+ .then(waitOnShutdownObserver)
+ .then(closeIframe);
+ },
+ };
+
+ function runTest() {
+ start()
+ .then(waitForActiveServiceWorker)
+ .then(registerPushNotification)
+ .then(subTest(test1))
+ .then(subTest(test2))
+ .then(subTest(test3))
+ .then(unregisterPushNotification)
+ .then(unregister)
+ .catch(function(e) {
+ ok(false, "Some test failed with error " + e);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(mockSocket).then(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_subscription_change.html b/dom/push/test/test_subscription_change.html
new file mode 100644
index 0000000000..6d8df58364
--- /dev/null
+++ b/dom/push/test/test_subscription_change.html
@@ -0,0 +1,67 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1205109: Make `pushsubscriptionchange` extendable.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1205109</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1205109">Mozilla Bug 1205109</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+ await setPushPermission(true);
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ add_task(async function togglePermission() {
+ var subscription = await registration.pushManager.subscribe();
+ ok(subscription, "Should create a push subscription");
+
+ await setPushPermission(false);
+ var permissionState = await registration.pushManager.permissionState();
+ is(permissionState, "denied", "Should deny push permission");
+
+ subscription = await registration.pushManager.getSubscription();
+ is(subscription, null, "Should not return subscription when permission is revoked");
+
+ var changePromise = controlledFrame.waitOnWorkerMessage("changed");
+ await setPushPermission(true);
+ await changePromise;
+
+ subscription = await registration.pushManager.getSubscription();
+ is(subscription, null, "Should drop subscription after reinstating permission");
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_try_registering_offline_disabled.html b/dom/push/test/test_try_registering_offline_disabled.html
new file mode 100644
index 0000000000..d993e73e60
--- /dev/null
+++ b/dom/push/test/test_try_registering_offline_disabled.html
@@ -0,0 +1,307 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1150812: Try to register when serviced if offline or connection is disabled.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1150812</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ function registerServiceWorker() {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."})
+ .then(swr => waitForActive(swr));
+ }
+
+ function unregister(swr) {
+ return swr.unregister()
+ .then(result => {
+ ok(result, "Unregister should return true.");
+ }, err => {
+ dump("Unregistering the SW failed with " + err + "\n");
+ throw err;
+ });
+ }
+
+ function subscribe(swr) {
+ return swr.pushManager.subscribe()
+ .then(sub => {
+ ok(true, "successful registered for push notification");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function subscribeFail(swr) {
+ return new Promise((res, rej) => {
+ swr.pushManager.subscribe()
+ .then(sub => {
+ ok(false, "successful registered for push notification");
+ throw new Error("Should fail");
+ }, err => {
+ ok(true, "could not register for push notification");
+ res(swr);
+ });
+ });
+ }
+
+ function getEndpointExpectNull(swr) {
+ return swr.pushManager.getSubscription()
+ .then(pushSubscription => {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function getEndpoint(swr, subOld) {
+ return swr.pushManager.getSubscription()
+ .then(sub => {
+ ok(subOld.endpoint == sub.endpoint, "getEndpoint - Got the same endpoint back.");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ // Load chrome script to change offline status in the
+ // parent process.
+ var offlineChromeScript = SpecialPowers.loadChromeScript(_ => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("change-status", function(offline) {
+ // eslint-disable-next-line mozilla/use-services
+ const ioService = Cc["@mozilla.org/network/io-service;1"]
+ .getService(Ci.nsIIOService);
+ ioService.offline = offline;
+ });
+ });
+
+ function offlineObserver(res) {
+ this._res = res;
+ }
+ offlineObserver.prototype = {
+ _res: null,
+
+ observe(subject, topic, data) {
+ debug("observe: " + subject + " " + topic + " " + data);
+ if (topic === "network:offline-status-changed") {
+ // eslint-disable-next-line mozilla/use-services
+ const obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
+ .getService(SpecialPowers.Ci.nsIObserverService);
+ obsService.removeObserver(this, topic);
+ this._res(null);
+ }
+ },
+ };
+
+ function changeOfflineState(offline) {
+ return new Promise(function(res, rej) {
+ // eslint-disable-next-line mozilla/use-services
+ const obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
+ .getService(SpecialPowers.Ci.nsIObserverService);
+ obsService.addObserver(SpecialPowers.wrapCallbackObject(new offlineObserver(res)),
+ "network:offline-status-changed");
+ offlineChromeScript.sendAsyncMessage("change-status", offline);
+ });
+ }
+
+ function changePushServerConnectionEnabled(enable) {
+ debug("changePushServerConnectionEnabled");
+ SpecialPowers.setBoolPref("dom.push.connection.enabled", enable);
+ }
+
+ function unsubscribe(sub) {
+ return sub.unsubscribe()
+ .then(_ => { ok(true, "Unsubscribed!"); });
+ }
+
+ // go offline then go online
+ function runTest1() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // disable - enable push connection.
+ function runTest2() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // go offline - disable - enable - go online
+ function runTest3() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // disable - offline - online - enable.
+ function runTest4() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // go offline - disable - go online - enable
+ function runTest5() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // disable - go offline - enable - go online.
+ function runTest6() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ function runTest() {
+ runTest1()
+ .then(_ => runTest2())
+ .then(_ => runTest3())
+ .then(_ => runTest4())
+ .then(_ => runTest5())
+ .then(_ => runTest6())
+ .then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket())
+ .then(_ => setPushPermission(true))
+ .then(_ => runTest());
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_unregister.html b/dom/push/test/test_unregister.html
new file mode 100644
index 0000000000..51f215c29b
--- /dev/null
+++ b/dom/push/test/test_unregister.html
@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1170817: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1170817</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1170817">Mozilla Bug 1170817</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function generateURL() {
+ return "worker.js?" + (Math.random());
+ }
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+ await setPushPermission(true);
+
+ registration = await navigator.serviceWorker.register(
+ generateURL(), {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var pushSubscription;
+ add_task(async function setupPushNotification() {
+ pushSubscription = await registration.pushManager.subscribe();
+ ok(pushSubscription, "successful registered for push notification");
+ });
+
+ add_task(async function unregisterPushNotification() {
+ var result = await pushSubscription.unsubscribe();
+ ok(result, "unsubscribe() on existing subscription should return true.");
+ });
+
+ add_task(async function unregisterAgain() {
+ var result = await pushSubscription.unsubscribe();
+ ok(!result, "unsubscribe() on previously unsubscribed subscription should return false.");
+ });
+
+ add_task(async function subscribeAgain() {
+ pushSubscription = await registration.pushManager.subscribe();
+ ok(pushSubscription, "Should create a new push subscription");
+
+ var result = await registration.unregister();
+ ok(result, "Should unregister the service worker");
+
+ registration = await navigator.serviceWorker.register(
+ generateURL(), {scope: "."});
+ await waitForActive(registration);
+ pushSubscription = await registration.pushManager.getSubscription();
+ ok(!pushSubscription,
+ "Unregistering a service worker should drop its subscription");
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_utils.js b/dom/push/test/test_utils.js
new file mode 100644
index 0000000000..0214318d09
--- /dev/null
+++ b/dom/push/test/test_utils.js
@@ -0,0 +1,304 @@
+"use strict";
+
+const url = SimpleTest.getTestFileURL("mockpushserviceparent.js");
+const chromeScript = SpecialPowers.loadChromeScript(url);
+
+/**
+ * Replaces `PushService.jsm` with a mock implementation that handles requests
+ * from the DOM API. This allows tests to simulate local errors and error
+ * reporting, bypassing the `PushService.jsm` machinery.
+ */
+async function replacePushService(mockService) {
+ chromeScript.addMessageListener("service-delivery-error", function (msg) {
+ mockService.reportDeliveryError(msg.messageId, msg.reason);
+ });
+ chromeScript.addMessageListener("service-request", function (msg) {
+ let promise;
+ try {
+ let handler = mockService[msg.name];
+ promise = Promise.resolve(handler(msg.params));
+ } catch (error) {
+ promise = Promise.reject(error);
+ }
+ promise.then(
+ result => {
+ chromeScript.sendAsyncMessage("service-response", {
+ id: msg.id,
+ result,
+ });
+ },
+ error => {
+ chromeScript.sendAsyncMessage("service-response", {
+ id: msg.id,
+ error,
+ });
+ }
+ );
+ });
+ await new Promise(resolve => {
+ chromeScript.addMessageListener("service-replaced", function onReplaced() {
+ chromeScript.removeMessageListener("service-replaced", onReplaced);
+ resolve();
+ });
+ chromeScript.sendAsyncMessage("service-replace");
+ });
+}
+
+async function restorePushService() {
+ await new Promise(resolve => {
+ chromeScript.addMessageListener("service-restored", function onRestored() {
+ chromeScript.removeMessageListener("service-restored", onRestored);
+ resolve();
+ });
+ chromeScript.sendAsyncMessage("service-restore");
+ });
+}
+
+let currentMockSocket = null;
+
+/**
+ * Sets up a mock connection for the WebSocket backend. This only replaces
+ * the transport layer; `PushService.jsm` still handles DOM API requests,
+ * observes permission changes, writes to IndexedDB, and notifies service
+ * workers of incoming push messages.
+ */
+function setupMockPushSocket(mockWebSocket) {
+ currentMockSocket = mockWebSocket;
+ currentMockSocket._isActive = true;
+ chromeScript.sendAsyncMessage("socket-setup");
+ chromeScript.addMessageListener("socket-client-msg", function (msg) {
+ mockWebSocket.handleMessage(msg);
+ });
+}
+
+function teardownMockPushSocket() {
+ if (currentMockSocket) {
+ return new Promise(resolve => {
+ currentMockSocket._isActive = false;
+ chromeScript.addMessageListener("socket-server-teardown", resolve);
+ chromeScript.sendAsyncMessage("socket-teardown");
+ });
+ }
+ return Promise.resolve();
+}
+
+/**
+ * Minimal implementation of web sockets for use in testing. Forwards
+ * messages to a mock web socket in the parent process that is used
+ * by the push service.
+ */
+class MockWebSocket {
+ // Default implementation to make the push server work minimally.
+ // Override methods to implement custom functionality.
+ constructor() {
+ this.userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8";
+ this.registerCount = 0;
+ // We only allow one active mock web socket to talk to the parent.
+ // This flag is used to keep track of which mock web socket is active.
+ this._isActive = false;
+ }
+
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: this.userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ }
+
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ uaid: this.userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/" + this.registerCount++,
+ })
+ );
+ }
+
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ }
+
+ onAck(request) {
+ // Do nothing.
+ }
+
+ handleMessage(msg) {
+ let request = JSON.parse(msg);
+ let messageType = request.messageType;
+ switch (messageType) {
+ case "hello":
+ this.onHello(request);
+ break;
+ case "register":
+ this.onRegister(request);
+ break;
+ case "unregister":
+ this.onUnregister(request);
+ break;
+ case "ack":
+ this.onAck(request);
+ break;
+ default:
+ throw new Error("Unexpected message: " + messageType);
+ }
+ }
+
+ serverSendMsg(msg) {
+ if (this._isActive) {
+ chromeScript.sendAsyncMessage("socket-server-msg", msg);
+ }
+ }
+}
+
+// Remove permissions and prefs when the test finishes.
+SimpleTest.registerCleanupFunction(async function () {
+ await new Promise(resolve => SpecialPowers.flushPermissions(resolve));
+ await SpecialPowers.flushPrefEnv();
+ await restorePushService();
+ await teardownMockPushSocket();
+});
+
+function setPushPermission(allow) {
+ let permissions = [
+ { type: "desktop-notification", allow, context: document },
+ ];
+
+ if (isXOrigin) {
+ // We need to add permission for the xorigin tests. In xorigin tests, the
+ // test page will be run under third-party context, so we need to use
+ // partitioned principal to add the permission.
+ let partitionedPrincipal =
+ SpecialPowers.wrap(document).partitionedPrincipal;
+
+ permissions.push({
+ type: "desktop-notification",
+ allow,
+ context: {
+ url: partitionedPrincipal.originNoSuffix,
+ originAttributes: {
+ partitionKey: partitionedPrincipal.originAttributes.partitionKey,
+ },
+ },
+ });
+ }
+
+ return SpecialPowers.pushPermissions(permissions);
+}
+
+function setupPrefs() {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.push.enabled", true],
+ ["dom.push.connection.enabled", true],
+ ["dom.push.maxRecentMessageIDsPerSubscription", 0],
+ ["dom.serviceWorkers.exemptFromPerDomainMax", true],
+ ["dom.serviceWorkers.enabled", true],
+ ["dom.serviceWorkers.testing.enabled", true],
+ ],
+ });
+}
+
+async function setupPrefsAndReplaceService(mockService) {
+ await replacePushService(mockService);
+ await setupPrefs();
+}
+
+function setupPrefsAndMockSocket(mockSocket) {
+ setupMockPushSocket(mockSocket);
+ return setupPrefs();
+}
+
+function injectControlledFrame(target = document.body) {
+ return new Promise(function (res, rej) {
+ var iframe = document.createElement("iframe");
+ iframe.src = "/tests/dom/push/test/frame.html";
+
+ var controlledFrame = {
+ remove() {
+ target.removeChild(iframe);
+ iframe = null;
+ },
+ waitOnWorkerMessage(type) {
+ return iframe
+ ? iframe.contentWindow.waitOnWorkerMessage(type)
+ : Promise.reject(new Error("Frame removed from document"));
+ },
+ innerWindowId() {
+ return SpecialPowers.wrap(iframe).browsingContext.currentWindowContext
+ .innerWindowId;
+ },
+ };
+
+ iframe.onload = () => res(controlledFrame);
+ target.appendChild(iframe);
+ });
+}
+
+function sendRequestToWorker(request) {
+ return navigator.serviceWorker.ready.then(registration => {
+ return new Promise((resolve, reject) => {
+ var channel = new MessageChannel();
+ channel.port1.onmessage = e => {
+ (e.data.error ? reject : resolve)(e.data);
+ };
+ registration.active.postMessage(request, [channel.port2]);
+ });
+ });
+}
+
+function waitForActive(swr) {
+ let sw = swr.installing || swr.waiting || swr.active;
+ return new Promise(resolve => {
+ if (sw.state === "activated") {
+ resolve(swr);
+ return;
+ }
+ sw.addEventListener("statechange", function onStateChange(evt) {
+ if (sw.state === "activated") {
+ sw.removeEventListener("statechange", onStateChange);
+ resolve(swr);
+ }
+ });
+ });
+}
+
+function base64UrlDecode(s) {
+ s = s.replace(/-/g, "+").replace(/_/g, "/");
+
+ // Replace padding if it was stripped by the sender.
+ // See http://tools.ietf.org/html/rfc4648#section-4
+ switch (s.length % 4) {
+ case 0:
+ break; // No pad chars in this case
+ case 2:
+ s += "==";
+ break; // Two pad chars
+ case 3:
+ s += "=";
+ break; // One pad char
+ default:
+ throw new Error("Illegal base64url string!");
+ }
+
+ // With correct padding restored, apply the standard base64 decoder
+ var decoded = atob(s);
+
+ var array = new Uint8Array(new ArrayBuffer(decoded.length));
+ for (var i = 0; i < decoded.length; i++) {
+ array[i] = decoded.charCodeAt(i);
+ }
+ return array;
+}
diff --git a/dom/push/test/webpush.js b/dom/push/test/webpush.js
new file mode 100644
index 0000000000..b97e465a8b
--- /dev/null
+++ b/dom/push/test/webpush.js
@@ -0,0 +1,228 @@
+/*
+ * Browser-based Web Push client for the application server piece.
+ *
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/licenses/publicdomain/
+ *
+ * Uses the WebCrypto API.
+ *
+ * Note that this test file uses the old, deprecated aesgcm128 encryption
+ * scheme. PushCrypto.encrypt() exists and uses the later aes128gcm, but
+ * there's no good reason to upgrade this at this time (and having mochitests
+ * use PushCrypto directly is easier said than done.)
+ */
+
+(function (g) {
+ "use strict";
+
+ var P256DH = {
+ name: "ECDH",
+ namedCurve: "P-256",
+ };
+ var webCrypto = g.crypto.subtle;
+ var ENCRYPT_INFO = new TextEncoder().encode("Content-Encoding: aesgcm128");
+ var NONCE_INFO = new TextEncoder().encode("Content-Encoding: nonce");
+
+ function chunkArray(array, size) {
+ var start = array.byteOffset || 0;
+ array = array.buffer || array;
+ var index = 0;
+ var result = [];
+ while (index + size <= array.byteLength) {
+ result.push(new Uint8Array(array, start + index, size));
+ index += size;
+ }
+ if (index < array.byteLength) {
+ result.push(new Uint8Array(array, start + index));
+ }
+ return result;
+ }
+
+ /* I can't believe that this is needed here, in this day and age ...
+ * Note: these are not efficient, merely expedient.
+ */
+ var base64url = {
+ _strmap: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
+ encode(data) {
+ data = new Uint8Array(data);
+ var len = Math.ceil((data.length * 4) / 3);
+ return chunkArray(data, 3)
+ .map(chunk =>
+ [
+ chunk[0] >>> 2,
+ ((chunk[0] & 0x3) << 4) | (chunk[1] >>> 4),
+ ((chunk[1] & 0xf) << 2) | (chunk[2] >>> 6),
+ chunk[2] & 0x3f,
+ ]
+ .map(v => base64url._strmap[v])
+ .join("")
+ )
+ .join("")
+ .slice(0, len);
+ },
+ _lookup(s, i) {
+ return base64url._strmap.indexOf(s.charAt(i));
+ },
+ decode(str) {
+ var v = new Uint8Array(Math.floor((str.length * 3) / 4));
+ var vi = 0;
+ for (var si = 0; si < str.length; ) {
+ var w = base64url._lookup(str, si++);
+ var x = base64url._lookup(str, si++);
+ var y = base64url._lookup(str, si++);
+ var z = base64url._lookup(str, si++);
+ v[vi++] = (w << 2) | (x >>> 4);
+ v[vi++] = (x << 4) | (y >>> 2);
+ v[vi++] = (y << 6) | z;
+ }
+ return v;
+ },
+ };
+
+ g.base64url = base64url;
+
+ /* Coerces data into a Uint8Array */
+ function ensureView(data) {
+ if (typeof data === "string") {
+ return new TextEncoder().encode(data);
+ }
+ if (data instanceof ArrayBuffer) {
+ return new Uint8Array(data);
+ }
+ if (ArrayBuffer.isView(data)) {
+ return new Uint8Array(data.buffer);
+ }
+ throw new Error("webpush() needs a string or BufferSource");
+ }
+
+ function bsConcat(arrays) {
+ var size = arrays.reduce((total, a) => total + a.byteLength, 0);
+ var index = 0;
+ return arrays.reduce((result, a) => {
+ result.set(new Uint8Array(a), index);
+ index += a.byteLength;
+ return result;
+ }, new Uint8Array(size));
+ }
+
+ function hmac(key) {
+ this.keyPromise = webCrypto.importKey(
+ "raw",
+ key,
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ }
+ hmac.prototype.hash = function (input) {
+ return this.keyPromise.then(k => webCrypto.sign("HMAC", k, input));
+ };
+
+ function hkdf(salt, ikm) {
+ this.prkhPromise = new hmac(salt).hash(ikm).then(prk => new hmac(prk));
+ }
+
+ hkdf.prototype.generate = function (info, len) {
+ var input = bsConcat([info, new Uint8Array([1])]);
+ return this.prkhPromise
+ .then(prkh => prkh.hash(input))
+ .then(h => {
+ if (h.byteLength < len) {
+ throw new Error("Length is too long");
+ }
+ return h.slice(0, len);
+ });
+ };
+
+ /* generate a 96-bit IV for use in GCM, 48-bits of which are populated */
+ function generateNonce(base, index) {
+ var nonce = base.slice(0, 12);
+ for (var i = 0; i < 6; ++i) {
+ nonce[nonce.length - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
+ }
+ return nonce;
+ }
+
+ function encrypt(localKey, remoteShare, salt, data) {
+ return webCrypto
+ .importKey("raw", remoteShare, P256DH, false, ["deriveBits"])
+ .then(remoteKey =>
+ webCrypto.deriveBits(
+ { name: P256DH.name, public: remoteKey },
+ localKey,
+ 256
+ )
+ )
+ .then(rawKey => {
+ var kdf = new hkdf(salt, rawKey);
+ return Promise.all([
+ kdf
+ .generate(ENCRYPT_INFO, 16)
+ .then(gcmBits =>
+ webCrypto.importKey("raw", gcmBits, "AES-GCM", false, ["encrypt"])
+ ),
+ kdf.generate(NONCE_INFO, 12),
+ ]);
+ })
+ .then(([key, nonce]) => {
+ if (data.byteLength === 0) {
+ // Send an authentication tag for empty messages.
+ return webCrypto
+ .encrypt(
+ {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, 0),
+ },
+ key,
+ new Uint8Array([0])
+ )
+ .then(value => [value]);
+ }
+ // 4096 is the default size, though we burn 1 for padding
+ return Promise.all(
+ chunkArray(data, 4095).map((slice, index) => {
+ var padded = bsConcat([new Uint8Array([0]), slice]);
+ return webCrypto.encrypt(
+ {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, index),
+ },
+ key,
+ padded
+ );
+ })
+ );
+ })
+ .then(bsConcat);
+ }
+
+ function webPushEncrypt(subscription, data) {
+ data = ensureView(data);
+
+ var salt = g.crypto.getRandomValues(new Uint8Array(16));
+ return webCrypto
+ .generateKey(P256DH, false, ["deriveBits"])
+ .then(localKey => {
+ return Promise.all([
+ encrypt(
+ localKey.privateKey,
+ subscription.getKey("p256dh"),
+ salt,
+ data
+ ),
+ // 1337 p-256 specific haxx to get the raw value out of the spki value
+ webCrypto.exportKey("raw", localKey.publicKey),
+ ]);
+ })
+ .then(([payload, pubkey]) => {
+ return {
+ data: base64url.encode(payload),
+ encryption: "keyid=p256dh;salt=" + base64url.encode(salt),
+ encryption_key: "keyid=p256dh;dh=" + base64url.encode(pubkey),
+ encoding: "aesgcm128",
+ };
+ });
+ }
+
+ g.webPushEncrypt = webPushEncrypt;
+})(this);
diff --git a/dom/push/test/worker.js b/dom/push/test/worker.js
new file mode 100644
index 0000000000..bcdbf0e0ad
--- /dev/null
+++ b/dom/push/test/worker.js
@@ -0,0 +1,174 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/licenses/publicdomain/
+
+// This worker is used for two types of tests. `handlePush` sends messages to
+// `frame.html`, which verifies that the worker can receive push messages.
+
+// `handleMessage` receives messages from `test_push_manager_worker.html`
+// and `test_data.html`, and verifies that `PushManager` can be used from
+// the worker.
+
+/* globals PushEvent */
+
+this.onpush = handlePush;
+this.onmessage = handleMessage;
+this.onpushsubscriptionchange = handlePushSubscriptionChange;
+
+function getJSON(data) {
+ var result = {
+ ok: false,
+ };
+ try {
+ result.value = data.json();
+ result.ok = true;
+ } catch (e) {
+ // Ignore syntax errors for invalid JSON.
+ }
+ return result;
+}
+
+function assert(value, message) {
+ if (!value) {
+ throw new Error(message);
+ }
+}
+
+function broadcast(event, promise) {
+ event.waitUntil(
+ Promise.resolve(promise).then(message => {
+ return self.clients.matchAll().then(clients => {
+ clients.forEach(client => client.postMessage(message));
+ });
+ })
+ );
+}
+
+function reply(event, promise) {
+ event.waitUntil(
+ Promise.resolve(promise)
+ .then(result => {
+ event.ports[0].postMessage(result);
+ })
+ .catch(error => {
+ event.ports[0].postMessage({
+ error: String(error),
+ });
+ })
+ );
+}
+
+function handlePush(event) {
+ if (event instanceof PushEvent) {
+ if (!("data" in event)) {
+ broadcast(event, { type: "finished", okay: "yes" });
+ return;
+ }
+ var message = {
+ type: "finished",
+ okay: "yes",
+ };
+ if (event.data) {
+ message.data = {
+ text: event.data.text(),
+ arrayBuffer: event.data.arrayBuffer(),
+ json: getJSON(event.data),
+ blob: event.data.blob(),
+ };
+ }
+ broadcast(event, message);
+ return;
+ }
+ broadcast(event, { type: "finished", okay: "no" });
+}
+
+var testHandlers = {
+ publicKey(data) {
+ return self.registration.pushManager
+ .getSubscription()
+ .then(subscription => ({
+ p256dh: subscription.getKey("p256dh"),
+ auth: subscription.getKey("auth"),
+ }));
+ },
+
+ resubscribe(data) {
+ return self.registration.pushManager
+ .getSubscription()
+ .then(subscription => {
+ assert(
+ subscription.endpoint == data.endpoint,
+ "Wrong push endpoint in worker"
+ );
+ return subscription.unsubscribe();
+ })
+ .then(result => {
+ assert(result, "Error unsubscribing in worker");
+ return self.registration.pushManager.getSubscription();
+ })
+ .then(subscription => {
+ assert(!subscription, "Subscription not removed in worker");
+ return self.registration.pushManager.subscribe();
+ })
+ .then(subscription => {
+ return {
+ endpoint: subscription.endpoint,
+ };
+ });
+ },
+
+ denySubscribe(data) {
+ return self.registration.pushManager
+ .getSubscription()
+ .then(subscription => {
+ assert(
+ !subscription,
+ "Should not return worker subscription with revoked permission"
+ );
+ return self.registration.pushManager.subscribe().then(
+ _ => {
+ assert(false, "Expected error subscribing with revoked permission");
+ },
+ error => {
+ return {
+ isDOMException: error instanceof DOMException,
+ name: error.name,
+ };
+ }
+ );
+ });
+ },
+
+ subscribeWithKey(data) {
+ return self.registration.pushManager
+ .subscribe({
+ applicationServerKey: data.key,
+ })
+ .then(
+ subscription => {
+ return {
+ endpoint: subscription.endpoint,
+ key: subscription.options.applicationServerKey,
+ };
+ },
+ error => {
+ return {
+ isDOMException: error instanceof DOMException,
+ name: error.name,
+ };
+ }
+ );
+ },
+};
+
+function handleMessage(event) {
+ var handler = testHandlers[event.data.type];
+ if (handler) {
+ reply(event, handler(event.data));
+ } else {
+ reply(event, Promise.reject("Invalid message type: " + event.data.type));
+ }
+}
+
+function handlePushSubscriptionChange(event) {
+ broadcast(event, { type: "changed", okay: "yes" });
+}
diff --git a/dom/push/test/xpcshell/broadcast_handler.sys.mjs b/dom/push/test/xpcshell/broadcast_handler.sys.mjs
new file mode 100644
index 0000000000..eecf220a6f
--- /dev/null
+++ b/dom/push/test/xpcshell/broadcast_handler.sys.mjs
@@ -0,0 +1,12 @@
+export var broadcastHandler = {
+ reset() {
+ this.notifications = [];
+
+ this.wasNotified = new Promise((resolve, reject) => {
+ this.receivedBroadcastMessage = function () {
+ resolve();
+ this.notifications.push(Array.from(arguments));
+ };
+ });
+ },
+};
diff --git a/dom/push/test/xpcshell/head-http2.js b/dom/push/test/xpcshell/head-http2.js
new file mode 100644
index 0000000000..2538bd3caa
--- /dev/null
+++ b/dom/push/test/xpcshell/head-http2.js
@@ -0,0 +1,42 @@
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+
+// Returns the test H/2 server port, throwing if it's missing or invalid.
+function getTestServerPort() {
+ let portEnv = Services.env.get("MOZHTTP2_PORT");
+ let port = parseInt(portEnv, 10);
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
+ throw new Error(`Invalid port in MOZHTTP2_PORT env var: ${portEnv}`);
+ }
+ info(`Using HTTP/2 server on port ${port}`);
+ return port;
+}
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+function addCertFromFile(certdb, filename, trustString) {
+ let certFile = do_get_file(filename, false);
+ let pem = readFile(certFile)
+ .replace(/-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----/, "")
+ .replace(/[\r\n]/g, "");
+ certdb.addCertFromBase64(pem, trustString);
+}
+
+function trustHttp2CA() {
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ addCertFromFile(
+ certdb,
+ "../../../../netwerk/test/unit/http2-ca.pem",
+ "CTu,u,u"
+ );
+}
diff --git a/dom/push/test/xpcshell/head.js b/dom/push/test/xpcshell/head.js
new file mode 100644
index 0000000000..497fac9d34
--- /dev/null
+++ b/dom/push/test/xpcshell/head.js
@@ -0,0 +1,499 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+ PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
+ PushService: "resource://gre/modules/PushService.sys.mjs",
+ PushServiceHttp2: "resource://gre/modules/PushService.sys.mjs",
+ PushServiceWebSocket: "resource://gre/modules/PushService.sys.mjs",
+ pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
+});
+var {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "PushServiceComponent",
+ "@mozilla.org/push/Service;1",
+ "nsIPushService"
+);
+
+const servicePrefs = new Preferences("dom.push.");
+
+const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
+
+const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000;
+
+var isParent =
+ Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+// Stop and clean up after the PushService.
+Services.obs.addObserver(function observe(subject, topic, data) {
+ Services.obs.removeObserver(observe, topic);
+ PushService.uninit();
+ // Occasionally, `profile-change-teardown` and `xpcom-shutdown` will fire
+ // before the PushService and AlarmService finish writing to IndexedDB. This
+ // causes spurious errors and crashes, so we spin the event loop to let the
+ // writes finish.
+ let done = false;
+ setTimeout(() => (done = true), 1000);
+ let thread = Services.tm.mainThread;
+ while (!done) {
+ try {
+ thread.processNextEvent(true);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}, "profile-change-net-teardown");
+
+/**
+ * Gates a function so that it is called only after the wrapper is called a
+ * given number of times.
+ *
+ * @param {Number} times The number of wrapper calls before |func| is called.
+ * @param {Function} func The function to gate.
+ * @returns {Function} The gated function wrapper.
+ */
+function after(times, func) {
+ return function afterFunc() {
+ if (--times <= 0) {
+ func.apply(this, arguments);
+ }
+ };
+}
+
+/**
+ * Defers one or more callbacks until the next turn of the event loop. Multiple
+ * callbacks are executed in order.
+ *
+ * @param {Function[]} callbacks The callbacks to execute. One callback will be
+ * executed per tick.
+ */
+function waterfall(...callbacks) {
+ callbacks
+ .reduce(
+ (promise, callback) =>
+ promise.then(() => {
+ callback();
+ }),
+ Promise.resolve()
+ )
+ .catch(console.error);
+}
+
+/**
+ * Waits for an observer notification to fire.
+ *
+ * @param {String} topic The notification topic.
+ * @returns {Promise} A promise that fulfills when the notification is fired.
+ */
+function promiseObserverNotification(topic, matchFunc) {
+ return new Promise((resolve, reject) => {
+ Services.obs.addObserver(function observe(subject, aTopic, data) {
+ let matches = typeof matchFunc != "function" || matchFunc(subject, data);
+ if (!matches) {
+ return;
+ }
+ Services.obs.removeObserver(observe, aTopic);
+ resolve({ subject, data });
+ }, topic);
+ });
+}
+
+/**
+ * Wraps an object in a proxy that traps property gets and returns stubs. If
+ * the stub is a function, the original value will be passed as the first
+ * argument. If the original value is a function, the proxy returns a wrapper
+ * that calls the stub; otherwise, the stub is called as a getter.
+ *
+ * @param {Object} target The object to wrap.
+ * @param {Object} stubs An object containing stubbed values and functions.
+ * @returns {Proxy} A proxy that returns stubs for property gets.
+ */
+function makeStub(target, stubs) {
+ return new Proxy(target, {
+ get(aTarget, property) {
+ if (!stubs || typeof stubs != "object" || !(property in stubs)) {
+ return aTarget[property];
+ }
+ let stub = stubs[property];
+ if (typeof stub != "function") {
+ return stub;
+ }
+ let original = aTarget[property];
+ if (typeof original != "function") {
+ return stub.call(this, original);
+ }
+ return function callStub(...params) {
+ return stub.call(this, original, ...params);
+ };
+ },
+ });
+}
+
+/**
+ * Sets default PushService preferences. All pref names are prefixed with
+ * `dom.push.`; any additional preferences will override the defaults.
+ *
+ * @param {Object} [prefs] Additional preferences to set.
+ */
+function setPrefs(prefs = {}) {
+ let defaultPrefs = Object.assign(
+ {
+ loglevel: "all",
+ serverURL: "wss://push.example.org",
+ "connection.enabled": true,
+ userAgentID: "",
+ enabled: true,
+ // Defaults taken from /modules/libpref/init/all.js.
+ requestTimeout: 10000,
+ retryBaseInterval: 5000,
+ pingInterval: 30 * 60 * 1000,
+ // Misc. defaults.
+ "http2.maxRetries": 2,
+ "http2.retryInterval": 500,
+ "http2.reset_retry_count_after_ms": 60000,
+ maxQuotaPerSubscription: 16,
+ quotaUpdateDelay: 3000,
+ "testing.notifyWorkers": false,
+ },
+ prefs
+ );
+ for (let pref in defaultPrefs) {
+ servicePrefs.set(pref, defaultPrefs[pref]);
+ }
+}
+
+function compareAscending(a, b) {
+ if (a > b) {
+ return 1;
+ }
+ return a < b ? -1 : 0;
+}
+
+/**
+ * Creates a mock WebSocket object that implements a subset of the
+ * nsIWebSocketChannel interface used by the PushService.
+ *
+ * The given protocol handlers are invoked for each Simple Push command sent
+ * by the PushService. The ping handler is optional; all others will throw if
+ * the PushService sends a command for which no handler is registered.
+ *
+ * All nsIWebSocketListener methods will be called asynchronously.
+ * serverSendMsg() and serverClose() can be used to respond to client messages
+ * and close the "server" end of the connection, respectively.
+ *
+ * @param {nsIURI} originalURI The original WebSocket URL.
+ * @param {Function} options.onHello The "hello" handshake command handler.
+ * @param {Function} options.onRegister The "register" command handler.
+ * @param {Function} options.onUnregister The "unregister" command handler.
+ * @param {Function} options.onACK The "ack" command handler.
+ * @param {Function} [options.onPing] An optional ping handler.
+ */
+function MockWebSocket(originalURI, handlers = {}) {
+ this._originalURI = originalURI;
+ this._onHello = handlers.onHello;
+ this._onRegister = handlers.onRegister;
+ this._onUnregister = handlers.onUnregister;
+ this._onACK = handlers.onACK;
+ this._onPing = handlers.onPing;
+ this._onBroadcastSubscribe = handlers.onBroadcastSubscribe;
+}
+
+MockWebSocket.prototype = {
+ _originalURI: null,
+ _onHello: null,
+ _onRegister: null,
+ _onUnregister: null,
+ _onACK: null,
+ _onPing: null,
+
+ _listener: null,
+ _context: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, originAttributes, windowId, listener, context) {
+ this._listener = listener;
+ this._context = context;
+ waterfall(() => this._listener.onStart(this._context));
+ },
+
+ _handleMessage(msg) {
+ let messageType, request;
+ if (msg == "{}") {
+ request = {};
+ messageType = "ping";
+ } else {
+ request = JSON.parse(msg);
+ messageType = request.messageType;
+ }
+ switch (messageType) {
+ case "hello":
+ if (typeof this._onHello != "function") {
+ throw new Error("Unexpected handshake request");
+ }
+ this._onHello(request);
+ break;
+
+ case "register":
+ if (typeof this._onRegister != "function") {
+ throw new Error("Unexpected register request");
+ }
+ this._onRegister(request);
+ break;
+
+ case "unregister":
+ if (typeof this._onUnregister != "function") {
+ throw new Error("Unexpected unregister request");
+ }
+ this._onUnregister(request);
+ break;
+
+ case "ack":
+ if (typeof this._onACK != "function") {
+ throw new Error("Unexpected acknowledgement");
+ }
+ this._onACK(request);
+ break;
+
+ case "ping":
+ if (typeof this._onPing == "function") {
+ this._onPing(request);
+ } else {
+ // Echo ping packets.
+ this.serverSendMsg("{}");
+ }
+ break;
+
+ case "broadcast_subscribe":
+ if (typeof this._onBroadcastSubscribe != "function") {
+ throw new Error("Unexpected broadcast_subscribe");
+ }
+ this._onBroadcastSubscribe(request);
+ break;
+
+ default:
+ throw new Error("Unexpected message: " + messageType);
+ }
+ },
+
+ sendMsg(msg) {
+ this._handleMessage(msg);
+ },
+
+ close(code, reason) {
+ waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
+ },
+
+ /**
+ * Responds with the given message, calling onMessageAvailable() and
+ * onAcknowledge() synchronously. Throws if the message is not a string.
+ * Used by the tests to respond to client commands.
+ *
+ * @param {String} msg The message to send to the client.
+ */
+ serverSendMsg(msg) {
+ if (typeof msg != "string") {
+ throw new Error("Invalid response message");
+ }
+ waterfall(
+ () => this._listener.onMessageAvailable(this._context, msg),
+ () => this._listener.onAcknowledge(this._context, 0)
+ );
+ },
+
+ /**
+ * Closes the server end of the connection, calling onServerClose()
+ * followed by onStop(). Used to test abrupt connection termination.
+ *
+ * @param {Number} [statusCode] The WebSocket connection close code.
+ * @param {String} [reason] The connection close reason.
+ */
+ serverClose(statusCode, reason = "") {
+ if (!isFinite(statusCode)) {
+ statusCode = WEBSOCKET_CLOSE_GOING_AWAY;
+ }
+ waterfall(
+ () => this._listener.onServerClose(this._context, statusCode, reason),
+ () => this._listener.onStop(this._context, Cr.NS_BASE_STREAM_CLOSED)
+ );
+ },
+
+ serverInterrupt(result = Cr.NS_ERROR_NET_RESET) {
+ waterfall(() => this._listener.onStop(this._context, result));
+ },
+};
+
+var setUpServiceInParent = async function (service, db) {
+ if (!isParent) {
+ return;
+ }
+
+ let userAgentID = "ce704e41-cb77-4206-b07b-5bf47114791b";
+ setPrefs({
+ userAgentID,
+ });
+
+ await db.put({
+ channelID: "6e2814e1-5f84-489e-b542-855cc1311f09",
+ pushEndpoint: "https://example.org/push/get",
+ scope: "https://example.com/get/ok",
+ originAttributes: "",
+ version: 1,
+ pushCount: 10,
+ lastPush: 1438360548322,
+ quota: 16,
+ });
+ await db.put({
+ channelID: "3a414737-2fd0-44c0-af05-7efc172475fc",
+ pushEndpoint: "https://example.org/push/unsub",
+ scope: "https://example.com/unsub/ok",
+ originAttributes: "",
+ version: 2,
+ pushCount: 10,
+ lastPush: 1438360848322,
+ quota: 4,
+ });
+ await db.put({
+ channelID: "ca3054e8-b59b-4ea0-9c23-4a3c518f3161",
+ pushEndpoint: "https://example.org/push/stale",
+ scope: "https://example.com/unsub/fail",
+ originAttributes: "",
+ version: 3,
+ pushCount: 10,
+ lastPush: 1438362348322,
+ quota: 1,
+ });
+
+ service.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ put(prev, record) {
+ if (record.scope == "https://example.com/sub/fail") {
+ return Promise.reject("synergies not aligned");
+ }
+ return prev.call(this, record);
+ },
+ delete(prev, channelID) {
+ if (channelID == "ca3054e8-b59b-4ea0-9c23-4a3c518f3161") {
+ return Promise.reject("splines not reticulated");
+ }
+ return prev.call(this, channelID);
+ },
+ getByIdentifiers(prev, identifiers) {
+ if (identifiers.scope == "https://example.com/get/fail") {
+ return Promise.reject("qualia unsynchronized");
+ }
+ return prev.call(this, identifiers);
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ if (request.key) {
+ let appServerKey = new Uint8Array(
+ ChromeUtils.base64URLDecode(request.key, {
+ padding: "require",
+ })
+ );
+ equal(appServerKey.length, 65, "Wrong app server key length");
+ equal(appServerKey[0], 4, "Wrong app server key format");
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.org/push/" + request.channelID,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+};
+
+var tearDownServiceInParent = async function (db) {
+ if (!isParent) {
+ return;
+ }
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/sub/ok",
+ originAttributes: "",
+ });
+ ok(
+ record.pushEndpoint.startsWith("https://example.org/push"),
+ "Wrong push endpoint in subscription record"
+ );
+
+ record = await db.getByKeyID("3a414737-2fd0-44c0-af05-7efc172475fc");
+ ok(!record, "Unsubscribed record should not exist");
+};
+
+function putTestRecord(db, keyID, scope, quota) {
+ return db.put({
+ channelID: keyID,
+ pushEndpoint: "https://example.org/push/" + keyID,
+ scope,
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota,
+ systemRecord: quota == Infinity,
+ });
+}
+
+function getAllKeyIDs(db) {
+ return db
+ .getAllKeyIDs()
+ .then(records =>
+ records.map(record => record.keyID).sort(compareAscending)
+ );
+}
diff --git a/dom/push/test/xpcshell/moz.build b/dom/push/test/xpcshell/moz.build
new file mode 100644
index 0000000000..fc154ce0d0
--- /dev/null
+++ b/dom/push/test/xpcshell/moz.build
@@ -0,0 +1,3 @@
+TESTING_JS_MODULES += [
+ "broadcast_handler.sys.mjs",
+]
diff --git a/dom/push/test/xpcshell/test_broadcast_success.js b/dom/push/test/xpcshell/test_broadcast_success.js
new file mode 100644
index 0000000000..16f586081b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_broadcast_success.js
@@ -0,0 +1,428 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Create the profile directory early to ensure pushBroadcastService
+// is initialized with the correct path
+do_get_profile();
+const { BroadcastService } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushBroadcastService.sys.mjs"
+);
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { broadcastHandler } = ChromeUtils.importESModule(
+ "resource://test/broadcast_handler.sys.mjs"
+);
+
+const broadcastService = pushBroadcastService;
+const assert = Assert;
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf";
+
+function run_test() {
+ setPrefs({
+ userAgentID,
+ alwaysConnect: true,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+function getPushServiceMock() {
+ return {
+ subscribed: [],
+ subscribeBroadcast(broadcastId, version) {
+ this.subscribed.push([broadcastId, version]);
+ },
+ };
+}
+
+add_task(async function test_register_success() {
+ await broadcastService._resetListeners();
+ const db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ const notifications = broadcastHandler.notifications;
+ let socket;
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await broadcastService.addListener("broadcast-test", "2018-02-01", {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+
+ var broadcastSubscriptions = [];
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ socket = this;
+ deepEqual(
+ data.broadcasts,
+ { "broadcast-test": "2018-02-01" },
+ "Handshake: doesn't consult listeners"
+ );
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+
+ onBroadcastSubscribe(data) {
+ broadcastSubscriptions.push(data);
+ },
+ });
+ },
+ });
+ await handshakePromise;
+
+ socket.serverSendMsg(
+ JSON.stringify({
+ messageType: "broadcast",
+ broadcasts: {
+ "broadcast-test": "2018-03-02",
+ },
+ })
+ );
+
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ notifications,
+ [
+ [
+ "2018-03-02",
+ "broadcast-test",
+ { phase: broadcastService.PHASES.BROADCAST },
+ ],
+ ],
+ "Broadcast notification didn't get delivered"
+ );
+
+ deepEqual(
+ await broadcastService.getListeners(),
+ {
+ "broadcast-test": "2018-03-02",
+ },
+ "Broadcast version wasn't updated"
+ );
+
+ await broadcastService.addListener("example-listener", "2018-03-01", {
+ moduleURI: "resource://gre/modules/not-real-example.jsm",
+ symbolName: "doesntExist",
+ });
+
+ deepEqual(broadcastSubscriptions, [
+ {
+ messageType: "broadcast_subscribe",
+ broadcasts: { "example-listener": "2018-03-01" },
+ },
+ ]);
+});
+
+add_task(async function test_handle_hello_broadcasts() {
+ PushService.uninit();
+ await broadcastService._resetListeners();
+ let db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ let notifications = broadcastHandler.notifications;
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await broadcastService.addListener("broadcast-test", "2018-02-01", {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ deepEqual(
+ data.broadcasts,
+ { "broadcast-test": "2018-02-01" },
+ "Handshake: doesn't consult listeners"
+ );
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ broadcasts: {
+ "broadcast-test": "2018-02-02",
+ },
+ })
+ );
+ },
+
+ onBroadcastSubscribe(data) {},
+ });
+ },
+ });
+
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ notifications,
+ [
+ [
+ "2018-02-02",
+ "broadcast-test",
+ { phase: broadcastService.PHASES.HELLO },
+ ],
+ ],
+ "Broadcast notification on hello was delivered"
+ );
+
+ deepEqual(
+ await broadcastService.getListeners(),
+ {
+ "broadcast-test": "2018-02-02",
+ },
+ "Broadcast version wasn't updated"
+ );
+});
+
+add_task(async function test_broadcast_context() {
+ await broadcastService._resetListeners();
+ const db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+
+ const serviceId = "broadcast-test";
+ const version = "2018-02-01";
+ await broadcastService.addListener(serviceId, version, {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ // PushServiceWebSocket._generateID = () => channelID;
+
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {},
+ });
+ },
+ });
+
+ // Simulate registration.
+ PushServiceWebSocket.sendSubscribeBroadcast(serviceId, version);
+
+ // Simulate broadcast reply received by PushWebSocketListener.
+ const message = JSON.stringify({
+ messageType: "broadcast",
+ broadcasts: {
+ [serviceId]: version,
+ },
+ });
+ PushServiceWebSocket._wsOnMessageAvailable({}, message);
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ broadcastHandler.notifications,
+ [[version, serviceId, { phase: broadcastService.PHASES.REGISTER }]],
+ "Broadcast passes REGISTER context"
+ );
+
+ // Simulate broadcast reply, without previous registration.
+ broadcastHandler.reset();
+ PushServiceWebSocket._wsOnMessageAvailable({}, message);
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ broadcastHandler.notifications,
+ [[version, serviceId, { phase: broadcastService.PHASES.BROADCAST }]],
+ "Broadcast passes BROADCAST context"
+ );
+});
+
+add_task(async function test_broadcast_unit() {
+ const fakeListenersData = {
+ abc: {
+ version: "2018-03-04",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/abc.jsm",
+ symbolName: "getAbc",
+ },
+ },
+ def: {
+ version: "2018-04-05",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/def.jsm",
+ symbolName: "getDef",
+ },
+ },
+ };
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+
+ const jsonFile = new JSONFile({ path });
+ jsonFile.data = {
+ listeners: fakeListenersData,
+ };
+ await jsonFile._save();
+
+ const pushServiceMock = getPushServiceMock();
+
+ const mockBroadcastService = new BroadcastService(pushServiceMock, path);
+ const listeners = await mockBroadcastService.getListeners();
+ deepEqual(listeners, {
+ abc: "2018-03-04",
+ def: "2018-04-05",
+ });
+
+ await mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ });
+
+ deepEqual(pushServiceMock.subscribed, [["ghi", "2018-05-06"]]);
+
+ await mockBroadcastService._saveImmediately();
+
+ const newJSONFile = new JSONFile({ path });
+ await newJSONFile.load();
+
+ deepEqual(newJSONFile.data, {
+ listeners: {
+ ...fakeListenersData,
+ ghi: {
+ version: "2018-05-06",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ },
+ },
+ },
+ version: 1,
+ });
+
+ deepEqual(await mockBroadcastService.getListeners(), {
+ abc: "2018-03-04",
+ def: "2018-04-05",
+ ghi: "2018-05-06",
+ });
+});
+
+add_task(async function test_broadcast_initialize_sane() {
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+ const mockBroadcastService = new BroadcastService(getPushServiceMock(), path);
+ deepEqual(
+ await mockBroadcastService.getListeners(),
+ {},
+ "listeners should start out sane"
+ );
+ await mockBroadcastService._saveImmediately();
+ let onDiskJSONFile = new JSONFile({ path });
+ await onDiskJSONFile.load();
+ deepEqual(
+ onDiskJSONFile.data,
+ { listeners: {}, version: 1 },
+ "written JSON file has listeners and version fields"
+ );
+
+ await mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ });
+
+ await mockBroadcastService._saveImmediately();
+
+ onDiskJSONFile = new JSONFile({ path });
+ await onDiskJSONFile.load();
+
+ deepEqual(
+ onDiskJSONFile.data,
+ {
+ listeners: {
+ ghi: {
+ version: "2018-05-06",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ },
+ },
+ },
+ version: 1,
+ },
+ "adding listeners to initial state is written OK"
+ );
+});
+
+add_task(async function test_broadcast_reject_invalid_sourceinfo() {
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+ const mockBroadcastService = new BroadcastService(getPushServiceMock(), path);
+
+ await assert.rejects(
+ mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleName: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }),
+ /moduleURI must be a string/,
+ "rejects sourceInfo that doesn't have moduleURI"
+ );
+});
+
+add_task(async function test_broadcast_reject_version_not_string() {
+ await assert.rejects(
+ broadcastService.addListener(
+ "ghi",
+ {},
+ {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }
+ ),
+ /version should be a string/,
+ "rejects version that isn't a string"
+ );
+});
+
+add_task(async function test_broadcast_reject_version_empty_string() {
+ await assert.rejects(
+ broadcastService.addListener("ghi", "", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }),
+ /version should not be an empty string/,
+ "rejects version that is an empty string"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_clearAll_successful.js b/dom/push/test/xpcshell/test_clearAll_successful.js
new file mode 100644
index 0000000000..a638fffaaf
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clearAll_successful.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var db;
+var unregisterDefers = {};
+var userAgentID = "4ce480ef-55b2-4f83-924c-dcd35ab978b4";
+
+function promiseUnregister(keyID, code) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(() => db.close()));
+
+ // Active subscriptions; should be expired then dropped.
+ await putTestRecord(db, "active-1", "https://example.info/some-page", 8);
+ await putTestRecord(db, "active-2", "https://example.com/another-page", 16);
+
+ // Expired subscription; should be dropped.
+ await putTestRecord(db, "expired", "https://example.net/yet-another-page", 0);
+
+ // A privileged subscription that should not be affected by sanitizing data
+ // because its quota is set to `Infinity`.
+ await putTestRecord(db, "privileged", "app://chrome/only", Infinity);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => (handshakeDone = r));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ resolve();
+ },
+ });
+ },
+ });
+ await handshakePromise;
+});
+
+add_task(async function test_sanitize() {
+ let modifiedScopes = [];
+ let changeScopes = [];
+
+ let promiseCleared = Promise.all([
+ // Active subscriptions should be unregistered.
+ promiseUnregister("active-1"),
+ promiseUnregister("active-2"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 3;
+ }
+ ),
+
+ // Privileged should be recreated.
+ promiseUnregister("privileged"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ changeScopes.push(data);
+ return changeScopes.length == 1;
+ }
+ ),
+ ]);
+
+ await PushService.clear({
+ domain: "*",
+ });
+
+ await promiseCleared;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "app://chrome/only",
+ "https://example.com/another-page",
+ "https://example.info/some-page",
+ ],
+ "Should modify active subscription scopes"
+ );
+
+ deepEqual(
+ changeScopes,
+ ["app://chrome/only"],
+ "Should fire change notification for privileged scope"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(remainingIDs, [], "Should drop all subscriptions");
+});
diff --git a/dom/push/test/xpcshell/test_clear_forgetAboutSite.js b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
new file mode 100644
index 0000000000..27ae57af25
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
@@ -0,0 +1,225 @@
+"use strict";
+
+const { ForgetAboutSite } = ChromeUtils.importESModule(
+ "resource://gre/modules/ForgetAboutSite.sys.mjs"
+);
+
+var db;
+var unregisterDefers = {};
+var userAgentID = "4fe01c2d-72ac-4c13-93d2-bb072caf461d";
+
+function promiseUnregister(keyID) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(() => db.close()));
+
+ // Active and expired subscriptions for a subdomain. The active subscription
+ // should be expired, then removed; the expired subscription should be
+ // removed immediately.
+ await putTestRecord(db, "active-sub", "https://sub.example.com/sub-page", 4);
+ await putTestRecord(
+ db,
+ "active-sub-b",
+ "https://sub.example.net/sub-page",
+ 4
+ );
+ await putTestRecord(
+ db,
+ "expired-sub",
+ "https://sub.example.com/yet-another-page",
+ 0
+ );
+
+ // Active subscriptions for another subdomain. Should be unsubscribed and
+ // dropped.
+ await putTestRecord(db, "active-1", "https://sub2.example.com/some-page", 8);
+ await putTestRecord(
+ db,
+ "active-2",
+ "https://sub3.example.com/another-page",
+ 16
+ );
+ await putTestRecord(
+ db,
+ "active-1-b",
+ "https://sub2.example.net/some-page",
+ 8
+ );
+ await putTestRecord(
+ db,
+ "active-2-b",
+ "https://sub3.example.net/another-page",
+ 16
+ );
+
+ // A privileged subscription with a real URL that should not be affected
+ // because its quota is set to `Infinity`.
+ await putTestRecord(
+ db,
+ "privileged",
+ "https://sub.example.com/real-url",
+ Infinity
+ );
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => (handshakeDone = r));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(request.code, 200, "Expected manual unregister reason");
+ resolve();
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID: request.channelID,
+ })
+ );
+ },
+ });
+ },
+ });
+ // For cleared subscriptions, we only send unregister requests in the
+ // background and if we're connected.
+ await handshakePromise;
+});
+
+add_task(async function test_forgetAboutSubdomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ // Active subscriptions should be dropped.
+ promiseUnregister("active-sub"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 1;
+ }
+ ),
+ ]);
+ await ForgetAboutSite.removeDataFromDomain("sub.example.com");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ ["https://sub.example.com/sub-page"],
+ "Should fire modified notifications for active subscriptions"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ [
+ "active-1",
+ "active-1-b",
+ "active-2",
+ "active-2-b",
+ "active-sub-b",
+ "privileged",
+ ],
+ "Should only forget subscriptions for subdomain"
+ );
+});
+
+add_task(async function test_forgetAboutRootDomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ promiseUnregister("active-1"),
+ promiseUnregister("active-2"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 2;
+ }
+ ),
+ ]);
+
+ await ForgetAboutSite.removeDataFromDomain("example.com");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "https://sub2.example.com/some-page",
+ "https://sub3.example.com/another-page",
+ ],
+ "Should fire modified notifications for entire domain"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ ["active-1-b", "active-2-b", "active-sub-b", "privileged"],
+ "Should ignore privileged records with a real URL"
+ );
+});
+
+// Tests the legacy removeDataFromDomain method.
+add_task(async function test_forgetAboutBaseDomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ promiseUnregister("active-sub-b"),
+ promiseUnregister("active-1-b"),
+ promiseUnregister("active-2-b"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 3;
+ }
+ ),
+ ]);
+
+ await ForgetAboutSite.removeDataFromDomain("example.net");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "https://sub.example.net/sub-page",
+ "https://sub2.example.net/some-page",
+ "https://sub3.example.net/another-page",
+ ],
+ "Should fire modified notifications for entire domain"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ ["privileged"],
+ "Should ignore privileged records with a real URL"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_clear_origin_data.js b/dom/push/test/xpcshell/test_clear_origin_data.js
new file mode 100644
index 0000000000..7c743148a6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_origin_data.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+
+let clearForPattern = async function (testRecords, pattern) {
+ let patternString = JSON.stringify(pattern);
+ await PushService._clearOriginData(patternString);
+
+ for (let length = testRecords.length; length--; ) {
+ let test = testRecords[length];
+ let originSuffix = ChromeUtils.originAttributesToSuffix(
+ test.originAttributes
+ );
+
+ let registration = await PushService.registration({
+ scope: test.scope,
+ originAttributes: originSuffix,
+ });
+
+ let url = test.scope + originSuffix;
+
+ if (ObjectUtils.deepEqual(test.clearIf, pattern)) {
+ ok(
+ !registration,
+ "Should clear registration " + url + " for pattern " + patternString
+ );
+ testRecords.splice(length, 1);
+ } else {
+ ok(
+ registration,
+ "Should not clear registration " + url + " for pattern " + patternString
+ );
+ }
+ }
+};
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_webapps_cleardata() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let testRecords = [
+ {
+ scope: "https://example.org/1",
+ originAttributes: {},
+ clearIf: { inIsolatedMozBrowser: false },
+ },
+ {
+ scope: "https://example.org/1",
+ originAttributes: { inIsolatedMozBrowser: true },
+ clearIf: {},
+ },
+ ];
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(
+ resolve => (unregisterDone = after(testRecords.length, resolve))
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(data) {
+ equal(data.messageType, "register", "Register: wrong message type");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: data.channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/" + Math.random(),
+ })
+ );
+ },
+ onUnregister(data) {
+ equal(data.code, 200, "Expected manual unregister reason");
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await Promise.all(
+ testRecords.map(test =>
+ PushService.register({
+ scope: test.scope,
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ test.originAttributes
+ ),
+ })
+ )
+ );
+
+ // Removes all the records, Excluding where `inIsolatedMozBrowser` is true.
+ await clearForPattern(testRecords, { inIsolatedMozBrowser: false });
+
+ // Removes the all the remaining records where `inIsolatedMozBrowser` is true.
+ await clearForPattern(testRecords, {});
+
+ equal(testRecords.length, 0, "Should remove all test records");
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_crypto.js b/dom/push/test/xpcshell/test_crypto.js
new file mode 100644
index 0000000000..e6b50e1d96
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -0,0 +1,666 @@
+"use strict";
+
+const { getCryptoParamsFromHeaders, PushCrypto } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushCrypto.sys.mjs"
+);
+
+const REJECT_PADDING = { padding: "reject" };
+
+// A common key to decrypt some aesgcm and aesgcm128 messages. Other decryption
+// tests have their own keys.
+const LEGACY_PRIVATE_KEY = {
+ kty: "EC",
+ crv: "P-256",
+ d: "4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg",
+ x: "sd85ZCbEG6dEkGMCmDyGBIt454Qy-Yo-1xhbaT2Jlk4",
+ y: "vr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs",
+ ext: true,
+};
+
+const LEGACY_PUBLIC_KEY =
+ "BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs";
+
+async function assertDecrypts(test, headers) {
+ let privateKey = test.privateKey;
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let authSecret = null;
+ if (test.authSecret) {
+ authSecret = ChromeUtils.base64URLDecode(test.authSecret, REJECT_PADDING);
+ }
+ let payload = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let result = await PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ headers,
+ payload
+ );
+ let decoder = new TextDecoder("utf-8");
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+}
+
+async function assertNotDecrypts(test, headers) {
+ let authSecret = null;
+ if (test.authSecret) {
+ authSecret = ChromeUtils.base64URLDecode(test.authSecret, REJECT_PADDING);
+ }
+ let data = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let promise = PushCrypto.decrypt(
+ test.privateKey,
+ publicKey,
+ authSecret,
+ headers,
+ data
+ );
+ await Assert.rejects(promise, test.expected, test.desc);
+}
+
+add_task(async function test_crypto_getCryptoParamsFromHeaders() {
+ // These headers should parse correctly.
+ let shouldParse = [
+ {
+ desc: "aesgcm with multiple keys",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: "keyid=p256dh;dh=Iy1Je2Kv11A,p256ecdsa=o2M8QfiEKuI",
+ encryption: "keyid=p256dh;salt=upk1yFkp1xI",
+ },
+ params: {
+ senderKey: "Iy1Je2Kv11A",
+ salt: "upk1yFkp1xI",
+ rs: 4096,
+ },
+ },
+ {
+ desc: "aesgcm with quoted key param",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: 'dh="byfHbUffc-k"',
+ encryption: "salt=C11AvAsp6Gc",
+ },
+ params: {
+ senderKey: "byfHbUffc-k",
+ salt: "C11AvAsp6Gc",
+ rs: 4096,
+ },
+ },
+ {
+ desc: "aesgcm with Crypto-Key and rs = 24",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: 'dh="ybuT4VDz-Bg"',
+ encryption: "salt=H7U7wcIoIKs; rs=24",
+ },
+ params: {
+ senderKey: "ybuT4VDz-Bg",
+ salt: "H7U7wcIoIKs",
+ rs: 24,
+ },
+ },
+ {
+ desc: "aesgcm128 with Encryption-Key and rs = 2",
+ headers: {
+ encoding: "aesgcm128",
+ encryption_key: "keyid=legacy; dh=LqrDQuVl9lY",
+ encryption: "keyid=legacy; salt=YngI8B7YapM; rs=2",
+ },
+ params: {
+ senderKey: "LqrDQuVl9lY",
+ salt: "YngI8B7YapM",
+ rs: 2,
+ },
+ },
+ {
+ desc: "aesgcm128 with Encryption-Key",
+ headers: {
+ encoding: "aesgcm128",
+ encryption_key: "keyid=v2; dh=VA6wmY1IpiE",
+ encryption: "keyid=v2; salt=khtpyXhpDKM",
+ },
+ params: {
+ senderKey: "VA6wmY1IpiE",
+ salt: "khtpyXhpDKM",
+ rs: 4096,
+ },
+ },
+ ];
+ for (let test of shouldParse) {
+ let params = getCryptoParamsFromHeaders(test.headers);
+ let senderKey = ChromeUtils.base64URLDecode(
+ test.params.senderKey,
+ REJECT_PADDING
+ );
+ let salt = ChromeUtils.base64URLDecode(test.params.salt, REJECT_PADDING);
+ deepEqual(
+ new Uint8Array(params.senderKey),
+ new Uint8Array(senderKey),
+ "Sender key should match for " + test.desc
+ );
+ deepEqual(
+ new Uint8Array(params.salt),
+ new Uint8Array(salt),
+ "Salt should match for " + test.desc
+ );
+ equal(
+ params.rs,
+ test.params.rs,
+ "Record size should match for " + test.desc
+ );
+ }
+
+ // These headers should be rejected.
+ let shouldThrow = [
+ {
+ desc: "aesgcm128 with Crypto-Key",
+ headers: {
+ encoding: "aesgcm128",
+ crypto_key: "keyid=v2; dh=VA6wmY1IpiE",
+ encryption: "keyid=v2; salt=F0Im7RtGgNY",
+ },
+ exception: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Invalid encoding",
+ headers: {
+ encoding: "nonexistent",
+ },
+ exception: /Missing encryption header/,
+ },
+ {
+ desc: "Invalid record size",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: "dh=pbmv1QkcEDY",
+ encryption: "dh=Esao8aTBfIk;rs=bad",
+ },
+ exception: /Invalid salt parameter/,
+ },
+ {
+ desc: "aesgcm with Encryption-Key",
+ headers: {
+ encoding: "aesgcm",
+ encryption_key: "dh=FplK5KkvUF0",
+ encryption: "salt=p6YHhFF3BQY",
+ },
+ exception: /Missing Crypto-Key header/,
+ },
+ ];
+ for (let test of shouldThrow) {
+ throws(
+ () => getCryptoParamsFromHeaders(test.headers),
+ test.exception,
+ test.desc
+ );
+ }
+});
+
+add_task(async function test_aes128gcm_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "Example from draft-ietf-webpush-encryption-latest",
+ result: "When I grow up, I want to be a watermelon",
+ data: "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN",
+ authSecret: "BTBZMqHH6r4Tts7J_aSIgg",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
+ x: "JXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzE",
+ y: "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ ext: true,
+ },
+ publicKey:
+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ },
+ {
+ desc: "rs = 24, pad = 0",
+ result:
+ "I am the very model of a modern Major-General; I've information vegetable, animal, and mineral",
+ data: "goagSH7PP0ZGwUsgShmdkwAAABhBBDJVyIuUJbOSVMeWHP8VNPnxY-dZSw86doqOkEzZZZY1ALBWVXTVf0rUDH3oi68I9Hrp-01zA-mr8XKWl5kcH8cX0KiV2PtCwdkEyaQ73YF5fsDxgoWDiaTA3wPqMvuLDqGsZWHnE9Psnfoy7UMEqKlh2a1nE7ZOXiXcOBHLNj260jYzSJnEPV2eXixSXfyWpaSJHAwfj4wVdAAocmViIg6ywk8wFB1hgJpnX2UVEU_qIOcaP6AOIOr1UUQPfosQqC2MEHe5u9gHXF5pi-E267LAlkoYefq01KV_xK_vjbxpw8GAYfSjQEm0L8FG-CN37c8pnQ2Yf61MkihaXac9OctfNeWq_22cN6hn4qsOq0F7QoWIiZqWhB1vS9cJ3KUlyPQvKI9cvevDxw0fJHWeTFzhuwT9BjdILjjb2Vkqc0-qTDOawqD4c8WXsvdGDQCec5Y1x3UhdQXdjR_mhXypxFM37OZTvKJBr1vPCpRXl-bI6iOd7KScgtMM1x5luKhGzZyz25HyuFyj1ec82A",
+ authSecret: "_tK2LDGoIt6be6agJ_nvGA",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "bGViEe3PvjjFJg8lcnLsqu71b2yqWGnZN9J2MTed-9s",
+ x: "auB0GHF0AZ2LAocFnvOXDS7EeCMopnzbg-tS21FMHrU",
+ y: "GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc",
+ ext: true,
+ },
+ publicKey:
+ "BGrgdBhxdAGdiwKHBZ7zlw0uxHgjKKZ824PrUttRTB61GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc",
+ },
+ {
+ desc: "rs = 49, pad = 84; ciphertext length falls on record boundary",
+ result: "Hello, world",
+ data: "-yiDzsHE_K3W0TcfbqSR4AAAADFBBC1EHuf5_2oDKaZJJ9BST9vnsixvtl4Qq0_cA4-UQgoMo_oo2tNshOyRoWLq4Hj6rSwc7XjegRPhlgKyDolPSXa5c-L89oL6DIzNmvPVv_Ht4W-tWjHOGdOLXh_h94pPrYQrvBAlTCxs3ZaitVKE2XLFPK2MO6yxD19X6w1KQzO2BBAroRfK4pEI-9n2Kai6aWDdAZRbOe03unBsQ0oQ_SvSCU_5JJvNrUUTX1_kX804Bx-LLTlBr9pDmBDXeqyvfOULVDJb9YyVAzN9BzeFoyPfo0M",
+ authSecret: "lfF1cOUI72orKtG09creMw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "ZwBKTqgg3u2OSdtelIDmPT6jzOGujhpgYJcT1SfQAe8",
+ x: "AU6PFLktoHzgg7k_ljZ-h7IXpH9-8u6TqdNDqgY-V1o",
+ y: "nzDVnGkMajmz_IFbFQyn3RSWAXQTN7U1B6UfQbFzpyE",
+ ext: true,
+ },
+ publicKey:
+ "BAFOjxS5LaB84IO5P5Y2foeyF6R_fvLuk6nTQ6oGPldanzDVnGkMajmz_IFbFQyn3RSWAXQTN7U1B6UfQbFzpyE",
+ },
+ {
+ desc: "rs = 18, pad = 0",
+ result: "1",
+ data: "fK69vCCTjuNAqUbxvU9o8QAAABJBBDfP21Ij2fleqgL27ZQP8i6vBbNiLpSdw86fM15u-bJq6qzKD3QICos2RZLyzMbV7d1DAEtwuRiH0UTZ-pPxbDvH6mj0_VR6lOyoSxbhOKYIAXc",
+ authSecret: "1loE35Xy215gSDn3F9zeeQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "J0M_q4lws8tShLYRg--0YoZWLNKnMw2MrpYJEaVXHQw",
+ x: "UV1DJjVWUjmdoksr6SQeYztc8U-vDPOm_WAxe5VMCi8",
+ y: "SEhUgASyewz3SAvIEMa-wDqPt5yOoA_IsF4A-INFY-8",
+ ext: true,
+ },
+ publicKey:
+ "BFFdQyY1VlI5naJLK-kkHmM7XPFPrwzzpv1gMXuVTAovSEhUgASyewz3SAvIEMa-wDqPt5yOoA_IsF4A-INFY-8",
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ let privateKey = test.privateKey;
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, {
+ padding: "reject",
+ });
+ let authSecret = ChromeUtils.base64URLDecode(test.authSecret, {
+ padding: "reject",
+ });
+ let payload = ChromeUtils.base64URLDecode(test.data, {
+ padding: "reject",
+ });
+ let result = await PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ {
+ encoding: "aes128gcm",
+ },
+ payload
+ );
+ let decoder = new TextDecoder("utf-8");
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+ }
+});
+
+add_task(async function test_aes128gcm_err() {
+ let expectedFailures = [
+ {
+ // Just the payload; no header at all.
+ desc: "Missing header block",
+ data: "RbdNK2m-mwdN47NaqH58FWEd",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "G-g_ODMu8JaB-vPzB7H_LhDKt4zHzatoOsDukqw_buE",
+ x: "26mRyiFTQ_Nr3T6FfK_ePRi_V_GDWygzutQU8IhBYgU",
+ y: "GslqCyRJADfQfPUo5OGOEAoaZOt0R0hUS_HiINq6zyw",
+ ext: true,
+ },
+ publicKey:
+ "BNupkcohU0Pza90-hXyv3j0Yv1fxg1soM7rUFPCIQWIFGslqCyRJADfQfPUo5OGOEAoaZOt0R0hUS_HiINq6zyw",
+ authSecret: "NHG7mEgeAlM785VCvPPbpA",
+ expected: /Truncated header/,
+ },
+ {
+ // The sender key should be 65 bytes; this header contains an invalid key
+ // that's only 1 byte.
+ desc: "Truncated sender key",
+ data: "3ltpa4fxoVy2revdedb5ngAAABIBALa8GCbDfJ9z3WtIWcK1BRgZUg",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "zojo4LMFekdS60yPqTHrYhwwLaWtA7ga9FnPZzVWDK4",
+ x: "oyXZkITEDeDOcioELESNlKMmkXIcp54890XnjGmIYZQ",
+ y: "sCzqGSJBdnlanU27sgc68szW-m8KTHxJaFVr5QKjuoE",
+ ext: true,
+ },
+ publicKey:
+ "BKMl2ZCExA3gznIqBCxEjZSjJpFyHKeePPdF54xpiGGUsCzqGSJBdnlanU27sgc68szW-m8KTHxJaFVr5QKjuoE",
+ authSecret: "XDHg2W2aE5iZrAlp01n3QA",
+ expected: /Invalid sender public key/,
+ },
+ {
+ // The message is encrypted with only the first 12 bytes of the 16-byte
+ // auth secret, so the derived decryption key and nonce won't match.
+ desc: "Encrypted with mismatched auth secret",
+ data: "gRX0mIuMOSp7rLQ8jxrFZQAAABJBBBmUSDxUHpvDmmrwP_cTqndFwoThOKQqJDW3l7IMS2mM9RGLT4VVMXwZDqvr-rdJwWTT9r3r4NRBcZExo1fYiQoTxNvUsW_z3VqD98ka1uBArEJzCn8LPNMkXp-Nb_McdR1BDP0",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "YMdjalF95wOaCsLQ4wZEAHlMeOfgSTmBKaInzuD5qAE",
+ x: "_dBBKKhcBYltf4H-EYvcuIe588H_QYOtxMgk0ShgcwA",
+ y: "6Yay37WmEOWvQ-QIoAcwWE-T49_d_ERzfV8I-y1viRY",
+ ext: true,
+ },
+ publicKey:
+ "BP3QQSioXAWJbX-B_hGL3LiHufPB_0GDrcTIJNEoYHMA6Yay37WmEOWvQ-QIoAcwWE-T49_d_ERzfV8I-y1viRY",
+ authSecret: "NVo4zW2b7xWZDi0zCNvWAA",
+ expected: /Bad encryption/,
+ },
+ {
+ // Multiple records; the first has padding delimiter = 2, but should be 1.
+ desc: "Early final record",
+ data: "2-IVUH0a09Lq6r6ubodNjwAAABJBBHvEND80qDSM3E5GL_x8QKpqjGGnOcTEHUUSVQX3Dp_F-e-oaFLdSI3Pjo6iyvt14Hq9XufJ1cA4uv7weVcbC9opRBHOmMdt0DHA5YBXekmAo3XkXtMEKb4OLunafm34aW0BuOw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "XdodkYvEB7o82hLLgBTUmqfgJpACggMERmvIADTKkkA",
+ x: "yVxlINrRHo9qG_gDGkDCpO4QRcGQO-BqHfp_gpzOst4",
+ y: "Akga5r0EdhIbEsVTLQsjF4gHfvoGg6W_4NYjObJRyzU",
+ ext: true,
+ },
+ publicKey:
+ "BMlcZSDa0R6Pahv4AxpAwqTuEEXBkDvgah36f4KczrLeAkga5r0EdhIbEsVTLQsjF4gHfvoGg6W_4NYjObJRyzU",
+ authSecret: "QMJB_eQmnuHm1yVZLZgnGA",
+ expected: /Padding is wrong!/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, { encoding: "aes128gcm" });
+ }
+});
+
+add_task(async function test_aesgcm_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "padSize = 2, rs = 24, pad = 0",
+ result: "Some message",
+ data: "Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU",
+ authSecret: "aTDc6JebzR6eScy2oLo4RQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHFVrflyxibGLlgztLwKelsRZp4gqX3tNfAKFaxAcBhpvYeN1yIUMrxaDKiLh4LNKPtj0BOXGdr-IQ-QP82Wjo",
+ encryption: "salt=zCU18Rw3A5aB_Xi-vfixmA; rs=24",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "padSize = 2, rs = 8, pad = 16",
+ result: "Yet another message",
+ data: "uEC5B_tR-fuQ3delQcrzrDCp40W6ipMZjGZ78USDJ5sMj-6bAOVG3AK6JqFl9E6AoWiBYYvMZfwThVxmDnw6RHtVeLKFM5DWgl1EwkOohwH2EhiDD0gM3io-d79WKzOPZE9rDWUSv64JstImSfX_ADQfABrvbZkeaWxh53EG59QMOElFJqHue4dMURpsMXg",
+ authSecret: "6plwZnSpVUbF7APDXus3UQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ",
+ encryption: "salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "padSize = 2, rs = 3, pad = 0",
+ result: "Small record size",
+ data: "oY4e5eDatDVt2fpQylxbPJM-3vrfhDasfPc8Q1PWt4tPfMVbz_sDNL_cvr0DXXkdFzS1lxsJsj550USx4MMl01ihjImXCjrw9R5xFgFrCAqJD3GwXA1vzS4T5yvGVbUp3SndMDdT1OCcEofTn7VC6xZ-zP8rzSQfDCBBxmPU7OISzr8Z4HyzFCGJeBfqiZ7yUfNlKF1x5UaZ4X6iU_TXx5KlQy_toV1dXZ2eEAMHJUcSdArvB6zRpFdEIxdcHcJyo1BIYgAYTDdAIy__IJVCPY_b2CE5W_6ohlYKB7xDyH8giNuWWXAgBozUfScLUVjPC38yJTpAUi6w6pXgXUWffende5FreQpnMFL1L4G-38wsI_-ISIOzdO8QIrXHxmtc1S5xzYu8bMqSgCinvCEwdeGFCmighRjj8t1zRWo0D14rHbQLPR_b1P5SvEeJTtS9Nm3iibM",
+ authSecret: "g2rWVHUCpUxgcL9Tz7vyeQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk",
+ encryption: "salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "Example from draft-ietf-httpbis-encryption-encoding-02",
+ result: "I am the walrus",
+ data: "6nqAQUME8hNqw5J3kl8cpVVJylXKYqZOeseZG8UueKpA",
+ authSecret: "R29vIGdvbyBnJyBqb29iIQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "9FWl15_QUQAWDaD3k3l50ZBZQJ4au27F1V4F0uLSD_M",
+ x: "ISQGPMvxncL6iLZDugTm3Y2n6nuiyMYuD3epQ_TC-pE",
+ y: "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU",
+ ext: true,
+ },
+ publicKey:
+ "BCEkBjzL8Z3C-oi2Q7oE5t2Np-p7osjGLg93qUP0wvqRT21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU",
+ headers: {
+ crypto_key:
+ 'keyid="dhkey"; dh="BNoRDbb84JGm8g5Z5CFxurSqsXWJ11ItfXEWYVLE85Y7CYkDjXsIEc4aqxYaQ1G8BqkXCJ6DPpDrWtdWj_mugHU"',
+ encryption: 'keyid="dhkey"; salt="lngarbyKfMoi9Z75xYXmkg"',
+ encoding: "aesgcm",
+ },
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ await assertDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm_err() {
+ let expectedFailures = [
+ {
+ desc: "aesgcm128 message decrypted as aesgcm",
+ data: "fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8",
+ authSecret: "BhbpNTWyO5wVJmVKTV6XaA",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0",
+ encryption: "salt=c6JQl9eJ0VvwrUVCQDxY7Q",
+ encoding: "aesgcm",
+ },
+ expected: /Bad encryption/,
+ },
+ {
+ // The plaintext is "O hai". The ciphertext is exactly `rs + 16` bytes,
+ // but we didn't include the empty trailing block that aesgcm requires for
+ // exact multiples.
+ desc: "rs = 7, no trailing block",
+ data: "YG4F-b06y590hRlnSsw_vuOw62V9Iz8",
+ authSecret: "QoDi0u6vcslIVJKiouXMXw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "2bu4paOAZbL2ef1u-wTzONuTIcDPc00o0zUJgg46XTc",
+ x: "uEvLZUMVn1my0cwnLdcFT0mj1gSU5uzI3HeGwXC7jX8",
+ y: "SfNVLGL-FurydsuzciDfw8K1cUHyoDWnJJ_16UG6Dbo",
+ ext: true,
+ },
+ publicKey:
+ "BLhLy2VDFZ9ZstHMJy3XBU9Jo9YElObsyNx3hsFwu41_SfNVLGL-FurydsuzciDfw8K1cUHyoDWnJJ_16UG6Dbo",
+ headers: {
+ crypto_key:
+ "dh=BD_bsTUpxBMvSv8eksith3vijMLj44D4jhJjO51y7wK1ytbUlsyYBBYYyB5AAe5bnREA_WipTgemDVz00LiWcfM",
+ encryption: "salt=xKWvs_jWWeg4KOsot_uBhA; rs=7",
+ encoding: "aesgcm",
+ },
+ expected: /Encrypted data truncated/,
+ },
+ {
+ // The last block is only 1 byte, but valid blocks must be at least 2 bytes.
+ desc: "Pad size > last block length",
+ data: "JvX9HsJ4lL5gzP8_uCKc6s15iRIaNhD4pFCgq5-dfwbUqEcNUkqv",
+ authSecret: "QtGZeY8MQfCaq-XwKOVGBQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "CosERAVXgvTvoh7UkrRC2V-iXoNs0bXle9I68qzkles",
+ x: "_D0YqEwirvTJQJdjG6xXrjstMVpeAzf221cUqZz6hgY",
+ y: "9MnFbM7U14uiYMDI5e2I4jN29tYmsM9F66QodhKmA-c",
+ ext: true,
+ },
+ publicKey:
+ "BPw9GKhMIq70yUCXYxusV647LTFaXgM39ttXFKmc-oYG9MnFbM7U14uiYMDI5e2I4jN29tYmsM9F66QodhKmA-c",
+ headers: {
+ crypto_key:
+ "dh=BBNZNEi5Ew_ID5S4Y9jWBi1NeVDje6Mjs7SDLViUn6A8VAZj-6X3QAuYQ3j20BblqjwTgYst7PRnY6UGrKyLbmU",
+ encryption: "salt=ot8hzbwOo6CYe6ZhdlwKtg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Decoded array is too short/,
+ },
+ {
+ // The last block is 3 bytes (2 bytes for the pad length; 1 byte of data),
+ // but claims its pad length is 2.
+ desc: "Padding length > last block length",
+ data: "oWSOFA-UO5oWq-kI79RHaFfwAejLiQJ4C7eTmrSTBl4gArLXfx7lZ-Y",
+ authSecret: "gKG_P6-de5pyzS8hyH_NyQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "9l-ahcBM-I0ykwbWiDS9KRrPdhyvTZ0SxKiPpj2aeaI",
+ x: "qx0tU4EDaQv6ayFA3xvLLBdMmn4mLxjn7SK6mIeIxeg",
+ y: "ymbMcmUOEyh_-rLrBsi26NG4UFCis2MTDs5FG2VdDPI",
+ ext: true,
+ },
+ publicKey:
+ "BKsdLVOBA2kL-mshQN8byywXTJp-Ji8Y5-0iupiHiMXoymbMcmUOEyh_-rLrBsi26NG4UFCis2MTDs5FG2VdDPI",
+ headers: {
+ crypto_key:
+ "dh=BKe2IBO_cwmEzQyTVscSbQcj0Y3uBSzGZ_mHlANMciS8uGpb7U8_Bw7TNdlYfpwWDLd0cxM8YYWNDbNJ_p2Rp4o",
+ encryption: "salt=z7QJ6UR89SiFRkd4RsC4Vg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Padding is wrong/,
+ },
+ {
+ // The first block has no padding, but claims its pad length is 1.
+ desc: "Non-zero padding",
+ data: "Qdvjh0LkZXKu_1Hvv56D0rOSF6Mww3y0F8xkxUNlwVu2U1iakOUUGRs",
+ authSecret: "cMpWQW58BrpDbJ8KqbS9ig",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "IzuaxLqFJmjSu8GjLCo2oEaDZjDButW4m4T0qx02XsM",
+ x: "Xy7vt_TJTynxwWsQyY069BcKmrhkRjhKPFuTi-AphoY",
+ y: "0M10IVM1ourR7Q5AUX2b2fgdmGyTWcYsdHcdFK_b4Hk",
+ ext: true,
+ },
+ publicKey:
+ "BF8u77f0yU8p8cFrEMmNOvQXCpq4ZEY4Sjxbk4vgKYaG0M10IVM1ourR7Q5AUX2b2fgdmGyTWcYsdHcdFK_b4Hk",
+ headers: {
+ crypto_key:
+ "dh=BBicj01QI0ryiFzAaty9VpW_crgq9XbU1bOCtEZI9UNE6tuOgp4lyN_UN0N905ECnLWK5v_sCPUIxnQgOuCseSo",
+ encryption: "salt=SbkGHONbQBBsBcj9dLyIUw; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Padding is wrong/,
+ },
+ {
+ // The first record is 22 bytes: 2 bytes for the pad length, 4 bytes of
+ // data, and a 16-byte auth tag. The second "record" is missing the pad
+ // and data, and contains just the auth tag.
+ desc: "rs = 6, second record truncated to only auth tag",
+ data: "C7u3j5AL4Yzh2yYB_umN6tzrVHxrt7D5baTEW9DE1Bk3up9fY4w",
+ authSecret: "3rWhsRCU_KdaqfKPbd3zBQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "nhOT9171xuoQBJGkiZ3aqT5qw_ILJ94_PPiVNu1LFSY",
+ x: "lCj7ctQTmRfwzTMcODlNfHjFMAHmgdI44OhTQXX_xpE",
+ y: "WBdgz4GWGtGAisC63O9DtP5l--hnCzPZiV-YZ-a6Lcw",
+ ext: true,
+ },
+ publicKey:
+ "BJQo-3LUE5kX8M0zHDg5TXx4xTAB5oHSOODoU0F1_8aRWBdgz4GWGtGAisC63O9DtP5l--hnCzPZiV-YZ-a6Lcw",
+ headers: {
+ crypto_key:
+ "dh=BI38Qs_OhDmQIxbszc6Nako-MrX3FzAE_8HzxM1wgoEIG4ocxyF-YAAVhfkpJUvDpRyKW2LDHIaoylaZuxQfRhE",
+ encryption: "salt=QClh48OlvGpSjZ0Mg0e8rg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Decoded array is too short/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm128_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "padSize = 1, rs = 4096, pad = 2",
+ result: "aesgcm128 encrypted message",
+ data: "ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ encryption_key:
+ "dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI",
+ encryption: "salt=btxxUtclbmgcc30b9rT3Bg; rs=4096",
+ encoding: "aesgcm128",
+ },
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ await assertDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm128_err() {
+ let expectedFailures = [
+ {
+ // aesgcm128 doesn't use an auth secret, but we've mixed one in during
+ // encryption, so the decryption key and nonce won't match.
+ desc: "padSize = 1, rs = 4096, auth secret, pad = 8",
+ data: "h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r",
+ authSecret: "Sxb6u0gJIhGEogyLawjmCw",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM",
+ encryption: "salt=aGBpoKklLtrLcAUCcCr7JQ",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ // The first byte of each record must be the pad length.
+ desc: "Missing padding",
+ data: "anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4",
+ encryption: "salt=Czx2i18rar8XWOXAVDnUuw",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Truncated input",
+ data: "AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0",
+ encryption: "salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Padding length > rs",
+ data: "Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls",
+ encryption: "salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, test.headers);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_crypto_encrypt.js b/dom/push/test/xpcshell/test_crypto_encrypt.js
new file mode 100644
index 0000000000..785b4b5363
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto_encrypt.js
@@ -0,0 +1,201 @@
+// Test PushCrypto.encrypt()
+"use strict";
+
+Cu.importGlobalProperties(["crypto"]);
+
+const { PushCrypto } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushCrypto.sys.mjs"
+);
+
+let from64 = v => {
+ // allow whitespace in the strings.
+ let stripped = v.replace(/ |\t|\r|\n/g, "");
+ return new Uint8Array(
+ ChromeUtils.base64URLDecode(stripped, { padding: "reject" })
+ );
+};
+
+let to64 = v => ChromeUtils.base64URLEncode(v, { pad: false });
+
+// A helper function to take a public key (as a buffer containing a 65-byte
+// buffer of uncompressed EC points) and a private key (32byte buffer) and
+// return 2 crypto keys.
+async function importKeyPair(publicKeyBuffer, privateKeyBuffer) {
+ let jwk = {
+ kty: "EC",
+ crv: "P-256",
+ x: to64(publicKeyBuffer.slice(1, 33)),
+ y: to64(publicKeyBuffer.slice(33, 65)),
+ ext: true,
+ };
+ let publicKey = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ []
+ );
+ jwk.d = to64(privateKeyBuffer);
+ let privateKey = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ["deriveBits"]
+ );
+ return { publicKey, privateKey };
+}
+
+// The example from draft-ietf-webpush-encryption-09.
+add_task(async function static_aes128gcm() {
+ let fixture = {
+ ciphertext:
+ from64(`DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
+ mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT
+ pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN`),
+ plaintext: new TextEncoder().encode(
+ "When I grow up, I want to be a watermelon"
+ ),
+ authSecret: from64("BTBZMqHH6r4Tts7J_aSIgg"),
+ receiver: {
+ private: from64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"),
+ public: from64(`BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx
+ aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4`),
+ },
+ sender: {
+ private: from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"),
+ public: from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIg
+ Dll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`),
+ },
+ salt: from64("DGv6ra1nlYgDCS1FRnbzlw"),
+ };
+
+ let options = {
+ senderKeyPair: await importKeyPair(
+ fixture.sender.public,
+ fixture.sender.private
+ ),
+ salt: fixture.salt,
+ };
+
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ fixture.plaintext,
+ fixture.receiver.public,
+ fixture.authSecret,
+ options
+ );
+
+ Assert.deepEqual(ciphertext, fixture.ciphertext);
+ Assert.equal(encoding, "aes128gcm");
+
+ // and for fun, decrypt it and check the plaintext.
+ let recvKeyPair = await importKeyPair(
+ fixture.receiver.public,
+ fixture.receiver.private
+ );
+ let jwk = await crypto.subtle.exportKey("jwk", recvKeyPair.privateKey);
+ let plaintext = await PushCrypto.decrypt(
+ jwk,
+ fixture.receiver.public,
+ fixture.authSecret,
+ { encoding: "aes128gcm" },
+ ciphertext
+ );
+ Assert.deepEqual(plaintext, fixture.plaintext);
+});
+
+// This is how we expect real code to interact with .encrypt.
+add_task(async function aes128gcm_simple() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ let message = new TextEncoder().encode("Fast for good.");
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+});
+
+// Variable record size tests
+add_task(async function aes128gcm_rs() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let rs of [-1, 0, 1, 17]) {
+ let payload = "x".repeat(1024);
+ info(`testing expected failure with rs=${rs}`);
+ let message = new TextEncoder().encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ await Assert.rejects(
+ PushCrypto.encrypt(message, recvPublicKey, authSecret, { rs }),
+ /recordsize is too small/
+ );
+ }
+ for (let rs of [18, 50, 1024, 4096, 16384]) {
+ info(`testing expected success with rs=${rs}`);
+ let payload = "x".repeat(rs * 3);
+ let message = new TextEncoder().encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret,
+ { rs }
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+ }
+});
+
+// And try and hit some edge-cases.
+add_task(async function aes128gcm_edgecases() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let size of [
+ 0,
+ 4096 - 16,
+ 4096 - 16 - 1,
+ 4096 - 16 + 1,
+ 4095,
+ 4096,
+ 4097,
+ 10240,
+ ]) {
+ info(`testing encryption of ${size} byte payload`);
+ let message = new TextEncoder().encode("x".repeat(size));
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_drop_expired.js b/dom/push/test/xpcshell/test_drop_expired.js
new file mode 100644
index 0000000000..823049c21f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_drop_expired.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "2c43af06-ab6e-476a-adc4-16cbda54fb89";
+
+var db;
+var quotaURI;
+var permURI;
+
+function visitURI(uri, timestamp) {
+ return PlacesTestUtils.addVisits({
+ uri,
+ title: uri.spec,
+ visitDate: timestamp * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+}
+
+var putRecord = async function ({ scope, perm, quota, lastPush, lastVisit }) {
+ let uri = Services.io.newURI(scope);
+
+ PermissionTestUtils.add(
+ uri,
+ "desktop-notification",
+ Ci.nsIPermissionManager[perm]
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ });
+
+ await visitURI(uri, lastVisit);
+
+ await db.put({
+ channelID: uri.pathQueryRef,
+ pushEndpoint: "https://example.org/push" + uri.pathQueryRef,
+ scope: uri.spec,
+ pushCount: 0,
+ lastPush,
+ version: null,
+ originAttributes: "",
+ quota,
+ });
+
+ return uri;
+};
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ run_next_test();
+}
+
+add_task(async function setUp() {
+ // An expired registration that should be evicted on startup. Permission is
+ // granted for this origin, and the last visit is more recent than the last
+ // push message.
+ await putRecord({
+ scope: "https://example.com/expired-quota-restored",
+ perm: "ALLOW_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now(),
+ });
+
+ // An expired registration that we should evict when the origin is visited
+ // again.
+ quotaURI = await putRecord({
+ scope: "https://example.xyz/expired-quota-exceeded",
+ perm: "ALLOW_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now() - 20,
+ });
+
+ // An expired registration that we should evict when permission is granted
+ // again.
+ permURI = await putRecord({
+ scope: "https://example.info/expired-perm-revoked",
+ perm: "DENY_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now(),
+ });
+
+ // An active registration that we should leave alone.
+ await putRecord({
+ scope: "https://example.ninja/active",
+ perm: "ALLOW_ACTION",
+ quota: 16,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now() - 20,
+ });
+
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.com/expired-quota-restored"
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await subChangePromise;
+});
+
+add_task(async function test_site_visited() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.xyz/expired-quota-exceeded"
+ );
+
+ await visitURI(quotaURI, Date.now());
+ PushService.observe(null, "idle-daily", "");
+
+ await subChangePromise;
+});
+
+add_task(async function test_perm_restored() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.info/expired-perm-revoked"
+ );
+
+ PermissionTestUtils.add(
+ permURI,
+ "desktop-notification",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+
+ await subChangePromise;
+});
diff --git a/dom/push/test/xpcshell/test_handler_service.js b/dom/push/test/xpcshell/test_handler_service.js
new file mode 100644
index 0000000000..bee29c1c77
--- /dev/null
+++ b/dom/push/test/xpcshell/test_handler_service.js
@@ -0,0 +1,74 @@
+"use strict";
+
+// Here we test that if an xpcom component is registered with the category
+// manager for push notifications against a specific scope, that service is
+// instantiated before the message is delivered.
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+let pushService = Cc["@mozilla.org/push/Service;1"].getService(
+ Ci.nsIPushService
+);
+
+function PushServiceHandler() {
+ // Register a push observer.
+ this.observed = [];
+ Services.obs.addObserver(this, pushService.pushTopic);
+ Services.obs.addObserver(this, pushService.subscriptionChangeTopic);
+ Services.obs.addObserver(this, pushService.subscriptionModifiedTopic);
+}
+
+PushServiceHandler.prototype = {
+ classID: Components.ID("{bb7c5199-c0f7-4976-9f6d-1306e32c5591}"),
+ QueryInterface: ChromeUtils.generateQI([]),
+
+ observe(subject, topic, data) {
+ this.observed.push({ subject, topic, data });
+ },
+};
+
+let handlerService = new PushServiceHandler();
+
+add_test(function test_service_instantiation() {
+ const CONTRACT_ID = "@mozilla.org/dom/push/test/PushServiceHandler;1";
+ let scope = "chrome://test-scope";
+
+ MockRegistrar.register(CONTRACT_ID, handlerService);
+ Services.catMan.addCategoryEntry("push", scope, CONTRACT_ID, false, false);
+
+ let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+ );
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ pushNotifier.notifyPush(scope, principal, "");
+
+ equal(handlerService.observed.length, 1);
+ equal(handlerService.observed[0].topic, pushService.pushTopic);
+ let message = handlerService.observed[0].subject.QueryInterface(
+ Ci.nsIPushMessage
+ );
+ equal(message.principal, principal);
+ strictEqual(message.data, null);
+ equal(handlerService.observed[0].data, scope);
+
+ // and a subscription change.
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ equal(handlerService.observed.length, 2);
+ equal(handlerService.observed[1].topic, pushService.subscriptionChangeTopic);
+ equal(handlerService.observed[1].subject, principal);
+ equal(handlerService.observed[1].data, scope);
+
+ // and a subscription modified event.
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ equal(handlerService.observed.length, 3);
+ equal(
+ handlerService.observed[2].topic,
+ pushService.subscriptionModifiedTopic
+ );
+ equal(handlerService.observed[2].subject, principal);
+ equal(handlerService.observed[2].data, scope);
+
+ run_next_test();
+});
diff --git a/dom/push/test/xpcshell/test_notification_ack.js b/dom/push/test/xpcshell/test_notification_ack.js
new file mode 100644
index 0000000000..87c29f417a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_ack.js
@@ -0,0 +1,163 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var userAgentID = "5ab1d1df-7a3d-4024-a469-b9e1bb399fad";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+add_task(async function test_notification_ack() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ pushEndpoint: "https://example.com/update/1",
+ scope: "https://example.org/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ pushEndpoint: "https://example.com/update/2",
+ scope: "https://example.org/2",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ pushEndpoint: "https://example.com/update/3",
+ scope: "https://example.org/3",
+ originAttributes: "",
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let notifyCount = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ () => ++notifyCount == 3
+ );
+
+ let acks = 0;
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should send matching device IDs in handshake"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ version: 2,
+ },
+ ],
+ })
+ );
+ },
+ onACK(request) {
+ equal(request.messageType, "ack", "Should send acknowledgements");
+ let updates = request.updates;
+ switch (++acks) {
+ case 1:
+ deepEqual(
+ [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ version: 2,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 1"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ version: 4,
+ },
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ version: 6,
+ },
+ ],
+ })
+ );
+ break;
+
+ case 2:
+ deepEqual(
+ [
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ version: 4,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 2"
+ );
+ break;
+
+ case 3:
+ deepEqual(
+ [
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ version: 6,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 3"
+ );
+ ackDone();
+ break;
+
+ default:
+ ok(false, "Unexpected acknowledgement " + acks);
+ }
+ },
+ });
+ },
+ });
+
+ await notifyPromise;
+ await ackPromise;
+});
diff --git a/dom/push/test/xpcshell/test_notification_data.js b/dom/push/test/xpcshell/test_notification_data.js
new file mode 100644
index 0000000000..9c9cc943a8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_data.js
@@ -0,0 +1,310 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let db;
+let userAgentID = "f5b47f8d-771f-4ea3-b999-91c135f8766d";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+function putRecord(channelID, scope, publicKey, privateKey, authSecret) {
+ return db.put({
+ channelID,
+ pushEndpoint: "https://example.org/push/" + channelID,
+ scope,
+ pushCount: 0,
+ lastPush: 0,
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ p256dhPublicKey: ChromeUtils.base64URLDecode(publicKey, {
+ padding: "reject",
+ }),
+ p256dhPrivateKey: privateKey,
+ authenticationSecret: ChromeUtils.base64URLDecode(authSecret, {
+ padding: "reject",
+ }),
+ });
+}
+
+let ackDone;
+let server;
+add_task(async function test_notification_ack_data_setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await putRecord(
+ "subscription1",
+ "https://example.com/page/1",
+ "BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ {
+ crv: "P-256",
+ d: "1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM",
+ y: "26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ },
+ "c_sGN6uCv9Hu7JOQT34jAQ"
+ );
+ await putRecord(
+ "subscription2",
+ "https://example.com/page/2",
+ "BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E",
+ {
+ crv: "P-256",
+ d: "lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE",
+ y: "5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E",
+ },
+ "t3P246Gj9vjKDHHRYaY6hw"
+ );
+ await putRecord(
+ "subscription3",
+ "https://example.com/page/3",
+ "BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI",
+ {
+ crv: "P-256",
+ d: "Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po",
+ y: "Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI",
+ },
+ "E0qiXGWvFSR0PS352ES1_Q"
+ );
+
+ let setupDone;
+ let setupDonePromise = new Promise(r => (setupDone = r));
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should send matching device IDs in handshake"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ server = this;
+ setupDone();
+ },
+ onACK(request) {
+ if (ackDone) {
+ ackDone(request);
+ }
+ },
+ });
+ },
+ });
+ await setupDonePromise;
+});
+
+add_task(async function test_notification_ack_data() {
+ let allTestData = [
+ {
+ channelID: "subscription1",
+ version: "v1",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification1"; dh="BO_tgGm-yvYAGLeRe16AvhzaUcpYRiqgsGOlXpt0DRWDRGGdzVLGlEVJMygqAUECarLnxCiAOHTP_znkedrlWoU"',
+ encryption: 'keyid="notification1";salt="uAZaiXpOSfOLJxtOCZ09dA"',
+ encoding: "aesgcm128",
+ },
+ data: "NwrrOWPxLE8Sv5Rr0Kep7n0-r_j3rsYrUw_CXPo",
+ version: "v1",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/1",
+ data: "Some message",
+ },
+ },
+ {
+ channelID: "subscription2",
+ version: "v2",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification2"; dh="BKVdQcgfncpNyNWsGrbecX0zq3eHIlHu5XbCGmVcxPnRSbhjrA6GyBIeGdqsUL69j5Z2CvbZd-9z1UBH0akUnGQ"',
+ encryption: 'keyid="notification2";salt="vFn3t3M_k42zHBdpch3VRw"',
+ encoding: "aesgcm128",
+ },
+ data: "Zt9dEdqgHlyAL_l83385aEtb98ZBilz5tgnGgmwEsl5AOCNgesUUJ4p9qUU",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/2",
+ data: "Some message",
+ },
+ },
+ {
+ channelID: "subscription3",
+ version: "v3",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification3";dh="BD3xV_ACT8r6hdIYES3BJj1qhz9wyv7MBrG9vM2UCnjPzwE_YFVpkD-SGqE-BR2--0M-Yf31wctwNsO1qjBUeMg"',
+ encryption:
+ 'keyid="notification3"; salt="DFq188piWU7osPBgqn4Nlg"; rs=24',
+ encoding: "aesgcm128",
+ },
+ data: "LKru3ZzxBZuAxYtsaCfaj_fehkrIvqbVd1iSwnwAUgnL-cTeDD-83blxHXTq7r0z9ydTdMtC3UjAcWi8LMnfY-BFzi0qJAjGYIikDA",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/3",
+ data: "Some message",
+ },
+ },
+ // A message encoded with `aesgcm` (2 bytes of padding, authenticated).
+ {
+ channelID: "subscription1",
+ version: "v5",
+ send: {
+ headers: {
+ crypto_key:
+ 'keyid=v4;dh="BMh_vsnqu79ZZkMTYkxl4gWDLdPSGE72Lr4w2hksSFW398xCMJszjzdblAWXyhSwakRNEU_GopAm4UGzyMVR83w"',
+ encryption: 'keyid="v4";salt="C14Wb7rQTlXzrgcPHtaUzw"',
+ encoding: "aesgcm",
+ },
+ data: "pus4kUaBWzraH34M-d_oN8e0LPpF_X6acx695AMXovDe",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/1",
+ data: "Another message",
+ },
+ },
+ // A message with 17 bytes of padding and rs of 24
+ {
+ channelID: "subscription2",
+ version: "v5",
+ send: {
+ headers: {
+ crypto_key:
+ 'keyid="v5"; dh="BOp-DpyR9eLY5Ci11_loIFqeHzWfc_0evJmq7N8NKzgp60UAMMM06XIi2VZp2_TSdw1omk7E19SyeCCwRp76E-U"',
+ encryption: 'keyid=v5;salt="TvjOou1TqJOQY_ZsOYV3Ww";rs=24',
+ encoding: "aesgcm",
+ },
+ data: "rG9WYQ2ZwUgfj_tMlZ0vcIaNpBN05FW-9RUBZAM-UUZf0_9eGpuENBpUDAw3mFmd2XJpmvPvAtLVs54l3rGwg1o",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/2",
+ data: "Some message",
+ },
+ },
+ // A message without key identifiers.
+ {
+ channelID: "subscription3",
+ version: "v6",
+ send: {
+ headers: {
+ crypto_key:
+ 'dh="BEEjwWbF5jZKCgW0kmUWgG-wNcRvaa9_3zZElHAF8przHwd4cp5_kQsc-IMNZcVA0iUix31jxuMOytU-5DwWtyQ"',
+ encryption: "salt=aAQcr2khAksgNspPiFEqiQ",
+ encoding: "aesgcm",
+ },
+ data: "pEYgefdI-7L46CYn5dR9TIy2AXGxe07zxclbhstY",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/3",
+ data: "Some message",
+ },
+ },
+ // A malformed encrypted message.
+ {
+ channelID: "subscription3",
+ version: "v7",
+ send: {
+ headers: {
+ crypto_key: "dh=AAAAAAAA",
+ encryption: "salt=AAAAAAAA",
+ },
+ data: "AAAAAAAA",
+ },
+ ackCode: 101,
+ receive: null,
+ },
+ ];
+
+ let sendAndReceive = testData => {
+ let messageReceived = testData.receive
+ ? promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ let notification = subject.QueryInterface(Ci.nsIPushMessage).data;
+ equal(
+ notification.text(),
+ testData.receive.data,
+ "Check data for notification " + testData.version
+ );
+ equal(
+ data,
+ testData.receive.scope,
+ "Check scope for notification " + testData.version
+ );
+ return true;
+ }
+ )
+ : Promise.resolve();
+
+ let ackReceived = new Promise(resolve => (ackDone = resolve)).then(
+ ackData => {
+ deepEqual(
+ {
+ messageType: "ack",
+ updates: [
+ {
+ channelID: testData.channelID,
+ version: testData.version,
+ code: testData.ackCode,
+ },
+ ],
+ },
+ ackData,
+ "Check updates for acknowledgment " + testData.version
+ );
+ }
+ );
+
+ let msg = JSON.parse(JSON.stringify(testData.send));
+ msg.messageType = "notification";
+ msg.channelID = testData.channelID;
+ msg.version = testData.version;
+ server.serverSendMsg(JSON.stringify(msg));
+
+ return Promise.all([messageReceived, ackReceived]);
+ };
+
+ await allTestData.reduce((p, testData) => {
+ return p.then(_ => sendAndReceive(testData));
+ }, Promise.resolve());
+});
diff --git a/dom/push/test/xpcshell/test_notification_duplicate.js b/dom/push/test/xpcshell/test_notification_duplicate.js
new file mode 100644
index 0000000000..9812b63149
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_duplicate.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1500e7d9-8cbe-4ee6-98da-7fa5d6a39852";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ maxRecentMessageIDsPerSubscription: 4,
+ userAgentID,
+ });
+ run_next_test();
+}
+
+// Should acknowledge duplicate notifications, but not notify apps.
+add_task(async function test_notification_duplicate() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "has-recents",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/1",
+ originAttributes: "",
+ recentMessageIDs: ["dupe"],
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "no-recents",
+ pushEndpoint: "https://example.org/update/2",
+ scope: "https://example.com/2",
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "dropped-recents",
+ pushEndpoint: "https://example.org/update/3",
+ scope: "https://example.com/3",
+ originAttributes: "",
+ recentMessageIDs: ["newest", "newer", "older", "oldest"],
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let testData = [
+ {
+ channelID: "has-recents",
+ updates: 1,
+ acks: [
+ {
+ version: "dupe",
+ code: 102,
+ },
+ {
+ version: "not-dupe",
+ code: 100,
+ },
+ ],
+ recents: ["not-dupe", "dupe"],
+ },
+ {
+ channelID: "no-recents",
+ updates: 1,
+ acks: [
+ {
+ version: "not-dupe",
+ code: 100,
+ },
+ ],
+ recents: ["not-dupe"],
+ },
+ {
+ channelID: "dropped-recents",
+ acks: [
+ {
+ version: "overflow",
+ code: 100,
+ },
+ {
+ version: "oldest",
+ code: 100,
+ },
+ ],
+ updates: 2,
+ recents: ["oldest", "overflow", "newest", "newer"],
+ },
+ ];
+
+ let expectedUpdates = testData.reduce((sum, { updates }) => sum + updates, 0);
+ let notifiedScopes = [];
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == expectedUpdates;
+ }
+ );
+
+ let expectedAcks = testData.reduce((sum, { acks }) => sum + acks.length, 0);
+ let ackDone;
+ let ackPromise = new Promise(
+ resolve => (ackDone = after(expectedAcks, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ for (let { channelID, acks } of testData) {
+ for (let { version } of acks) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ channelID,
+ version,
+ })
+ );
+ }
+ }
+ },
+ onACK(request) {
+ let [ack] = request.updates;
+ let expectedData = testData.find(
+ test => test.channelID == ack.channelID
+ );
+ ok(expectedData, `Unexpected channel ${ack.channelID}`);
+ let expectedAck = expectedData.acks.find(
+ a => a.version == ack.version
+ );
+ ok(
+ expectedAck,
+ `Unexpected ack for message ${ack.version} on ${ack.channelID}`
+ );
+ equal(
+ expectedAck.code,
+ ack.code,
+ `Wrong ack status for message ${ack.version} on ${ack.channelID}`
+ );
+ ackDone();
+ },
+ });
+ },
+ });
+
+ await notifyPromise;
+ await ackPromise;
+
+ for (let { channelID, recents } of testData) {
+ let record = await db.getByKeyID(channelID);
+ deepEqual(
+ record.recentMessageIDs,
+ recents,
+ `Wrong recent message IDs for ${channelID}`
+ );
+ }
+});
diff --git a/dom/push/test/xpcshell/test_notification_error.js b/dom/push/test/xpcshell/test_notification_error.js
new file mode 100644
index 0000000000..21ab7ab94f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_error.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "3c7462fc-270f-45be-a459-b9d631b0d093";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let originAttributes = "";
+ let records = [
+ {
+ channelID: "f04f1e46-9139-4826-b2d1-9411b0821283",
+ pushEndpoint: "https://example.org/update/success-1",
+ scope: "https://example.com/a",
+ originAttributes,
+ version: 1,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "3c3930ba-44de-40dc-a7ca-8a133ec1a866",
+ pushEndpoint: "https://example.org/update/error",
+ scope: "https://example.com/b",
+ originAttributes,
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "b63f7bef-0a0d-4236-b41e-086a69dfd316",
+ pushEndpoint: "https://example.org/update/success-2",
+ scope: "https://example.com/c",
+ originAttributes,
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let scopes = [];
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => scopes.push(data) == 2
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(
+ resolve => (ackDone = after(records.length, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ getByKeyID(prev, channelID) {
+ if (channelID == "3c3930ba-44de-40dc-a7ca-8a133ec1a866") {
+ return Promise.reject("splines not reticulated");
+ }
+ return prev.call(this, channelID);
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: records.map(({ channelID, version }) => ({
+ channelID,
+ version: ++version,
+ })),
+ })
+ );
+ },
+ // Should acknowledge all received updates, even if updating
+ // IndexedDB fails.
+ onACK: ackDone,
+ });
+ },
+ });
+
+ await notifyPromise;
+ ok(
+ scopes.includes("https://example.com/a"),
+ "Missing scope for notification A"
+ );
+ ok(
+ scopes.includes("https://example.com/c"),
+ "Missing scope for notification C"
+ );
+
+ await ackPromise;
+
+ let aRecord = await db.getByIdentifiers({
+ scope: "https://example.com/a",
+ originAttributes,
+ });
+ equal(
+ aRecord.channelID,
+ "f04f1e46-9139-4826-b2d1-9411b0821283",
+ "Wrong channel ID for record A"
+ );
+ strictEqual(aRecord.version, 2, "Should return the new version for record A");
+
+ let bRecord = await db.getByIdentifiers({
+ scope: "https://example.com/b",
+ originAttributes,
+ });
+ equal(
+ bRecord.channelID,
+ "3c3930ba-44de-40dc-a7ca-8a133ec1a866",
+ "Wrong channel ID for record B"
+ );
+ strictEqual(
+ bRecord.version,
+ 2,
+ "Should return the previous version for record B"
+ );
+
+ let cRecord = await db.getByIdentifiers({
+ scope: "https://example.com/c",
+ originAttributes,
+ });
+ equal(
+ cRecord.channelID,
+ "b63f7bef-0a0d-4236-b41e-086a69dfd316",
+ "Wrong channel ID for record C"
+ );
+ strictEqual(cRecord.version, 4, "Should return the new version for record C");
+});
diff --git a/dom/push/test/xpcshell/test_notification_incomplete.js b/dom/push/test/xpcshell/test_notification_incomplete.js
new file mode 100644
index 0000000000..48aba51132
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_incomplete.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1ca1cf66-eeb4-4df7-87c1-d5c92906ab90";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_incomplete() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "123",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/page/1",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "3ad1ed95-d37a-4d88-950f-22cbe2e240d7",
+ pushEndpoint: "https://example.org/update/2",
+ scope: "https://example.com/page/2",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "d239498b-1c85-4486-b99b-205866e82d1f",
+ pushEndpoint: "https://example.org/update/3",
+ scope: "https://example.com/page/3",
+ version: 3,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "a50de97d-b496-43ce-8b53-05522feb78db",
+ pushEndpoint: "https://example.org/update/4",
+ scope: "https://example.com/page/4",
+ version: 10,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ function observeMessage(subject, topic, data) {
+ ok(false, "Should not deliver malformed updates");
+ }
+ registerCleanupFunction(() =>
+ Services.obs.removeObserver(observeMessage, PushServiceComponent.pushTopic)
+ );
+ Services.obs.addObserver(observeMessage, PushServiceComponent.pushTopic);
+
+ let notificationDone;
+ let notificationPromise = new Promise(
+ resolve => (notificationDone = after(2, resolve))
+ );
+ let prevHandler = PushServiceWebSocket._handleNotificationReply;
+ PushServiceWebSocket._handleNotificationReply =
+ function _handleNotificationReply() {
+ notificationDone();
+ return prevHandler.apply(this, arguments);
+ };
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ // Missing "updates" field; should ignore message.
+ messageType: "notification",
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ // Wrong channel ID field type.
+ channelID: 123,
+ version: 3,
+ },
+ {
+ // Missing version field.
+ channelID: "3ad1ed95-d37a-4d88-950f-22cbe2e240d7",
+ },
+ {
+ // Wrong version field type.
+ channelID: "d239498b-1c85-4486-b99b-205866e82d1f",
+ version: true,
+ },
+ {
+ // Negative versions should be ignored.
+ channelID: "a50de97d-b496-43ce-8b53-05522feb78db",
+ version: -5,
+ },
+ ],
+ })
+ );
+ },
+ onACK() {
+ ok(false, "Should not acknowledge malformed updates");
+ },
+ });
+ },
+ });
+
+ await notificationPromise;
+
+ let storeRecords = await db.getAllKeyIDs();
+ storeRecords.sort(({ pushEndpoint: a }, { pushEndpoint: b }) =>
+ compareAscending(a, b)
+ );
+ recordsAreEqual(records, storeRecords);
+});
+
+function recordIsEqual(a, b) {
+ strictEqual(a.channelID, b.channelID, "Wrong channel ID in record");
+ strictEqual(a.pushEndpoint, b.pushEndpoint, "Wrong push endpoint in record");
+ strictEqual(a.scope, b.scope, "Wrong scope in record");
+ strictEqual(a.version, b.version, "Wrong version in record");
+}
+
+function recordsAreEqual(a, b) {
+ equal(a.length, b.length, "Mismatched record count");
+ for (let i = 0; i < a.length; i++) {
+ recordIsEqual(a[i], b[i]);
+ }
+}
diff --git a/dom/push/test/xpcshell/test_notification_version_string.js b/dom/push/test/xpcshell/test_notification_version_string.js
new file mode 100644
index 0000000000..7aaaee5269
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_version_string.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "ba31ac13-88d4-4984-8e6b-8731315a7cf8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_version_string() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID: "6ff97d56-d0c0-43bc-8f5b-61b855e1d93b",
+ pushEndpoint: "https://example.org/updates/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ });
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "6ff97d56-d0c0-43bc-8f5b-61b855e1d93b",
+ version: "4",
+ },
+ ],
+ })
+ );
+ },
+ onACK: ackDone,
+ });
+ },
+ });
+
+ let { subject: message } = await notifyPromise;
+ equal(
+ message.QueryInterface(Ci.nsIPushMessage).data,
+ null,
+ "Unexpected data for Simple Push message"
+ );
+
+ await ackPromise;
+
+ let storeRecord = await db.getByKeyID("6ff97d56-d0c0-43bc-8f5b-61b855e1d93b");
+ strictEqual(storeRecord.version, 4, "Wrong record version");
+ equal(storeRecord.quota, Infinity, "Wrong quota");
+});
diff --git a/dom/push/test/xpcshell/test_observer_data.js b/dom/push/test/xpcshell/test_observer_data.js
new file mode 100644
index 0000000000..01c331237c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_data.js
@@ -0,0 +1,61 @@
+"use strict";
+
+var pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+);
+var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+add_task(async function test_notifyWithData() {
+ let textData = '{"hello":"world"}';
+ let payload = new TextEncoder().encode(textData);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ pushNotifier.notifyPushWithData(
+ "chrome://notify-test",
+ systemPrincipal,
+ "" /* messageId */,
+ payload
+ );
+
+ let data = (await notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage
+ ).data;
+ deepEqual(
+ data.json(),
+ {
+ hello: "world",
+ },
+ "Should extract JSON values"
+ );
+ deepEqual(
+ data.binary(),
+ Array.from(payload),
+ "Should extract raw binary data"
+ );
+ equal(data.text(), textData, "Should extract text data");
+});
+
+add_task(async function test_empty_notifyWithData() {
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ pushNotifier.notifyPushWithData(
+ "chrome://notify-test",
+ systemPrincipal,
+ "" /* messageId */,
+ []
+ );
+
+ let data = (await notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage
+ ).data;
+ throws(
+ _ => data.json(),
+ /InvalidStateError/,
+ "Should throw an error when parsing an empty string as JSON"
+ );
+ strictEqual(data.text(), "", "Should return an empty string");
+ deepEqual(data.binary(), [], "Should return an empty array");
+});
diff --git a/dom/push/test/xpcshell/test_observer_remoting.js b/dom/push/test/xpcshell/test_observer_remoting.js
new file mode 100644
index 0000000000..086fdeab80
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_remoting.js
@@ -0,0 +1,139 @@
+"use strict";
+
+const pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+);
+
+add_task(async function test_observer_remoting() {
+ do_get_profile();
+ if (isParent) {
+ await testInParent();
+ } else {
+ await testInChild();
+ }
+});
+
+const childTests = [
+ {
+ text: "Hello from child!",
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+ },
+];
+
+const parentTests = [
+ {
+ text: "Hello from parent!",
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+ },
+];
+
+async function testInParent() {
+ setPrefs();
+ // Register observers for notifications from the child, then run the test in
+ // the child and wait for the notifications.
+ let promiseNotifications = childTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
+ Promise.resolve()
+ );
+ let promiseFinished = run_test_in_child("./test_observer_remoting.js");
+ await promiseNotifications;
+
+ // Wait until the child is listening for notifications from the parent.
+ await do_await_remote_message("push_test_observer_remoting_child_ready");
+
+ // Fire an observer notification in the parent that should be forwarded to
+ // the child.
+ await parentTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Wait for the child to exit.
+ await promiseFinished;
+}
+
+async function testInChild() {
+ // Fire an observer notification in the child that should be forwarded to
+ // the parent.
+ await childTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Register observers for notifications from the parent, let the parent know
+ // we're ready, and wait for the notifications.
+ let promiseNotifierObservers = parentTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
+ Promise.resolve()
+ );
+ do_send_remote_message("push_test_observer_remoting_child_ready");
+ await promiseNotifierObservers;
+}
+
+var waitForNotifierObservers = async function (
+ { text, principal },
+ shouldNotify = false
+) {
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic
+ );
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ let scope = "chrome://test-scope";
+ let data = new TextEncoder().encode(text);
+
+ if (shouldNotify) {
+ pushNotifier.notifyPushWithData(scope, principal, "", data);
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ }
+
+ let { data: notifyScope, subject: notifySubject } = await notifyPromise;
+ equal(
+ notifyScope,
+ scope,
+ "Should fire push notifications with the correct scope"
+ );
+ let message = notifySubject.QueryInterface(Ci.nsIPushMessage);
+ equal(
+ message.principal,
+ principal,
+ "Should include the principal in the push message"
+ );
+ strictEqual(message.data.text(), text, "Should include data");
+
+ let { data: subChangeScope, subject: subChangePrincipal } =
+ await subChangePromise;
+ equal(
+ subChangeScope,
+ scope,
+ "Should fire subscription change notifications with the correct scope"
+ );
+ equal(
+ subChangePrincipal,
+ principal,
+ "Should pass the principal as the subject of a change notification"
+ );
+
+ let { data: subModifiedScope, subject: subModifiedPrincipal } =
+ await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ scope,
+ "Should fire subscription modified notifications with the correct scope"
+ );
+ equal(
+ subModifiedPrincipal,
+ principal,
+ "Should pass the principal as the subject of a modified notification"
+ );
+};
diff --git a/dom/push/test/xpcshell/test_permissions.js b/dom/push/test/xpcshell/test_permissions.js
new file mode 100644
index 0000000000..1b3e3282bb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_permissions.js
@@ -0,0 +1,332 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "2c43af06-ab6e-476a-adc4-16cbda54fb89";
+
+let db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ run_next_test();
+}
+
+let unregisterDefers = {};
+
+function promiseUnregister(keyID) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function makePushPermission(url, capability) {
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIPermission"]),
+ capability: Ci.nsIPermissionManager[capability],
+ expireTime: 0,
+ expireType: Ci.nsIPermissionManager.EXPIRE_NEVER,
+ principal: Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {}
+ ),
+ type: "desktop-notification",
+ };
+}
+
+function promiseObserverNotifications(topic, count) {
+ let notifiedScopes = [];
+ let subChangePromise = promiseObserverNotification(topic, (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == count;
+ });
+ return subChangePromise.then(_ => notifiedScopes.sort());
+}
+
+function promiseSubscriptionChanges(count) {
+ return promiseObserverNotifications(
+ PushServiceComponent.subscriptionChangeTopic,
+ count
+ );
+}
+
+function promiseSubscriptionModifications(count) {
+ return promiseObserverNotifications(
+ PushServiceComponent.subscriptionModifiedTopic,
+ count
+ );
+}
+
+function allExpired(...keyIDs) {
+ return Promise.all(keyIDs.map(keyID => db.getByKeyID(keyID))).then(records =>
+ records.every(record => record.isExpired())
+ );
+}
+
+add_task(async function setUp() {
+ // Active registration; quota should be reset to 16. Since the quota isn't
+ // exposed to content, we shouldn't receive a subscription change event.
+ await putTestRecord(db, "active-allow", "https://example.info/page/1", 8);
+
+ // Expired registration; should be dropped.
+ await putTestRecord(db, "expired-allow", "https://example.info/page/2", 0);
+
+ // Active registration; should be expired when we change the permission
+ // to "deny".
+ await putTestRecord(
+ db,
+ "active-deny-changed",
+ "https://example.xyz/page/1",
+ 16
+ );
+
+ // Two active registrations for a visited site. These will expire when we
+ // add a "deny" permission.
+ await putTestRecord(db, "active-deny-added-1", "https://example.net/ham", 16);
+ await putTestRecord(
+ db,
+ "active-deny-added-2",
+ "https://example.net/green",
+ 8
+ );
+
+ // An already-expired registration for a visited site. We shouldn't send an
+ // `unregister` request for this one, but still receive an observer
+ // notification when we restore permissions.
+ await putTestRecord(db, "expired-deny-added", "https://example.net/eggs", 0);
+
+ // A registration that should not be affected by permission list changes
+ // because its quota is set to `Infinity`.
+ await putTestRecord(db, "never-expires", "app://chrome/only", Infinity);
+
+ // A registration that should be dropped when we clear the permission
+ // list.
+ await putTestRecord(db, "drop-on-clear", "https://example.edu/lonely", 16);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(
+ request.code,
+ 202,
+ "Expected permission revoked unregister reason"
+ );
+ resolve();
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID: request.channelID,
+ })
+ );
+ },
+ onACK(request) {},
+ });
+ },
+ });
+ await handshakePromise;
+});
+
+add_task(async function test_permissions_allow_added() {
+ let subChangePromise = promiseSubscriptionChanges(1);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.info", "ALLOW_ACTION"),
+ "added"
+ );
+ let notifiedScopes = await subChangePromise;
+
+ deepEqual(
+ notifiedScopes,
+ ["https://example.info/page/2"],
+ "Wrong scopes after adding allow"
+ );
+
+ let record = await db.getByKeyID("active-allow");
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota for active records after adding allow"
+ );
+
+ record = await db.getByKeyID("expired-allow");
+ ok(!record, "Should drop expired records after adding allow");
+});
+
+add_task(async function test_permissions_allow_deleted() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister("active-allow");
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.info", "ALLOW_ACTION"),
+ "deleted"
+ );
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.info/page/1"],
+ "Wrong scopes modified after deleting allow"
+ );
+
+ let record = await db.getByKeyID("active-allow");
+ ok(record.isExpired(), "Should expire active record after deleting allow");
+});
+
+add_task(async function test_permissions_deny_added() {
+ let subModifiedPromise = promiseSubscriptionModifications(2);
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister("active-deny-added-1"),
+ promiseUnregister("active-deny-added-2"),
+ ]);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "DENY_ACTION"),
+ "added"
+ );
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.net/green", "https://example.net/ham"],
+ "Wrong scopes modified after adding deny"
+ );
+
+ let isExpired = await allExpired("active-deny-added-1", "expired-deny-added");
+ ok(isExpired, "Should expire all registrations after adding deny");
+});
+
+add_task(async function test_permissions_deny_deleted() {
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "DENY_ACTION"),
+ "deleted"
+ );
+
+ let isExpired = await allExpired("active-deny-added-1", "expired-deny-added");
+ ok(isExpired, "Should retain expired registrations after deleting deny");
+});
+
+add_task(async function test_permissions_allow_changed() {
+ let subChangePromise = promiseSubscriptionChanges(3);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "ALLOW_ACTION"),
+ "changed"
+ );
+
+ let notifiedScopes = await subChangePromise;
+
+ deepEqual(
+ notifiedScopes,
+ [
+ "https://example.net/eggs",
+ "https://example.net/green",
+ "https://example.net/ham",
+ ],
+ "Wrong scopes after changing to allow"
+ );
+
+ let droppedRecords = await Promise.all([
+ db.getByKeyID("active-deny-added-1"),
+ db.getByKeyID("active-deny-added-2"),
+ db.getByKeyID("expired-deny-added"),
+ ]);
+ ok(
+ !droppedRecords.some(Boolean),
+ "Should drop all expired registrations after changing to allow"
+ );
+});
+
+add_task(async function test_permissions_deny_changed() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister("active-deny-changed");
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.xyz", "DENY_ACTION"),
+ "changed"
+ );
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.xyz/page/1"],
+ "Wrong scopes modified after changing to deny"
+ );
+
+ let record = await db.getByKeyID("active-deny-changed");
+ ok(record.isExpired(), "Should expire active record after changing to deny");
+});
+
+add_task(async function test_permissions_clear() {
+ let subModifiedPromise = promiseSubscriptionModifications(3);
+
+ deepEqual(
+ await getAllKeyIDs(db),
+ ["active-allow", "active-deny-changed", "drop-on-clear", "never-expires"],
+ "Wrong records in database before clearing"
+ );
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister("active-allow"),
+ promiseUnregister("active-deny-changed"),
+ promiseUnregister("drop-on-clear"),
+ ]);
+
+ await PushService._onPermissionChange(null, "cleared");
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ [
+ "https://example.edu/lonely",
+ "https://example.info/page/1",
+ "https://example.xyz/page/1",
+ ],
+ "Wrong scopes modified after clearing registrations"
+ );
+
+ deepEqual(
+ await getAllKeyIDs(db),
+ ["never-expires"],
+ "Unrestricted registrations should not be dropped"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_quota_exceeded.js b/dom/push/test/xpcshell/test_quota_exceeded.js
new file mode 100644
index 0000000000..f8365aa888
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_exceeded.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "7eb873f9-8d47-4218-804b-fff78dc04e88";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ "testing.ignorePermission": true,
+ });
+ run_next_test();
+}
+
+add_task(async function test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(_ => db.close()));
+
+ await db.put({
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/auctions",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+ await db.put({
+ channelID: "46cc6f6a-c106-4ffa-bb7c-55c60bd50c41",
+ pushEndpoint: "https://example.org/push/2",
+ scope: "https://example.com/deals",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // The notification threshold is per-origin, even with multiple service
+ // workers for different scopes.
+ await PlacesTestUtils.addVisits([
+ {
+ uri: "https://example.com/login",
+ title: "Sign in to see your auctions",
+ visitDate: (Date.now() - 7 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ },
+ // We'll always use your most recent visit to an origin.
+ {
+ uri: "https://example.com/auctions",
+ title: "Your auctions",
+ visitDate: (Date.now() - 2 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ },
+ // ...But we won't count downloads or embeds.
+ {
+ uri: "https://example.com/invoices/invoice.pdf",
+ title: "Invoice #123",
+ visitDate: (Date.now() - 1 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_EMBED,
+ },
+ {
+ uri: "https://example.com/invoices/invoice.pdf",
+ title: "Invoice #123",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ ]);
+
+ // We expect to receive 6 notifications: 5 on the `auctions` channel,
+ // and 1 on the `deals` channel. They're from the same origin, but
+ // different scopes, so each can send 5 notifications before we remove
+ // their subscription.
+ let updates = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ updates++;
+ return updates == 6;
+ }
+ );
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ // We last visited the site 2 days ago, so we can send 5
+ // notifications without throttling. Sending a 6th should
+ // drop the registration.
+ for (let version = 1; version <= 6; version++) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ version,
+ },
+ ],
+ })
+ );
+ }
+ // But the limits are per-channel, so we can send 5 more
+ // notifications on a different channel.
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "46cc6f6a-c106-4ffa-bb7c-55c60bd50c41",
+ version: 1,
+ },
+ ],
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(
+ request.channelID,
+ "eb33fc90-c883-4267-b5cb-613969e8e349",
+ "Unregistered wrong channel ID"
+ );
+ equal(request.code, 201, "Expected quota exceeded unregister reason");
+ unregisterDone();
+ },
+ // We expect to receive acks, but don't care about their
+ // contents.
+ onACK(request) {},
+ });
+ },
+ });
+
+ await unregisterPromise;
+
+ await notifyPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "eb33fc90-c883-4267-b5cb-613969e8e349"
+ );
+ strictEqual(expiredRecord.quota, 0, "Expired record not updated");
+});
diff --git a/dom/push/test/xpcshell/test_quota_observer.js b/dom/push/test/xpcshell/test_quota_observer.js
new file mode 100644
index 0000000000..447f509967
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_observer.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "28cd09e2-7506-42d8-9e50-b02785adc7ef";
+
+var db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+let putRecord = async function (perm, record) {
+ let uri = Services.io.newURI(record.scope);
+
+ PermissionTestUtils.add(
+ uri,
+ "desktop-notification",
+ Ci.nsIPermissionManager[perm]
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ });
+
+ await db.put(record);
+};
+
+add_task(async function test_expiration_history_observer() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(_ => db.close()));
+
+ // A registration that we'll expire...
+ await putRecord("ALLOW_ACTION", {
+ channelID: "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/deals",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // ...And a registration that we'll evict on startup.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "4cb6e454-37cf-41c4-a013-4e3a7fdd0bf1",
+ pushEndpoint: "https://example.org/push/3",
+ scope: "https://example.com/stuff",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/infrequent",
+ title: "Infrequently-visited page",
+ visitDate: (Date.now() - 14 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.com/stuff"
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ version: 2,
+ },
+ ],
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(
+ request.channelID,
+ "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ "Dropped wrong channel ID"
+ );
+ equal(request.code, 201, "Expected quota exceeded unregister reason");
+ unregisterDone();
+ },
+ onACK(request) {},
+ });
+ },
+ });
+
+ await subChangePromise;
+ await unregisterPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "379c0668-8323-44d2-a315-4ee83f1a9ee9"
+ );
+ strictEqual(expiredRecord.quota, 0, "Expired record not updated");
+
+ let notifiedScopes = [];
+ subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == 2;
+ }
+ );
+
+ // Add an expired registration that we'll revive later using the idle
+ // observer.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ pushEndpoint: "https://example.org/push/2",
+ scope: "https://example.com/auctions",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+ // ...And an expired registration that we'll revive on fetch.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "6b2d13fe-d848-4c5f-bdda-e9fc89727dca",
+ pushEndpoint: "https://example.org/push/4",
+ scope: "https://example.net/sales",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+
+ // Now visit the site...
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/another-page",
+ title: "Infrequently-visited page",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ Services.obs.notifyObservers(null, "idle-daily");
+
+ // And we should receive notifications for both scopes.
+ await subChangePromise;
+ deepEqual(
+ notifiedScopes.sort(),
+ ["https://example.com/auctions", "https://example.com/deals"],
+ "Wrong scopes for subscription changes"
+ );
+
+ let aRecord = await db.getByKeyID("379c0668-8323-44d2-a315-4ee83f1a9ee9");
+ ok(!aRecord, "Should drop expired record");
+
+ let bRecord = await db.getByKeyID("eb33fc90-c883-4267-b5cb-613969e8e349");
+ ok(!bRecord, "Should drop evicted record");
+
+ // Simulate a visit to a site with an expired registration, then fetch the
+ // record. This should drop the expired record and fire an observer
+ // notification.
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.net/sales",
+ title: "Firefox plushies, 99% off",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ if (data == "https://example.net/sales") {
+ ok(
+ subject.isContentPrincipal,
+ "Should pass subscription principal as the subject"
+ );
+ return true;
+ }
+ return false;
+ }
+ );
+ let record = await PushService.registration({
+ scope: "https://example.net/sales",
+ originAttributes: "",
+ });
+ ok(!record, "Should not return evicted record");
+ ok(
+ !(await db.getByKeyID("6b2d13fe-d848-4c5f-bdda-e9fc89727dca")),
+ "Should drop evicted record on fetch"
+ );
+ await subChangePromise;
+});
diff --git a/dom/push/test/xpcshell/test_quota_with_notification.js b/dom/push/test/xpcshell/test_quota_with_notification.js
new file mode 100644
index 0000000000..d2e6d7cae8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_with_notification.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "aaabf1f8-2f68-44f1-a920-b88e9e7d7559";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ "testing.ignorePermission": true,
+ });
+ run_next_test();
+}
+
+add_task(async function test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ PushService.notificationForOriginClosed("https://example.com");
+ return db.drop().then(_ => db.close());
+ });
+
+ // Simulate a notification being shown for the origin,
+ // this should relax the quota and allow as many push messages
+ // as we want.
+ PushService.notificationForOriginShown("https://example.com");
+
+ await db.put({
+ channelID: "f56645a9-1f32-4655-92ad-ddc37f6d54fb",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/quota",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // A visit one day ago should provide a quota of 8 messages.
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/login",
+ title: "Sign in to see your auctions",
+ visitDate: (Date.now() - MS_IN_ONE_DAY) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+
+ let numMessages = 10;
+
+ let updates = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ updates++;
+ return updates == numMessages;
+ }
+ );
+
+ let modifications = 0;
+ let modifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ // Each subscription should be modified twice: once to update the message
+ // count and last push time, and the second time to update the quota.
+ modifications++;
+ return modifications == numMessages * 2;
+ }
+ );
+
+ let updateQuotaPromise = new Promise((resolve, reject) => {
+ let quotaUpdateCount = 0;
+ PushService._updateQuotaTestCallback = function () {
+ quotaUpdateCount++;
+ if (quotaUpdateCount == numMessages) {
+ resolve();
+ }
+ };
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+
+ // If the origin has visible notifications, the
+ // message should not affect quota.
+ for (let version = 1; version <= 10; version++) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "f56645a9-1f32-4655-92ad-ddc37f6d54fb",
+ version,
+ },
+ ],
+ })
+ );
+ }
+ },
+ onUnregister(request) {
+ ok(false, "Channel should not be unregistered.");
+ },
+ // We expect to receive acks, but don't care about their
+ // contents.
+ onACK(request) {},
+ });
+ },
+ });
+
+ await notifyPromise;
+
+ await updateQuotaPromise;
+ await modifiedPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "f56645a9-1f32-4655-92ad-ddc37f6d54fb"
+ );
+ notStrictEqual(expiredRecord.quota, 0, "Expired record not updated");
+});
diff --git a/dom/push/test/xpcshell/test_reconnect_retry.js b/dom/push/test/xpcshell/test_reconnect_retry.js
new file mode 100644
index 0000000000..7ff3740ee8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_reconnect_retry.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 10000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_reconnect_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let registers = 0;
+ let channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "083e6c17-1063-4677-8638-ab705aebebc2",
+ })
+ );
+ },
+ onRegister(request) {
+ registers++;
+ if (registers == 1) {
+ channelID = request.channelID;
+ this.serverClose();
+ return;
+ }
+ if (registers == 2) {
+ equal(
+ request.channelID,
+ channelID,
+ "Should retry registers after reconnect"
+ );
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ channelID: request.channelID,
+ pushEndpoint: "https://example.org/push/" + request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let registration = await PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let retryEndpoint = "https://example.org/push/" + channelID;
+ equal(
+ registration.endpoint,
+ retryEndpoint,
+ "Wrong endpoint for retried request"
+ );
+
+ registration = await PushService.register({
+ scope: "https://example.com/page/2",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ notEqual(
+ registration.endpoint,
+ retryEndpoint,
+ "Wrong endpoint for new request"
+ );
+
+ equal(registers, 3, "Wrong registration count");
+});
diff --git a/dom/push/test/xpcshell/test_record.js b/dom/push/test/xpcshell/test_record.js
new file mode 100644
index 0000000000..144c0ee346
--- /dev/null
+++ b/dom/push/test/xpcshell/test_record.js
@@ -0,0 +1,132 @@
+"use strict";
+
+const { PushRecord } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushRecord.sys.mjs"
+);
+
+add_task(async function test_updateQuota() {
+ let record = new PushRecord({
+ quota: 8,
+ lastPush: Date.now() - 1 * MS_IN_ONE_DAY,
+ });
+
+ record.updateQuota(Date.now() - 2 * MS_IN_ONE_DAY);
+ equal(
+ record.quota,
+ 8,
+ "Should not update quota if last visit is older than last push"
+ );
+
+ record.updateQuota(Date.now());
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota if last visit is newer than last push"
+ );
+
+ record.reduceQuota();
+ equal(record.quota, 15, "Should reduce quota");
+
+ // Make sure we calculate the quota correctly for visit dates in the
+ // future (bug 1206424).
+ record.updateQuota(Date.now() + 1 * MS_IN_ONE_DAY);
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota to maximum if last visit is in the future"
+ );
+
+ record.updateQuota(-1);
+ strictEqual(record.quota, 0, "Should set quota to 0 if history was cleared");
+ ok(record.isExpired(), "Should expire records once the quota reaches 0");
+ record.reduceQuota();
+ strictEqual(record.quota, 0, "Quota should never be negative");
+});
+
+add_task(async function test_systemRecord_updateQuota() {
+ let systemRecord = new PushRecord({
+ quota: Infinity,
+ systemRecord: true,
+ });
+ systemRecord.updateQuota(Date.now() - 3 * MS_IN_ONE_DAY);
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore quota updates"
+ );
+ systemRecord.updateQuota(-1);
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore the last visit time"
+ );
+ systemRecord.reduceQuota();
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore quota reductions"
+ );
+});
+
+function testPermissionCheck(props) {
+ let record = new PushRecord(props);
+ let originSuffix;
+ equal(
+ record.uri.spec,
+ props.scope,
+ `Record URI should match scope URL for ${JSON.stringify(props)}`
+ );
+ if (props.originAttributes) {
+ originSuffix = ChromeUtils.originAttributesToSuffix(
+ record.principal.originAttributes
+ );
+ equal(
+ originSuffix,
+ props.originAttributes,
+ `Origin suffixes should match for ${JSON.stringify(props)}`
+ );
+ }
+ ok(
+ !record.hasPermission(),
+ `Record ${JSON.stringify(props)} should not have permission yet`
+ );
+ // Adding permission from origin string
+ PermissionTestUtils.add(
+ props.scope + (originSuffix || ""),
+ "desktop-notification",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+ try {
+ ok(
+ record.hasPermission(),
+ `Record ${JSON.stringify(props)} should have permission`
+ );
+ } finally {
+ PermissionTestUtils.remove(
+ props.scope + (originSuffix || ""),
+ "desktop-notification"
+ );
+ }
+}
+
+add_task(async function test_principal_permissions() {
+ let testProps = [
+ {
+ scope: "https://example.com/",
+ },
+ {
+ scope: "https://example.com/",
+ originAttributes: "^userContextId=1",
+ },
+ {
+ scope: "https://xn--90aexm.xn--80ag3aejvc.xn--p1ai/",
+ },
+ {
+ scope: "https://xn--90aexm.xn--80ag3aejvc.xn--p1ai/",
+ originAttributes: "^userContextId=1",
+ },
+ ];
+ for (let props of testProps) {
+ testPermissionCheck(props);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_register_5xxCode_http2.js b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
new file mode 100644
index 0000000000..502928f088
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var httpServer = null;
+
+XPCOMUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var retries = 0;
+
+function subscribe5xxCodeHandler(metadata, response) {
+ if (retries == 0) {
+ ok(true, "Subscribe 5xx code");
+ do_test_finished();
+ response.setHeader("Retry-After", "1");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+ } else {
+ ok(true, "Subscribed");
+ do_test_finished();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/subscription"
+ );
+ response.setHeader(
+ "Link",
+ '</pushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</receiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+ }
+ retries++;
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ ok(retries == 2, "Should try 2 times.");
+ do_test_finished();
+ response.setHeader("Retry-After", "10");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscribe5xxCode", subscribe5xxCodeHandler);
+httpServer.registerPathHandler("/subscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ do_test_pending();
+ do_test_pending();
+ do_test_pending();
+ do_test_pending();
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe5xxCode",
+ db,
+ });
+
+ let originAttributes = ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ });
+ let newRecord = await PushService.register({
+ scope: "https://example.com/retry5xxCode",
+ originAttributes,
+ });
+
+ var subscriptionUri = serverURL + "/subscription";
+ var pushEndpoint = serverURL + "/pushEndpoint";
+ var pushReceiptEndpoint = serverURL + "/receiptPushEndpoint";
+ equal(
+ newRecord.endpoint,
+ pushEndpoint,
+ "Wrong push endpoint in registration record"
+ );
+
+ equal(
+ newRecord.pushReceiptEndpoint,
+ pushReceiptEndpoint,
+ "Wrong push endpoint receipt in registration record"
+ );
+
+ let record = await db.getByKeyID(subscriptionUri);
+ equal(
+ record.subscriptionUri,
+ subscriptionUri,
+ "Wrong subscription ID in database record"
+ );
+ equal(
+ record.pushEndpoint,
+ pushEndpoint,
+ "Wrong push endpoint in database record"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ pushReceiptEndpoint,
+ "Wrong push endpoint receipt in database record"
+ );
+ equal(
+ record.scope,
+ "https://example.com/retry5xxCode",
+ "Wrong scope in database record"
+ );
+
+ httpServer.stop(do_test_finished);
+});
diff --git a/dom/push/test/xpcshell/test_register_case.js b/dom/push/test/xpcshell/test_register_case.js
new file mode 100644
index 0000000000..1ae6f127f3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_case.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1760b1f5-c3ba-40e3-9344-adef7c18ab12";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_case() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "HELLO",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "ReGiStEr",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/update/case",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.net/case",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.com/update/case",
+ "Wrong push endpoint in registration record"
+ );
+
+ let record = await db.getByPushEndpoint("https://example.com/update/case");
+ equal(
+ record.scope,
+ "https://example.net/case",
+ "Wrong scope in database record"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_register_flush.js b/dom/push/test/xpcshell/test_register_flush.js
new file mode 100644
index 0000000000..2c12ecb9ba
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_flush.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "9ce1e6d3-7bdb-4fe9-90a5-def1d64716f1";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_flush() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let record = {
+ channelID: "9bcc7efb-86c7-4457-93ea-e24e6eb59b74",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ };
+ await db.put(record);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = after(2, resolve)));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: request.channelID,
+ version: 2,
+ },
+ {
+ channelID: "9bcc7efb-86c7-4457-93ea-e24e6eb59b74",
+ version: 3,
+ },
+ ],
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: request.channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.org/update/2",
+ })
+ );
+ },
+ onACK: ackDone,
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.com/page/2",
+ originAttributes: "",
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.org/update/2",
+ "Wrong push endpoint in record"
+ );
+
+ let { data: scope } = await notifyPromise;
+ equal(scope, "https://example.com/page/1", "Wrong notification scope");
+
+ await ackPromise;
+
+ let prevRecord = await db.getByKeyID("9bcc7efb-86c7-4457-93ea-e24e6eb59b74");
+ equal(
+ prevRecord.pushEndpoint,
+ "https://example.org/update/1",
+ "Wrong existing push endpoint"
+ );
+ strictEqual(
+ prevRecord.version,
+ 3,
+ "Should record version updates sent before register responses"
+ );
+
+ let registeredRecord = await db.getByPushEndpoint(
+ "https://example.org/update/2"
+ );
+ ok(!registeredRecord.version, "Should not record premature updates");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_channel.js b/dom/push/test/xpcshell/test_register_invalid_channel.js
new file mode 100644
index 0000000000..0f1b6cf1b1
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_channel.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "52b2b04c-b6cc-42c6-abdf-bef9cbdbea00";
+const channelID = "cafed00d";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_channel() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 403,
+ channelID,
+ error: "Invalid channel ID",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/invalid-channel",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid channel ID"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records for error responses");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_endpoint.js b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
new file mode 100644
index 0000000000..64398b97f8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "c9a12e81-ea5e-40f9-8bf4-acee34621671";
+const channelID = "c0660af8-b532-4931-81f0-9fd27a12d6ab";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_endpoint() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "!@#$%^&*",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/invalid-endpoint",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid endpoint"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records with invalid endpoints");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_json.js b/dom/push/test/xpcshell/test_register_invalid_json.js
new file mode 100644
index 0000000000..d7a19e789c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_json.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "8271186b-8073-43a3-adf6-225bd44a8b0a";
+const channelID = "2d08571e-feab-48a0-9f05-8254c3c7e61f";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_json() {
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+ let registers = 0;
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(");alert(1);(");
+ registers++;
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/invalid-json",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid JSON response"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_no_id.js b/dom/push/test/xpcshell/test_register_no_id.js
new file mode 100644
index 0000000000..763350b11e
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_no_id.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var userAgentID = "9a2f9efe-2ebb-4bcb-a5d9-9e2b73d30afe";
+var channelID = "264c2ba0-f6db-4e84-acdb-bd225b62d9e3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_no_id() {
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ registers++;
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/incomplete",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for incomplete register response"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_request_queue.js b/dom/push/test/xpcshell/test_register_request_queue.js
new file mode 100644
index 0000000000..6d7928c52a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_request_queue.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_request_queue() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let onHello;
+ let helloPromise = new Promise(
+ resolve =>
+ (onHello = after(2, function onHelloReceived(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "54b08a9e-59c6-4ed7-bb54-f4fd60d6f606",
+ })
+ );
+ resolve();
+ }))
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello,
+ onRegister() {
+ ok(false, "Should cancel timed-out requests");
+ },
+ });
+ },
+ });
+
+ let firstRegister = PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let secondRegister = PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+
+ await Promise.all([
+ // eslint-disable-next-line mozilla/rejects-requires-await
+ Assert.rejects(
+ firstRegister,
+ /Registration error/,
+ "Should time out the first request"
+ ),
+ // eslint-disable-next-line mozilla/rejects-requires-await
+ Assert.rejects(
+ secondRegister,
+ /Registration error/,
+ "Should time out the second request"
+ ),
+ ]);
+
+ await helloPromise;
+});
diff --git a/dom/push/test/xpcshell/test_register_rollback.js b/dom/push/test/xpcshell/test_register_rollback.js
new file mode 100644
index 0000000000..9a0233aca8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_rollback.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "b2546987-4f63-49b1-99f7-739cd3c40e44";
+const channelID = "35a820f7-d7dd-43b3-af21-d65352212ae3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_rollback() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let handshakes = 0;
+ let registers = 0;
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ put(prev, record) {
+ return Promise.reject("universe has imploded");
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ handshakes++;
+ if (registers > 0) {
+ equal(request.uaid, userAgentID, "Handshake: wrong device ID");
+ } else {
+ ok(
+ !request.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ uaid: userAgentID,
+ channelID,
+ pushEndpoint: "https://example.com/update/rollback",
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(request.channelID, channelID, "Unregister: wrong channel ID");
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ // Should return a rejected promise if storage fails.
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/storage-error",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /universe has imploded/,
+ "Expected error for unregister database failure"
+ );
+
+ // Should send an out-of-band unregister request.
+ await unregisterPromise;
+ equal(handshakes, 1, "Wrong handshake count");
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_success.js b/dom/push/test/xpcshell/test_register_success.js
new file mode 100644
index 0000000000..9b6692ca25
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_success.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(data) {
+ equal(data.messageType, "register", "Register: wrong message type");
+ equal(data.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/1",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ let newRecord = await PushService.register({
+ scope: "https://example.org/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.com/update/1",
+ "Wrong push endpoint in registration record"
+ );
+
+ let { data: subModifiedScope } = await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ "https://example.org/1",
+ "Should fire a subscription modified event after subscribing"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ equal(record.channelID, channelID, "Wrong channel ID in database record");
+ equal(
+ record.pushEndpoint,
+ "https://example.com/update/1",
+ "Wrong push endpoint in database record"
+ );
+ equal(record.quota, 16, "Wrong quota in database record");
+});
diff --git a/dom/push/test/xpcshell/test_register_timeout.js b/dom/push/test/xpcshell/test_register_timeout.js
new file mode 100644
index 0000000000..27a5994e7c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_timeout.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "a4be0df9-b16d-4b5f-8f58-0f93b6f1e23d";
+const channelID = "e1944e0b-48df-45e7-bdc0-d1fbaa7986d3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_timeout() {
+ let handshakes = 0;
+ let timeoutDone;
+ let timeoutPromise = new Promise(resolve => (timeoutDone = resolve));
+ let registers = 0;
+
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ if (registers > 0) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should include device ID on reconnect with subscriptions"
+ );
+ } else {
+ ok(
+ !request.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ }
+ if (handshakes > 1) {
+ ok(false, "Unexpected reconnect attempt " + handshakes);
+ }
+ handshakes++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ equal(
+ request.channelID,
+ channelID,
+ "Wrong channel ID in register request"
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => {
+ // Should ignore replies for timed-out requests.
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/timeout",
+ })
+ );
+ registers++;
+ timeoutDone();
+ }, 2000);
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/timeout",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for request timeout"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records for timed-out responses");
+
+ await timeoutPromise;
+ equal(registers, 1, "Should not handle timed-out register requests");
+});
diff --git a/dom/push/test/xpcshell/test_register_wrong_id.js b/dom/push/test/xpcshell/test_register_wrong_id.js
new file mode 100644
index 0000000000..674ce9f9ff
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_id.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "84afc774-6995-40d1-9c90-8c34ddcd0cb4";
+const clientChannelID = "4b42a681c99e4dfbbb166a7e01a09b8b";
+const serverChannelID = "3f5aeb89c6e8405a9569619522783436";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_wrong_id() {
+ // Should reconnect after the register request times out.
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushServiceWebSocket._generateID = () => clientChannelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ equal(
+ request.channelID,
+ clientChannelID,
+ "Register: wrong channel ID"
+ );
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ // Reply with a different channel ID. Since the ID is used as a
+ // nonce, the registration request will time out.
+ channelID: serverChannelID,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/mismatched",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for mismatched register reply"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_wrong_type.js b/dom/push/test/xpcshell/test_register_wrong_type.js
new file mode 100644
index 0000000000..b3b9aaa927
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_type.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "c293fdc5-a75e-4eb1-af88-a203991c0787";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_wrong_type() {
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushService._generateID = () => "1234";
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: 1234,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.org/update/wrong-type",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/mistyped",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for non-string channel ID"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_registration_error.js b/dom/push/test/xpcshell/test_registration_error.js
new file mode 100644
index 0000000000..cba22ddc1c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: "6faed1f0-1439-4aac-a978-db21c81cd5eb",
+ });
+ run_next_test();
+}
+
+add_task(async function test_registrations_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers(prev, scope) {
+ return Promise.reject("Database error");
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+
+ await Assert.rejects(
+ PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ function (error) {
+ return error == "Database error";
+ },
+ "Wrong message"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_error_http2.js b/dom/push/test/xpcshell/test_registration_error_http2.js
new file mode 100644
index 0000000000..101cef8dc6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error_http2.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+add_task(async function test_registrations_error() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "https://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers() {
+ return Promise.reject("Database error");
+ },
+ }),
+ });
+
+ await Assert.rejects(
+ PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ function (error) {
+ return error == "Database error";
+ },
+ "Wrong message"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_missing_scope.js b/dom/push/test/xpcshell/test_registration_missing_scope.js
new file mode 100644
index 0000000000..dd0dab842c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_missing_scope.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_registration_missing_scope() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+ await Assert.rejects(
+ PushService.registration({ scope: "", originAttributes: "" }),
+ /Invalid page record/,
+ "Record missing page and manifest URLs"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_none.js b/dom/push/test/xpcshell/test_registration_none.js
new file mode 100644
index 0000000000..3e9235b0ad
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_none.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "a722e448-c481-4c48-aea0-fc411cb7c9ed";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+// Should not open a connection if the client has no registrations.
+add_task(async function test_registration_none() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(!registration, "Should not open a connection without registration");
+});
diff --git a/dom/push/test/xpcshell/test_registration_success.js b/dom/push/test/xpcshell/test_registration_success.js
new file mode 100644
index 0000000000..e3113e2007
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "997ee7ba-36b1-4526-ae9e-2d3f38d6efe8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+add_task(async function test_registration_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "bf001fe0-2684-42f2-bc4d-a3e14b11dd5b",
+ pushEndpoint: "https://example.com/update/same-manifest/1",
+ scope: "https://example.net/a",
+ originAttributes: "",
+ version: 5,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(request.uaid, userAgentID, "Wrong device ID in handshake");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+ });
+ },
+ });
+
+ await handshakePromise;
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/a",
+ originAttributes: "",
+ });
+ equal(
+ registration.endpoint,
+ "https://example.com/update/same-manifest/1",
+ "Wrong push endpoint for scope"
+ );
+ equal(registration.version, 5, "Wrong version for scope");
+});
diff --git a/dom/push/test/xpcshell/test_registration_success_http2.js b/dom/push/test/xpcshell/test_registration_success_http2.js
new file mode 100644
index 0000000000..ebb4cb3f26
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success_http2.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+
+ run_next_test();
+}
+
+add_task(async function test_pushNotifications() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscriptionA",
+ pushEndpoint: serverURL + "/pushEndpointA",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointA",
+ scope: "https://example.net/a",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ {
+ subscriptionUri: serverURL + "/subscriptionB",
+ pushEndpoint: serverURL + "/pushEndpointB",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointB",
+ scope: "https://example.net/b",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ {
+ subscriptionUri: serverURL + "/subscriptionC",
+ pushEndpoint: serverURL + "/pushEndpointC",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointC",
+ scope: "https://example.net/c",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL,
+ db,
+ });
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/a",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ registration.endpoint,
+ serverURL + "/pushEndpointA",
+ "Wrong push endpoint for scope"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
new file mode 100644
index 0000000000..79f660d753
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var httpServer = null;
+
+XPCOMUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(3, r)));
+
+function listen4xxCodeHandler(metadata, response) {
+ ok(true, "Listener point error");
+ handlerDone();
+ response.setStatusLine(metadata.httpVersion, 410, "GONE");
+}
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscription4xxCode", listen4xxCodeHandler);
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "testing.notifyWorkers": false,
+ "testing.notifyAllObservers": true,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscription4xxCode",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
new file mode 100644
index 0000000000..af64d2b8b0
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var httpServer = null;
+
+XPCOMUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var retries = 0;
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(5, r)));
+
+function listen5xxCodeHandler(metadata, response) {
+ ok(true, "Listener 5xx code");
+ handlerDone();
+ retries++;
+ response.setHeader("Retry-After", "1");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ ok(retries == 3, "Should retry 2 times.");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscription5xxCode", listen5xxCodeHandler);
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscription5xxCode",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
new file mode 100644
index 0000000000..c784572ec0
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var httpServer = null;
+
+XPCOMUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(2, r)));
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: "http://localhost/subscriptionNotExist",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ p256dhPublicKey:
+ "BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ p256dhPrivateKey: {
+ crv: "P-256",
+ d: "1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM",
+ y: "26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ },
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_retry_ws.js b/dom/push/test/xpcshell/test_retry_ws.js
new file mode 100644
index 0000000000..19df72acd3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_retry_ws.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "05f7b940-51b6-4b6f-8032-b83ebb577ded";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ pingInterval: 2000,
+ retryBaseInterval: 25,
+ });
+ run_next_test();
+}
+
+add_task(async function test_ws_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await db.put({
+ channelID: "61770ba9-2d57-4134-b949-d40404630d5b",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.net/push/1",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ });
+
+ // Use a mock timer to avoid waiting for the backoff interval.
+ let reconnects = 0;
+ PushServiceWebSocket._backoffTimer = {
+ init(observer, delay, type) {
+ reconnects++;
+ ok(
+ delay >= 5 && delay <= 2000,
+ `Backoff delay ${delay} out of range for attempt ${reconnects}`
+ );
+ observer.observe(this, "timer-callback", null);
+ },
+
+ cancel() {},
+ };
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ if (reconnects == 10) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ return;
+ }
+ this.serverInterrupt();
+ },
+ });
+ },
+ });
+
+ await handshakePromise;
+});
diff --git a/dom/push/test/xpcshell/test_service_child.js b/dom/push/test/xpcshell/test_service_child.js
new file mode 100644
index 0000000000..2c96200902
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_child.js
@@ -0,0 +1,354 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.importGlobalProperties(["crypto"]);
+
+var db;
+
+function done() {
+ do_test_finished();
+ run_next_test();
+}
+
+function generateKey() {
+ return crypto.subtle
+ .generateKey(
+ {
+ name: "ECDSA",
+ namedCurve: "P-256",
+ },
+ true,
+ ["sign", "verify"]
+ )
+ .then(cryptoKey => crypto.subtle.exportKey("raw", cryptoKey.publicKey))
+ .then(publicKey => new Uint8Array(publicKey));
+}
+
+function run_test() {
+ if (isParent) {
+ do_get_profile();
+ }
+ run_next_test();
+}
+
+if (isParent) {
+ add_test(function setUp() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ setUpServiceInParent(PushService, db).then(run_next_test, run_next_test);
+ });
+}
+
+add_test(function test_subscribe_success() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "https://example.com/sub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(Components.isSuccessCode(result), "Error creating subscription");
+ ok(subscription.isSystemSubscription, "Expected system subscription");
+ ok(
+ subscription.endpoint.startsWith("https://example.org/push"),
+ "Wrong endpoint prefix"
+ );
+ equal(subscription.pushCount, 0, "Wrong push count");
+ equal(subscription.lastPush, 0, "Wrong last push time");
+ equal(subscription.quota, -1, "Wrong quota for system subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_error() {
+ do_test_pending();
+
+ let invalidKey = [0, 1];
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/invalid",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ invalidKey,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with invalid key"
+ );
+ equal(
+ result,
+ Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR,
+ "Wrong error code for invalid key"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_success() {
+ do_test_pending();
+
+ generateKey().then(
+ key => {
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ key,
+ (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error creating subscription with key"
+ );
+ notStrictEqual(subscription, null, "Expected subscription");
+ done();
+ }
+ );
+ },
+ error => {
+ ok(false, "Error generating app server key");
+ done();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_conflict() {
+ do_test_pending();
+
+ generateKey().then(
+ differentKey => {
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ differentKey,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with conflicting key"
+ );
+ equal(
+ result,
+ Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR,
+ "Wrong error code for mismatched key"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+ done();
+ }
+ );
+ },
+ error => {
+ ok(false, "Error generating different app server key");
+ done();
+ }
+ );
+});
+
+add_test(function test_subscribe_error() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "https://example.com/sub/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_exists() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(Components.isSuccessCode(result), "Error getting subscription");
+
+ equal(
+ subscription.endpoint,
+ "https://example.org/push/get",
+ "Wrong endpoint"
+ );
+ equal(subscription.pushCount, 10, "Wrong push count");
+ equal(subscription.lastPush, 1438360548322, "Wrong last push");
+ equal(subscription.quota, 16, "Wrong quota for subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_missing() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/missing",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error getting nonexistent subscription"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Nonexistent subscriptions should return null"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_error() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error getting subscription"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_success() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(Components.isSuccessCode(result), "Error unsubscribing");
+ strictEqual(success, true, "Expected successful unsubscribe");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_nonexistent() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error removing nonexistent subscription"
+ );
+ strictEqual(
+ success,
+ false,
+ "Nonexistent subscriptions should return false"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_error() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(!Components.isSuccessCode(result), "Expected error unsubscribing");
+ strictEqual(success, false, "Unexpected successful unsubscribe");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribe_origin_principal() {
+ let scope = "https://example.net/origin-principal";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(scope);
+
+ do_test_pending();
+ PushServiceComponent.subscribe(scope, principal, (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Expected error creating subscription with origin principal"
+ );
+ ok(
+ !subscription.isSystemSubscription,
+ "Unexpected system subscription for origin principal"
+ );
+ equal(subscription.quota, 16, "Wrong quota for origin subscription");
+
+ do_test_finished();
+ run_next_test();
+ });
+});
+
+add_test(function test_subscribe_null_principal() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "chrome://push/null-principal",
+ Services.scriptSecurityManager.createNullPrincipal({}),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with null principal"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Unexpected subscription with null principal"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribe_missing_principal() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "chrome://push/missing-principal",
+ null,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription without principal"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Unexpected subscription without principal"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+if (isParent) {
+ add_test(function tearDown() {
+ tearDownServiceInParent(db).then(run_next_test, run_next_test);
+ });
+}
diff --git a/dom/push/test/xpcshell/test_service_parent.js b/dom/push/test/xpcshell/test_service_parent.js
new file mode 100644
index 0000000000..7dda1db775
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_parent.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_service_parent() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+ await setUpServiceInParent(PushService, db);
+
+ // Accessing the lazy service getter will start the service in the main
+ // process.
+ equal(
+ PushServiceComponent.pushTopic,
+ "push-message",
+ "Wrong push message observer topic"
+ );
+ equal(
+ PushServiceComponent.subscriptionChangeTopic,
+ "push-subscription-change",
+ "Wrong subscription change observer topic"
+ );
+
+ await run_test_in_child("./test_service_child.js");
+
+ await tearDownServiceInParent(db);
+});
diff --git a/dom/push/test/xpcshell/test_unregister_empty_scope.js b/dom/push/test/xpcshell/test_unregister_empty_scope.js
new file mode 100644
index 0000000000..b5b6109ddb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_empty_scope() {
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "5619557c-86fe-4711-8078-d1fd6987aef7",
+ })
+ );
+ handshakeDone();
+ },
+ });
+ },
+ });
+ await handshakePromise;
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Invalid page record/,
+ "Expected error for empty endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_unregister_error.js b/dom/push/test/xpcshell/test_unregister_error.js
new file mode 100644
index 0000000000..bc56dc49b4
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_error.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const channelID = "00c7fa13-7b71-447d-bd27-a91abc09d1b2";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID,
+ pushEndpoint: "https://example.org/update/failure",
+ scope: "https://example.net/page/failure",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "083e6c17-1063-4677-8638-ab705aebebc2",
+ })
+ );
+ },
+ onUnregister(request) {
+ // The server is notified out-of-band. Since channels may be pruned,
+ // any failures are swallowed.
+ equal(request.channelID, channelID, "Unregister: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 500,
+ error: "omg, everything is exploding",
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await PushService.unregister({
+ scope: "https://example.net/page/failure",
+ originAttributes: "",
+ });
+
+ let result = await db.getByKeyID(channelID);
+ ok(!result, "Deleted push record exists");
+
+ // Make sure we send a request to the server.
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_invalid_json.js b/dom/push/test/xpcshell/test_unregister_invalid_json.js
new file mode 100644
index 0000000000..fa709c8eae
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_invalid_json.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "7f0af1bb-7e1f-4fb8-8e4a-e8de434abde3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 150,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_unregister_invalid_json() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "87902e90-c57e-4d18-8354-013f4a556559",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.edu/page/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ },
+ {
+ channelID: "057caa8f-9b99-47ff-891c-adad18ce603e",
+ pushEndpoint: "https://example.com/update/2",
+ scope: "https://example.net/page/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(
+ resolve => (unregisterDone = after(2, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(");alert(1);(");
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "https://example.edu/page/1",
+ originAttributes: "",
+ }),
+ /Request timed out/,
+ "Expected error for first invalid JSON response"
+ );
+
+ let record = await db.getByKeyID("87902e90-c57e-4d18-8354-013f4a556559");
+ ok(!record, "Failed to delete unregistered record");
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "https://example.net/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Request timed out/,
+ "Expected error for second invalid JSON response"
+ );
+
+ record = await db.getByKeyID("057caa8f-9b99-47ff-891c-adad18ce603e");
+ ok(
+ !record,
+ "Failed to delete unregistered record after receiving invalid JSON"
+ );
+
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_not_found.js b/dom/push/test/xpcshell/test_unregister_not_found.js
new file mode 100644
index 0000000000..a0346c39de
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_not_found.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_not_found() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "f074ed80-d479-44fa-ba65-792104a79ea9",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let result = await PushService.unregister({
+ scope: "https://example.net/nonexistent",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(
+ result === false,
+ "unregister should resolve with false for nonexistent scope"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_unregister_success.js b/dom/push/test/xpcshell/test_unregister_success.js
new file mode 100644
index 0000000000..ccd0d31495
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "fbe865a6-aeb8-446f-873c-aeebdb8d493c";
+const channelID = "db0a7021-ec2d-4bd3-8802-7a6966f10ed8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_unregister_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID,
+ pushEndpoint: "https://example.org/update/unregister-success",
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(request.channelID, channelID, "Should include the channel ID");
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ await PushService.unregister({
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: "",
+ });
+
+ let { data: subModifiedScope } = await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ "https://example.com/page/unregister-success",
+ "Should fire a subscription modified event after unsubscribing"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Unregister did not remove record");
+
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_success_http2.js b/dom/push/test/xpcshell/test_unregister_success_http2.js
new file mode 100644
index 0000000000..8ae2ebafdb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success_http2.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var pushEnabled;
+var pushConnectionEnabled;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ pushEnabled = Services.prefs.getBoolPref("dom.push.enabled");
+ pushConnectionEnabled = Services.prefs.getBoolPref(
+ "dom.push.connection.enabled"
+ );
+
+ // Set to allow the cert presented by our H2 server
+ var oldPref = Services.prefs.getIntPref(
+ "network.http.speculative-parallel-limit"
+ );
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ Services.prefs.setBoolPref("dom.push.enabled", true);
+ Services.prefs.setBoolPref("dom.push.connection.enabled", true);
+
+ trustHttp2CA();
+
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+
+ run_next_test();
+}
+
+add_task(async function test_pushUnsubscriptionSuccess() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ await db.put({
+ subscriptionUri: serverURL + "/subscriptionUnsubscriptionSuccess",
+ pushEndpoint: serverURL + "/pushEndpointUnsubscriptionSuccess",
+ pushReceiptEndpoint:
+ serverURL + "/receiptPushEndpointUnsubscriptionSuccess",
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ });
+
+ PushService.init({
+ serverURI: serverURL,
+ db,
+ });
+
+ await PushService.unregister({
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let record = await db.getByKeyID(
+ serverURL + "/subscriptionUnsubscriptionSuccess"
+ );
+ ok(!record, "Unregister did not remove record");
+});
+
+add_task(async function test_complete() {
+ Services.prefs.setBoolPref("dom.push.enabled", pushEnabled);
+ Services.prefs.setBoolPref(
+ "dom.push.connection.enabled",
+ pushConnectionEnabled
+ );
+});
diff --git a/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
new file mode 100644
index 0000000000..e3e6383371
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+var httpServer = null;
+
+function listenHandler(metadata, response) {
+ Assert.ok(true, "Start listening");
+ httpServer.stop(do_test_finished);
+ response.setHeader("Retry-After", "10");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscriptionNoKey", listenHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+
+ do_test_pending();
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let record = {
+ subscriptionUri: serverURL + "/subscriptionNoKey",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ };
+
+ await db.put(record);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ _ => true
+ );
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await notifyPromise;
+
+ let aRecord = await db.getByKeyID(serverURL + "/subscriptionNoKey");
+ ok(aRecord, "The record should still be there");
+ ok(aRecord.p256dhPublicKey, "There should be a public key");
+ ok(aRecord.p256dhPrivateKey, "There should be a private key");
+});
diff --git a/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
new file mode 100644
index 0000000000..b54735c042
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "4dffd396-6582-471d-8c0c-84f394e9f7db";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_with_data_enabled() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let [publicKey, privateKey] = await PushCrypto.generateKeys();
+ let records = [
+ {
+ channelID: "eb18f12a-cc42-4f14-accb-3bfc1227f1aa",
+ pushEndpoint: "https://example.org/push/no-key/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "0d8886b9-8da1-4778-8f5d-1cf93a877ed6",
+ pushEndpoint: "https://example.org/push/key",
+ scope: "https://example.com/page/2",
+ originAttributes: "",
+ p256dhPublicKey: publicKey,
+ p256dhPrivateKey: privateKey,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ ok(
+ request.use_webpush,
+ "Should use Web Push if data delivery is enabled"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: request.uaid,
+ use_webpush: true,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ uaid: userAgentID,
+ channelID: request.channelID,
+ pushEndpoint: "https://example.org/push/new",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.com/page/3",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(newRecord.p256dhKey, "Should generate public keys for new records");
+
+ let record = await db.getByKeyID("eb18f12a-cc42-4f14-accb-3bfc1227f1aa");
+ ok(record.p256dhPublicKey, "Should add public key to partial record");
+ ok(record.p256dhPrivateKey, "Should add private key to partial record");
+
+ record = await db.getByKeyID("0d8886b9-8da1-4778-8f5d-1cf93a877ed6");
+ deepEqual(
+ record.p256dhPublicKey,
+ publicKey,
+ "Should leave existing public key"
+ );
+ deepEqual(
+ record.p256dhPrivateKey,
+ privateKey,
+ "Should leave existing private key"
+ );
+});
diff --git a/dom/push/test/xpcshell/xpcshell.ini b/dom/push/test/xpcshell/xpcshell.ini
new file mode 100644
index 0000000000..d0686763de
--- /dev/null
+++ b/dom/push/test/xpcshell/xpcshell.ini
@@ -0,0 +1,73 @@
+[DEFAULT]
+head = head.js head-http2.js
+# Push notifications and alarms are currently disabled on Android.
+skip-if = toolkit == 'android'
+support-files = broadcast_handler.sys.mjs
+
+[test_broadcast_success.js]
+[test_clear_forgetAboutSite.js]
+[test_clear_origin_data.js]
+[test_crypto.js]
+[test_crypto_encrypt.js]
+[test_drop_expired.js]
+[test_handler_service.js]
+[test_notification_ack.js]
+[test_notification_data.js]
+[test_notification_duplicate.js]
+[test_notification_error.js]
+[test_notification_incomplete.js]
+[test_notification_version_string.js]
+[test_observer_data.js]
+[test_observer_remoting.js]
+skip-if = serviceworker_e10s
+
+[test_permissions.js]
+run-sequentially = This will delete all existing push subscriptions.
+
+[test_quota_exceeded.js]
+[test_quota_observer.js]
+[test_quota_with_notification.js]
+[test_record.js]
+[test_register_case.js]
+[test_register_flush.js]
+[test_register_invalid_channel.js]
+[test_register_invalid_endpoint.js]
+[test_register_invalid_json.js]
+[test_register_no_id.js]
+[test_register_request_queue.js]
+[test_register_rollback.js]
+[test_register_success.js]
+[test_register_timeout.js]
+[test_register_wrong_id.js]
+[test_register_wrong_type.js]
+[test_registration_error.js]
+[test_registration_missing_scope.js]
+[test_registration_none.js]
+[test_registration_success.js]
+[test_unregister_empty_scope.js]
+[test_unregister_error.js]
+[test_unregister_invalid_json.js]
+skip-if =
+ os == "win" # Bug 1627379
+run-sequentially = very high failure rate in parallel
+[test_unregister_not_found.js]
+[test_unregister_success.js]
+[test_updateRecordNoEncryptionKeys_ws.js]
+skip-if = os == "linux" # Bug 1265233
+[test_reconnect_retry.js]
+[test_retry_ws.js]
+[test_service_parent.js]
+[test_service_child.js]
+
+#http2 test
+[test_resubscribe_4xxCode_http2.js]
+[test_resubscribe_5xxCode_http2.js]
+[test_resubscribe_listening_for_msg_error_http2.js]
+[test_register_5xxCode_http2.js]
+[test_updateRecordNoEncryptionKeys_http2.js]
+[test_clearAll_successful.js]
+# This used to be hasNode, but that caused too many issues with tests being
+# silently disabled, so now we explicitly call out the platforms not known
+# to have node installed.
+skip-if = os == "android"
+run-sequentially = This will delete all existing push subscriptions.