summaryrefslogtreecommitdiff
path: root/dom/push
diff options
context:
space:
mode:
authorMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
committerMatt A. Tobin <mattatobin@localhost.localdomain>2018-02-02 04:16:08 -0500
commitad18d877ddd2a44d98fa12ccd3dbbcf4d0ac4299 (patch)
tree10027f336435511475e392454359edea8e25895d /dom/push
parent15477ed9af4859dacb069040b5d4de600803d3bc (diff)
downloaduxp-ad18d877ddd2a44d98fa12ccd3dbbcf4d0ac4299.tar.gz
Add m-esr52 at 52.6.0
Diffstat (limited to 'dom/push')
-rw-r--r--dom/push/Push.js284
-rw-r--r--dom/push/Push.manifest11
-rw-r--r--dom/push/PushComponents.js558
-rw-r--r--dom/push/PushCrypto.jsm454
-rw-r--r--dom/push/PushDB.jsm440
-rw-r--r--dom/push/PushManager.cpp600
-rw-r--r--dom/push/PushManager.h117
-rw-r--r--dom/push/PushNotifier.cpp550
-rw-r--r--dom/push/PushNotifier.h203
-rw-r--r--dom/push/PushRecord.jsm318
-rw-r--r--dom/push/PushService.jsm1365
-rw-r--r--dom/push/PushServiceAndroidGCM.jsm275
-rw-r--r--dom/push/PushServiceHttp2.jsm820
-rw-r--r--dom/push/PushServiceWebSocket.jsm1145
-rw-r--r--dom/push/PushSubscription.cpp398
-rw-r--r--dom/push/PushSubscription.h99
-rw-r--r--dom/push/PushSubscriptionOptions.cpp79
-rw-r--r--dom/push/PushSubscriptionOptions.h56
-rw-r--r--dom/push/PushUtil.cpp64
-rw-r--r--dom/push/PushUtil.h43
-rw-r--r--dom/push/moz.build65
-rw-r--r--dom/push/test/error_worker.js10
-rw-r--r--dom/push/test/frame.html24
-rw-r--r--dom/push/test/lifetime_worker.js85
-rw-r--r--dom/push/test/mochitest.ini24
-rw-r--r--dom/push/test/mockpushserviceparent.js168
-rw-r--r--dom/push/test/test_data.html218
-rw-r--r--dom/push/test/test_error_reporting.html130
-rw-r--r--dom/push/test/test_has_permissions.html84
-rw-r--r--dom/push/test/test_multiple_register.html130
-rw-r--r--dom/push/test/test_multiple_register_different_scope.html123
-rw-r--r--dom/push/test/test_multiple_register_during_service_activation.html111
-rw-r--r--dom/push/test/test_permissions.html106
-rw-r--r--dom/push/test/test_register.html109
-rw-r--r--dom/push/test/test_register_key.html210
-rw-r--r--dom/push/test/test_serviceworker_lifetime.html362
-rw-r--r--dom/push/test/test_subscription_change.html69
-rw-r--r--dom/push/test/test_try_registering_offline_disabled.html305
-rw-r--r--dom/push/test/test_unregister.html81
-rw-r--r--dom/push/test/test_utils.js245
-rw-r--r--dom/push/test/webpush.js186
-rw-r--r--dom/push/test/worker.js152
-rw-r--r--dom/push/test/xpcshell/PushServiceHandler.js31
-rw-r--r--dom/push/test/xpcshell/PushServiceHandler.manifest4
-rw-r--r--dom/push/test/xpcshell/head-http2.js62
-rw-r--r--dom/push/test/xpcshell/head.js463
-rw-r--r--dom/push/test/xpcshell/moz.build4
-rw-r--r--dom/push/test/xpcshell/test_clearAll_successful.js115
-rw-r--r--dom/push/test/xpcshell/test_clear_forgetAboutSite.js128
-rw-r--r--dom/push/test/xpcshell/test_clear_origin_data.js141
-rw-r--r--dom/push/test/xpcshell/test_crypto.js249
-rw-r--r--dom/push/test/xpcshell/test_drop_expired.js154
-rw-r--r--dom/push/test/xpcshell/test_handler_service.js47
-rw-r--r--dom/push/test/xpcshell/test_notification_ack.js125
-rw-r--r--dom/push/test/xpcshell/test_notification_data.js280
-rw-r--r--dom/push/test/xpcshell/test_notification_duplicate.js140
-rw-r--r--dom/push/test/xpcshell/test_notification_error.js117
-rw-r--r--dom/push/test/xpcshell/test_notification_http2.js189
-rw-r--r--dom/push/test/xpcshell/test_notification_incomplete.js130
-rw-r--r--dom/push/test/xpcshell/test_notification_version_string.js69
-rw-r--r--dom/push/test/xpcshell/test_observer_data.js42
-rw-r--r--dom/push/test/xpcshell/test_observer_remoting.js111
-rw-r--r--dom/push/test/xpcshell/test_permissions.js296
-rw-r--r--dom/push/test/xpcshell/test_quota_exceeded.js141
-rw-r--r--dom/push/test/xpcshell/test_quota_observer.js183
-rw-r--r--dom/push/test/xpcshell/test_quota_with_notification.js120
-rw-r--r--dom/push/test/xpcshell/test_reconnect_retry.js73
-rw-r--r--dom/push/test/xpcshell/test_record.js93
-rw-r--r--dom/push/test/xpcshell/test_register_5xxCode_http2.js112
-rw-r--r--dom/push/test/xpcshell/test_register_case.js56
-rw-r--r--dom/push/test/xpcshell/test_register_error_http2.js201
-rw-r--r--dom/push/test/xpcshell/test_register_flush.js96
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_channel.js57
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_endpoint.js58
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_json.js58
-rw-r--r--dom/push/test/xpcshell/test_register_no_id.js62
-rw-r--r--dom/push/test/xpcshell/test_register_request_queue.js61
-rw-r--r--dom/push/test/xpcshell/test_register_rollback.js87
-rw-r--r--dom/push/test/xpcshell/test_register_success.js77
-rw-r--r--dom/push/test/xpcshell/test_register_success_http2.js128
-rw-r--r--dom/push/test/xpcshell/test_register_timeout.js87
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_id.js68
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_type.js62
-rw-r--r--dom/push/test/xpcshell/test_registration_error.js43
-rw-r--r--dom/push/test/xpcshell/test_registration_error_http2.js37
-rw-r--r--dom/push/test/xpcshell/test_registration_missing_scope.js25
-rw-r--r--dom/push/test/xpcshell/test_registration_none.js31
-rw-r--r--dom/push/test/xpcshell/test_registration_success.js78
-rw-r--r--dom/push/test/xpcshell/test_registration_success_http2.js77
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js103
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js106
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js105
-rw-r--r--dom/push/test/xpcshell/test_retry_ws.js69
-rw-r--r--dom/push/test/xpcshell/test_service_child.js307
-rw-r--r--dom/push/test/xpcshell/test_service_parent.js28
-rw-r--r--dom/push/test/xpcshell/test_startup_error.js71
-rw-r--r--dom/push/test/xpcshell/test_unregister_empty_scope.js38
-rw-r--r--dom/push/test/xpcshell/test_unregister_error.js68
-rw-r--r--dom/push/test/xpcshell/test_unregister_invalid_json.js92
-rw-r--r--dom/push/test/xpcshell/test_unregister_not_found.js36
-rw-r--r--dom/push/test/xpcshell/test_unregister_success.js76
-rw-r--r--dom/push/test/xpcshell/test_unregister_success_http2.js81
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js77
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js86
-rw-r--r--dom/push/test/xpcshell/xpcshell.ini83
105 files changed, 17370 insertions, 0 deletions
diff --git a/dom/push/Push.js b/dom/push/Push.js
new file mode 100644
index 0000000000..134f0a4704
--- /dev/null
+++ b/dom/push/Push.js
@@ -0,0 +1,284 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/DOMRequestHelper.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "Push",
+ });
+});
+
+XPCOMUtils.defineLazyServiceGetter(this, "PushService",
+ "@mozilla.org/push/Service;1", "nsIPushService");
+
+const PUSH_CID = Components.ID("{cde1d019-fad8-4044-b141-65fb4fb7a245}");
+
+/**
+ * The Push component runs in the child process and exposes the Push API
+ * to the web application. The PushService running in the parent process is the
+ * one actually performing all operations.
+ */
+function Push() {
+ console.debug("Push()");
+}
+
+Push.prototype = {
+ __proto__: DOMRequestIpcHelper.prototype,
+
+ contractID: "@mozilla.org/push/PushManager;1",
+
+ classID : PUSH_CID,
+
+ QueryInterface : XPCOMUtils.generateQI([Ci.nsIDOMGlobalPropertyInitializer,
+ Ci.nsISupportsWeakReference,
+ Ci.nsIObserver]),
+
+ init: function(win) {
+ console.debug("init()");
+
+ this._window = win;
+
+ this.initDOMRequestHelper(win);
+
+ this._principal = win.document.nodePrincipal;
+ },
+
+ __init: function(scope) {
+ this._scope = scope;
+ },
+
+ askPermission: function () {
+ console.debug("askPermission()");
+
+ return this.createPromise((resolve, reject) => {
+ let permissionDenied = () => {
+ reject(new this._window.DOMException(
+ "User denied permission to use the Push API.",
+ "NotAllowedError"
+ ));
+ };
+
+ let permission = Ci.nsIPermissionManager.UNKNOWN_ACTION;
+ try {
+ permission = this._testPermission();
+ } catch (e) {
+ permissionDenied();
+ return;
+ }
+
+ if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
+ resolve();
+ } else if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
+ permissionDenied();
+ } else {
+ this._requestPermission(resolve, permissionDenied);
+ }
+ });
+ },
+
+ subscribe: function(options) {
+ console.debug("subscribe()", this._scope);
+
+ let histogram = Services.telemetry.getHistogramById("PUSH_API_USED");
+ histogram.add(true);
+ return this.askPermission().then(() =>
+ this.createPromise((resolve, reject) => {
+ let callback = new PushSubscriptionCallback(this, resolve, reject);
+
+ if (!options || !options.applicationServerKey) {
+ PushService.subscribe(this._scope, this._principal, callback);
+ return;
+ }
+
+ let appServerKey = options.applicationServerKey;
+ let keyView = new this._window.Uint8Array(ArrayBuffer.isView(appServerKey) ?
+ appServerKey.buffer : appServerKey);
+ if (keyView.byteLength === 0) {
+ callback._rejectWithError(Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
+ return;
+ }
+ PushService.subscribeWithKey(this._scope, this._principal,
+ appServerKey.length, appServerKey,
+ callback);
+ })
+ );
+ },
+
+ getSubscription: function() {
+ console.debug("getSubscription()", this._scope);
+
+ return this.createPromise((resolve, reject) => {
+ let callback = new PushSubscriptionCallback(this, resolve, reject);
+ PushService.getSubscription(this._scope, this._principal, callback);
+ });
+ },
+
+ permissionState: function() {
+ console.debug("permissionState()", this._scope);
+
+ return this.createPromise((resolve, reject) => {
+ let permission = Ci.nsIPermissionManager.UNKNOWN_ACTION;
+
+ try {
+ permission = this._testPermission();
+ } catch(e) {
+ reject();
+ return;
+ }
+
+ let pushPermissionStatus = "prompt";
+ if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
+ pushPermissionStatus = "granted";
+ } else if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
+ pushPermissionStatus = "denied";
+ }
+ resolve(pushPermissionStatus);
+ });
+ },
+
+ _testPermission: function() {
+ let permission = Services.perms.testExactPermissionFromPrincipal(
+ this._principal, "desktop-notification");
+ if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
+ return permission;
+ }
+ try {
+ if (Services.prefs.getBoolPref("dom.push.testing.ignorePermission")) {
+ permission = Ci.nsIPermissionManager.ALLOW_ACTION;
+ }
+ } catch (e) {}
+ return permission;
+ },
+
+ _requestPermission: function(allowCallback, cancelCallback) {
+ // Create an array with a single nsIContentPermissionType element.
+ let type = {
+ type: "desktop-notification",
+ access: null,
+ options: [],
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPermissionType]),
+ };
+ let typeArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ typeArray.appendElement(type, false);
+
+ // create a nsIContentPermissionRequest
+ let request = {
+ types: typeArray,
+ principal: this._principal,
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPermissionRequest]),
+ allow: function() {
+ let histogram = Services.telemetry.getHistogramById("PUSH_API_PERMISSION_GRANTED");
+ histogram.add();
+ allowCallback();
+ },
+ cancel: function() {
+ let histogram = Services.telemetry.getHistogramById("PUSH_API_PERMISSION_DENIED");
+ histogram.add();
+ cancelCallback();
+ },
+ window: this._window,
+ };
+
+ let histogram = Services.telemetry.getHistogramById("PUSH_API_PERMISSION_REQUESTED");
+ histogram.add(1);
+ // Using askPermission from nsIDOMWindowUtils that takes care of the
+ // remoting if needed.
+ let windowUtils = this._window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ windowUtils.askPermission(request);
+ },
+};
+
+function PushSubscriptionCallback(pushManager, resolve, reject) {
+ this.pushManager = pushManager;
+ this.resolve = resolve;
+ this.reject = reject;
+}
+
+PushSubscriptionCallback.prototype = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIPushSubscriptionCallback]),
+
+ onPushSubscription: function(ok, subscription) {
+ let {pushManager} = this;
+ if (!Components.isSuccessCode(ok)) {
+ this._rejectWithError(ok);
+ return;
+ }
+
+ if (!subscription) {
+ this.resolve(null);
+ return;
+ }
+
+ let p256dhKey = this._getKey(subscription, "p256dh");
+ let authSecret = this._getKey(subscription, "auth");
+ let options = {
+ endpoint: subscription.endpoint,
+ scope: pushManager._scope,
+ p256dhKey: p256dhKey,
+ authSecret: authSecret,
+ };
+ let appServerKey = this._getKey(subscription, "appServer");
+ if (appServerKey) {
+ // Avoid passing null keys to work around bug 1256449.
+ options.appServerKey = appServerKey;
+ }
+ let sub = new pushManager._window.PushSubscription(options);
+ this.resolve(sub);
+ },
+
+ _getKey: function(subscription, name) {
+ let outKeyLen = {};
+ let rawKey = Cu.cloneInto(subscription.getKey(name, outKeyLen),
+ this.pushManager._window);
+ if (!outKeyLen.value) {
+ return null;
+ }
+
+ let key = new this.pushManager._window.ArrayBuffer(outKeyLen.value);
+ let keyView = new this.pushManager._window.Uint8Array(key);
+ keyView.set(rawKey);
+ return key;
+ },
+
+ _rejectWithError: function(result) {
+ let error;
+ switch (result) {
+ case Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR:
+ error = new this.pushManager._window.DOMException(
+ "Invalid raw ECDSA P-256 public key.",
+ "InvalidAccessError"
+ );
+ break;
+
+ case Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR:
+ error = new this.pushManager._window.DOMException(
+ "A subscription with a different application server key already exists.",
+ "InvalidStateError"
+ );
+ break;
+
+ default:
+ error = new this.pushManager._window.DOMException(
+ "Error retrieving push subscription.",
+ "AbortError"
+ );
+ }
+ this.reject(error);
+ },
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Push]);
diff --git a/dom/push/Push.manifest b/dom/push/Push.manifest
new file mode 100644
index 0000000000..1d467d821a
--- /dev/null
+++ b/dom/push/Push.manifest
@@ -0,0 +1,11 @@
+# DOM API
+component {cde1d019-fad8-4044-b141-65fb4fb7a245} Push.js
+contract @mozilla.org/push/PushManager;1 {cde1d019-fad8-4044-b141-65fb4fb7a245}
+
+# XPCOM components.
+component {daaa8d73-677e-4233-8acd-2c404bd01658} PushComponents.js
+contract @mozilla.org/push/Service;1 {daaa8d73-677e-4233-8acd-2c404bd01658}
+category app-startup PushServiceParent @mozilla.org/push/Service;1
+
+# For immediate loading of PushService instead of delayed loading.
+category android-push-service PushServiceParent @mozilla.org/push/Service;1
diff --git a/dom/push/PushComponents.js b/dom/push/PushComponents.js
new file mode 100644
index 0000000000..214e9fc476
--- /dev/null
+++ b/dom/push/PushComponents.js
@@ -0,0 +1,558 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * This file exports XPCOM components for C++ and chrome JavaScript callers to
+ * interact with the Push service.
+ */
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+var isParent = Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+// The default Push service implementation.
+XPCOMUtils.defineLazyGetter(this, "PushService", function() {
+ const {PushService} = Cu.import("resource://gre/modules/PushService.jsm",
+ {});
+ PushService.init();
+ return PushService;
+});
+
+// Observer notification topics for push messages and subscription status
+// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed
+// on `nsIPushService` so that JS callers only need to import this service.
+const OBSERVER_TOPIC_PUSH = "push-message";
+const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change";
+const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified";
+
+/**
+ * `PushServiceBase`, `PushServiceParent`, and `PushServiceContent` collectively
+ * implement the `nsIPushService` interface. This interface provides calls
+ * similar to the Push DOM API, but does not require service workers.
+ *
+ * Push service methods may be called from the parent or content process. The
+ * parent process implementation loads `PushService.jsm` at app startup, and
+ * calls its methods directly. The content implementation forwards calls to
+ * the parent Push service via IPC.
+ *
+ * The implementations share a class and contract ID.
+ */
+function PushServiceBase() {
+ this.wrappedJSObject = this;
+ this._addListeners();
+}
+
+PushServiceBase.prototype = {
+ classID: Components.ID("{daaa8d73-677e-4233-8acd-2c404bd01658}"),
+ contractID: "@mozilla.org/push/Service;1",
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsIObserver,
+ Ci.nsISupportsWeakReference,
+ Ci.nsIPushService,
+ Ci.nsIPushQuotaManager,
+ Ci.nsIPushErrorReporter,
+ ]),
+
+ pushTopic: OBSERVER_TOPIC_PUSH,
+ subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
+ subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
+
+ _handleReady() {},
+
+ _addListeners() {
+ for (let message of this._messages) {
+ this._mm.addMessageListener(message, this);
+ }
+ },
+
+ _isValidMessage(message) {
+ return this._messages.includes(message.name);
+ },
+
+ observe(subject, topic, data) {
+ if (topic === "app-startup") {
+ Services.obs.addObserver(this, "sessionstore-windows-restored", true);
+ return;
+ }
+ if (topic === "sessionstore-windows-restored") {
+ Services.obs.removeObserver(this, "sessionstore-windows-restored");
+ this._handleReady();
+ return;
+ }
+ if (topic === "android-push-service") {
+ // Load PushService immediately.
+ this._handleReady();
+ return;
+ }
+ },
+
+ _deliverSubscription(request, props) {
+ if (!props) {
+ request.onPushSubscription(Cr.NS_OK, null);
+ return;
+ }
+ request.onPushSubscription(Cr.NS_OK, new PushSubscription(props));
+ },
+
+ _deliverSubscriptionError(request, error) {
+ let result = typeof error.result == "number" ?
+ error.result : Cr.NS_ERROR_FAILURE;
+ request.onPushSubscription(result, null);
+ },
+};
+
+/**
+ * The parent process implementation of `nsIPushService`. This version loads
+ * `PushService.jsm` at startup and calls its methods directly. It also
+ * receives and responds to requests from the content process.
+ */
+function PushServiceParent() {
+ PushServiceBase.call(this);
+}
+
+PushServiceParent.prototype = Object.create(PushServiceBase.prototype);
+
+XPCOMUtils.defineLazyServiceGetter(PushServiceParent.prototype, "_mm",
+ "@mozilla.org/parentprocessmessagemanager;1", "nsIMessageBroadcaster");
+
+Object.assign(PushServiceParent.prototype, {
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(PushServiceParent),
+
+ _messages: [
+ "Push:Register",
+ "Push:Registration",
+ "Push:Unregister",
+ "Push:Clear",
+ "Push:NotificationForOriginShown",
+ "Push:NotificationForOriginClosed",
+ "Push:ReportError",
+ ],
+
+ // nsIPushService methods
+
+ subscribe(scope, principal, callback) {
+ this.subscribeWithKey(scope, principal, 0, null, callback);
+ },
+
+ subscribeWithKey(scope, principal, keyLen, key, callback) {
+ this._handleRequest("Push:Register", principal, {
+ scope: scope,
+ appServerKey: key,
+ }).then(result => {
+ this._deliverSubscription(callback, result);
+ }, error => {
+ this._deliverSubscriptionError(callback, error);
+ }).catch(Cu.reportError);
+ },
+
+ unsubscribe(scope, principal, callback) {
+ this._handleRequest("Push:Unregister", principal, {
+ scope: scope,
+ }).then(result => {
+ callback.onUnsubscribe(Cr.NS_OK, result);
+ }, error => {
+ callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
+ }).catch(Cu.reportError);
+ },
+
+ getSubscription(scope, principal, callback) {
+ return this._handleRequest("Push:Registration", principal, {
+ scope: scope,
+ }).then(result => {
+ this._deliverSubscription(callback, result);
+ }, error => {
+ this._deliverSubscriptionError(callback, error);
+ }).catch(Cu.reportError);
+ },
+
+ clearForDomain(domain, callback) {
+ return this._handleRequest("Push:Clear", null, {
+ domain: domain,
+ }).then(result => {
+ callback.onClear(Cr.NS_OK);
+ }, error => {
+ callback.onClear(Cr.NS_ERROR_FAILURE);
+ }).catch(Cu.reportError);
+ },
+
+ // nsIPushQuotaManager methods
+
+ notificationForOriginShown(origin) {
+ this.service.notificationForOriginShown(origin);
+ },
+
+ notificationForOriginClosed(origin) {
+ this.service.notificationForOriginClosed(origin);
+ },
+
+ // nsIPushErrorReporter methods
+
+ reportDeliveryError(messageId, reason) {
+ this.service.reportDeliveryError(messageId, reason);
+ },
+
+ receiveMessage(message) {
+ if (!this._isValidMessage(message)) {
+ return;
+ }
+ let {name, principal, target, data} = message;
+ if (name === "Push:NotificationForOriginShown") {
+ this.notificationForOriginShown(data);
+ return;
+ }
+ if (name === "Push:NotificationForOriginClosed") {
+ this.notificationForOriginClosed(data);
+ return;
+ }
+ if (!target.assertPermission("push")) {
+ return;
+ }
+ if (name === "Push:ReportError") {
+ this.reportDeliveryError(data.messageId, data.reason);
+ return;
+ }
+ let sender = target.QueryInterface(Ci.nsIMessageSender);
+ return this._handleRequest(name, principal, data).then(result => {
+ sender.sendAsyncMessage(this._getResponseName(name, "OK"), {
+ requestID: data.requestID,
+ result: result
+ });
+ }, error => {
+ sender.sendAsyncMessage(this._getResponseName(name, "KO"), {
+ requestID: data.requestID,
+ result: error.result,
+ });
+ }).catch(Cu.reportError);
+ },
+
+ _handleReady() {
+ this.service.init();
+ },
+
+ _toPageRecord(principal, data) {
+ if (!data.scope) {
+ throw new Error("Invalid page record: missing scope");
+ }
+ if (!principal) {
+ throw new Error("Invalid page record: missing principal");
+ }
+ if (principal.isNullPrincipal || principal.isExpandedPrincipal) {
+ throw new Error("Invalid page record: unsupported principal");
+ }
+
+ // System subscriptions can only be created by chrome callers, and are
+ // exempt from the background message quota and permission checks. They
+ // also do not fire service worker events.
+ data.systemRecord = principal.isSystemPrincipal;
+
+ data.originAttributes =
+ ChromeUtils.originAttributesToSuffix(principal.originAttributes);
+
+ return data;
+ },
+
+ _handleRequest(name, principal, data) {
+ if (name == "Push:Clear") {
+ return this.service.clear(data);
+ }
+
+ let pageRecord;
+ try {
+ pageRecord = this._toPageRecord(principal, data);
+ } catch (e) {
+ return Promise.reject(e);
+ }
+
+ if (name === "Push:Register") {
+ return this.service.register(pageRecord);
+ }
+ if (name === "Push:Registration") {
+ return this.service.registration(pageRecord);
+ }
+ if (name === "Push:Unregister") {
+ return this.service.unregister(pageRecord);
+ }
+
+ return Promise.reject(new Error("Invalid request: unknown name"));
+ },
+
+ _getResponseName(requestName, suffix) {
+ let name = requestName.slice("Push:".length);
+ return "PushService:" + name + ":" + suffix;
+ },
+
+ // Methods used for mocking in tests.
+
+ replaceServiceBackend(options) {
+ return this.service.changeTestServer(options.serverURI, options);
+ },
+
+ restoreServiceBackend() {
+ var defaultServerURL = Services.prefs.getCharPref("dom.push.serverURL");
+ return this.service.changeTestServer(defaultServerURL);
+ },
+});
+
+// Used to replace the implementation with a mock.
+Object.defineProperty(PushServiceParent.prototype, "service", {
+ get() {
+ return this._service || PushService;
+ },
+ set(impl) {
+ this._service = impl;
+ },
+});
+
+/**
+ * The content process implementation of `nsIPushService`. This version
+ * uses the child message manager to forward calls to the parent process.
+ * The parent Push service instance handles the request, and responds with a
+ * message containing the result.
+ */
+function PushServiceContent() {
+ PushServiceBase.apply(this, arguments);
+ this._requests = new Map();
+ this._requestId = 0;
+}
+
+PushServiceContent.prototype = Object.create(PushServiceBase.prototype);
+
+XPCOMUtils.defineLazyServiceGetter(PushServiceContent.prototype,
+ "_mm", "@mozilla.org/childprocessmessagemanager;1",
+ "nsISyncMessageSender");
+
+Object.assign(PushServiceContent.prototype, {
+ _xpcom_factory: XPCOMUtils.generateSingletonFactory(PushServiceContent),
+
+ _messages: [
+ "PushService:Register:OK",
+ "PushService:Register:KO",
+ "PushService:Registration:OK",
+ "PushService:Registration:KO",
+ "PushService:Unregister:OK",
+ "PushService:Unregister:KO",
+ "PushService:Clear:OK",
+ "PushService:Clear:KO",
+ ],
+
+ // nsIPushService methods
+
+ subscribe(scope, principal, callback) {
+ this.subscribeWithKey(scope, principal, 0, null, callback);
+ },
+
+ subscribeWithKey(scope, principal, keyLen, key, callback) {
+ let requestId = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Register", {
+ scope: scope,
+ appServerKey: key,
+ requestID: requestId,
+ }, null, principal);
+ },
+
+ unsubscribe(scope, principal, callback) {
+ let requestId = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Unregister", {
+ scope: scope,
+ requestID: requestId,
+ }, null, principal);
+ },
+
+ getSubscription(scope, principal, callback) {
+ let requestId = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Registration", {
+ scope: scope,
+ requestID: requestId,
+ }, null, principal);
+ },
+
+ clearForDomain(domain, callback) {
+ let requestId = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Clear", {
+ domain: domain,
+ requestID: requestId,
+ });
+ },
+
+ // nsIPushQuotaManager methods
+
+ notificationForOriginShown(origin) {
+ this._mm.sendAsyncMessage("Push:NotificationForOriginShown", origin);
+ },
+
+ notificationForOriginClosed(origin) {
+ this._mm.sendAsyncMessage("Push:NotificationForOriginClosed", origin);
+ },
+
+ // nsIPushErrorReporter methods
+
+ reportDeliveryError(messageId, reason) {
+ this._mm.sendAsyncMessage("Push:ReportError", {
+ messageId: messageId,
+ reason: reason,
+ });
+ },
+
+ _addRequest(data) {
+ let id = ++this._requestId;
+ this._requests.set(id, data);
+ return id;
+ },
+
+ _takeRequest(requestId) {
+ let d = this._requests.get(requestId);
+ this._requests.delete(requestId);
+ return d;
+ },
+
+ receiveMessage(message) {
+ if (!this._isValidMessage(message)) {
+ return;
+ }
+ let {name, data} = message;
+ let request = this._takeRequest(data.requestID);
+
+ if (!request) {
+ return;
+ }
+
+ switch (name) {
+ case "PushService:Register:OK":
+ case "PushService:Registration:OK":
+ this._deliverSubscription(request, data.result);
+ break;
+
+ case "PushService:Register:KO":
+ case "PushService:Registration:KO":
+ this._deliverSubscriptionError(request, data);
+ break;
+
+ case "PushService:Unregister:OK":
+ if (typeof data.result === "boolean") {
+ request.onUnsubscribe(Cr.NS_OK, data.result);
+ } else {
+ request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
+ }
+ break;
+
+ case "PushService:Unregister:KO":
+ request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
+ break;
+
+ case "PushService:Clear:OK":
+ request.onClear(Cr.NS_OK);
+ break;
+
+ case "PushService:Clear:KO":
+ request.onClear(Cr.NS_ERROR_FAILURE);
+ break;
+
+ default:
+ break;
+ }
+ },
+});
+
+/** `PushSubscription` instances are passed to all subscription callbacks. */
+function PushSubscription(props) {
+ this._props = props;
+}
+
+PushSubscription.prototype = {
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsIPushSubscription]),
+
+ /** The URL for sending messages to this subscription. */
+ get endpoint() {
+ return this._props.endpoint;
+ },
+
+ /** The last time a message was sent to this subscription. */
+ get lastPush() {
+ return this._props.lastPush;
+ },
+
+ /** The total number of messages sent to this subscription. */
+ get pushCount() {
+ return this._props.pushCount;
+ },
+
+ /** The number of remaining background messages that can be sent to this
+ * subscription, or -1 of the subscription is exempt from the quota.
+ */
+ get quota() {
+ return this._props.quota;
+ },
+
+ /**
+ * Indicates whether this subscription was created with the system principal.
+ * System subscriptions are exempt from the background message quota and
+ * permission checks.
+ */
+ get isSystemSubscription() {
+ return !!this._props.systemRecord;
+ },
+
+ /** The private key used to decrypt incoming push messages, in JWK format */
+ get p256dhPrivateKey() {
+ return this._props.p256dhPrivateKey;
+ },
+
+ /**
+ * Indicates whether this subscription is subject to the background message
+ * quota.
+ */
+ quotaApplies() {
+ return this.quota >= 0;
+ },
+
+ /**
+ * Indicates whether this subscription exceeded the background message quota,
+ * or the user revoked the notification permission. The caller must request a
+ * new subscription to continue receiving push messages.
+ */
+ isExpired() {
+ return this.quota === 0;
+ },
+
+ /**
+ * Returns a key for encrypting messages sent to this subscription. JS
+ * callers receive the key buffer as a return value, while C++ callers
+ * receive the key size and buffer as out parameters.
+ */
+ getKey(name, outKeyLen) {
+ switch (name) {
+ case "p256dh":
+ return this._getRawKey(this._props.p256dhKey, outKeyLen);
+
+ case "auth":
+ return this._getRawKey(this._props.authenticationSecret, outKeyLen);
+
+ case "appServer":
+ return this._getRawKey(this._props.appServerKey, outKeyLen);
+ }
+ return null;
+ },
+
+ _getRawKey(key, outKeyLen) {
+ if (!key) {
+ return null;
+ }
+ let rawKey = new Uint8Array(key);
+ if (outKeyLen) {
+ outKeyLen.value = rawKey.length;
+ }
+ return rawKey;
+ },
+};
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([
+ // Export the correct implementation depending on whether we're running in
+ // the parent or content process.
+ isParent ? PushServiceParent : PushServiceContent,
+]);
diff --git a/dom/push/PushCrypto.jsm b/dom/push/PushCrypto.jsm
new file mode 100644
index 0000000000..5a669875c1
--- /dev/null
+++ b/dom/push/PushCrypto.jsm
@@ -0,0 +1,454 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+'use strict';
+
+const Cu = Components.utils;
+
+Cu.import('resource://gre/modules/Services.jsm');
+Cu.import('resource://gre/modules/XPCOMUtils.jsm');
+
+XPCOMUtils.defineLazyGetter(this, 'gDOMBundle', () =>
+ Services.strings.createBundle('chrome://global/locale/dom/dom.properties'));
+
+Cu.importGlobalProperties(['crypto']);
+
+this.EXPORTED_SYMBOLS = ['PushCrypto', 'concatArray'];
+
+var UTF8 = new TextEncoder('utf-8');
+
+// Legacy encryption scheme (draft-thomson-http-encryption-02).
+var AESGCM128_ENCODING = 'aesgcm128';
+var AESGCM128_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm128');
+
+// New encryption scheme (draft-ietf-httpbis-encryption-encoding-01).
+var AESGCM_ENCODING = 'aesgcm';
+var AESGCM_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm');
+
+var NONCE_INFO = UTF8.encode('Content-Encoding: nonce');
+var AUTH_INFO = UTF8.encode('Content-Encoding: auth\0'); // note nul-terminus
+var P256DH_INFO = UTF8.encode('P-256\0');
+var ECDH_KEY = { name: 'ECDH', namedCurve: 'P-256' };
+var ECDSA_KEY = { name: 'ECDSA', namedCurve: 'P-256' };
+// A default keyid with a name that won't conflict with a real keyid.
+var DEFAULT_KEYID = '';
+
+/** Localized error property names. */
+
+// `Encryption` header missing or malformed.
+const BAD_ENCRYPTION_HEADER = 'PushMessageBadEncryptionHeader';
+// `Crypto-Key` or legacy `Encryption-Key` header missing.
+const BAD_CRYPTO_KEY_HEADER = 'PushMessageBadCryptoKeyHeader';
+const BAD_ENCRYPTION_KEY_HEADER = 'PushMessageBadEncryptionKeyHeader';
+// `Content-Encoding` header missing or contains unsupported encoding.
+const BAD_ENCODING_HEADER = 'PushMessageBadEncodingHeader';
+// `dh` parameter of `Crypto-Key` header missing or not base64url-encoded.
+const BAD_DH_PARAM = 'PushMessageBadSenderKey';
+// `salt` parameter of `Encryption` header missing or not base64url-encoded.
+const BAD_SALT_PARAM = 'PushMessageBadSalt';
+// `rs` parameter of `Encryption` header not a number or less than pad size.
+const BAD_RS_PARAM = 'PushMessageBadRecordSize';
+// Invalid or insufficient padding for encrypted chunk.
+const BAD_PADDING = 'PushMessageBadPaddingError';
+// Generic crypto error.
+const BAD_CRYPTO = 'PushMessageBadCryptoError';
+
+class CryptoError extends Error {
+ /**
+ * Creates an error object indicating an incoming push message could not be
+ * decrypted.
+ *
+ * @param {String} message A human-readable error message. This is only for
+ * internal module logging, and doesn't need to be localized.
+ * @param {String} property The localized property name from `dom.properties`.
+ * @param {String...} params Substitutions to insert into the localized
+ * string.
+ */
+ constructor(message, property, ...params) {
+ super(message);
+ this.isCryptoError = true;
+ this.property = property;
+ this.params = params;
+ }
+
+ /**
+ * Formats a localized string for reporting decryption errors to the Web
+ * Console.
+ *
+ * @param {String} scope The scope of the service worker receiving the
+ * message, prepended to any other substitutions in the string.
+ * @returns {String} The localized string.
+ */
+ format(scope) {
+ let params = [scope, ...this.params].map(String);
+ return gDOMBundle.formatStringFromName(this.property, params,
+ params.length);
+ }
+}
+
+function getEncryptionKeyParams(encryptKeyField) {
+ if (!encryptKeyField) {
+ return null;
+ }
+ var params = encryptKeyField.split(',');
+ return params.reduce((m, p) => {
+ var pmap = p.split(';').reduce(parseHeaderFieldParams, {});
+ if (pmap.keyid && pmap.dh) {
+ m[pmap.keyid] = pmap.dh;
+ }
+ if (!m[DEFAULT_KEYID] && pmap.dh) {
+ m[DEFAULT_KEYID] = pmap.dh;
+ }
+ return m;
+ }, {});
+}
+
+function getEncryptionParams(encryptField) {
+ if (!encryptField) {
+ throw new CryptoError('Missing encryption header',
+ BAD_ENCRYPTION_HEADER);
+ }
+ var p = encryptField.split(',', 1)[0];
+ if (!p) {
+ throw new CryptoError('Encryption header missing params',
+ BAD_ENCRYPTION_HEADER);
+ }
+ return p.split(';').reduce(parseHeaderFieldParams, {});
+}
+
+function getCryptoParams(headers) {
+ if (!headers) {
+ return null;
+ }
+
+ var keymap;
+ var padSize;
+ if (!headers.encoding) {
+ throw new CryptoError('Missing Content-Encoding header',
+ BAD_ENCODING_HEADER);
+ }
+ if (headers.encoding == AESGCM_ENCODING) {
+ // aesgcm uses the Crypto-Key header, 2 bytes for the pad length, and an
+ // authentication secret.
+ // https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-01
+ keymap = getEncryptionKeyParams(headers.crypto_key);
+ if (!keymap) {
+ throw new CryptoError('Missing Crypto-Key header',
+ BAD_CRYPTO_KEY_HEADER);
+ }
+ padSize = 2;
+ } else if (headers.encoding == AESGCM128_ENCODING) {
+ // aesgcm128 uses Encryption-Key, 1 byte for the pad length, and no secret.
+ // https://tools.ietf.org/html/draft-thomson-http-encryption-02
+ keymap = getEncryptionKeyParams(headers.encryption_key);
+ if (!keymap) {
+ throw new CryptoError('Missing Encryption-Key header',
+ BAD_ENCRYPTION_KEY_HEADER);
+ }
+ padSize = 1;
+ } else {
+ throw new CryptoError('Unsupported Content-Encoding: ' + headers.encoding,
+ BAD_ENCODING_HEADER);
+ }
+
+ var enc = getEncryptionParams(headers.encryption);
+ var dh = keymap[enc.keyid || DEFAULT_KEYID];
+ if (!dh) {
+ throw new CryptoError('Missing dh parameter', BAD_DH_PARAM);
+ }
+ var salt = enc.salt;
+ if (!salt) {
+ throw new CryptoError('Missing salt parameter', BAD_SALT_PARAM);
+ }
+ var rs = enc.rs ? parseInt(enc.rs, 10) : 4096;
+ if (isNaN(rs)) {
+ throw new CryptoError('rs parameter must be a number', BAD_RS_PARAM);
+ }
+ if (rs <= padSize) {
+ throw new CryptoError('rs parameter must be at least ' + padSize,
+ BAD_RS_PARAM, padSize);
+ }
+ return {dh, salt, rs, padSize};
+}
+
+// Decodes an unpadded, base64url-encoded string.
+function base64URLDecode(string) {
+ try {
+ return ChromeUtils.base64URLDecode(string, {
+ // draft-ietf-httpbis-encryption-encoding-01 prohibits padding.
+ padding: 'reject',
+ });
+ } catch (ex) {}
+ return null;
+}
+
+var parseHeaderFieldParams = (m, v) => {
+ var i = v.indexOf('=');
+ if (i >= 0) {
+ // A quoted string with internal quotes is invalid for all the possible
+ // values of this header field.
+ m[v.substring(0, i).trim()] = v.substring(i + 1).trim()
+ .replace(/^"(.*)"$/, '$1');
+ }
+ return m;
+};
+
+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;
+}
+
+this.concatArray = function(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));
+};
+
+var HMAC_SHA256 = { name: 'HMAC', hash: 'SHA-256' };
+
+function hmac(key) {
+ this.keyPromise = crypto.subtle.importKey('raw', key, HMAC_SHA256,
+ false, ['sign']);
+}
+
+hmac.prototype.hash = function(input) {
+ return this.keyPromise.then(k => crypto.subtle.sign('HMAC', k, input));
+};
+
+function hkdf(salt, ikm) {
+ this.prkhPromise = new hmac(salt).hash(ikm)
+ .then(prk => new hmac(prk));
+}
+
+hkdf.prototype.extract = function(info, len) {
+ var input = concatArray([info, new Uint8Array([1])]);
+ return this.prkhPromise
+ .then(prkh => prkh.hash(input))
+ .then(h => {
+ if (h.byteLength < len) {
+ throw new CryptoError('HKDF length is too long', BAD_CRYPTO);
+ }
+ return h.slice(0, len);
+ });
+};
+
+/* generate a 96-bit nonce for use in GCM, 48-bits of which are populated */
+function generateNonce(base, index) {
+ if (index >= Math.pow(2, 48)) {
+ throw new CryptoError('Nonce index is too large', BAD_CRYPTO);
+ }
+ var nonce = base.slice(0, 12);
+ nonce = new Uint8Array(nonce);
+ for (var i = 0; i < 6; ++i) {
+ nonce[nonce.byteLength - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
+ }
+ return nonce;
+}
+
+this.PushCrypto = {
+
+ generateAuthenticationSecret() {
+ return crypto.getRandomValues(new Uint8Array(16));
+ },
+
+ validateAppServerKey(key) {
+ return crypto.subtle.importKey('raw', key, ECDSA_KEY,
+ true, ['verify'])
+ .then(_ => key);
+ },
+
+ generateKeys() {
+ return crypto.subtle.generateKey(ECDH_KEY, true, ['deriveBits'])
+ .then(cryptoKey =>
+ Promise.all([
+ crypto.subtle.exportKey('raw', cryptoKey.publicKey),
+ crypto.subtle.exportKey('jwk', cryptoKey.privateKey)
+ ]));
+ },
+
+ /**
+ * Decrypts a push message.
+ *
+ * @param {JsonWebKey} privateKey The ECDH private key of the subscription
+ * receiving the message, in JWK form.
+ * @param {BufferSource} publicKey The ECDH public key of the subscription
+ * receiving the message, in raw form.
+ * @param {BufferSource} authenticationSecret The 16-byte shared
+ * authentication secret of the subscription receiving the message.
+ * @param {Object} headers The encryption headers passed to `getCryptoParams`.
+ * @param {BufferSource} ciphertext The encrypted message data.
+ * @returns {Promise} Resolves with a `Uint8Array` containing the decrypted
+ * message data. Rejects with a `CryptoError` if decryption fails.
+ */
+ decrypt(privateKey, publicKey, authenticationSecret, headers, ciphertext) {
+ return Promise.resolve().then(_ => {
+ let cryptoParams = getCryptoParams(headers);
+ if (!cryptoParams) {
+ return null;
+ }
+ return this._decodeMsg(ciphertext, privateKey, publicKey,
+ cryptoParams.dh, cryptoParams.salt,
+ cryptoParams.rs, authenticationSecret,
+ cryptoParams.padSize);
+ }).catch(error => {
+ if (error.isCryptoError) {
+ throw error;
+ }
+ // Web Crypto returns an unhelpful "operation failed for an
+ // operation-specific reason" error if decryption fails. We don't have
+ // context about what went wrong, so we throw a generic error instead.
+ throw new CryptoError('Bad encryption', BAD_CRYPTO);
+ });
+ },
+
+ _decodeMsg(aData, aPrivateKey, aPublicKey, aSenderPublicKey, aSalt, aRs,
+ aAuthenticationSecret, aPadSize) {
+
+ if (aData.byteLength === 0) {
+ // Zero length messages will be passed as null.
+ return null;
+ }
+
+ // The last chunk of data must be less than aRs, if it is not return an
+ // error.
+ if (aData.byteLength % (aRs + 16) === 0) {
+ throw new CryptoError('Encrypted data truncated', BAD_CRYPTO);
+ }
+
+ let senderKey = base64URLDecode(aSenderPublicKey);
+ if (!senderKey) {
+ throw new CryptoError('dh parameter is not base64url-encoded',
+ BAD_DH_PARAM);
+ }
+
+ let salt = base64URLDecode(aSalt);
+ if (!salt) {
+ throw new CryptoError('salt parameter is not base64url-encoded',
+ BAD_SALT_PARAM);
+ }
+
+ return Promise.all([
+ crypto.subtle.importKey('raw', senderKey, ECDH_KEY,
+ false, ['deriveBits']),
+ crypto.subtle.importKey('jwk', aPrivateKey, ECDH_KEY,
+ false, ['deriveBits'])
+ ])
+ .then(([appServerKey, subscriptionPrivateKey]) =>
+ crypto.subtle.deriveBits({ name: 'ECDH', public: appServerKey },
+ subscriptionPrivateKey, 256))
+ .then(ikm => this._deriveKeyAndNonce(aPadSize,
+ new Uint8Array(ikm),
+ salt,
+ aPublicKey,
+ senderKey,
+ aAuthenticationSecret))
+ .then(r =>
+ // AEAD_AES_128_GCM expands ciphertext to be 16 octets longer.
+ Promise.all(chunkArray(aData, aRs + 16).map((slice, index) =>
+ this._decodeChunk(aPadSize, slice, index, r[1], r[0]))))
+ .then(r => concatArray(r));
+ },
+
+ _deriveKeyAndNonce(padSize, ikm, salt, receiverKey, senderKey,
+ authenticationSecret) {
+ var kdfPromise;
+ var context;
+ var encryptInfo;
+ // The size of the padding determines which key derivation we use.
+ //
+ // 1. If the pad size is 1, we assume "aesgcm128". This scheme ignores the
+ // authenticationSecret, and uses "Content-Encoding: <blah>" for the
+ // context string. It should eventually be removed: bug 1230038.
+ //
+ // 2. If the pad size is 2, we assume "aesgcm", and mix the
+ // authenticationSecret with the ikm using HKDF. The context string is:
+ // "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
+ // receiver key and sender key.
+ if (padSize == 2) {
+ // Since we are using an authentication secret, we need to run an extra
+ // round of HKDF with the authentication secret as salt.
+ var authKdf = new hkdf(authenticationSecret, ikm);
+ kdfPromise = authKdf.extract(AUTH_INFO, 32)
+ .then(ikm2 => new hkdf(salt, ikm2));
+
+ // aesgcm requires extra context for the info parameter.
+ context = concatArray([
+ new Uint8Array([0]), P256DH_INFO,
+ this._encodeLength(receiverKey), receiverKey,
+ this._encodeLength(senderKey), senderKey
+ ]);
+ encryptInfo = AESGCM_ENCRYPT_INFO;
+ } else {
+ kdfPromise = Promise.resolve(new hkdf(salt, ikm));
+ context = new Uint8Array(0);
+ encryptInfo = AESGCM128_ENCRYPT_INFO;
+ }
+ return kdfPromise.then(kdf => Promise.all([
+ kdf.extract(concatArray([encryptInfo, context]), 16)
+ .then(gcmBits => crypto.subtle.importKey('raw', gcmBits, 'AES-GCM', false,
+ ['decrypt'])),
+ kdf.extract(concatArray([NONCE_INFO, context]), 12)
+ ]));
+ },
+
+ _encodeLength(buffer) {
+ return new Uint8Array([0, buffer.byteLength]);
+ },
+
+ _decodeChunk(aPadSize, aSlice, aIndex, aNonce, aKey) {
+ let params = {
+ name: 'AES-GCM',
+ iv: generateNonce(aNonce, aIndex)
+ };
+ return crypto.subtle.decrypt(params, aKey, aSlice)
+ .then(decoded => this._unpadChunk(aPadSize, new Uint8Array(decoded)));
+ },
+
+ /**
+ * Removes padding from a decrypted chunk.
+ *
+ * @param {Number} padSize The size of the padding length prepended to each
+ * chunk. For aesgcm, the padding length is expressed as a 16-bit unsigned
+ * big endian integer. For aesgcm128, the padding is an 8-bit integer.
+ * @param {Uint8Array} decoded The decrypted, padded chunk.
+ * @returns {Uint8Array} The chunk with padding removed.
+ */
+ _unpadChunk(padSize, decoded) {
+ if (padSize < 1 || padSize > 2) {
+ throw new CryptoError('Unsupported pad size', BAD_CRYPTO);
+ }
+ if (decoded.length < padSize) {
+ throw new CryptoError('Decoded array is too short!', BAD_PADDING);
+ }
+ var pad = decoded[0];
+ if (padSize == 2) {
+ pad = (pad << 8) | decoded[1];
+ }
+ if (pad > decoded.length) {
+ throw new CryptoError('Padding is wrong!', BAD_PADDING);
+ }
+ // All padded bytes must be zero except the first one.
+ for (var i = padSize; i <= pad; i++) {
+ if (decoded[i] !== 0) {
+ throw new CryptoError('Padding is wrong!', BAD_PADDING);
+ }
+ }
+ return decoded.slice(pad + padSize);
+ },
+};
diff --git a/dom/push/PushDB.jsm b/dom/push/PushDB.jsm
new file mode 100644
index 0000000000..02f623fa70
--- /dev/null
+++ b/dom/push/PushDB.jsm
@@ -0,0 +1,440 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cu = Components.utils;
+Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.importGlobalProperties(["indexedDB"]);
+
+this.EXPORTED_SYMBOLS = ["PushDB"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushDB",
+ });
+});
+
+this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) {
+ console.debug("PushDB()");
+ this._dbStoreName = dbStoreName;
+ this._keyPath = keyPath;
+ this._model = model;
+
+ // set the indexeddb database
+ this.initDBHelper(dbName, dbVersion,
+ [dbStoreName]);
+};
+
+this.PushDB.prototype = {
+ __proto__: IndexedDBHelper.prototype,
+
+ toPushRecord: function(record) {
+ if (!record) {
+ return;
+ }
+ return new this._model(record);
+ },
+
+ isValidRecord: function(record) {
+ return record && typeof record.scope == "string" &&
+ typeof record.originAttributes == "string" &&
+ record.quota >= 0 &&
+ typeof record[this._keyPath] == "string";
+ },
+
+ upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) {
+ if (aOldVersion <= 3) {
+ //XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
+ //registrations away without even informing the app.
+ if (aDb.objectStoreNames.contains(this._dbStoreName)) {
+ aDb.deleteObjectStore(this._dbStoreName);
+ }
+
+ let objectStore = aDb.createObjectStore(this._dbStoreName,
+ { keyPath: this._keyPath });
+
+ // index to fetch records based on endpoints. used by unregister
+ objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
+
+ // index to fetch records by identifiers.
+ // In the current security model, the originAttributes distinguish between
+ // different 'apps' on the same origin. Since ServiceWorkers are
+ // same-origin to the scope they are registered for, the attributes and
+ // scope are enough to reconstruct a valid principal.
+ objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
+ objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
+ }
+
+ if (aOldVersion < 4) {
+ let objectStore = aTransaction.objectStore(this._dbStoreName);
+
+ // index to fetch active and expired registrations.
+ objectStore.createIndex("quota", "quota", { unique: false });
+ }
+ },
+
+ /*
+ * @param aRecord
+ * The record to be added.
+ */
+
+ put: function(aRecord) {
+ console.debug("put()", aRecord);
+ if (!this.isValidRecord(aRecord)) {
+ return Promise.reject(new TypeError(
+ "Scope, originAttributes, and quota are required! " +
+ JSON.stringify(aRecord)
+ )
+ );
+ }
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ aStore.put(aRecord).onsuccess = aEvent => {
+ console.debug("put: Request successful. Updated record",
+ aEvent.target.result);
+ aTxn.result = this.toPushRecord(aRecord);
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ /*
+ * @param aKeyID
+ * The ID of record to be deleted.
+ */
+ delete: function(aKeyID) {
+ console.debug("delete()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ console.debug("delete: Removing record", aKeyID);
+ aStore.get(aKeyID).onsuccess = event => {
+ aTxn.result = this.toPushRecord(event.target.result);
+ aStore.delete(aKeyID);
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ // testFn(record) is called with a database record and should return true if
+ // that record should be deleted.
+ clearIf: function(testFn) {
+ console.debug("clearIf()");
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ aStore.openCursor().onsuccess = event => {
+ let cursor = event.target.result;
+ if (cursor) {
+ let record = this.toPushRecord(cursor.value);
+ if (testFn(record)) {
+ let deleteRequest = cursor.delete();
+ deleteRequest.onerror = e => {
+ console.error("clearIf: Error removing record",
+ record.keyID, e);
+ }
+ }
+ cursor.continue();
+ }
+ }
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ getByPushEndpoint: function(aPushEndpoint) {
+ console.debug("getByPushEndpoint()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readonly",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ let index = aStore.index("pushEndpoint");
+ index.get(aPushEndpoint).onsuccess = aEvent => {
+ let record = this.toPushRecord(aEvent.target.result);
+ console.debug("getByPushEndpoint: Got record", record);
+ aTxn.result = record;
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ getByKeyID: function(aKeyID) {
+ console.debug("getByKeyID()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readonly",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ aStore.get(aKeyID).onsuccess = aEvent => {
+ let record = this.toPushRecord(aEvent.target.result);
+ console.debug("getByKeyID: Got record", record);
+ aTxn.result = record;
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ /**
+ * Iterates over all records associated with an origin.
+ *
+ * @param {String} origin The origin, matched as a prefix against the scope.
+ * @param {String} originAttributes Additional origin attributes. Requires
+ * an exact match.
+ * @param {Function} callback A function with the signature `(record,
+ * cursor)`, called for each record. `record` is the registration, and
+ * `cursor` is an `IDBCursor`.
+ * @returns {Promise} Resolves once all records have been processed.
+ */
+ forEachOrigin: function(origin, originAttributes, callback) {
+ console.debug("forEachOrigin()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ let index = aStore.index("identifiers");
+ let range = IDBKeyRange.bound(
+ [origin, originAttributes],
+ [origin + "\x7f", originAttributes]
+ );
+ index.openCursor(range).onsuccess = event => {
+ let cursor = event.target.result;
+ if (!cursor) {
+ return;
+ }
+ callback(this.toPushRecord(cursor.value), cursor);
+ cursor.continue();
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ // Perform a unique match against { scope, originAttributes }
+ getByIdentifiers: function(aPageRecord) {
+ console.debug("getByIdentifiers()", aPageRecord);
+ if (!aPageRecord.scope || aPageRecord.originAttributes == undefined) {
+ console.error("getByIdentifiers: Scope and originAttributes are required",
+ aPageRecord);
+ return Promise.reject(new TypeError("Invalid page record"));
+ }
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readonly",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ let index = aStore.index("identifiers");
+ let request = index.get(IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes]));
+ request.onsuccess = aEvent => {
+ aTxn.result = this.toPushRecord(aEvent.target.result);
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ _getAllByKey: function(aKeyName, aKeyValue) {
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readonly",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+
+ let index = aStore.index(aKeyName);
+ // It seems ok to use getAll here, since unlike contacts or other
+ // high storage APIs, we don't expect more than a handful of
+ // registrations per domain, and usually only one.
+ let getAllReq = index.mozGetAll(aKeyValue);
+ getAllReq.onsuccess = aEvent => {
+ aTxn.result = aEvent.target.result.map(
+ record => this.toPushRecord(record));
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ // aOriginAttributes must be a string!
+ getAllByOriginAttributes: function(aOriginAttributes) {
+ if (typeof aOriginAttributes !== "string") {
+ return Promise.reject("Expected string!");
+ }
+ return this._getAllByKey("originAttributes", aOriginAttributes);
+ },
+
+ getAllKeyIDs: function() {
+ console.debug("getAllKeyIDs()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readonly",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = undefined;
+ aStore.mozGetAll().onsuccess = event => {
+ aTxn.result = event.target.result.map(
+ record => this.toPushRecord(record));
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ _getAllByPushQuota: function(range) {
+ console.debug("getAllByPushQuota()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readonly",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aTxn.result = [];
+
+ let index = aStore.index("quota");
+ index.openCursor(range).onsuccess = event => {
+ let cursor = event.target.result;
+ if (cursor) {
+ aTxn.result.push(this.toPushRecord(cursor.value));
+ cursor.continue();
+ }
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ getAllUnexpired: function() {
+ console.debug("getAllUnexpired()");
+ return this._getAllByPushQuota(IDBKeyRange.lowerBound(1));
+ },
+
+ getAllExpired: function() {
+ console.debug("getAllExpired()");
+ return this._getAllByPushQuota(IDBKeyRange.only(0));
+ },
+
+ /**
+ * Updates an existing push registration.
+ *
+ * @param {String} aKeyID The registration ID.
+ * @param {Function} aUpdateFunc A function that receives the existing
+ * registration record as its argument, and returns a new record.
+ * @returns {Promise} A promise resolved with either the updated record.
+ * Rejects if the record does not exist, or the function returns an invalid
+ * record.
+ */
+ update: function(aKeyID, aUpdateFunc) {
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ aStore.get(aKeyID).onsuccess = aEvent => {
+ aTxn.result = undefined;
+
+ let record = aEvent.target.result;
+ if (!record) {
+ throw new Error("Record " + aKeyID + " does not exist");
+ }
+ let newRecord = aUpdateFunc(this.toPushRecord(record));
+ if (!this.isValidRecord(newRecord)) {
+ console.error("update: Ignoring invalid update",
+ aKeyID, newRecord);
+ throw new Error("Invalid update for record " + aKeyID);
+ }
+ function putRecord() {
+ let req = aStore.put(newRecord);
+ req.onsuccess = aEvent => {
+ console.debug("update: Update successful", aKeyID, newRecord);
+ aTxn.result = newRecord;
+ };
+ }
+ if (aKeyID === newRecord.keyID) {
+ putRecord();
+ } else {
+ // If we changed the primary key, delete the old record to avoid
+ // unique constraint errors.
+ aStore.delete(aKeyID).onsuccess = putRecord;
+ }
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ drop: function() {
+ console.debug("drop()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ function txnCb(aTxn, aStore) {
+ aStore.clear();
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+};
diff --git a/dom/push/PushManager.cpp b/dom/push/PushManager.cpp
new file mode 100644
index 0000000000..2cb5a38775
--- /dev/null
+++ b/dom/push/PushManager.cpp
@@ -0,0 +1,600 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/dom/PushManager.h"
+
+#include "mozilla/Preferences.h"
+#include "mozilla/Services.h"
+#include "mozilla/Unused.h"
+#include "mozilla/dom/PushManagerBinding.h"
+#include "mozilla/dom/PushSubscription.h"
+#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
+#include "mozilla/dom/PushUtil.h"
+
+#include "mozilla/dom/Promise.h"
+#include "mozilla/dom/PromiseWorkerProxy.h"
+
+#include "nsIGlobalObject.h"
+#include "nsIPermissionManager.h"
+#include "nsIPrincipal.h"
+#include "nsIPushService.h"
+
+#include "nsComponentManagerUtils.h"
+#include "nsContentUtils.h"
+
+#include "WorkerRunnable.h"
+#include "WorkerPrivate.h"
+#include "WorkerScope.h"
+
+namespace mozilla {
+namespace dom {
+
+using namespace workers;
+
+namespace {
+
+nsresult
+GetPermissionState(nsIPrincipal* aPrincipal,
+ PushPermissionState& aState)
+{
+ nsCOMPtr<nsIPermissionManager> permManager =
+ mozilla::services::GetPermissionManager();
+
+ if (!permManager) {
+ return NS_ERROR_FAILURE;
+ }
+ uint32_t permission = nsIPermissionManager::UNKNOWN_ACTION;
+ nsresult rv = permManager->TestExactPermissionFromPrincipal(
+ aPrincipal,
+ "desktop-notification",
+ &permission);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ if (permission == nsIPermissionManager::ALLOW_ACTION ||
+ Preferences::GetBool("dom.push.testing.ignorePermission", false)) {
+ aState = PushPermissionState::Granted;
+ } else if (permission == nsIPermissionManager::DENY_ACTION) {
+ aState = PushPermissionState::Denied;
+ } else {
+ aState = PushPermissionState::Prompt;
+ }
+
+ return NS_OK;
+}
+
+// A helper class that frees an `nsIPushSubscription` key buffer when it
+// goes out of scope.
+class MOZ_RAII AutoFreeKeyBuffer final
+{
+ uint8_t** mKeyBuffer;
+
+public:
+ explicit AutoFreeKeyBuffer(uint8_t** aKeyBuffer)
+ : mKeyBuffer(aKeyBuffer)
+ {
+ MOZ_ASSERT(mKeyBuffer);
+ }
+
+ ~AutoFreeKeyBuffer()
+ {
+ NS_Free(*mKeyBuffer);
+ }
+};
+
+// Copies a subscription key buffer into an array.
+nsresult
+CopySubscriptionKeyToArray(nsIPushSubscription* aSubscription,
+ const nsAString& aKeyName,
+ nsTArray<uint8_t>& aKey)
+{
+ uint8_t* keyBuffer = nullptr;
+ AutoFreeKeyBuffer autoFree(&keyBuffer);
+
+ uint32_t keyLen;
+ nsresult rv = aSubscription->GetKey(aKeyName, &keyLen, &keyBuffer);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+ if (!aKey.SetCapacity(keyLen, fallible) ||
+ !aKey.InsertElementsAt(0, keyBuffer, keyLen, fallible)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ return NS_OK;
+}
+
+nsresult
+GetSubscriptionParams(nsIPushSubscription* aSubscription,
+ nsAString& aEndpoint,
+ nsTArray<uint8_t>& aRawP256dhKey,
+ nsTArray<uint8_t>& aAuthSecret,
+ nsTArray<uint8_t>& aAppServerKey)
+{
+ if (!aSubscription) {
+ return NS_OK;
+ }
+
+ nsresult rv = aSubscription->GetEndpoint(aEndpoint);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ rv = CopySubscriptionKeyToArray(aSubscription, NS_LITERAL_STRING("p256dh"),
+ aRawP256dhKey);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ rv = CopySubscriptionKeyToArray(aSubscription, NS_LITERAL_STRING("auth"),
+ aAuthSecret);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ rv = CopySubscriptionKeyToArray(aSubscription, NS_LITERAL_STRING("appServer"),
+ aAppServerKey);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ return NS_OK;
+}
+
+class GetSubscriptionResultRunnable final : public WorkerRunnable
+{
+public:
+ GetSubscriptionResultRunnable(WorkerPrivate* aWorkerPrivate,
+ already_AddRefed<PromiseWorkerProxy>&& aProxy,
+ nsresult aStatus,
+ const nsAString& aEndpoint,
+ const nsAString& aScope,
+ nsTArray<uint8_t>&& aRawP256dhKey,
+ nsTArray<uint8_t>&& aAuthSecret,
+ nsTArray<uint8_t>&& aAppServerKey)
+ : WorkerRunnable(aWorkerPrivate)
+ , mProxy(Move(aProxy))
+ , mStatus(aStatus)
+ , mEndpoint(aEndpoint)
+ , mScope(aScope)
+ , mRawP256dhKey(Move(aRawP256dhKey))
+ , mAuthSecret(Move(aAuthSecret))
+ , mAppServerKey(Move(aAppServerKey))
+ { }
+
+ bool
+ WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override
+ {
+ RefPtr<Promise> promise = mProxy->WorkerPromise();
+ if (NS_SUCCEEDED(mStatus)) {
+ if (mEndpoint.IsEmpty()) {
+ promise->MaybeResolve(JS::NullHandleValue);
+ } else {
+ RefPtr<PushSubscription> sub =
+ new PushSubscription(nullptr, mEndpoint, mScope,
+ Move(mRawP256dhKey), Move(mAuthSecret),
+ Move(mAppServerKey));
+ promise->MaybeResolve(sub);
+ }
+ } else if (NS_ERROR_GET_MODULE(mStatus) == NS_ERROR_MODULE_DOM_PUSH ) {
+ promise->MaybeReject(mStatus);
+ } else {
+ promise->MaybeReject(NS_ERROR_DOM_PUSH_ABORT_ERR);
+ }
+
+ mProxy->CleanUp();
+
+ return true;
+ }
+private:
+ ~GetSubscriptionResultRunnable()
+ {}
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsresult mStatus;
+ nsString mEndpoint;
+ nsString mScope;
+ nsTArray<uint8_t> mRawP256dhKey;
+ nsTArray<uint8_t> mAuthSecret;
+ nsTArray<uint8_t> mAppServerKey;
+};
+
+class GetSubscriptionCallback final : public nsIPushSubscriptionCallback
+{
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit GetSubscriptionCallback(PromiseWorkerProxy* aProxy,
+ const nsAString& aScope)
+ : mProxy(aProxy)
+ , mScope(aScope)
+ {}
+
+ NS_IMETHOD
+ OnPushSubscription(nsresult aStatus,
+ nsIPushSubscription* aSubscription) override
+ {
+ AssertIsOnMainThread();
+ MOZ_ASSERT(mProxy, "OnPushSubscription() called twice?");
+
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+
+ nsAutoString endpoint;
+ nsTArray<uint8_t> rawP256dhKey, authSecret, appServerKey;
+ if (NS_SUCCEEDED(aStatus)) {
+ aStatus = GetSubscriptionParams(aSubscription, endpoint, rawP256dhKey,
+ authSecret, appServerKey);
+ }
+
+ WorkerPrivate* worker = mProxy->GetWorkerPrivate();
+ RefPtr<GetSubscriptionResultRunnable> r =
+ new GetSubscriptionResultRunnable(worker,
+ mProxy.forget(),
+ aStatus,
+ endpoint,
+ mScope,
+ Move(rawP256dhKey),
+ Move(authSecret),
+ Move(appServerKey));
+ MOZ_ALWAYS_TRUE(r->Dispatch());
+
+ return NS_OK;
+ }
+
+ // Convenience method for use in this file.
+ void
+ OnPushSubscriptionError(nsresult aStatus)
+ {
+ Unused << NS_WARN_IF(NS_FAILED(
+ OnPushSubscription(aStatus, nullptr)));
+ }
+
+protected:
+ ~GetSubscriptionCallback()
+ {}
+
+private:
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsString mScope;
+};
+
+NS_IMPL_ISUPPORTS(GetSubscriptionCallback, nsIPushSubscriptionCallback)
+
+class GetSubscriptionRunnable final : public Runnable
+{
+public:
+ GetSubscriptionRunnable(PromiseWorkerProxy* aProxy,
+ const nsAString& aScope,
+ PushManager::SubscriptionAction aAction,
+ nsTArray<uint8_t>&& aAppServerKey)
+ : mProxy(aProxy)
+ , mScope(aScope)
+ , mAction(aAction)
+ , mAppServerKey(Move(aAppServerKey))
+ {}
+
+ NS_IMETHOD
+ Run() override
+ {
+ AssertIsOnMainThread();
+
+ nsCOMPtr<nsIPrincipal> principal;
+
+ {
+ // Bug 1228723: If permission is revoked or an error occurs, the
+ // subscription callback will be called synchronously. This causes
+ // `GetSubscriptionCallback::OnPushSubscription` to deadlock when
+ // it tries to acquire the lock.
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+ principal = mProxy->GetWorkerPrivate()->GetPrincipal();
+ }
+
+ MOZ_ASSERT(principal);
+
+ RefPtr<GetSubscriptionCallback> callback = new GetSubscriptionCallback(mProxy, mScope);
+
+ PushPermissionState state;
+ nsresult rv = GetPermissionState(principal, state);
+ if (NS_FAILED(rv)) {
+ callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
+ return NS_OK;
+ }
+
+ if (state != PushPermissionState::Granted) {
+ if (mAction == PushManager::GetSubscriptionAction) {
+ callback->OnPushSubscriptionError(NS_OK);
+ return NS_OK;
+ }
+ callback->OnPushSubscriptionError(NS_ERROR_DOM_PUSH_DENIED_ERR);
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPushService> service =
+ do_GetService("@mozilla.org/push/Service;1");
+ if (NS_WARN_IF(!service)) {
+ callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
+ return NS_OK;
+ }
+
+ if (mAction == PushManager::SubscribeAction) {
+ if (mAppServerKey.IsEmpty()) {
+ rv = service->Subscribe(mScope, principal, callback);
+ } else {
+ rv = service->SubscribeWithKey(mScope, principal,
+ mAppServerKey.Length(),
+ mAppServerKey.Elements(), callback);
+ }
+ } else {
+ MOZ_ASSERT(mAction == PushManager::GetSubscriptionAction);
+ rv = service->GetSubscription(mScope, principal, callback);
+ }
+
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
+ return NS_OK;
+ }
+
+ return NS_OK;
+ }
+
+private:
+ ~GetSubscriptionRunnable()
+ {}
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsString mScope;
+ PushManager::SubscriptionAction mAction;
+ nsTArray<uint8_t> mAppServerKey;
+};
+
+class PermissionResultRunnable final : public WorkerRunnable
+{
+public:
+ PermissionResultRunnable(PromiseWorkerProxy *aProxy,
+ nsresult aStatus,
+ PushPermissionState aState)
+ : WorkerRunnable(aProxy->GetWorkerPrivate())
+ , mProxy(aProxy)
+ , mStatus(aStatus)
+ , mState(aState)
+ {
+ AssertIsOnMainThread();
+ }
+
+ bool
+ WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override
+ {
+ MOZ_ASSERT(aWorkerPrivate);
+ aWorkerPrivate->AssertIsOnWorkerThread();
+
+ RefPtr<Promise> promise = mProxy->WorkerPromise();
+ if (NS_SUCCEEDED(mStatus)) {
+ promise->MaybeResolve(mState);
+ } else {
+ promise->MaybeReject(aCx, JS::UndefinedHandleValue);
+ }
+
+ mProxy->CleanUp();
+
+ return true;
+ }
+
+private:
+ ~PermissionResultRunnable()
+ {}
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsresult mStatus;
+ PushPermissionState mState;
+};
+
+class PermissionStateRunnable final : public Runnable
+{
+public:
+ explicit PermissionStateRunnable(PromiseWorkerProxy* aProxy)
+ : mProxy(aProxy)
+ {}
+
+ NS_IMETHOD
+ Run() override
+ {
+ AssertIsOnMainThread();
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+
+ PushPermissionState state;
+ nsresult rv = GetPermissionState(
+ mProxy->GetWorkerPrivate()->GetPrincipal(),
+ state
+ );
+
+ RefPtr<PermissionResultRunnable> r =
+ new PermissionResultRunnable(mProxy, rv, state);
+ MOZ_ALWAYS_TRUE(r->Dispatch());
+
+ return NS_OK;
+ }
+
+private:
+ ~PermissionStateRunnable()
+ {}
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+};
+
+} // anonymous namespace
+
+PushManager::PushManager(nsIGlobalObject* aGlobal, PushManagerImpl* aImpl)
+ : mGlobal(aGlobal)
+ , mImpl(aImpl)
+{
+ AssertIsOnMainThread();
+ MOZ_ASSERT(aImpl);
+}
+
+PushManager::PushManager(const nsAString& aScope)
+ : mScope(aScope)
+{
+#ifdef DEBUG
+ // There's only one global on a worker, so we don't need to pass a global
+ // object to the constructor.
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+#endif
+}
+
+PushManager::~PushManager()
+{}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(PushManager, mGlobal, mImpl)
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushManager)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushManager)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushManager)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+JSObject*
+PushManager::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto)
+{
+ return PushManagerBinding::Wrap(aCx, this, aGivenProto);
+}
+
+// static
+already_AddRefed<PushManager>
+PushManager::Constructor(GlobalObject& aGlobal,
+ const nsAString& aScope,
+ ErrorResult& aRv)
+{
+ if (!NS_IsMainThread()) {
+ RefPtr<PushManager> ret = new PushManager(aScope);
+ return ret.forget();
+ }
+
+ RefPtr<PushManagerImpl> impl = PushManagerImpl::Constructor(aGlobal,
+ aGlobal.Context(),
+ aScope, aRv);
+ if (aRv.Failed()) {
+ return nullptr;
+ }
+
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+ RefPtr<PushManager> ret = new PushManager(global, impl);
+
+ return ret.forget();
+}
+
+already_AddRefed<Promise>
+PushManager::Subscribe(const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv)
+{
+ if (mImpl) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mImpl->Subscribe(aOptions, aRv);
+ }
+
+ return PerformSubscriptionActionFromWorker(SubscribeAction, aOptions, aRv);
+}
+
+already_AddRefed<Promise>
+PushManager::GetSubscription(ErrorResult& aRv)
+{
+ if (mImpl) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mImpl->GetSubscription(aRv);
+ }
+
+ return PerformSubscriptionActionFromWorker(GetSubscriptionAction, aRv);
+}
+
+already_AddRefed<Promise>
+PushManager::PermissionState(const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv)
+{
+ if (mImpl) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mImpl->PermissionState(aOptions, aRv);
+ }
+
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+
+ nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
+ RefPtr<Promise> p = Promise::Create(global, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
+ if (!proxy) {
+ p->MaybeReject(worker->GetJSContext(), JS::UndefinedHandleValue);
+ return p.forget();
+ }
+
+ RefPtr<PermissionStateRunnable> r =
+ new PermissionStateRunnable(proxy);
+ NS_DispatchToMainThread(r);
+
+ return p.forget();
+}
+
+already_AddRefed<Promise>
+PushManager::PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
+ ErrorResult& aRv)
+{
+ PushSubscriptionOptionsInit options;
+ return PerformSubscriptionActionFromWorker(aAction, options, aRv);
+}
+
+already_AddRefed<Promise>
+PushManager::PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
+ const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv)
+{
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+
+ nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
+ RefPtr<Promise> p = Promise::Create(global, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
+ if (!proxy) {
+ p->MaybeReject(NS_ERROR_DOM_PUSH_ABORT_ERR);
+ return p.forget();
+ }
+
+ nsTArray<uint8_t> appServerKey;
+ if (!aOptions.mApplicationServerKey.IsNull()) {
+ const OwningArrayBufferViewOrArrayBuffer& bufferSource =
+ aOptions.mApplicationServerKey.Value();
+ if (!PushUtil::CopyBufferSourceToArray(bufferSource, appServerKey) ||
+ appServerKey.IsEmpty()) {
+ p->MaybeReject(NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
+ return p.forget();
+ }
+ }
+
+ RefPtr<GetSubscriptionRunnable> r =
+ new GetSubscriptionRunnable(proxy, mScope, aAction, Move(appServerKey));
+ MOZ_ALWAYS_SUCCEEDS(NS_DispatchToMainThread(r));
+
+ return p.forget();
+}
+
+} // namespace dom
+} // namespace mozilla
diff --git a/dom/push/PushManager.h b/dom/push/PushManager.h
new file mode 100644
index 0000000000..8b47536483
--- /dev/null
+++ b/dom/push/PushManager.h
@@ -0,0 +1,117 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * PushManager and PushSubscription are exposed on the main and worker threads.
+ * The main thread version is implemented in Push.js. The JS implementation
+ * makes it easier to use certain APIs like the permission prompt and Promises.
+ *
+ * Unfortunately, JS-implemented WebIDL is not supported off the main thread.
+ * To work around this, we use a chain of runnables to query the JS-implemented
+ * nsIPushService component for subscription information, and return the
+ * results to the worker. We don't have to deal with permission prompts, since
+ * we just reject calls if the principal does not have permission.
+ *
+ * On the main thread, PushManager wraps a JS-implemented PushManagerImpl
+ * instance. The C++ wrapper is necessary because our bindings code cannot
+ * accomodate "JS-implemented on the main thread, C++ on the worker" bindings.
+ *
+ * PushSubscription is in C++ on both threads since it isn't particularly
+ * verbose to implement in C++ compared to JS.
+ */
+
+#ifndef mozilla_dom_PushManager_h
+#define mozilla_dom_PushManager_h
+
+#include "nsWrapperCache.h"
+
+#include "mozilla/AlreadyAddRefed.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/TypedArray.h"
+
+#include "nsCOMPtr.h"
+#include "nsContentUtils.h" // Required for nsContentUtils::PushEnabled
+#include "mozilla/RefPtr.h"
+
+class nsIGlobalObject;
+class nsIPrincipal;
+
+namespace mozilla {
+namespace dom {
+
+namespace workers {
+class WorkerPrivate;
+}
+
+class Promise;
+class PushManagerImpl;
+struct PushSubscriptionOptionsInit;
+
+class PushManager final : public nsISupports
+ , public nsWrapperCache
+{
+public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushManager)
+
+ enum SubscriptionAction {
+ SubscribeAction,
+ GetSubscriptionAction,
+ };
+
+ // The main thread constructor.
+ PushManager(nsIGlobalObject* aGlobal, PushManagerImpl* aImpl);
+
+ // The worker thread constructor.
+ explicit PushManager(const nsAString& aScope);
+
+ nsIGlobalObject*
+ GetParentObject() const
+ {
+ return mGlobal;
+ }
+
+ JSObject*
+ WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
+
+ static already_AddRefed<PushManager>
+ Constructor(GlobalObject& aGlobal, const nsAString& aScope,
+ ErrorResult& aRv);
+
+ already_AddRefed<Promise>
+ PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
+ ErrorResult& aRv);
+
+ already_AddRefed<Promise>
+ PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
+ const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv);
+
+ already_AddRefed<Promise>
+ Subscribe(const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv);
+
+ already_AddRefed<Promise>
+ GetSubscription(ErrorResult& aRv);
+
+ already_AddRefed<Promise>
+ PermissionState(const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv);
+
+private:
+ ~PushManager();
+
+ // The following are only set and accessed on the main thread.
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+ RefPtr<PushManagerImpl> mImpl;
+
+ // Only used on the worker thread.
+ nsString mScope;
+};
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushManager_h
diff --git a/dom/push/PushNotifier.cpp b/dom/push/PushNotifier.cpp
new file mode 100644
index 0000000000..e60db2d975
--- /dev/null
+++ b/dom/push/PushNotifier.cpp
@@ -0,0 +1,550 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "PushNotifier.h"
+
+#include "nsContentUtils.h"
+#include "nsCOMPtr.h"
+#include "nsICategoryManager.h"
+#include "nsIXULRuntime.h"
+#include "nsNetUtil.h"
+#include "nsXPCOM.h"
+#include "ServiceWorkerManager.h"
+
+#include "mozilla/Services.h"
+#include "mozilla/Unused.h"
+
+#include "mozilla/dom/BodyUtil.h"
+#include "mozilla/dom/ContentChild.h"
+#include "mozilla/dom/ContentParent.h"
+
+namespace mozilla {
+namespace dom {
+
+using workers::AssertIsOnMainThread;
+using workers::ServiceWorkerManager;
+
+PushNotifier::PushNotifier()
+{}
+
+PushNotifier::~PushNotifier()
+{}
+
+NS_IMPL_CYCLE_COLLECTION_0(PushNotifier)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushNotifier)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushNotifier)
+ NS_INTERFACE_MAP_ENTRY(nsIPushNotifier)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushNotifier)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushNotifier)
+
+NS_IMETHODIMP
+PushNotifier::NotifyPushWithData(const nsACString& aScope,
+ nsIPrincipal* aPrincipal,
+ const nsAString& aMessageId,
+ uint32_t aDataLen, uint8_t* aData)
+{
+ NS_ENSURE_ARG(aPrincipal);
+ nsTArray<uint8_t> data;
+ if (!data.SetCapacity(aDataLen, fallible)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ if (!data.InsertElementsAt(0, aData, aDataLen, fallible)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ PushMessageDispatcher dispatcher(aScope, aPrincipal, aMessageId, Some(data));
+ return Dispatch(dispatcher);
+}
+
+NS_IMETHODIMP
+PushNotifier::NotifyPush(const nsACString& aScope, nsIPrincipal* aPrincipal,
+ const nsAString& aMessageId)
+{
+ NS_ENSURE_ARG(aPrincipal);
+ PushMessageDispatcher dispatcher(aScope, aPrincipal, aMessageId, Nothing());
+ return Dispatch(dispatcher);
+}
+
+NS_IMETHODIMP
+PushNotifier::NotifySubscriptionChange(const nsACString& aScope,
+ nsIPrincipal* aPrincipal)
+{
+ NS_ENSURE_ARG(aPrincipal);
+ PushSubscriptionChangeDispatcher dispatcher(aScope, aPrincipal);
+ return Dispatch(dispatcher);
+}
+
+NS_IMETHODIMP
+PushNotifier::NotifySubscriptionModified(const nsACString& aScope,
+ nsIPrincipal* aPrincipal)
+{
+ NS_ENSURE_ARG(aPrincipal);
+ PushSubscriptionModifiedDispatcher dispatcher(aScope, aPrincipal);
+ return Dispatch(dispatcher);
+}
+
+NS_IMETHODIMP
+PushNotifier::NotifyError(const nsACString& aScope, nsIPrincipal* aPrincipal,
+ const nsAString& aMessage, uint32_t aFlags)
+{
+ NS_ENSURE_ARG(aPrincipal);
+ PushErrorDispatcher dispatcher(aScope, aPrincipal, aMessage, aFlags);
+ return Dispatch(dispatcher);
+}
+
+nsresult
+PushNotifier::Dispatch(PushDispatcher& aDispatcher)
+{
+ if (XRE_IsParentProcess()) {
+ // Always notify XPCOM observers in the parent process.
+ Unused << NS_WARN_IF(NS_FAILED(aDispatcher.NotifyObservers()));
+
+ nsTArray<ContentParent*> contentActors;
+ ContentParent::GetAll(contentActors);
+ if (!contentActors.IsEmpty()) {
+ // At least one content process is active, so e10s must be enabled.
+ // Broadcast a message to notify observers and service workers.
+ for (uint32_t i = 0; i < contentActors.Length(); ++i) {
+ Unused << NS_WARN_IF(!aDispatcher.SendToChild(contentActors[i]));
+ }
+ return NS_OK;
+ }
+
+ if (BrowserTabsRemoteAutostart()) {
+ // e10s is enabled, but no content processes are active.
+ return aDispatcher.HandleNoChildProcesses();
+ }
+
+ // e10s is disabled; notify workers in the parent.
+ return aDispatcher.NotifyWorkers();
+ }
+
+ // Otherwise, we're in the content process, so e10s must be enabled. Notify
+ // observers and workers, then send a message to notify observers in the
+ // parent.
+ MOZ_ASSERT(XRE_IsContentProcess());
+
+ nsresult rv = aDispatcher.NotifyObserversAndWorkers();
+
+ ContentChild* parentActor = ContentChild::GetSingleton();
+ if (!NS_WARN_IF(!parentActor)) {
+ Unused << NS_WARN_IF(!aDispatcher.SendToParent(parentActor));
+ }
+
+ return rv;
+}
+
+PushData::PushData(const nsTArray<uint8_t>& aData)
+ : mData(aData)
+{}
+
+PushData::~PushData()
+{}
+
+NS_IMPL_CYCLE_COLLECTION_0(PushData)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushData)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushData)
+ NS_INTERFACE_MAP_ENTRY(nsIPushData)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushData)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushData)
+
+nsresult
+PushData::EnsureDecodedText()
+{
+ if (mData.IsEmpty() || !mDecodedText.IsEmpty()) {
+ return NS_OK;
+ }
+ nsresult rv = BodyUtil::ConsumeText(
+ mData.Length(),
+ reinterpret_cast<uint8_t*>(mData.Elements()),
+ mDecodedText
+ );
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ mDecodedText.Truncate();
+ return rv;
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PushData::Text(nsAString& aText)
+{
+ nsresult rv = EnsureDecodedText();
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ aText = mDecodedText;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PushData::Json(JSContext* aCx,
+ JS::MutableHandle<JS::Value> aResult)
+{
+ nsresult rv = EnsureDecodedText();
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ ErrorResult error;
+ BodyUtil::ConsumeJson(aCx, aResult, mDecodedText, error);
+ return error.StealNSResult();
+}
+
+NS_IMETHODIMP
+PushData::Binary(uint32_t* aDataLen, uint8_t** aData)
+{
+ NS_ENSURE_ARG_POINTER(aDataLen);
+ NS_ENSURE_ARG_POINTER(aData);
+
+ *aData = nullptr;
+ if (mData.IsEmpty()) {
+ *aDataLen = 0;
+ return NS_OK;
+ }
+ uint32_t length = mData.Length();
+ uint8_t* data = static_cast<uint8_t*>(NS_Alloc(length * sizeof(uint8_t)));
+ if (!data) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ memcpy(data, mData.Elements(), length * sizeof(uint8_t));
+ *aDataLen = length;
+ *aData = data;
+ return NS_OK;
+}
+
+PushMessage::PushMessage(nsIPrincipal* aPrincipal, nsIPushData* aData)
+ : mPrincipal(aPrincipal)
+ , mData(aData)
+{}
+
+PushMessage::~PushMessage()
+{}
+
+NS_IMPL_CYCLE_COLLECTION(PushMessage, mPrincipal, mData)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushMessage)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushMessage)
+ NS_INTERFACE_MAP_ENTRY(nsIPushMessage)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushMessage)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushMessage)
+
+NS_IMETHODIMP
+PushMessage::GetPrincipal(nsIPrincipal** aPrincipal)
+{
+ NS_ENSURE_ARG_POINTER(aPrincipal);
+
+ nsCOMPtr<nsIPrincipal> principal = mPrincipal;
+ principal.forget(aPrincipal);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PushMessage::GetData(nsIPushData** aData)
+{
+ NS_ENSURE_ARG_POINTER(aData);
+
+ nsCOMPtr<nsIPushData> data = mData;
+ data.forget(aData);
+ return NS_OK;
+}
+
+PushDispatcher::PushDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal)
+ : mScope(aScope)
+ , mPrincipal(aPrincipal)
+{}
+
+PushDispatcher::~PushDispatcher()
+{}
+
+nsresult
+PushDispatcher::HandleNoChildProcesses()
+{
+ return NS_OK;
+}
+
+nsresult
+PushDispatcher::NotifyObserversAndWorkers()
+{
+ Unused << NS_WARN_IF(NS_FAILED(NotifyObservers()));
+ return NotifyWorkers();
+}
+
+bool
+PushDispatcher::ShouldNotifyWorkers()
+{
+ if (NS_WARN_IF(!mPrincipal)) {
+ return false;
+ }
+ // System subscriptions use observer notifications instead of service worker
+ // events. The `testing.notifyWorkers` pref disables worker events for
+ // non-system subscriptions.
+ return !nsContentUtils::IsSystemPrincipal(mPrincipal) &&
+ Preferences::GetBool("dom.push.testing.notifyWorkers", true);
+}
+
+nsresult
+PushDispatcher::DoNotifyObservers(nsISupports *aSubject, const char *aTopic,
+ const nsACString& aScope)
+{
+ nsCOMPtr<nsIObserverService> obsService =
+ mozilla::services::GetObserverService();
+ if (!obsService) {
+ return NS_ERROR_FAILURE;
+ }
+ // If there's a service for this push category, make sure it is alive.
+ nsCOMPtr<nsICategoryManager> catMan =
+ do_GetService(NS_CATEGORYMANAGER_CONTRACTID);
+ if (catMan) {
+ nsXPIDLCString contractId;
+ nsresult rv = catMan->GetCategoryEntry("push",
+ mScope.BeginReading(),
+ getter_Copies(contractId));
+ if (NS_SUCCEEDED(rv)) {
+ // Ensure the service is created - we don't need to do anything with
+ // it though - we assume the service constructor attaches a listener.
+ nsCOMPtr<nsISupports> service = do_GetService(contractId);
+ }
+ }
+ return obsService->NotifyObservers(aSubject, aTopic,
+ NS_ConvertUTF8toUTF16(mScope).get());
+}
+
+PushMessageDispatcher::PushMessageDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal,
+ const nsAString& aMessageId,
+ const Maybe<nsTArray<uint8_t>>& aData)
+ : PushDispatcher(aScope, aPrincipal)
+ , mMessageId(aMessageId)
+ , mData(aData)
+{}
+
+PushMessageDispatcher::~PushMessageDispatcher()
+{}
+
+nsresult
+PushMessageDispatcher::NotifyObservers()
+{
+ nsCOMPtr<nsIPushData> data;
+ if (mData) {
+ data = new PushData(mData.ref());
+ }
+ nsCOMPtr<nsIPushMessage> message = new PushMessage(mPrincipal, data);
+ return DoNotifyObservers(message, OBSERVER_TOPIC_PUSH, mScope);
+}
+
+nsresult
+PushMessageDispatcher::NotifyWorkers()
+{
+ if (!ShouldNotifyWorkers()) {
+ return NS_OK;
+ }
+ RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
+ if (!swm) {
+ return NS_ERROR_FAILURE;
+ }
+ nsAutoCString originSuffix;
+ nsresult rv = mPrincipal->GetOriginSuffix(originSuffix);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return swm->SendPushEvent(originSuffix, mScope, mMessageId, mData);
+}
+
+bool
+PushMessageDispatcher::SendToParent(ContentChild* aParentActor)
+{
+ if (mData) {
+ return aParentActor->SendNotifyPushObserversWithData(mScope,
+ IPC::Principal(mPrincipal),
+ mMessageId,
+ mData.ref());
+ }
+ return aParentActor->SendNotifyPushObservers(mScope,
+ IPC::Principal(mPrincipal),
+ mMessageId);
+}
+
+bool
+PushMessageDispatcher::SendToChild(ContentParent* aContentActor)
+{
+ if (mData) {
+ return aContentActor->SendPushWithData(mScope, IPC::Principal(mPrincipal),
+ mMessageId, mData.ref());
+ }
+ return aContentActor->SendPush(mScope, IPC::Principal(mPrincipal),
+ mMessageId);
+}
+
+PushSubscriptionChangeDispatcher::PushSubscriptionChangeDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal)
+ : PushDispatcher(aScope, aPrincipal)
+{}
+
+PushSubscriptionChangeDispatcher::~PushSubscriptionChangeDispatcher()
+{}
+
+nsresult
+PushSubscriptionChangeDispatcher::NotifyObservers()
+{
+ return DoNotifyObservers(mPrincipal, OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
+ mScope);
+}
+
+nsresult
+PushSubscriptionChangeDispatcher::NotifyWorkers()
+{
+ if (!ShouldNotifyWorkers()) {
+ return NS_OK;
+ }
+ RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
+ if (!swm) {
+ return NS_ERROR_FAILURE;
+ }
+ nsAutoCString originSuffix;
+ nsresult rv = mPrincipal->GetOriginSuffix(originSuffix);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return swm->SendPushSubscriptionChangeEvent(originSuffix, mScope);
+}
+
+bool
+PushSubscriptionChangeDispatcher::SendToParent(ContentChild* aParentActor)
+{
+ return aParentActor->SendNotifyPushSubscriptionChangeObservers(mScope,
+ IPC::Principal(mPrincipal));
+}
+
+bool
+PushSubscriptionChangeDispatcher::SendToChild(ContentParent* aContentActor)
+{
+ return aContentActor->SendPushSubscriptionChange(mScope,
+ IPC::Principal(mPrincipal));
+}
+
+PushSubscriptionModifiedDispatcher::PushSubscriptionModifiedDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal)
+ : PushDispatcher(aScope, aPrincipal)
+{}
+
+PushSubscriptionModifiedDispatcher::~PushSubscriptionModifiedDispatcher()
+{}
+
+nsresult
+PushSubscriptionModifiedDispatcher::NotifyObservers()
+{
+ return DoNotifyObservers(mPrincipal, OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
+ mScope);
+}
+
+nsresult
+PushSubscriptionModifiedDispatcher::NotifyWorkers()
+{
+ return NS_OK;
+}
+
+bool
+PushSubscriptionModifiedDispatcher::SendToParent(ContentChild* aParentActor)
+{
+ return aParentActor->SendNotifyPushSubscriptionModifiedObservers(mScope,
+ IPC::Principal(mPrincipal));
+}
+
+bool
+PushSubscriptionModifiedDispatcher::SendToChild(ContentParent* aContentActor)
+{
+ return aContentActor->SendNotifyPushSubscriptionModifiedObservers(mScope,
+ IPC::Principal(mPrincipal));
+}
+
+PushErrorDispatcher::PushErrorDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal,
+ const nsAString& aMessage,
+ uint32_t aFlags)
+ : PushDispatcher(aScope, aPrincipal)
+ , mMessage(aMessage)
+ , mFlags(aFlags)
+{}
+
+PushErrorDispatcher::~PushErrorDispatcher()
+{}
+
+nsresult
+PushErrorDispatcher::NotifyObservers()
+{
+ return NS_OK;
+}
+
+nsresult
+PushErrorDispatcher::NotifyWorkers()
+{
+ if (!ShouldNotifyWorkers()) {
+ // For system subscriptions, log the error directly to the browser console.
+ return nsContentUtils::ReportToConsoleNonLocalized(mMessage,
+ mFlags,
+ NS_LITERAL_CSTRING("Push"),
+ nullptr, /* aDocument */
+ nullptr, /* aURI */
+ EmptyString(), /* aLine */
+ 0, /* aLineNumber */
+ 0, /* aColumnNumber */
+ nsContentUtils::eOMIT_LOCATION);
+ }
+ // For service worker subscriptions, report the error to all clients.
+ RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
+ if (swm) {
+ swm->ReportToAllClients(mScope,
+ mMessage,
+ NS_ConvertUTF8toUTF16(mScope), /* aFilename */
+ EmptyString(), /* aLine */
+ 0, /* aLineNumber */
+ 0, /* aColumnNumber */
+ mFlags);
+ }
+ return NS_OK;
+}
+
+bool
+PushErrorDispatcher::SendToParent(ContentChild*)
+{
+ return true;
+}
+
+bool
+PushErrorDispatcher::SendToChild(ContentParent* aContentActor)
+{
+ return aContentActor->SendPushError(mScope, IPC::Principal(mPrincipal),
+ mMessage, mFlags);
+}
+
+nsresult
+PushErrorDispatcher::HandleNoChildProcesses()
+{
+ // Report to the console if no content processes are active.
+ nsCOMPtr<nsIURI> scopeURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(scopeURI), mScope);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return nsContentUtils::ReportToConsoleNonLocalized(mMessage,
+ mFlags,
+ NS_LITERAL_CSTRING("Push"),
+ nullptr, /* aDocument */
+ scopeURI, /* aURI */
+ EmptyString(), /* aLine */
+ 0, /* aLineNumber */
+ 0, /* aColumnNumber */
+ nsContentUtils::eOMIT_LOCATION);
+}
+
+} // namespace dom
+} // namespace mozilla
diff --git a/dom/push/PushNotifier.h b/dom/push/PushNotifier.h
new file mode 100644
index 0000000000..878e601dfc
--- /dev/null
+++ b/dom/push/PushNotifier.h
@@ -0,0 +1,203 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_PushNotifier_h
+#define mozilla_dom_PushNotifier_h
+
+#include "nsIPushNotifier.h"
+
+#include "nsCycleCollectionParticipant.h"
+#include "nsIPrincipal.h"
+#include "nsString.h"
+
+#include "mozilla/Maybe.h"
+
+namespace mozilla {
+namespace dom {
+
+class ContentChild;
+class ContentParent;
+
+/**
+ * `PushDispatcher` is a base class used to forward observer notifications and
+ * service worker events to the correct process.
+ */
+class MOZ_STACK_CLASS PushDispatcher
+{
+public:
+ // Fires an XPCOM observer notification. This method may be called from both
+ // processes.
+ virtual nsresult NotifyObservers() = 0;
+
+ // Fires a service worker event. This method is called from the content
+ // process if e10s is enabled, or the parent otherwise.
+ virtual nsresult NotifyWorkers() = 0;
+
+ // A convenience method that calls `NotifyObservers` and `NotifyWorkers`.
+ nsresult NotifyObserversAndWorkers();
+
+ // Sends an IPDL message to fire an observer notification in the parent
+ // process. This method is only called from the content process, and only
+ // if e10s is enabled.
+ virtual bool SendToParent(ContentChild* aParentActor) = 0;
+
+ // Sends an IPDL message to fire an observer notification and a service worker
+ // event in the content process. This method is only called from the parent,
+ // and only if e10s is enabled.
+ virtual bool SendToChild(ContentParent* aContentActor) = 0;
+
+ // An optional method, called from the parent if e10s is enabled and there
+ // are no active content processes. The default behavior is a no-op.
+ virtual nsresult HandleNoChildProcesses();
+
+protected:
+ PushDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal);
+
+ virtual ~PushDispatcher();
+
+ bool ShouldNotifyWorkers();
+ nsresult DoNotifyObservers(nsISupports *aSubject, const char *aTopic,
+ const nsACString& aScope);
+
+ const nsCString mScope;
+ nsCOMPtr<nsIPrincipal> mPrincipal;
+};
+
+/**
+ * `PushNotifier` implements the `nsIPushNotifier` interface. This service
+ * broadcasts XPCOM observer notifications for incoming push messages, then
+ * forwards incoming push messages to service workers.
+ *
+ * All scriptable methods on this interface may be called from the parent or
+ * content process. Observer notifications are broadcasted to both processes.
+ */
+class PushNotifier final : public nsIPushNotifier
+{
+public:
+ PushNotifier();
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushNotifier, nsIPushNotifier)
+ NS_DECL_NSIPUSHNOTIFIER
+
+private:
+ ~PushNotifier();
+
+ nsresult Dispatch(PushDispatcher& aDispatcher);
+};
+
+/**
+ * `PushData` provides methods for retrieving push message data in different
+ * formats. This class is similar to the `PushMessageData` WebIDL interface.
+ */
+class PushData final : public nsIPushData
+{
+public:
+ explicit PushData(const nsTArray<uint8_t>& aData);
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushData, nsIPushData)
+ NS_DECL_NSIPUSHDATA
+
+private:
+ ~PushData();
+
+ nsresult EnsureDecodedText();
+
+ nsTArray<uint8_t> mData;
+ nsString mDecodedText;
+};
+
+/**
+ * `PushMessage` exposes the subscription principal and data for a push
+ * message. Each `push-message` observer receives an instance of this class
+ * as the subject.
+ */
+class PushMessage final : public nsIPushMessage
+{
+public:
+ PushMessage(nsIPrincipal* aPrincipal, nsIPushData* aData);
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushMessage, nsIPushMessage)
+ NS_DECL_NSIPUSHMESSAGE
+
+private:
+ ~PushMessage();
+
+ nsCOMPtr<nsIPrincipal> mPrincipal;
+ nsCOMPtr<nsIPushData> mData;
+};
+
+class PushMessageDispatcher final : public PushDispatcher
+{
+public:
+ PushMessageDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal,
+ const nsAString& aMessageId,
+ const Maybe<nsTArray<uint8_t>>& aData);
+ ~PushMessageDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+
+private:
+ const nsString mMessageId;
+ const Maybe<nsTArray<uint8_t>> mData;
+};
+
+class PushSubscriptionChangeDispatcher final : public PushDispatcher
+{
+public:
+ PushSubscriptionChangeDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal);
+ ~PushSubscriptionChangeDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+};
+
+class PushSubscriptionModifiedDispatcher : public PushDispatcher
+{
+public:
+ PushSubscriptionModifiedDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal);
+ ~PushSubscriptionModifiedDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+};
+
+class PushErrorDispatcher final : public PushDispatcher
+{
+public:
+ PushErrorDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal,
+ const nsAString& aMessage,
+ uint32_t aFlags);
+ ~PushErrorDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+
+private:
+ nsresult HandleNoChildProcesses() override;
+
+ const nsString mMessage;
+ uint32_t mFlags;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushNotifier_h
diff --git a/dom/push/PushRecord.jsm b/dom/push/PushRecord.jsm
new file mode 100644
index 0000000000..08a7678e04
--- /dev/null
+++ b/dom/push/PushRecord.jsm
@@ -0,0 +1,318 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Messaging",
+ "resource://gre/modules/Messaging.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Task",
+ "resource://gre/modules/Task.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+
+this.EXPORTED_SYMBOLS = ["PushRecord"];
+
+const prefs = new Preferences("dom.push.");
+
+/**
+ * The push subscription record, stored in IndexedDB.
+ */
+function PushRecord(props) {
+ this.pushEndpoint = props.pushEndpoint;
+ this.scope = props.scope;
+ this.originAttributes = props.originAttributes;
+ this.pushCount = props.pushCount || 0;
+ this.lastPush = props.lastPush || 0;
+ this.p256dhPublicKey = props.p256dhPublicKey;
+ this.p256dhPrivateKey = props.p256dhPrivateKey;
+ this.authenticationSecret = props.authenticationSecret;
+ this.systemRecord = !!props.systemRecord;
+ this.appServerKey = props.appServerKey;
+ this.recentMessageIDs = props.recentMessageIDs;
+ this.setQuota(props.quota);
+ this.ctime = (typeof props.ctime === "number") ? props.ctime : 0;
+}
+
+PushRecord.prototype = {
+ setQuota(suggestedQuota) {
+ if (this.quotaApplies()) {
+ let quota = +suggestedQuota;
+ this.quota = quota >= 0 ? quota : prefs.get("maxQuotaPerSubscription");
+ } else {
+ this.quota = Infinity;
+ }
+ },
+
+ resetQuota() {
+ this.quota = this.quotaApplies() ?
+ prefs.get("maxQuotaPerSubscription") : Infinity;
+ },
+
+ updateQuota(lastVisit) {
+ if (this.isExpired() || !this.quotaApplies()) {
+ // Ignore updates if the registration is already expired, or isn't
+ // subject to quota.
+ return;
+ }
+ if (lastVisit < 0) {
+ // If the user cleared their history, but retained the push permission,
+ // mark the registration as expired.
+ this.quota = 0;
+ return;
+ }
+ if (lastVisit > this.lastPush) {
+ // If the user visited the site since the last time we received a
+ // notification, reset the quota. `Math.max(0, ...)` ensures the
+ // last visit date isn't in the future.
+ let daysElapsed =
+ Math.max(0, (Date.now() - lastVisit) / 24 / 60 / 60 / 1000);
+ this.quota = Math.min(
+ Math.round(8 * Math.pow(daysElapsed, -0.8)),
+ prefs.get("maxQuotaPerSubscription")
+ );
+ Services.telemetry.getHistogramById("PUSH_API_QUOTA_RESET_TO").add(this.quota);
+ }
+ },
+
+ receivedPush(lastVisit) {
+ this.updateQuota(lastVisit);
+ this.pushCount++;
+ this.lastPush = Date.now();
+ },
+
+ /**
+ * Records a message ID sent to this push registration. We track the last few
+ * messages sent to each registration to avoid firing duplicate events for
+ * unacknowledged messages.
+ */
+ noteRecentMessageID(id) {
+ if (this.recentMessageIDs) {
+ this.recentMessageIDs.unshift(id);
+ } else {
+ this.recentMessageIDs = [id];
+ }
+ // Drop older message IDs from the end of the list.
+ let maxRecentMessageIDs = Math.min(
+ this.recentMessageIDs.length,
+ Math.max(prefs.get("maxRecentMessageIDsPerSubscription"), 0)
+ );
+ this.recentMessageIDs.length = maxRecentMessageIDs || 0;
+ },
+
+ hasRecentMessageID(id) {
+ return this.recentMessageIDs && this.recentMessageIDs.includes(id);
+ },
+
+ reduceQuota() {
+ if (!this.quotaApplies()) {
+ return;
+ }
+ this.quota = Math.max(this.quota - 1, 0);
+ // We check for ctime > 0 to skip older records that did not have ctime.
+ if (this.isExpired() && this.ctime > 0) {
+ let duration = Date.now() - this.ctime;
+ Services.telemetry.getHistogramById("PUSH_API_QUOTA_EXPIRATION_TIME").add(duration / 1000);
+ }
+ },
+
+ /**
+ * Queries the Places database for the last time a user visited the site
+ * associated with a push registration.
+ *
+ * @returns {Promise} A promise resolved with either the last time the user
+ * visited the site, or `-Infinity` if the site is not in the user's history.
+ * The time is expressed in milliseconds since Epoch.
+ */
+ getLastVisit: Task.async(function* () {
+ if (!this.quotaApplies() || this.isTabOpen()) {
+ // If the registration isn't subject to quota, or the user already
+ // has the site open, skip expensive database queries.
+ return Date.now();
+ }
+
+ if (AppConstants.MOZ_ANDROID_HISTORY) {
+ let result = yield Messaging.sendRequestForResult({
+ type: "History:GetPrePathLastVisitedTimeMilliseconds",
+ prePath: this.uri.prePath,
+ });
+ return result == 0 ? -Infinity : result;
+ }
+
+ // Places History transition types that can fire a
+ // `pushsubscriptionchange` event when the user visits a site with expired push
+ // registrations. Visits only count if the user sees the origin in the address
+ // bar. This excludes embedded resources, downloads, and framed links.
+ const QUOTA_REFRESH_TRANSITIONS_SQL = [
+ Ci.nsINavHistoryService.TRANSITION_LINK,
+ Ci.nsINavHistoryService.TRANSITION_TYPED,
+ Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
+ Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
+ Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY
+ ].join(",");
+
+ let db = yield PlacesUtils.promiseDBConnection();
+ // We're using a custom query instead of `nsINavHistoryQueryOptions`
+ // because the latter doesn't expose a way to filter by transition type:
+ // `setTransitions` performs a logical "and," but we want an "or." We
+ // also avoid an unneeded left join on `moz_favicons`, and an `ORDER BY`
+ // clause that emits a suboptimal index warning.
+ let rows = yield db.executeCached(
+ `SELECT MAX(visit_date) AS lastVisit
+ FROM moz_places p
+ JOIN moz_historyvisits ON p.id = place_id
+ WHERE rev_host = get_unreversed_host(:host || '.') || '.'
+ AND url BETWEEN :prePath AND :prePath || X'FFFF'
+ AND visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
+ `,
+ {
+ // Restrict the query to all pages for this origin.
+ host: this.uri.host,
+ prePath: this.uri.prePath,
+ }
+ );
+
+ if (!rows.length) {
+ return -Infinity;
+ }
+ // Places records times in microseconds.
+ let lastVisit = rows[0].getResultByName("lastVisit");
+
+ return lastVisit / 1000;
+ }),
+
+ isTabOpen() {
+ let windows = Services.wm.getEnumerator("navigator:browser");
+ while (windows.hasMoreElements()) {
+ let window = windows.getNext();
+ if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
+ continue;
+ }
+ // `gBrowser` on Desktop; `BrowserApp` on Fennec.
+ let tabs = window.gBrowser ? window.gBrowser.tabContainer.children :
+ window.BrowserApp.tabs;
+ for (let tab of tabs) {
+ // `linkedBrowser` on Desktop; `browser` on Fennec.
+ let tabURI = (tab.linkedBrowser || tab.browser).currentURI;
+ if (tabURI.prePath == this.uri.prePath) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Indicates whether the registration can deliver push messages to its
+ * associated service worker. System subscriptions are exempt from the
+ * permission check.
+ */
+ hasPermission() {
+ if (this.systemRecord || prefs.get("testing.ignorePermission")) {
+ return true;
+ }
+ let permission = Services.perms.testExactPermissionFromPrincipal(
+ this.principal, "desktop-notification");
+ return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
+ },
+
+ quotaChanged() {
+ if (!this.hasPermission()) {
+ return Promise.resolve(false);
+ }
+ return this.getLastVisit()
+ .then(lastVisit => lastVisit > this.lastPush);
+ },
+
+ quotaApplies() {
+ return !this.systemRecord;
+ },
+
+ isExpired() {
+ return this.quota === 0;
+ },
+
+ matchesOriginAttributes(pattern) {
+ if (this.systemRecord) {
+ return false;
+ }
+ return ChromeUtils.originAttributesMatchPattern(
+ this.principal.originAttributes, pattern);
+ },
+
+ hasAuthenticationSecret() {
+ return !!this.authenticationSecret &&
+ this.authenticationSecret.byteLength == 16;
+ },
+
+ matchesAppServerKey(key) {
+ if (!this.appServerKey) {
+ return !key;
+ }
+ if (!key) {
+ return false;
+ }
+ return this.appServerKey.length === key.length &&
+ this.appServerKey.every((value, index) => value === key[index]);
+ },
+
+ toSubscription() {
+ return {
+ endpoint: this.pushEndpoint,
+ lastPush: this.lastPush,
+ pushCount: this.pushCount,
+ p256dhKey: this.p256dhPublicKey,
+ p256dhPrivateKey: this.p256dhPrivateKey,
+ authenticationSecret: this.authenticationSecret,
+ appServerKey: this.appServerKey,
+ quota: this.quotaApplies() ? this.quota : -1,
+ systemRecord: this.systemRecord,
+ };
+ },
+};
+
+// Define lazy getters for the principal and scope URI. IndexedDB can't store
+// `nsIPrincipal` objects, so we keep them in a private weak map.
+var principals = new WeakMap();
+Object.defineProperties(PushRecord.prototype, {
+ principal: {
+ get() {
+ if (this.systemRecord) {
+ return Services.scriptSecurityManager.getSystemPrincipal();
+ }
+ let principal = principals.get(this);
+ if (!principal) {
+ let uri = Services.io.newURI(this.scope, null, null);
+ // Allow tests to omit origin attributes.
+ let originSuffix = this.originAttributes || "";
+ let originAttributes =
+ principal = Services.scriptSecurityManager.createCodebasePrincipal(uri,
+ ChromeUtils.createOriginAttributesFromOrigin(originSuffix));
+ principals.set(this, principal);
+ }
+ return principal;
+ },
+ configurable: true,
+ },
+
+ uri: {
+ get() {
+ return this.principal.URI;
+ },
+ configurable: true,
+ },
+});
diff --git a/dom/push/PushService.jsm b/dom/push/PushService.jsm
new file mode 100644
index 0000000000..3738070248
--- /dev/null
+++ b/dom/push/PushService.jsm
@@ -0,0 +1,1365 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {
+ PushCrypto,
+ getCryptoParams,
+ CryptoError,
+} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+
+const CONNECTION_PROTOCOLS = (function() {
+ if ('android' != AppConstants.MOZ_WIDGET_TOOLKIT) {
+ const {PushServiceWebSocket} = Cu.import("resource://gre/modules/PushServiceWebSocket.jsm");
+ const {PushServiceHttp2} = Cu.import("resource://gre/modules/PushServiceHttp2.jsm");
+ return [PushServiceWebSocket, PushServiceHttp2];
+ } else {
+ const {PushServiceAndroidGCM} = Cu.import("resource://gre/modules/PushServiceAndroidGCM.jsm");
+ return [PushServiceAndroidGCM];
+ }
+})();
+
+XPCOMUtils.defineLazyServiceGetter(this, "gPushNotifier",
+ "@mozilla.org/push/Notifier;1",
+ "nsIPushNotifier");
+
+this.EXPORTED_SYMBOLS = ["PushService"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushService",
+ });
+});
+
+const prefs = new Preferences("dom.push.");
+
+const PUSH_SERVICE_UNINIT = 0;
+const PUSH_SERVICE_INIT = 1; // No serverURI
+const PUSH_SERVICE_ACTIVATING = 2;//activating db
+const PUSH_SERVICE_CONNECTION_DISABLE = 3;
+const PUSH_SERVICE_ACTIVE_OFFLINE = 4;
+const PUSH_SERVICE_RUNNING = 5;
+
+// Telemetry failure to send push notification to Service Worker reasons.
+// Key not found in local database.
+const kDROP_NOTIFICATION_REASON_KEY_NOT_FOUND = 0;
+// User cleared history.
+const kDROP_NOTIFICATION_REASON_NO_HISTORY = 1;
+// Version of message received not newer than previous one.
+const kDROP_NOTIFICATION_REASON_NO_VERSION_INCREMENT = 2;
+// Subscription has expired.
+const kDROP_NOTIFICATION_REASON_EXPIRED = 3;
+
+/**
+ * State is change only in couple of functions:
+ * init - change state to PUSH_SERVICE_INIT if state was PUSH_SERVICE_UNINIT
+ * changeServerURL - change state to PUSH_SERVICE_ACTIVATING if serverURL
+ * present or PUSH_SERVICE_INIT if not present.
+ * changeStateConnectionEnabledEvent - it is call on pref change or during
+ * the service activation and it can
+ * change state to
+ * PUSH_SERVICE_CONNECTION_DISABLE
+ * changeStateOfflineEvent - it is called when offline state changes or during
+ * the service activation and it change state to
+ * PUSH_SERVICE_ACTIVE_OFFLINE or
+ * PUSH_SERVICE_RUNNING.
+ * uninit - change state to PUSH_SERVICE_UNINIT.
+ **/
+
+// This is for starting and stopping service.
+const STARTING_SERVICE_EVENT = 0;
+const CHANGING_SERVICE_EVENT = 1;
+const STOPPING_SERVICE_EVENT = 2;
+const UNINIT_EVENT = 3;
+
+/**
+ * Annotates an error with an XPCOM result code. We use this helper
+ * instead of `Components.Exception` because the latter can assert in
+ * `nsXPCComponents_Exception::HasInstance` when inspected at shutdown.
+ */
+function errorWithResult(message, result = Cr.NS_ERROR_FAILURE) {
+ let error = new Error(message);
+ error.result = result;
+ return error;
+}
+
+/**
+ * Copied from ForgetAboutSite.jsm.
+ *
+ * Returns true if the string passed in is part of the root domain of the
+ * current string. For example, if this is "www.mozilla.org", and we pass in
+ * "mozilla.org", this will return true. It would return false the other way
+ * around.
+ */
+function hasRootDomain(str, aDomain)
+{
+ let index = str.indexOf(aDomain);
+ // If aDomain is not found, we know we do not have it as a root domain.
+ if (index == -1)
+ return false;
+
+ // If the strings are the same, we obviously have a match.
+ if (str == aDomain)
+ return true;
+
+ // Otherwise, we have aDomain as our root domain iff the index of aDomain is
+ // aDomain.length subtracted from our length and (since we do not have an
+ // exact match) the character before the index is a dot or slash.
+ let prevChar = str[index - 1];
+ return (index == (str.length - aDomain.length)) &&
+ (prevChar == "." || prevChar == "/");
+}
+
+/**
+ * The implementation of the push system. It uses WebSockets
+ * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
+ * for persistence.
+ */
+this.PushService = {
+ _service: null,
+ _state: PUSH_SERVICE_UNINIT,
+ _db: null,
+ _options: null,
+ _visibleNotifications: new Map(),
+
+ // Callback that is called after attempting to
+ // reduce the quota for a record. Used for testing purposes.
+ _updateQuotaTestCallback: null,
+
+ // Set of timeout ID of tasks to reduce quota.
+ _updateQuotaTimeouts: new Set(),
+
+ // When serverURI changes (this is used for testing), db is cleaned up and a
+ // a new db is started. This events must be sequential.
+ _stateChangeProcessQueue: null,
+ _stateChangeProcessEnqueue: function(op) {
+ if (!this._stateChangeProcessQueue) {
+ this._stateChangeProcessQueue = Promise.resolve();
+ }
+
+ this._stateChangeProcessQueue = this._stateChangeProcessQueue
+ .then(op)
+ .catch(error => {
+ console.error(
+ "stateChangeProcessEnqueue: Error transitioning state", error);
+ return this._shutdownService();
+ })
+ .catch(error => {
+ console.error(
+ "stateChangeProcessEnqueue: Error shutting down service", error);
+ });
+ return this._stateChangeProcessQueue;
+ },
+
+ // Pending request. If a worker try to register for the same scope again, do
+ // not send a new registration request. Therefore we need queue of pending
+ // register requests. This is the list of scopes which pending registration.
+ _pendingRegisterRequest: {},
+ _notifyActivated: null,
+ _activated: null,
+ _checkActivated: function() {
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ return Promise.reject(new Error("Push service not active"));
+ } else if (this._state > PUSH_SERVICE_ACTIVATING) {
+ return Promise.resolve();
+ } else {
+ return (this._activated) ? this._activated :
+ this._activated = new Promise((res, rej) =>
+ this._notifyActivated = {resolve: res,
+ reject: rej});
+ }
+ },
+
+ _makePendingKey: function(aPageRecord) {
+ return aPageRecord.scope + "|" + aPageRecord.originAttributes;
+ },
+
+ _lookupOrPutPendingRequest: function(aPageRecord) {
+ let key = this._makePendingKey(aPageRecord);
+ if (this._pendingRegisterRequest[key]) {
+ return this._pendingRegisterRequest[key];
+ }
+
+ return this._pendingRegisterRequest[key] = this._registerWithServer(aPageRecord);
+ },
+
+ _deletePendingRequest: function(aPageRecord) {
+ let key = this._makePendingKey(aPageRecord);
+ if (this._pendingRegisterRequest[key]) {
+ delete this._pendingRegisterRequest[key];
+ }
+ },
+
+ _setState: function(aNewState) {
+ console.debug("setState()", "new state", aNewState, "old state", this._state);
+
+ if (this._state == aNewState) {
+ return;
+ }
+
+ if (this._state == PUSH_SERVICE_ACTIVATING) {
+ // It is not important what is the new state as soon as we leave
+ // PUSH_SERVICE_ACTIVATING
+ if (this._notifyActivated) {
+ if (aNewState < PUSH_SERVICE_ACTIVATING) {
+ this._notifyActivated.reject(new Error("Push service not active"));
+ } else {
+ this._notifyActivated.resolve();
+ }
+ }
+ this._notifyActivated = null;
+ this._activated = null;
+ }
+ this._state = aNewState;
+ },
+
+ _changeStateOfflineEvent: function(offline, calledFromConnEnabledEvent) {
+ console.debug("changeStateOfflineEvent()", offline);
+
+ if (this._state < PUSH_SERVICE_ACTIVE_OFFLINE &&
+ this._state != PUSH_SERVICE_ACTIVATING &&
+ !calledFromConnEnabledEvent) {
+ return Promise.resolve();
+ }
+
+ if (offline) {
+ if (this._state == PUSH_SERVICE_RUNNING) {
+ this._service.disconnect();
+ }
+ this._setState(PUSH_SERVICE_ACTIVE_OFFLINE);
+ return Promise.resolve();
+ }
+
+ if (this._state == PUSH_SERVICE_RUNNING) {
+ // PushService was not in the offline state, but got notification to
+ // go online (a offline notification has not been sent).
+ // Disconnect first.
+ this._service.disconnect();
+ }
+ return this.getAllUnexpired().then(records => {
+ this._setState(PUSH_SERVICE_RUNNING);
+ if (records.length > 0) {
+ // if there are request waiting
+ this._service.connect(records);
+ }
+ });
+ },
+
+ _changeStateConnectionEnabledEvent: function(enabled) {
+ console.debug("changeStateConnectionEnabledEvent()", enabled);
+
+ if (this._state < PUSH_SERVICE_CONNECTION_DISABLE &&
+ this._state != PUSH_SERVICE_ACTIVATING) {
+ return Promise.resolve();
+ }
+
+ if (enabled) {
+ return this._changeStateOfflineEvent(Services.io.offline, true);
+ }
+
+ if (this._state == PUSH_SERVICE_RUNNING) {
+ this._service.disconnect();
+ }
+ this._setState(PUSH_SERVICE_CONNECTION_DISABLE);
+ return Promise.resolve();
+ },
+
+ // Used for testing.
+ changeTestServer(url, options = {}) {
+ console.debug("changeTestServer()");
+
+ return this._stateChangeProcessEnqueue(_ => {
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ console.debug("changeTestServer: PushService not activated?");
+ return Promise.resolve();
+ }
+
+ return this._changeServerURL(url, CHANGING_SERVICE_EVENT, options);
+ });
+ },
+
+ observe: function observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ /*
+ * We need to call uninit() on shutdown to clean up things that modules
+ * aren't very good at automatically cleaning up, so we don't get shutdown
+ * leaks on browser shutdown.
+ */
+ case "quit-application":
+ this.uninit();
+ break;
+ case "network:offline-status-changed":
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeStateOfflineEvent(aData === "offline", false)
+ );
+ break;
+
+ case "nsPref:changed":
+ if (aData == "dom.push.serverURL") {
+ console.debug("observe: dom.push.serverURL changed for websocket",
+ prefs.get("serverURL"));
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeServerURL(prefs.get("serverURL"),
+ CHANGING_SERVICE_EVENT)
+ );
+
+ } else if (aData == "dom.push.connection.enabled") {
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"))
+ );
+ }
+ break;
+
+ case "idle-daily":
+ this._dropExpiredRegistrations().catch(error => {
+ console.error("Failed to drop expired registrations on idle", error);
+ });
+ break;
+
+ case "perm-changed":
+ this._onPermissionChange(aSubject, aData).catch(error => {
+ console.error("onPermissionChange: Error updating registrations:",
+ error);
+ })
+ break;
+
+ case "clear-origin-attributes-data":
+ this._clearOriginData(aData).catch(error => {
+ console.error("clearOriginData: Error clearing origin data:", error);
+ });
+ break;
+ }
+ },
+
+ _clearOriginData: function(data) {
+ console.log("clearOriginData()");
+
+ if (!data) {
+ return Promise.resolve();
+ }
+
+ let pattern = JSON.parse(data);
+ return this._dropRegistrationsIf(record =>
+ record.matchesOriginAttributes(pattern));
+ },
+
+ /**
+ * Sends an unregister request to the server in the background. If the
+ * service is not connected, this function is a no-op.
+ *
+ * @param {PushRecord} record The record to unregister.
+ * @param {Number} reason An `nsIPushErrorReporter` unsubscribe reason,
+ * indicating why this record was removed.
+ */
+ _backgroundUnregister(record, reason) {
+ console.debug("backgroundUnregister()");
+
+ if (!this._service.isConnected() || !record) {
+ return;
+ }
+
+ console.debug("backgroundUnregister: Notifying server", record);
+ this._sendUnregister(record, reason).then(() => {
+ gPushNotifier.notifySubscriptionModified(record.scope, record.principal);
+ }).catch(e => {
+ console.error("backgroundUnregister: Error notifying server", e);
+ });
+ },
+
+ _findService: function(serverURL) {
+ console.debug("findService()");
+
+ let uri;
+ let service;
+
+ if (!serverURL) {
+ console.warn("findService: No dom.push.serverURL found");
+ return [];
+ }
+
+ try {
+ uri = Services.io.newURI(serverURL, null, null);
+ } catch (e) {
+ console.warn("findService: Error creating valid URI from",
+ "dom.push.serverURL", serverURL);
+ return [];
+ }
+
+ for (let connProtocol of CONNECTION_PROTOCOLS) {
+ if (connProtocol.validServerURI(uri)) {
+ service = connProtocol;
+ break;
+ }
+ }
+ return [service, uri];
+ },
+
+ _changeServerURL: function(serverURI, event, options = {}) {
+ console.debug("changeServerURL()");
+
+ switch(event) {
+ case UNINIT_EVENT:
+ return this._stopService(event);
+
+ case STARTING_SERVICE_EVENT:
+ {
+ let [service, uri] = this._findService(serverURI);
+ if (!service) {
+ this._setState(PUSH_SERVICE_INIT);
+ return Promise.resolve();
+ }
+ return this._startService(service, uri, options)
+ .then(_ => this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"))
+ );
+ }
+ case CHANGING_SERVICE_EVENT:
+ let [service, uri] = this._findService(serverURI);
+ if (service) {
+ if (this._state == PUSH_SERVICE_INIT) {
+ this._setState(PUSH_SERVICE_ACTIVATING);
+ // The service has not been running - start it.
+ return this._startService(service, uri, options)
+ .then(_ => this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"))
+ );
+
+ } else {
+ this._setState(PUSH_SERVICE_ACTIVATING);
+ // If we already had running service - stop service, start the new
+ // one and check connection.enabled and offline state(offline state
+ // check is called in changeStateConnectionEnabledEvent function)
+ return this._stopService(CHANGING_SERVICE_EVENT)
+ .then(_ =>
+ this._startService(service, uri, options)
+ )
+ .then(_ => this._changeStateConnectionEnabledEvent(prefs.get("connection.enabled"))
+ );
+
+ }
+ } else {
+ if (this._state == PUSH_SERVICE_INIT) {
+ return Promise.resolve();
+
+ } else {
+ // The new serverUri is empty or misconfigured - stop service.
+ this._setState(PUSH_SERVICE_INIT);
+ return this._stopService(STOPPING_SERVICE_EVENT);
+ }
+ }
+ default:
+ console.error("Unexpected event in _changeServerURL", event);
+ return Promise.reject(new Error(`Unexpected event ${event}`));
+ }
+ },
+
+ /**
+ * PushService initialization is divided into 4 parts:
+ * init() - start listening for quit-application and serverURL changes.
+ * state is change to PUSH_SERVICE_INIT
+ * startService() - if serverURL is present this function is called. It starts
+ * listening for broadcasted messages, starts db and
+ * PushService connection (WebSocket).
+ * state is change to PUSH_SERVICE_ACTIVATING.
+ * startObservers() - start other observers.
+ * changeStateConnectionEnabledEvent - checks prefs and offline state.
+ * It changes state to:
+ * PUSH_SERVICE_RUNNING,
+ * PUSH_SERVICE_ACTIVE_OFFLINE or
+ * PUSH_SERVICE_CONNECTION_DISABLE.
+ */
+ init: function(options = {}) {
+ console.debug("init()");
+
+ if (this._state > PUSH_SERVICE_UNINIT) {
+ return;
+ }
+
+ this._setState(PUSH_SERVICE_ACTIVATING);
+
+ prefs.observe("serverURL", this);
+ Services.obs.addObserver(this, "quit-application", false);
+
+ if (options.serverURI) {
+ // this is use for xpcshell test.
+
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeServerURL(options.serverURI, STARTING_SERVICE_EVENT, options));
+
+ } else {
+ // This is only used for testing. Different tests require connecting to
+ // slightly different URLs.
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeServerURL(prefs.get("serverURL"), STARTING_SERVICE_EVENT));
+ }
+ },
+
+ _startObservers: function() {
+ console.debug("startObservers()");
+
+ if (this._state != PUSH_SERVICE_ACTIVATING) {
+ return;
+ }
+
+ Services.obs.addObserver(this, "clear-origin-attributes-data", false);
+
+ // The offline-status-changed event is used to know
+ // when to (dis)connect. It may not fire if the underlying OS changes
+ // networks; in such a case we rely on timeout.
+ Services.obs.addObserver(this, "network:offline-status-changed", false);
+
+ // Used to monitor if the user wishes to disable Push.
+ prefs.observe("connection.enabled", this);
+
+ // Prunes expired registrations and notifies dormant service workers.
+ Services.obs.addObserver(this, "idle-daily", false);
+
+ // Prunes registrations for sites for which the user revokes push
+ // permissions.
+ Services.obs.addObserver(this, "perm-changed", false);
+ },
+
+ _startService(service, serverURI, options) {
+ console.debug("startService()");
+
+ if (this._state != PUSH_SERVICE_ACTIVATING) {
+ return Promise.reject();
+ }
+
+ this._service = service;
+
+ this._db = options.db;
+ if (!this._db) {
+ this._db = this._service.newPushDB();
+ }
+
+ return this._service.init(options, this, serverURI)
+ .then(() => {
+ this._startObservers();
+ return this._dropExpiredRegistrations();
+ });
+ },
+
+ /**
+ * PushService uninitialization is divided into 3 parts:
+ * stopObservers() - stot observers started in startObservers.
+ * stopService() - It stops listening for broadcasted messages, stops db and
+ * PushService connection (WebSocket).
+ * state is changed to PUSH_SERVICE_INIT.
+ * uninit() - stop listening for quit-application and serverURL changes.
+ * state is change to PUSH_SERVICE_UNINIT
+ */
+ _stopService: function(event) {
+ console.debug("stopService()");
+
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ return Promise.resolve();
+ }
+
+ this._stopObservers();
+
+ this._service.disconnect();
+ this._service.uninit();
+ this._service = null;
+
+ this._updateQuotaTimeouts.forEach((timeoutID) => clearTimeout(timeoutID));
+ this._updateQuotaTimeouts.clear();
+
+ if (!this._db) {
+ return Promise.resolve();
+ }
+ if (event == UNINIT_EVENT) {
+ // If it is uninitialized just close db.
+ this._db.close();
+ this._db = null;
+ return Promise.resolve();
+ }
+
+ return this.dropUnexpiredRegistrations()
+ .then(_ => {
+ this._db.close();
+ this._db = null;
+ }, err => {
+ this._db.close();
+ this._db = null;
+ });
+ },
+
+ _stopObservers: function() {
+ console.debug("stopObservers()");
+
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ return;
+ }
+
+ prefs.ignore("connection.enabled", this);
+
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ Services.obs.removeObserver(this, "clear-origin-attributes-data");
+ Services.obs.removeObserver(this, "idle-daily");
+ Services.obs.removeObserver(this, "perm-changed");
+ },
+
+ _shutdownService() {
+ let promiseChangeURL = this._changeServerURL("", UNINIT_EVENT);
+ this._setState(PUSH_SERVICE_UNINIT);
+ console.debug("shutdownService: shutdown complete!");
+ return promiseChangeURL;
+ },
+
+ uninit: function() {
+ console.debug("uninit()");
+
+ if (this._state == PUSH_SERVICE_UNINIT) {
+ return;
+ }
+
+ prefs.ignore("serverURL", this);
+ Services.obs.removeObserver(this, "quit-application");
+
+ this._stateChangeProcessEnqueue(_ => this._shutdownService());
+ },
+
+ /**
+ * Drops all active registrations and notifies the associated service
+ * workers. This function is called when the user switches Push servers,
+ * or when the server invalidates all existing registrations.
+ *
+ * We ignore expired registrations because they're already handled in other
+ * code paths. Registrations that expired after exceeding their quotas are
+ * evicted at startup, or on the next `idle-daily` event. Registrations that
+ * expired because the user revoked the notification permission are evicted
+ * once the permission is reinstated.
+ */
+ dropUnexpiredRegistrations: function() {
+ return this._db.clearIf(record => {
+ if (record.isExpired()) {
+ return false;
+ }
+ this._notifySubscriptionChangeObservers(record);
+ return true;
+ });
+ },
+
+ _notifySubscriptionChangeObservers: function(record) {
+ if (!record) {
+ return;
+ }
+
+ Services.telemetry.getHistogramById("PUSH_API_NOTIFY_REGISTRATION_LOST").add();
+ gPushNotifier.notifySubscriptionChange(record.scope, record.principal);
+ },
+
+ /**
+ * Drops a registration and notifies the associated service worker. If the
+ * registration does not exist, this function is a no-op.
+ *
+ * @param {String} keyID The registration ID to remove.
+ * @returns {Promise} Resolves once the worker has been notified.
+ */
+ dropRegistrationAndNotifyApp: function(aKeyID) {
+ return this._db.delete(aKeyID)
+ .then(record => this._notifySubscriptionChangeObservers(record));
+ },
+
+ /**
+ * Replaces an existing registration and notifies the associated service
+ * worker.
+ *
+ * @param {String} aOldKey The registration ID to replace.
+ * @param {PushRecord} aNewRecord The new record.
+ * @returns {Promise} Resolves once the worker has been notified.
+ */
+ updateRegistrationAndNotifyApp: function(aOldKey, aNewRecord) {
+ return this.updateRecordAndNotifyApp(aOldKey, _ => aNewRecord);
+ },
+ /**
+ * Updates a registration and notifies the associated service worker.
+ *
+ * @param {String} keyID The registration ID to update.
+ * @param {Function} updateFunc Returns the updated record.
+ * @returns {Promise} Resolves with the updated record once the worker
+ * has been notified.
+ */
+ updateRecordAndNotifyApp: function(aKeyID, aUpdateFunc) {
+ return this._db.update(aKeyID, aUpdateFunc)
+ .then(record => {
+ this._notifySubscriptionChangeObservers(record);
+ return record;
+ });
+ },
+
+ ensureCrypto: function(record) {
+ if (record.hasAuthenticationSecret() &&
+ record.p256dhPublicKey &&
+ record.p256dhPrivateKey) {
+ return Promise.resolve(record);
+ }
+
+ let keygen = Promise.resolve([]);
+ if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
+ keygen = PushCrypto.generateKeys();
+ }
+ // We do not have a encryption key. so we need to generate it. This
+ // is only going to happen on db upgrade from version 4 to higher.
+ return keygen
+ .then(([pubKey, privKey]) => {
+ return this.updateRecordAndNotifyApp(record.keyID, record => {
+ if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
+ record.p256dhPublicKey = pubKey;
+ record.p256dhPrivateKey = privKey;
+ }
+ if (!record.hasAuthenticationSecret()) {
+ record.authenticationSecret = PushCrypto.generateAuthenticationSecret();
+ }
+ return record;
+ });
+ }, error => {
+ return this.dropRegistrationAndNotifyApp(record.keyID).then(
+ () => Promise.reject(error));
+ });
+ },
+
+ _recordDidNotNotify: function(reason) {
+ Services.telemetry.
+ getHistogramById("PUSH_API_NOTIFICATION_RECEIVED_BUT_DID_NOT_NOTIFY").
+ add(reason);
+ },
+
+ /**
+ * Dispatches an incoming message to a service worker, recalculating the
+ * quota for the associated push registration. If the quota is exceeded,
+ * the registration and message will be dropped, and the worker will not
+ * be notified.
+ *
+ * @param {String} keyID The push registration ID.
+ * @param {String} messageID The message ID, used to report service worker
+ * delivery failures. For Web Push messages, this is the version. If empty,
+ * failures will not be reported.
+ * @param {Object} headers The encryption headers.
+ * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
+ * @param {Function} updateFunc A function that receives the existing
+ * registration record as its argument, and returns a new record. If the
+ * function returns `null` or `undefined`, the record will not be updated.
+ * `PushServiceWebSocket` uses this to drop incoming updates with older
+ * versions.
+ * @returns {Promise} Resolves with an `nsIPushErrorReporter` ack status
+ * code, indicating whether the message was delivered successfully.
+ */
+ receivedPushMessage(keyID, messageID, headers, data, updateFunc) {
+ console.debug("receivedPushMessage()");
+ Services.telemetry.getHistogramById("PUSH_API_NOTIFICATION_RECEIVED").add();
+
+ return this._updateRecordAfterPush(keyID, updateFunc).then(record => {
+ if (record.quotaApplies()) {
+ // Update quota after the delay, at which point
+ // we check for visible notifications.
+ let timeoutID = setTimeout(_ =>
+ {
+ this._updateQuota(keyID);
+ if (!this._updateQuotaTimeouts.delete(timeoutID)) {
+ console.debug("receivedPushMessage: quota update timeout missing?");
+ }
+ }, prefs.get("quotaUpdateDelay"));
+ this._updateQuotaTimeouts.add(timeoutID);
+ }
+ return this._decryptAndNotifyApp(record, messageID, headers, data);
+ }).catch(error => {
+ console.error("receivedPushMessage: Error notifying app", error);
+ return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
+ });
+ },
+
+ /**
+ * Updates a registration record after receiving a push message.
+ *
+ * @param {String} keyID The push registration ID.
+ * @param {Function} updateFunc The function passed to `receivedPushMessage`.
+ * @returns {Promise} Resolves with the updated record, or rejects if the
+ * record was not updated.
+ */
+ _updateRecordAfterPush(keyID, updateFunc) {
+ return this.getByKeyID(keyID).then(record => {
+ if (!record) {
+ this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_KEY_NOT_FOUND);
+ throw new Error("No record for key ID " + keyID);
+ }
+ return record.getLastVisit().then(lastVisit => {
+ // As a special case, don't notify the service worker if the user
+ // cleared their history.
+ if (!isFinite(lastVisit)) {
+ this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_HISTORY);
+ throw new Error("Ignoring message sent to unvisited origin");
+ }
+ return lastVisit;
+ }).then(lastVisit => {
+ // Update the record, resetting the quota if the user has visited the
+ // site since the last push.
+ return this._db.update(keyID, record => {
+ let newRecord = updateFunc(record);
+ if (!newRecord) {
+ this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_NO_VERSION_INCREMENT);
+ return null;
+ }
+ // Because `unregister` is advisory only, we can still receive messages
+ // for stale Simple Push registrations from the server. To work around
+ // this, we check if the record has expired before *and* after updating
+ // the quota.
+ if (newRecord.isExpired()) {
+ return null;
+ }
+ newRecord.receivedPush(lastVisit);
+ return newRecord;
+ });
+ });
+ }).then(record => {
+ gPushNotifier.notifySubscriptionModified(record.scope,
+ record.principal);
+ return record;
+ });
+ },
+
+ /**
+ * Decrypts an incoming message and notifies the associated service worker.
+ *
+ * @param {PushRecord} record The receiving registration.
+ * @param {String} messageID The message ID.
+ * @param {Object} headers The encryption headers.
+ * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
+ * @returns {Promise} Resolves with an ack status code.
+ */
+ _decryptAndNotifyApp(record, messageID, headers, data) {
+ return PushCrypto.decrypt(record.p256dhPrivateKey, record.p256dhPublicKey,
+ record.authenticationSecret, headers, data)
+ .then(
+ message => this._notifyApp(record, messageID, message),
+ error => {
+ console.warn("decryptAndNotifyApp: Error decrypting message",
+ record.scope, messageID, error);
+
+ let message = error.format(record.scope);
+ gPushNotifier.notifyError(record.scope, record.principal, message,
+ Ci.nsIScriptError.errorFlag);
+ return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
+ });
+ },
+
+ _updateQuota: function(keyID) {
+ console.debug("updateQuota()");
+
+ this._db.update(keyID, record => {
+ // Record may have expired from an earlier quota update.
+ if (record.isExpired()) {
+ console.debug(
+ "updateQuota: Trying to update quota for expired record", record);
+ return null;
+ }
+ // If there are visible notifications, don't apply the quota penalty
+ // for the message.
+ if (record.uri && !this._visibleNotifications.has(record.uri.prePath)) {
+ record.reduceQuota();
+ }
+ return record;
+ }).then(record => {
+ if (record.isExpired()) {
+ this._recordDidNotNotify(kDROP_NOTIFICATION_REASON_EXPIRED);
+ // Drop the registration in the background. If the user returns to the
+ // site, the service worker will be notified on the next `idle-daily`
+ // event.
+ this._backgroundUnregister(record,
+ Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED);
+ } else {
+ gPushNotifier.notifySubscriptionModified(record.scope,
+ record.principal);
+ }
+ if (this._updateQuotaTestCallback) {
+ // Callback so that test may be notified when the quota update is complete.
+ this._updateQuotaTestCallback();
+ }
+ }).catch(error => {
+ console.debug("updateQuota: Error while trying to update quota", error);
+ });
+ },
+
+ notificationForOriginShown(origin) {
+ console.debug("notificationForOriginShown()", origin);
+ let count;
+ if (this._visibleNotifications.has(origin)) {
+ count = this._visibleNotifications.get(origin);
+ } else {
+ count = 0;
+ }
+ this._visibleNotifications.set(origin, count + 1);
+ },
+
+ notificationForOriginClosed(origin) {
+ console.debug("notificationForOriginClosed()", origin);
+ let count;
+ if (this._visibleNotifications.has(origin)) {
+ count = this._visibleNotifications.get(origin);
+ } else {
+ console.debug("notificationForOriginClosed: closing notification that has not been shown?");
+ return;
+ }
+ if (count > 1) {
+ this._visibleNotifications.set(origin, count - 1);
+ } else {
+ this._visibleNotifications.delete(origin);
+ }
+ },
+
+ reportDeliveryError(messageID, reason) {
+ console.debug("reportDeliveryError()", messageID, reason);
+ if (this._state == PUSH_SERVICE_RUNNING &&
+ this._service.isConnected()) {
+
+ // Only report errors if we're initialized and connected.
+ this._service.reportDeliveryError(messageID, reason);
+ }
+ },
+
+ _notifyApp(aPushRecord, messageID, message) {
+ if (!aPushRecord || !aPushRecord.scope ||
+ aPushRecord.originAttributes === undefined) {
+ console.error("notifyApp: Invalid record", aPushRecord);
+ return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
+ }
+
+ console.debug("notifyApp()", aPushRecord.scope);
+
+ // If permission has been revoked, trash the message.
+ if (!aPushRecord.hasPermission()) {
+ console.warn("notifyApp: Missing push permission", aPushRecord);
+ return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
+ }
+
+ let payload = ArrayBuffer.isView(message) ?
+ new Uint8Array(message.buffer) : message;
+
+ if (aPushRecord.quotaApplies()) {
+ // Don't record telemetry for chrome push messages.
+ Services.telemetry.getHistogramById("PUSH_API_NOTIFY").add();
+ }
+
+ if (payload) {
+ gPushNotifier.notifyPushWithData(aPushRecord.scope,
+ aPushRecord.principal,
+ messageID, payload.length, payload);
+ } else {
+ gPushNotifier.notifyPush(aPushRecord.scope, aPushRecord.principal,
+ messageID);
+ }
+
+ return Ci.nsIPushErrorReporter.ACK_DELIVERED;
+ },
+
+ getByKeyID: function(aKeyID) {
+ return this._db.getByKeyID(aKeyID);
+ },
+
+ getAllUnexpired: function() {
+ return this._db.getAllUnexpired();
+ },
+
+ _sendRequest(action, ...params) {
+ if (this._state == PUSH_SERVICE_CONNECTION_DISABLE) {
+ return Promise.reject(new Error("Push service disabled"));
+ }
+ if (this._state == PUSH_SERVICE_ACTIVE_OFFLINE) {
+ return Promise.reject(new Error("Push service offline"));
+ }
+ // Ensure the backend is ready. `getByPageRecord` already checks this, but
+ // we need to check again here in case the service was restarted in the
+ // meantime.
+ return this._checkActivated().then(_ => {
+ switch (action) {
+ case "register":
+ return this._service.register(...params);
+ case "unregister":
+ return this._service.unregister(...params);
+ }
+ return Promise.reject(new Error("Unknown request type: " + action));
+ });
+ },
+
+ /**
+ * Called on message from the child process. aPageRecord is an object sent by
+ * the push manager, identifying the sending page and other fields.
+ */
+ _registerWithServer: function(aPageRecord) {
+ console.debug("registerWithServer()", aPageRecord);
+
+ Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_ATTEMPT").add();
+ return this._sendRequest("register", aPageRecord)
+ .then(record => this._onRegisterSuccess(record),
+ err => this._onRegisterError(err))
+ .then(record => {
+ this._deletePendingRequest(aPageRecord);
+ gPushNotifier.notifySubscriptionModified(record.scope,
+ record.principal);
+ return record.toSubscription();
+ }, err => {
+ this._deletePendingRequest(aPageRecord);
+ throw err;
+ });
+ },
+
+ _sendUnregister(aRecord, aReason) {
+ Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_ATTEMPT").add();
+ return this._sendRequest("unregister", aRecord, aReason).then(function(v) {
+ Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_SUCCEEDED").add();
+ return v;
+ }).catch(function(e) {
+ Services.telemetry.getHistogramById("PUSH_API_UNSUBSCRIBE_FAILED").add();
+ return Promise.reject(e);
+ });
+ },
+
+ /**
+ * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained
+ * from _service.request, causing the promise to be rejected instead.
+ */
+ _onRegisterSuccess: function(aRecord) {
+ console.debug("_onRegisterSuccess()");
+
+ return this._db.put(aRecord)
+ .then(record => {
+ Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_SUCCEEDED").add();
+ return record;
+ })
+ .catch(error => {
+ Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_FAILED").add()
+ // Unable to save. Destroy the subscription in the background.
+ this._backgroundUnregister(aRecord,
+ Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL);
+ throw error;
+ });
+ },
+
+ /**
+ * Exceptions thrown in _onRegisterError are caught by the promise obtained
+ * from _service.request, causing the promise to be rejected instead.
+ */
+ _onRegisterError: function(reply) {
+ console.debug("_onRegisterError()");
+ Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_FAILED").add()
+ if (!reply.error) {
+ console.warn("onRegisterError: Called without valid error message!",
+ reply);
+ throw new Error("Registration error");
+ }
+ throw reply.error;
+ },
+
+ notificationsCleared() {
+ this._visibleNotifications.clear();
+ },
+
+ _getByPageRecord(pageRecord) {
+ return this._checkActivated().then(_ =>
+ this._db.getByIdentifiers(pageRecord)
+ );
+ },
+
+ register: function(aPageRecord) {
+ console.debug("register()", aPageRecord);
+
+ let keyPromise;
+ if (aPageRecord.appServerKey) {
+ let keyView = new Uint8Array(aPageRecord.appServerKey);
+ keyPromise = PushCrypto.validateAppServerKey(keyView)
+ .catch(error => {
+ // Normalize Web Crypto exceptions. `nsIPushService` will forward the
+ // error result to the DOM API implementation in `PushManager.cpp` or
+ // `Push.js`, which will convert it to the correct `DOMException`.
+ throw errorWithResult("Invalid app server key",
+ Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
+ });
+ } else {
+ keyPromise = Promise.resolve(null);
+ }
+
+ return Promise.all([
+ keyPromise,
+ this._getByPageRecord(aPageRecord),
+ ]).then(([appServerKey, record]) => {
+ aPageRecord.appServerKey = appServerKey;
+ if (!record) {
+ return this._lookupOrPutPendingRequest(aPageRecord);
+ }
+ if (!record.matchesAppServerKey(appServerKey)) {
+ throw errorWithResult("Mismatched app server key",
+ Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR);
+ }
+ if (record.isExpired()) {
+ return record.quotaChanged().then(isChanged => {
+ if (isChanged) {
+ // If the user revisited the site, drop the expired push
+ // registration and re-register.
+ return this.dropRegistrationAndNotifyApp(record.keyID);
+ }
+ throw new Error("Push subscription expired");
+ }).then(_ => this._lookupOrPutPendingRequest(aPageRecord));
+ }
+ return record.toSubscription();
+ });
+ },
+
+ /**
+ * Called on message from the child process.
+ *
+ * Why is the record being deleted from the local database before the server
+ * is told?
+ *
+ * Unregistration is for the benefit of the app and the AppServer
+ * so that the AppServer does not keep pinging a channel the UserAgent isn't
+ * watching The important part of the transaction in this case is left to the
+ * app, to tell its server of the unregistration. Even if the request to the
+ * PushServer were to fail, it would not affect correctness of the protocol,
+ * and the server GC would just clean up the channelID/subscription
+ * eventually. Since the appserver doesn't ping it, no data is lost.
+ *
+ * If rather we were to unregister at the server and update the database only
+ * on success: If the server receives the unregister, and deletes the
+ * channelID/subscription, but the response is lost because of network
+ * failure, the application is never informed. In addition the application may
+ * retry the unregister when it fails due to timeout (websocket) or any other
+ * reason at which point the server will say it does not know of this
+ * unregistration. We'll have to make the registration/unregistration phases
+ * have retries and attempts to resend messages from the server, and have the
+ * client acknowledge. On a server, data is cheap, reliable notification is
+ * not.
+ */
+ unregister: function(aPageRecord) {
+ console.debug("unregister()", aPageRecord);
+
+ return this._getByPageRecord(aPageRecord)
+ .then(record => {
+ if (record === undefined) {
+ return false;
+ }
+
+ let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL;
+ return Promise.all([
+ this._sendUnregister(record, reason),
+ this._db.delete(record.keyID).then(record => {
+ if (record) {
+ gPushNotifier.notifySubscriptionModified(record.scope,
+ record.principal);
+ }
+ }),
+ ]).then(([success]) => success);
+ });
+ },
+
+ clear: function(info) {
+ return this._checkActivated()
+ .then(_ => {
+ return this._dropRegistrationsIf(record =>
+ info.domain == "*" ||
+ (record.uri && hasRootDomain(record.uri.prePath, info.domain))
+ );
+ })
+ .catch(e => {
+ console.warn("clear: Error dropping subscriptions for domain",
+ info.domain, e);
+ return Promise.resolve();
+ });
+ },
+
+ registration: function(aPageRecord) {
+ console.debug("registration()");
+
+ return this._getByPageRecord(aPageRecord)
+ .then(record => {
+ if (!record) {
+ return null;
+ }
+ if (record.isExpired()) {
+ return record.quotaChanged().then(isChanged => {
+ if (isChanged) {
+ return this.dropRegistrationAndNotifyApp(record.keyID).then(_ => null);
+ }
+ return null;
+ });
+ }
+ return record.toSubscription();
+ });
+ },
+
+ _dropExpiredRegistrations: function() {
+ console.debug("dropExpiredRegistrations()");
+
+ return this._db.getAllExpired().then(records => {
+ return Promise.all(records.map(record =>
+ record.quotaChanged().then(isChanged => {
+ if (isChanged) {
+ // If the user revisited the site, drop the expired push
+ // registration and notify the associated service worker.
+ return this.dropRegistrationAndNotifyApp(record.keyID);
+ }
+ }).catch(error => {
+ console.error("dropExpiredRegistrations: Error dropping registration",
+ record.keyID, error);
+ })
+ ));
+ });
+ },
+
+ _onPermissionChange: function(subject, data) {
+ console.debug("onPermissionChange()");
+
+ if (data == "cleared") {
+ return this._clearPermissions();
+ }
+
+ let permission = subject.QueryInterface(Ci.nsIPermission);
+ if (permission.type != "desktop-notification") {
+ return Promise.resolve();
+ }
+
+ return this._updatePermission(permission, data);
+ },
+
+ _clearPermissions() {
+ console.debug("clearPermissions()");
+
+ return this._db.clearIf(record => {
+ if (!record.quotaApplies()) {
+ // Only drop registrations that are subject to quota.
+ return false;
+ }
+ this._backgroundUnregister(record,
+ Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED);
+ return true;
+ });
+ },
+
+ _updatePermission: function(permission, type) {
+ console.debug("updatePermission()");
+
+ let isAllow = permission.capability ==
+ Ci.nsIPermissionManager.ALLOW_ACTION;
+ let isChange = type == "added" || type == "changed";
+
+ if (isAllow && isChange) {
+ // Permission set to "allow". Drop all expired registrations for this
+ // site, notify the associated service workers, and reset the quota
+ // for active registrations.
+ return this._forEachPrincipal(
+ permission.principal,
+ (record, cursor) => this._permissionAllowed(record, cursor)
+ );
+ } else if (isChange || (isAllow && type == "deleted")) {
+ // Permission set to "block" or "always ask," or "allow" permission
+ // removed. Expire all registrations for this site.
+ return this._forEachPrincipal(
+ permission.principal,
+ (record, cursor) => this._permissionDenied(record, cursor)
+ );
+ }
+
+ return Promise.resolve();
+ },
+
+ _forEachPrincipal: function(principal, callback) {
+ return this._db.forEachOrigin(
+ principal.URI.prePath,
+ ChromeUtils.originAttributesToSuffix(principal.originAttributes),
+ callback
+ );
+ },
+
+ /**
+ * The update function called for each registration record if the push
+ * permission is revoked. We only expire the record so we can notify the
+ * service worker as soon as the permission is reinstated. If we just
+ * deleted the record, the worker wouldn't be notified until the next visit
+ * to the site.
+ *
+ * @param {PushRecord} record The record to expire.
+ * @param {IDBCursor} cursor The IndexedDB cursor.
+ */
+ _permissionDenied: function(record, cursor) {
+ console.debug("permissionDenied()");
+
+ if (!record.quotaApplies() || record.isExpired()) {
+ // Ignore already-expired records.
+ return;
+ }
+ // Drop the registration in the background.
+ this._backgroundUnregister(record,
+ Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED);
+ record.setQuota(0);
+ cursor.update(record);
+ },
+
+ /**
+ * The update function called for each registration record if the push
+ * permission is granted. If the record has expired, it will be dropped;
+ * otherwise, its quota will be reset to the default value.
+ *
+ * @param {PushRecord} record The record to update.
+ * @param {IDBCursor} cursor The IndexedDB cursor.
+ */
+ _permissionAllowed(record, cursor) {
+ console.debug("permissionAllowed()");
+
+ if (!record.quotaApplies()) {
+ return;
+ }
+ if (record.isExpired()) {
+ // If the registration has expired, drop and notify the worker
+ // unconditionally.
+ this._notifySubscriptionChangeObservers(record);
+ cursor.delete();
+ return;
+ }
+ record.resetQuota();
+ cursor.update(record);
+ },
+
+ /**
+ * Drops all matching registrations from the database. Notifies the
+ * associated service workers if permission is granted, and removes
+ * unexpired registrations from the server.
+ *
+ * @param {Function} predicate A function called for each record.
+ * @returns {Promise} Resolves once the registrations have been dropped.
+ */
+ _dropRegistrationsIf(predicate) {
+ return this._db.clearIf(record => {
+ if (!predicate(record)) {
+ return false;
+ }
+ if (record.hasPermission()) {
+ // "Clear Recent History" and the Forget button remove permissions
+ // before clearing registrations, but it's possible for the worker to
+ // resubscribe if the "dom.push.testing.ignorePermission" pref is set.
+ this._notifySubscriptionChangeObservers(record);
+ }
+ if (!record.isExpired()) {
+ // Only unregister active registrations, since we already told the
+ // server about expired ones.
+ this._backgroundUnregister(record,
+ Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL);
+ }
+ return true;
+ });
+ },
+};
diff --git a/dom/push/PushServiceAndroidGCM.jsm b/dom/push/PushServiceAndroidGCM.jsm
new file mode 100644
index 0000000000..ed07be3395
--- /dev/null
+++ b/dom/push/PushServiceAndroidGCM.jsm
@@ -0,0 +1,275 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
+const {PushCrypto} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+Cu.import("resource://gre/modules/Messaging.jsm"); /*global: Messaging */
+Cu.import("resource://gre/modules/Services.jsm"); /*global: Services */
+Cu.import("resource://gre/modules/Preferences.jsm"); /*global: Preferences */
+Cu.import("resource://gre/modules/Promise.jsm"); /*global: Promise */
+Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global: XPCOMUtils */
+
+const Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("Push");
+
+this.EXPORTED_SYMBOLS = ["PushServiceAndroidGCM"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ dump: Log.i,
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushServiceAndroidGCM",
+ });
+});
+
+const kPUSHANDROIDGCMDB_DB_NAME = "pushAndroidGCM";
+const kPUSHANDROIDGCMDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
+const kPUSHANDROIDGCMDB_STORE_NAME = "pushAndroidGCM";
+
+const FXA_PUSH_SCOPE = "chrome://fxa-push";
+
+const prefs = new Preferences("dom.push.");
+
+/**
+ * The implementation of WebPush push backed by Android's GCM
+ * delivery.
+ */
+this.PushServiceAndroidGCM = {
+ _mainPushService: null,
+ _serverURI: null,
+
+ newPushDB: function() {
+ return new PushDB(kPUSHANDROIDGCMDB_DB_NAME,
+ kPUSHANDROIDGCMDB_DB_VERSION,
+ kPUSHANDROIDGCMDB_STORE_NAME,
+ "channelID",
+ PushRecordAndroidGCM);
+ },
+
+ validServerURI: function(serverURI) {
+ if (!serverURI) {
+ return false;
+ }
+
+ if (serverURI.scheme == "https") {
+ return true;
+ }
+ if (serverURI.scheme == "http") {
+ // Allow insecure server URLs for development and testing.
+ return !!prefs.get("testing.allowInsecureServerURL");
+ }
+ console.info("Unsupported Android GCM dom.push.serverURL scheme", serverURI.scheme);
+ return false;
+ },
+
+ observe: function(subject, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ if (data == "dom.push.debug") {
+ // Reconfigure.
+ let debug = !!prefs.get("debug");
+ console.info("Debug parameter changed; updating configuration with new debug", debug);
+ this._configure(this._serverURI, debug);
+ }
+ break;
+ case "PushServiceAndroidGCM:ReceivedPushMessage":
+ this._onPushMessageReceived(data);
+ break;
+ default:
+ break;
+ }
+ },
+
+ _onPushMessageReceived(data) {
+ // TODO: Use Messaging.jsm for this.
+ if (this._mainPushService == null) {
+ // Shouldn't ever happen, but let's be careful.
+ console.error("No main PushService! Dropping message.");
+ return;
+ }
+ if (!data) {
+ console.error("No data from Java! Dropping message.");
+ return;
+ }
+ data = JSON.parse(data);
+ console.debug("ReceivedPushMessage with data", data);
+
+ let { headers, message } = this._messageAndHeaders(data);
+
+ console.debug("Delivering message to main PushService:", message, headers);
+ this._mainPushService.receivedPushMessage(
+ data.channelID, "", headers, message, (record) => {
+ // Always update the stored record.
+ return record;
+ });
+ },
+
+ _messageAndHeaders(data) {
+ // Default is no data (and no encryption).
+ let message = null;
+ let headers = null;
+
+ if (data.message && data.enc && (data.enckey || data.cryptokey)) {
+ headers = {
+ encryption_key: data.enckey,
+ crypto_key: data.cryptokey,
+ encryption: data.enc,
+ encoding: data.con,
+ };
+ // Ciphertext is (urlsafe) Base 64 encoded.
+ message = ChromeUtils.base64URLDecode(data.message, {
+ // The Push server may append padding.
+ padding: "ignore",
+ });
+ }
+ return { headers, message };
+ },
+
+ _configure: function(serverURL, debug) {
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:Configure",
+ endpoint: serverURL.spec,
+ debug: debug,
+ });
+ },
+
+ init: function(options, mainPushService, serverURL) {
+ console.debug("init()");
+ this._mainPushService = mainPushService;
+ this._serverURI = serverURL;
+
+ prefs.observe("debug", this);
+ Services.obs.addObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage", false);
+
+ return this._configure(serverURL, !!prefs.get("debug")).then(() => {
+ Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:Initialized"
+ });
+ });
+ },
+
+ uninit: function() {
+ console.debug("uninit()");
+ Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:Uninitialized"
+ });
+
+ this._mainPushService = null;
+ Services.obs.removeObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage");
+ prefs.ignore("debug", this);
+ },
+
+ onAlarmFired: function() {
+ // No action required.
+ },
+
+ connect: function(records) {
+ console.debug("connect:", records);
+ // It's possible for the registration or subscriptions backing the
+ // PushService to not be registered with the underlying AndroidPushService.
+ // Expire those that are unrecognized.
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:DumpSubscriptions",
+ })
+ .then(subscriptions => {
+ console.debug("connect:", subscriptions);
+ // subscriptions maps chid => subscription data.
+ return Promise.all(records.map(record => {
+ if (subscriptions.hasOwnProperty(record.keyID)) {
+ console.debug("connect:", "hasOwnProperty", record.keyID);
+ return Promise.resolve();
+ }
+ console.debug("connect:", "!hasOwnProperty", record.keyID);
+ // Subscription is known to PushService.jsm but not to AndroidPushService. Drop it.
+ return this._mainPushService.dropRegistrationAndNotifyApp(record.keyID)
+ .catch(error => {
+ console.error("connect: Error dropping registration", record.keyID, error);
+ });
+ }));
+ });
+ },
+
+ isConnected: function() {
+ return this._mainPushService != null;
+ },
+
+ disconnect: function() {
+ console.debug("disconnect");
+ },
+
+ register: function(record) {
+ console.debug("register:", record);
+ let ctime = Date.now();
+ let appServerKey = record.appServerKey ?
+ ChromeUtils.base64URLEncode(record.appServerKey, {
+ // The Push server requires padding.
+ pad: true,
+ }) : null;
+ let message = {
+ type: "PushServiceAndroidGCM:SubscribeChannel",
+ appServerKey: appServerKey,
+ }
+ if (record.scope == FXA_PUSH_SCOPE) {
+ message.service = "fxa";
+ }
+ // Caller handles errors.
+ return Messaging.sendRequestForResult(message)
+ .then(data => {
+ console.debug("Got data:", data);
+ return PushCrypto.generateKeys()
+ .then(exportedKeys =>
+ new PushRecordAndroidGCM({
+ // Straight from autopush.
+ channelID: data.channelID,
+ pushEndpoint: data.endpoint,
+ // Common to all PushRecord implementations.
+ scope: record.scope,
+ originAttributes: record.originAttributes,
+ ctime: ctime,
+ systemRecord: record.systemRecord,
+ // Cryptography!
+ p256dhPublicKey: exportedKeys[0],
+ p256dhPrivateKey: exportedKeys[1],
+ authenticationSecret: PushCrypto.generateAuthenticationSecret(),
+ appServerKey: record.appServerKey,
+ })
+ );
+ });
+ },
+
+ unregister: function(record) {
+ console.debug("unregister: ", record);
+ return Messaging.sendRequestForResult({
+ type: "PushServiceAndroidGCM:UnsubscribeChannel",
+ channelID: record.keyID,
+ });
+ },
+
+ reportDeliveryError: function(messageID, reason) {
+ console.warn("reportDeliveryError: Ignoring message delivery error",
+ messageID, reason);
+ },
+};
+
+function PushRecordAndroidGCM(record) {
+ PushRecord.call(this, record);
+ this.channelID = record.channelID;
+}
+
+PushRecordAndroidGCM.prototype = Object.create(PushRecord.prototype, {
+ keyID: {
+ get() {
+ return this.channelID;
+ },
+ },
+});
diff --git a/dom/push/PushServiceHttp2.jsm b/dom/push/PushServiceHttp2.jsm
new file mode 100644
index 0000000000..ce7e325ae3
--- /dev/null
+++ b/dom/push/PushServiceHttp2.jsm
@@ -0,0 +1,820 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+
+const {
+ PushCrypto,
+ concatArray,
+} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+
+this.EXPORTED_SYMBOLS = ["PushServiceHttp2"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushServiceHttp2",
+ });
+});
+
+const prefs = new Preferences("dom.push.");
+
+const kPUSHHTTP2DB_DB_NAME = "pushHttp2";
+const kPUSHHTTP2DB_DB_VERSION = 5; // Change this if the IndexedDB format changes
+const kPUSHHTTP2DB_STORE_NAME = "pushHttp2";
+
+/**
+ * A proxy between the PushService and connections listening for incoming push
+ * messages. The PushService can silence messages from the connections by
+ * setting PushSubscriptionListener._pushService to null. This is required
+ * because it can happen that there is an outstanding push message that will
+ * be send on OnStopRequest but the PushService may not be interested in these.
+ * It's easier to stop listening than to have checks at specific points.
+ */
+var PushSubscriptionListener = function(pushService, uri) {
+ console.debug("PushSubscriptionListener()");
+ this._pushService = pushService;
+ this.uri = uri;
+};
+
+PushSubscriptionListener.prototype = {
+
+ QueryInterface: function (aIID) {
+ if (aIID.equals(Ci.nsIHttpPushListener) ||
+ aIID.equals(Ci.nsIStreamListener)) {
+ return this;
+ }
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ },
+
+ getInterface: function(aIID) {
+ return this.QueryInterface(aIID);
+ },
+
+ onStartRequest: function(aRequest, aContext) {
+ console.debug("PushSubscriptionListener: onStartRequest()");
+ // We do not do anything here.
+ },
+
+ onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
+ console.debug("PushSubscriptionListener: onDataAvailable()");
+ // Nobody should send data, but just to be sure, otherwise necko will
+ // complain.
+ if (aCount === 0) {
+ return;
+ }
+
+ let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+
+ inputStream.init(aStream);
+ var data = inputStream.read(aCount);
+ },
+
+ onStopRequest: function(aRequest, aContext, aStatusCode) {
+ console.debug("PushSubscriptionListener: onStopRequest()");
+ if (!this._pushService) {
+ return;
+ }
+
+ this._pushService.connOnStop(aRequest,
+ Components.isSuccessCode(aStatusCode),
+ this.uri);
+ },
+
+ onPush: function(associatedChannel, pushChannel) {
+ console.debug("PushSubscriptionListener: onPush()");
+ var pushChannelListener = new PushChannelListener(this);
+ pushChannel.asyncOpen2(pushChannelListener);
+ },
+
+ disconnect: function() {
+ this._pushService = null;
+ }
+};
+
+/**
+ * The listener for pushed messages. The message data is collected in
+ * OnDataAvailable and send to the app in OnStopRequest.
+ */
+var PushChannelListener = function(pushSubscriptionListener) {
+ console.debug("PushChannelListener()");
+ this._mainListener = pushSubscriptionListener;
+ this._message = [];
+ this._ackUri = null;
+};
+
+PushChannelListener.prototype = {
+
+ onStartRequest: function(aRequest, aContext) {
+ this._ackUri = aRequest.URI.spec;
+ },
+
+ onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
+ console.debug("PushChannelListener: onDataAvailable()");
+
+ if (aCount === 0) {
+ return;
+ }
+
+ let inputStream = Cc["@mozilla.org/binaryinputstream;1"]
+ .createInstance(Ci.nsIBinaryInputStream);
+
+ inputStream.setInputStream(aStream);
+ let chunk = new ArrayBuffer(aCount);
+ inputStream.readArrayBuffer(aCount, chunk);
+ this._message.push(chunk);
+ },
+
+ onStopRequest: function(aRequest, aContext, aStatusCode) {
+ console.debug("PushChannelListener: onStopRequest()", "status code",
+ aStatusCode);
+ if (Components.isSuccessCode(aStatusCode) &&
+ this._mainListener &&
+ this._mainListener._pushService) {
+ let headers = {
+ encryption_key: getHeaderField(aRequest, "Encryption-Key"),
+ crypto_key: getHeaderField(aRequest, "Crypto-Key"),
+ encryption: getHeaderField(aRequest, "Encryption"),
+ encoding: getHeaderField(aRequest, "Content-Encoding"),
+ };
+ let msg = concatArray(this._message);
+
+ this._mainListener._pushService._pushChannelOnStop(this._mainListener.uri,
+ this._ackUri,
+ headers,
+ msg);
+ }
+ }
+};
+
+function getHeaderField(aRequest, name) {
+ try {
+ return aRequest.getRequestHeader(name);
+ } catch(e) {
+ // getRequestHeader can throw.
+ return null;
+ }
+}
+
+var PushServiceDelete = function(resolve, reject) {
+ this._resolve = resolve;
+ this._reject = reject;
+};
+
+PushServiceDelete.prototype = {
+
+ onStartRequest: function(aRequest, aContext) {},
+
+ onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
+ // Nobody should send data, but just to be sure, otherwise necko will
+ // complain.
+ if (aCount === 0) {
+ return;
+ }
+
+ let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+
+ inputStream.init(aStream);
+ var data = inputStream.read(aCount);
+ },
+
+ onStopRequest: function(aRequest, aContext, aStatusCode) {
+
+ if (Components.isSuccessCode(aStatusCode)) {
+ this._resolve();
+ } else {
+ this._reject(new Error("Error removing subscription: " + aStatusCode));
+ }
+ }
+};
+
+var SubscriptionListener = function(aSubInfo, aResolve, aReject,
+ aServerURI, aPushServiceHttp2) {
+ console.debug("SubscriptionListener()");
+ this._subInfo = aSubInfo;
+ this._resolve = aResolve;
+ this._reject = aReject;
+ this._data = '';
+ this._serverURI = aServerURI;
+ this._service = aPushServiceHttp2;
+ this._ctime = Date.now();
+ this._retryTimeoutID = null;
+};
+
+SubscriptionListener.prototype = {
+
+ onStartRequest: function(aRequest, aContext) {},
+
+ onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
+ console.debug("SubscriptionListener: onDataAvailable()");
+
+ // We do not expect any data, but necko will complain if we do not consume
+ // it.
+ if (aCount === 0) {
+ return;
+ }
+
+ let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+
+ inputStream.init(aStream);
+ this._data.concat(inputStream.read(aCount));
+ },
+
+ onStopRequest: function(aRequest, aContext, aStatus) {
+ console.debug("SubscriptionListener: onStopRequest()");
+
+ // Check if pushService is still active.
+ if (!this._service.hasmainPushService()) {
+ this._reject(new Error("Push service unavailable"));
+ return;
+ }
+
+ if (!Components.isSuccessCode(aStatus)) {
+ this._reject(new Error("Error listening for messages: " + aStatus));
+ return;
+ }
+
+ var statusCode = aRequest.QueryInterface(Ci.nsIHttpChannel).responseStatus;
+
+ if (Math.floor(statusCode / 100) == 5) {
+ if (this._subInfo.retries < prefs.get("http2.maxRetries")) {
+ this._subInfo.retries++;
+ var retryAfter = retryAfterParser(aRequest);
+ this._retryTimeoutID = setTimeout(_ =>
+ {
+ this._reject(
+ {
+ retry: true,
+ subInfo: this._subInfo
+ });
+ this._service.removeListenerPendingRetry(this);
+ this._retryTimeoutID = null;
+ }, retryAfter);
+ this._service.addListenerPendingRetry(this);
+ } else {
+ this._reject(new Error("Unexpected server response: " + statusCode));
+ }
+ return;
+ } else if (statusCode != 201) {
+ this._reject(new Error("Unexpected server response: " + statusCode));
+ return;
+ }
+
+ var subscriptionUri;
+ try {
+ subscriptionUri = aRequest.getResponseHeader("location");
+ } catch (err) {
+ this._reject(new Error("Missing Location header"));
+ return;
+ }
+
+ console.debug("onStopRequest: subscriptionUri", subscriptionUri);
+
+ var linkList;
+ try {
+ linkList = aRequest.getResponseHeader("link");
+ } catch (err) {
+ this._reject(new Error("Missing Link header"));
+ return;
+ }
+
+ var linkParserResult;
+ try {
+ linkParserResult = linkParser(linkList, this._serverURI);
+ } catch (e) {
+ this._reject(e);
+ return;
+ }
+
+ if (!subscriptionUri) {
+ this._reject(new Error("Invalid Location header"));
+ return;
+ }
+ try {
+ let uriTry = Services.io.newURI(subscriptionUri, null, null);
+ } catch (e) {
+ console.error("onStopRequest: Invalid subscription URI",
+ subscriptionUri);
+ this._reject(new Error("Invalid subscription endpoint: " +
+ subscriptionUri));
+ return;
+ }
+
+ let reply = new PushRecordHttp2({
+ subscriptionUri: subscriptionUri,
+ pushEndpoint: linkParserResult.pushEndpoint,
+ pushReceiptEndpoint: linkParserResult.pushReceiptEndpoint,
+ scope: this._subInfo.record.scope,
+ originAttributes: this._subInfo.record.originAttributes,
+ systemRecord: this._subInfo.record.systemRecord,
+ appServerKey: this._subInfo.record.appServerKey,
+ ctime: Date.now(),
+ });
+
+ Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_HTTP2_TIME").add(Date.now() - this._ctime);
+ this._resolve(reply);
+ },
+
+ abortRetry: function() {
+ if (this._retryTimeoutID != null) {
+ clearTimeout(this._retryTimeoutID);
+ this._retryTimeoutID = null;
+ } else {
+ console.debug("SubscriptionListener.abortRetry: aborting non-existent retry?");
+ }
+ },
+};
+
+function retryAfterParser(aRequest) {
+ var retryAfter = 0;
+ try {
+ var retryField = aRequest.getResponseHeader("retry-after");
+ if (isNaN(retryField)) {
+ retryAfter = Date.parse(retryField) - (new Date().getTime());
+ } else {
+ retryAfter = parseInt(retryField, 10) * 1000;
+ }
+ retryAfter = (retryAfter > 0) ? retryAfter : 0;
+ } catch(e) {}
+
+ return retryAfter;
+}
+
+function linkParser(linkHeader, serverURI) {
+
+ var linkList = linkHeader.split(',');
+ if ((linkList.length < 1)) {
+ throw new Error("Invalid Link header");
+ }
+
+ var pushEndpoint;
+ var pushReceiptEndpoint;
+
+ linkList.forEach(link => {
+ var linkElems = link.split(';');
+
+ if (linkElems.length == 2) {
+ if (linkElems[1].trim() === 'rel="urn:ietf:params:push"') {
+ pushEndpoint = linkElems[0].substring(linkElems[0].indexOf('<') + 1,
+ linkElems[0].indexOf('>'));
+
+ } else if (linkElems[1].trim() === 'rel="urn:ietf:params:push:receipt"') {
+ pushReceiptEndpoint = linkElems[0].substring(linkElems[0].indexOf('<') + 1,
+ linkElems[0].indexOf('>'));
+ }
+ }
+ });
+
+ console.debug("linkParser: pushEndpoint", pushEndpoint);
+ console.debug("linkParser: pushReceiptEndpoint", pushReceiptEndpoint);
+ // Missing pushReceiptEndpoint is allowed.
+ if (!pushEndpoint) {
+ throw new Error("Missing push endpoint");
+ }
+
+ var pushURI = Services.io.newURI(pushEndpoint, null, serverURI);
+ var pushReceiptURI;
+ if (pushReceiptEndpoint) {
+ pushReceiptURI = Services.io.newURI(pushReceiptEndpoint, null,
+ serverURI);
+ }
+
+ return {
+ pushEndpoint: pushURI.spec,
+ pushReceiptEndpoint: (pushReceiptURI) ? pushReceiptURI.spec : "",
+ };
+}
+
+/**
+ * The implementation of the WebPush.
+ */
+this.PushServiceHttp2 = {
+ _mainPushService: null,
+ _serverURI: null,
+
+ // Keep information about all connections, e.g. the channel, listener...
+ _conns: {},
+ _started: false,
+
+ // Set of SubscriptionListeners that are pending a subscription retry attempt.
+ _listenersPendingRetry: new Set(),
+
+ newPushDB: function() {
+ return new PushDB(kPUSHHTTP2DB_DB_NAME,
+ kPUSHHTTP2DB_DB_VERSION,
+ kPUSHHTTP2DB_STORE_NAME,
+ "subscriptionUri",
+ PushRecordHttp2);
+ },
+
+ hasmainPushService: function() {
+ return this._mainPushService !== null;
+ },
+
+ validServerURI: function(serverURI) {
+ if (serverURI.scheme == "http") {
+ return !!prefs.get("testing.allowInsecureServerURL");
+ }
+ return serverURI.scheme == "https";
+ },
+
+ connect: function(subscriptions) {
+ this.startConnections(subscriptions);
+ },
+
+ isConnected: function() {
+ return this._mainPushService != null;
+ },
+
+ disconnect: function() {
+ this._shutdownConnections(false);
+ },
+
+ _makeChannel: function(aUri) {
+ var chan = NetUtil.newChannel({uri: aUri, loadUsingSystemPrincipal: true})
+ .QueryInterface(Ci.nsIHttpChannel);
+
+ var loadGroup = Cc["@mozilla.org/network/load-group;1"]
+ .createInstance(Ci.nsILoadGroup);
+ chan.loadGroup = loadGroup;
+ return chan;
+ },
+
+ /**
+ * Subscribe new resource.
+ */
+ register: function(aRecord) {
+ console.debug("subscribeResource()");
+
+ return this._subscribeResourceInternal({
+ record: aRecord,
+ retries: 0
+ })
+ .then(result =>
+ PushCrypto.generateKeys()
+ .then(([publicKey, privateKey]) => {
+ result.p256dhPublicKey = publicKey;
+ result.p256dhPrivateKey = privateKey;
+ result.authenticationSecret = PushCrypto.generateAuthenticationSecret();
+ this._conns[result.subscriptionUri] = {
+ channel: null,
+ listener: null,
+ countUnableToConnect: 0,
+ lastStartListening: 0,
+ retryTimerID: 0,
+ };
+ this._listenForMsgs(result.subscriptionUri);
+ return result;
+ })
+ );
+ },
+
+ _subscribeResourceInternal: function(aSubInfo) {
+ console.debug("subscribeResourceInternal()");
+
+ return new Promise((resolve, reject) => {
+ var listener = new SubscriptionListener(aSubInfo,
+ resolve,
+ reject,
+ this._serverURI,
+ this);
+
+ var chan = this._makeChannel(this._serverURI.spec);
+ chan.requestMethod = "POST";
+ chan.asyncOpen2(listener);
+ })
+ .catch(err => {
+ if ("retry" in err) {
+ return this._subscribeResourceInternal(err.subInfo);
+ } else {
+ throw err;
+ }
+ })
+ },
+
+ _deleteResource: function(aUri) {
+
+ return new Promise((resolve,reject) => {
+ var chan = this._makeChannel(aUri);
+ chan.requestMethod = "DELETE";
+ chan.asyncOpen2(new PushServiceDelete(resolve, reject));
+ });
+ },
+
+ /**
+ * Unsubscribe the resource with a subscription uri aSubscriptionUri.
+ * We can't do anything about it if it fails, so we don't listen for response.
+ */
+ _unsubscribeResource: function(aSubscriptionUri) {
+ console.debug("unsubscribeResource()");
+
+ return this._deleteResource(aSubscriptionUri);
+ },
+
+ /**
+ * Start listening for messages.
+ */
+ _listenForMsgs: function(aSubscriptionUri) {
+ console.debug("listenForMsgs()", aSubscriptionUri);
+ if (!this._conns[aSubscriptionUri]) {
+ console.warn("listenForMsgs: We do not have this subscription",
+ aSubscriptionUri);
+ return;
+ }
+
+ var chan = this._makeChannel(aSubscriptionUri);
+ var conn = {};
+ conn.channel = chan;
+ var listener = new PushSubscriptionListener(this, aSubscriptionUri);
+ conn.listener = listener;
+
+ chan.notificationCallbacks = listener;
+
+ try {
+ chan.asyncOpen2(listener);
+ } catch (e) {
+ console.error("listenForMsgs: Error connecting to push server.",
+ "asyncOpen2 failed", e);
+ conn.listener.disconnect();
+ chan.cancel(Cr.NS_ERROR_ABORT);
+ this._retryAfterBackoff(aSubscriptionUri, -1);
+ return;
+ }
+
+ this._conns[aSubscriptionUri].lastStartListening = Date.now();
+ this._conns[aSubscriptionUri].channel = conn.channel;
+ this._conns[aSubscriptionUri].listener = conn.listener;
+
+ },
+
+ _ackMsgRecv: function(aAckUri) {
+ console.debug("ackMsgRecv()", aAckUri);
+ return this._deleteResource(aAckUri);
+ },
+
+ init: function(aOptions, aMainPushService, aServerURL) {
+ console.debug("init()");
+ this._mainPushService = aMainPushService;
+ this._serverURI = aServerURL;
+
+ return Promise.resolve();
+ },
+
+ _retryAfterBackoff: function(aSubscriptionUri, retryAfter) {
+ console.debug("retryAfterBackoff()");
+
+ var resetRetryCount = prefs.get("http2.reset_retry_count_after_ms");
+ // If it was running for some time, reset retry counter.
+ if ((Date.now() - this._conns[aSubscriptionUri].lastStartListening) >
+ resetRetryCount) {
+ this._conns[aSubscriptionUri].countUnableToConnect = 0;
+ }
+
+ let maxRetries = prefs.get("http2.maxRetries");
+ if (this._conns[aSubscriptionUri].countUnableToConnect >= maxRetries) {
+ this._shutdownSubscription(aSubscriptionUri);
+ this._resubscribe(aSubscriptionUri);
+ return;
+ }
+
+ if (retryAfter !== -1) {
+ // This is a 5xx response.
+ this._conns[aSubscriptionUri].countUnableToConnect++;
+ this._conns[aSubscriptionUri].retryTimerID =
+ setTimeout(_ => this._listenForMsgs(aSubscriptionUri), retryAfter);
+ return;
+ }
+
+ retryAfter = prefs.get("http2.retryInterval") *
+ Math.pow(2, this._conns[aSubscriptionUri].countUnableToConnect);
+
+ retryAfter = retryAfter * (0.8 + Math.random() * 0.4); // add +/-20%.
+
+ this._conns[aSubscriptionUri].countUnableToConnect++;
+ this._conns[aSubscriptionUri].retryTimerID =
+ setTimeout(_ => this._listenForMsgs(aSubscriptionUri), retryAfter);
+
+ console.debug("retryAfterBackoff: Retry in", retryAfter);
+ },
+
+ // Close connections.
+ _shutdownConnections: function(deleteInfo) {
+ console.debug("shutdownConnections()");
+
+ for (let subscriptionUri in this._conns) {
+ if (this._conns[subscriptionUri]) {
+ if (this._conns[subscriptionUri].listener) {
+ this._conns[subscriptionUri].listener._pushService = null;
+ }
+
+ if (this._conns[subscriptionUri].channel) {
+ try {
+ this._conns[subscriptionUri].channel.cancel(Cr.NS_ERROR_ABORT);
+ } catch (e) {}
+ }
+ this._conns[subscriptionUri].listener = null;
+ this._conns[subscriptionUri].channel = null;
+
+ if (this._conns[subscriptionUri].retryTimerID > 0) {
+ clearTimeout(this._conns[subscriptionUri].retryTimerID);
+ }
+
+ if (deleteInfo) {
+ delete this._conns[subscriptionUri];
+ }
+ }
+ }
+ },
+
+ // Start listening if subscriptions present.
+ startConnections: function(aSubscriptions) {
+ console.debug("startConnections()", aSubscriptions.length);
+
+ for (let i = 0; i < aSubscriptions.length; i++) {
+ let record = aSubscriptions[i];
+ this._mainPushService.ensureCrypto(record).then(record => {
+ this._startSingleConnection(record);
+ }, error => {
+ console.error("startConnections: Error updating record",
+ record.keyID, error);
+ });
+ }
+ },
+
+ _startSingleConnection: function(record) {
+ console.debug("_startSingleConnection()");
+ if (typeof this._conns[record.subscriptionUri] != "object") {
+ this._conns[record.subscriptionUri] = {channel: null,
+ listener: null,
+ countUnableToConnect: 0,
+ retryTimerID: 0};
+ }
+ if (!this._conns[record.subscriptionUri].conn) {
+ this._listenForMsgs(record.subscriptionUri);
+ }
+ },
+
+ // Close connection and notify apps that subscription are gone.
+ _shutdownSubscription: function(aSubscriptionUri) {
+ console.debug("shutdownSubscriptions()");
+
+ if (typeof this._conns[aSubscriptionUri] == "object") {
+ if (this._conns[aSubscriptionUri].listener) {
+ this._conns[aSubscriptionUri].listener._pushService = null;
+ }
+
+ if (this._conns[aSubscriptionUri].channel) {
+ try {
+ this._conns[aSubscriptionUri].channel.cancel(Cr.NS_ERROR_ABORT);
+ } catch (e) {}
+ }
+ delete this._conns[aSubscriptionUri];
+ }
+ },
+
+ uninit: function() {
+ console.debug("uninit()");
+ this._abortPendingSubscriptionRetries();
+ this._shutdownConnections(true);
+ this._mainPushService = null;
+ },
+
+ _abortPendingSubscriptionRetries: function() {
+ this._listenersPendingRetry.forEach((listener) => listener.abortRetry());
+ this._listenersPendingRetry.clear();
+ },
+
+ unregister: function(aRecord) {
+ this._shutdownSubscription(aRecord.subscriptionUri);
+ return this._unsubscribeResource(aRecord.subscriptionUri);
+ },
+
+ reportDeliveryError: function(messageID, reason) {
+ console.warn("reportDeliveryError: Ignoring message delivery error",
+ messageID, reason);
+ },
+
+ /** Push server has deleted subscription.
+ * Re-subscribe - if it succeeds send update db record and send
+ * pushsubscriptionchange,
+ * - on error delete record and send pushsubscriptionchange
+ * TODO: maybe pushsubscriptionerror will be included.
+ */
+ _resubscribe: function(aSubscriptionUri) {
+ this._mainPushService.getByKeyID(aSubscriptionUri)
+ .then(record => this.register(record)
+ .then(recordNew => {
+ if (this._mainPushService) {
+ this._mainPushService
+ .updateRegistrationAndNotifyApp(aSubscriptionUri, recordNew)
+ .catch(Cu.reportError);
+ }
+ }, error => {
+ if (this._mainPushService) {
+ this._mainPushService
+ .dropRegistrationAndNotifyApp(aSubscriptionUri)
+ .catch(Cu.reportError);
+ }
+ })
+ );
+ },
+
+ connOnStop: function(aRequest, aSuccess,
+ aSubscriptionUri) {
+ console.debug("connOnStop() succeeded", aSuccess);
+
+ var conn = this._conns[aSubscriptionUri];
+ if (!conn) {
+ // there is no connection description that means that we closed
+ // connection, so do nothing. But we should have already deleted
+ // the listener.
+ return;
+ }
+
+ conn.channel = null;
+ conn.listener = null;
+
+ if (!aSuccess) {
+ this._retryAfterBackoff(aSubscriptionUri, -1);
+
+ } else if (Math.floor(aRequest.responseStatus / 100) == 5) {
+ var retryAfter = retryAfterParser(aRequest);
+ this._retryAfterBackoff(aSubscriptionUri, retryAfter);
+
+ } else if (Math.floor(aRequest.responseStatus / 100) == 4) {
+ this._shutdownSubscription(aSubscriptionUri);
+ this._resubscribe(aSubscriptionUri);
+ } else if (Math.floor(aRequest.responseStatus / 100) == 2) { // This should be 204
+ setTimeout(_ => this._listenForMsgs(aSubscriptionUri), 0);
+ } else {
+ this._retryAfterBackoff(aSubscriptionUri, -1);
+ }
+ },
+
+ addListenerPendingRetry: function(aListener) {
+ this._listenersPendingRetry.add(aListener);
+ },
+
+ removeListenerPendingRetry: function(aListener) {
+ if (!this._listenersPendingRetry.remove(aListener)) {
+ console.debug("removeListenerPendingRetry: listener not in list?");
+ }
+ },
+
+ _pushChannelOnStop: function(aUri, aAckUri, aHeaders, aMessage) {
+ console.debug("pushChannelOnStop()");
+
+ this._mainPushService.receivedPushMessage(
+ aUri, "", aHeaders, aMessage, record => {
+ // Always update the stored record.
+ return record;
+ }
+ )
+ .then(_ => this._ackMsgRecv(aAckUri))
+ .catch(err => {
+ console.error("pushChannelOnStop: Error receiving message",
+ err);
+ });
+ },
+};
+
+function PushRecordHttp2(record) {
+ PushRecord.call(this, record);
+ this.subscriptionUri = record.subscriptionUri;
+ this.pushReceiptEndpoint = record.pushReceiptEndpoint;
+}
+
+PushRecordHttp2.prototype = Object.create(PushRecord.prototype, {
+ keyID: {
+ get() {
+ return this.subscriptionUri;
+ },
+ },
+});
+
+PushRecordHttp2.prototype.toSubscription = function() {
+ let subscription = PushRecord.prototype.toSubscription.call(this);
+ subscription.pushReceiptEndpoint = this.pushReceiptEndpoint;
+ return subscription;
+};
diff --git a/dom/push/PushServiceWebSocket.jsm b/dom/push/PushServiceWebSocket.jsm
new file mode 100644
index 0000000000..46b12b8f07
--- /dev/null
+++ b/dom/push/PushServiceWebSocket.jsm
@@ -0,0 +1,1145 @@
+/* jshint moz: true, esnext: true */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+const Cr = Components.results;
+
+Cu.import("resource://gre/modules/AppConstants.jsm");
+Cu.import("resource://gre/modules/Preferences.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Timer.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
+const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
+const {PushCrypto} = Cu.import("resource://gre/modules/PushCrypto.jsm");
+
+const kPUSHWSDB_DB_NAME = "pushapi";
+const kPUSHWSDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
+const kPUSHWSDB_STORE_NAME = "pushapi";
+
+// WebSocket close code sent by the server to indicate that the client should
+// not automatically reconnect.
+const kBACKOFF_WS_STATUS_CODE = 4774;
+
+// Maps ack statuses, unsubscribe reasons, and delivery error reasons to codes
+// included in request payloads.
+const kACK_STATUS_TO_CODE = {
+ [Ci.nsIPushErrorReporter.ACK_DELIVERED]: 100,
+ [Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR]: 101,
+ [Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED]: 102,
+};
+
+const kUNREGISTER_REASON_TO_CODE = {
+ [Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL]: 200,
+ [Ci.nsIPushErrorReporter.UNSUBSCRIBE_QUOTA_EXCEEDED]: 201,
+ [Ci.nsIPushErrorReporter.UNSUBSCRIBE_PERMISSION_REVOKED]: 202,
+};
+
+const kDELIVERY_REASON_TO_CODE = {
+ [Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION]: 301,
+ [Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION]: 302,
+ [Ci.nsIPushErrorReporter.DELIVERY_INTERNAL_ERROR]: 303,
+};
+
+const prefs = new Preferences("dom.push.");
+
+this.EXPORTED_SYMBOLS = ["PushServiceWebSocket"];
+
+XPCOMUtils.defineLazyGetter(this, "console", () => {
+ let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushServiceWebSocket",
+ });
+});
+
+/**
+ * A proxy between the PushService and the WebSocket. The listener is used so
+ * that the PushService can silence messages from the WebSocket by setting
+ * PushWebSocketListener._pushService to null. This is required because
+ * a WebSocket can continue to send messages or errors after it has been
+ * closed but the PushService may not be interested in these. It's easier to
+ * stop listening than to have checks at specific points.
+ */
+var PushWebSocketListener = function(pushService) {
+ this._pushService = pushService;
+};
+
+PushWebSocketListener.prototype = {
+ onStart: function(context) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnStart(context);
+ },
+
+ onStop: function(context, statusCode) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnStop(context, statusCode);
+ },
+
+ onAcknowledge: function(context, size) {
+ // EMPTY
+ },
+
+ onBinaryMessageAvailable: function(context, message) {
+ // EMPTY
+ },
+
+ onMessageAvailable: function(context, message) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnMessageAvailable(context, message);
+ },
+
+ onServerClose: function(context, aStatusCode, aReason) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnServerClose(context, aStatusCode, aReason);
+ }
+};
+
+// websocket states
+// websocket is off
+const STATE_SHUT_DOWN = 0;
+// Websocket has been opened on client side, waiting for successful open.
+// (_wsOnStart)
+const STATE_WAITING_FOR_WS_START = 1;
+// Websocket opened, hello sent, waiting for server reply (_handleHelloReply).
+const STATE_WAITING_FOR_HELLO = 2;
+// Websocket operational, handshake completed, begin protocol messaging.
+const STATE_READY = 3;
+
+this.PushServiceWebSocket = {
+ _mainPushService: null,
+ _serverURI: null,
+
+ newPushDB: function() {
+ return new PushDB(kPUSHWSDB_DB_NAME,
+ kPUSHWSDB_DB_VERSION,
+ kPUSHWSDB_STORE_NAME,
+ "channelID",
+ PushRecordWebSocket);
+ },
+
+ disconnect: function() {
+ this._shutdownWS();
+ },
+
+ observe: function(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed" && aData == "dom.push.userAgentID") {
+ this._onUAIDChanged();
+ } else if (aTopic == "timer-callback") {
+ this._onTimerFired(aSubject);
+ }
+ },
+
+ /**
+ * Handles a UAID change. Unlike reconnects, we cancel all pending requests
+ * after disconnecting. Existing subscriptions stored in IndexedDB will be
+ * dropped on reconnect.
+ */
+ _onUAIDChanged() {
+ console.debug("onUAIDChanged()");
+
+ this._shutdownWS();
+ this._startBackoffTimer();
+ },
+
+ /** Handles a ping, backoff, or request timeout timer event. */
+ _onTimerFired(timer) {
+ console.debug("onTimerFired()");
+
+ if (timer == this._pingTimer) {
+ this._sendPing();
+ return;
+ }
+
+ if (timer == this._backoffTimer) {
+ console.debug("onTimerFired: Reconnecting after backoff");
+ this._beginWSSetup();
+ return;
+ }
+
+ if (timer == this._requestTimeoutTimer) {
+ this._timeOutRequests();
+ return;
+ }
+ },
+
+ /**
+ * Sends a ping to the server. Bypasses the request queue, but starts the
+ * request timeout timer. If the socket is already closed, or the server
+ * does not respond within the timeout, the client will reconnect.
+ */
+ _sendPing() {
+ console.debug("sendPing()");
+
+ this._startRequestTimeoutTimer();
+ try {
+ this._wsSendMessage({});
+ this._lastPingTime = Date.now();
+ } catch (e) {
+ console.debug("sendPing: Error sending ping", e);
+ this._reconnect();
+ }
+ },
+
+ /** Times out any pending requests. */
+ _timeOutRequests() {
+ console.debug("timeOutRequests()");
+
+ if (!this._hasPendingRequests()) {
+ // Cancel the repeating timer and exit early if we aren't waiting for
+ // pongs or requests.
+ this._requestTimeoutTimer.cancel();
+ return;
+ }
+
+ let now = Date.now();
+
+ // Set to true if at least one request timed out, or we're still waiting
+ // for a pong after the request timeout.
+ let requestTimedOut = false;
+
+ if (this._lastPingTime > 0 &&
+ now - this._lastPingTime > this._requestTimeout) {
+
+ console.debug("timeOutRequests: Did not receive pong in time");
+ requestTimedOut = true;
+
+ } else {
+ for (let [key, request] of this._pendingRequests) {
+ let duration = now - request.ctime;
+ // If any of the registration requests time out, all the ones after it
+ // also made to fail, since we are going to be disconnecting the
+ // socket.
+ requestTimedOut |= duration > this._requestTimeout;
+ if (requestTimedOut) {
+ request.reject(new Error("Request timed out: " + key));
+ this._pendingRequests.delete(key);
+ }
+ }
+ }
+
+ // The most likely reason for a pong or registration request timing out is
+ // that the socket has disconnected. Best to reconnect.
+ if (requestTimedOut) {
+ this._reconnect();
+ }
+ },
+
+ validServerURI: function(serverURI) {
+ if (serverURI.scheme == "ws") {
+ return !!prefs.get("testing.allowInsecureServerURL");
+ }
+ return serverURI.scheme == "wss";
+ },
+
+ get _UAID() {
+ return prefs.get("userAgentID");
+ },
+
+ set _UAID(newID) {
+ if (typeof(newID) !== "string") {
+ console.warn("Got invalid, non-string UAID", newID,
+ "Not updating userAgentID");
+ return;
+ }
+ console.debug("New _UAID", newID);
+ prefs.set("userAgentID", newID);
+ },
+
+ _ws: null,
+ _pendingRequests: new Map(),
+ _currentState: STATE_SHUT_DOWN,
+ _requestTimeout: 0,
+ _requestTimeoutTimer: null,
+ _retryFailCount: 0,
+
+ /**
+ * According to the WS spec, servers should immediately close the underlying
+ * TCP connection after they close a WebSocket. This causes wsOnStop to be
+ * called with error NS_BASE_STREAM_CLOSED. Since the client has to keep the
+ * WebSocket up, it should try to reconnect. But if the server closes the
+ * WebSocket because it wants the client to back off, then the client
+ * shouldn't re-establish the connection. If the server sends the backoff
+ * close code, this field will be set to true in wsOnServerClose. It is
+ * checked in wsOnStop.
+ */
+ _skipReconnect: false,
+
+ /** Indicates whether the server supports Web Push-style message delivery. */
+ _dataEnabled: false,
+
+ /**
+ * The last time the client sent a ping to the server. If non-zero, keeps the
+ * request timeout timer active. Reset to zero when the server responds with
+ * a pong or pending messages.
+ */
+ _lastPingTime: 0,
+
+ /**
+ * A one-shot timer used to ping the server, to avoid timing out idle
+ * connections. Reset to the ping interval on each incoming message.
+ */
+ _pingTimer: null,
+
+ /** A one-shot timer fired after the reconnect backoff period. */
+ _backoffTimer: null,
+
+ /**
+ * Sends a message to the Push Server through an open websocket.
+ * typeof(msg) shall be an object
+ */
+ _wsSendMessage: function(msg) {
+ if (!this._ws) {
+ console.warn("wsSendMessage: No WebSocket initialized.",
+ "Cannot send a message");
+ return;
+ }
+ msg = JSON.stringify(msg);
+ console.debug("wsSendMessage: Sending message", msg);
+ this._ws.sendMsg(msg);
+ },
+
+ init: function(options, mainPushService, serverURI) {
+ console.debug("init()");
+
+ this._mainPushService = mainPushService;
+ this._serverURI = serverURI;
+
+ // Override the default WebSocket factory function. The returned object
+ // must be null or satisfy the nsIWebSocketChannel interface. Used by
+ // the tests to provide a mock WebSocket implementation.
+ if (options.makeWebSocket) {
+ this._makeWebSocket = options.makeWebSocket;
+ }
+
+ this._requestTimeout = prefs.get("requestTimeout");
+
+ return Promise.resolve();
+ },
+
+ _reconnect: function () {
+ console.debug("reconnect()");
+ this._shutdownWS(false);
+ this._startBackoffTimer();
+ },
+
+ _shutdownWS: function(shouldCancelPending = true) {
+ console.debug("shutdownWS()");
+
+ if (this._currentState == STATE_READY) {
+ prefs.ignore("userAgentID", this);
+ }
+
+ this._currentState = STATE_SHUT_DOWN;
+ this._skipReconnect = false;
+
+ if (this._wsListener) {
+ this._wsListener._pushService = null;
+ }
+ try {
+ this._ws.close(0, null);
+ } catch (e) {}
+ this._ws = null;
+
+ this._lastPingTime = 0;
+
+ if (this._pingTimer) {
+ this._pingTimer.cancel();
+ }
+
+ if (shouldCancelPending) {
+ this._cancelPendingRequests();
+ }
+
+ if (this._notifyRequestQueue) {
+ this._notifyRequestQueue();
+ this._notifyRequestQueue = null;
+ }
+ },
+
+ uninit: function() {
+ // All pending requests (ideally none) are dropped at this point. We
+ // shouldn't have any applications performing registration/unregistration
+ // or receiving notifications.
+ this._shutdownWS();
+
+ if (this._backoffTimer) {
+ this._backoffTimer.cancel();
+ }
+ if (this._requestTimeoutTimer) {
+ this._requestTimeoutTimer.cancel();
+ }
+
+ this._mainPushService = null;
+
+ this._dataEnabled = false;
+ },
+
+ /**
+ * How retries work: If the WS is closed due to a socket error,
+ * _startBackoffTimer() is called. The retry timer is started and when
+ * it times out, beginWSSetup() is called again.
+ *
+ * If we are in the middle of a timeout (i.e. waiting), but
+ * a register/unregister is called, we don't want to wait around anymore.
+ * _sendRequest will automatically call beginWSSetup(), which will cancel the
+ * timer. In addition since the state will have changed, even if a pending
+ * timer event comes in (because the timer fired the event before it was
+ * cancelled), so the connection won't be reset.
+ */
+ _startBackoffTimer() {
+ console.debug("startBackoffTimer()");
+
+ // Calculate new timeout, but cap it to pingInterval.
+ let retryTimeout = prefs.get("retryBaseInterval") *
+ Math.pow(2, this._retryFailCount);
+ retryTimeout = Math.min(retryTimeout, prefs.get("pingInterval"));
+
+ this._retryFailCount++;
+
+ console.debug("startBackoffTimer: Retry in", retryTimeout,
+ "Try number", this._retryFailCount);
+
+ if (!this._backoffTimer) {
+ this._backoffTimer = Cc["@mozilla.org/timer;1"]
+ .createInstance(Ci.nsITimer);
+ }
+ this._backoffTimer.init(this, retryTimeout, Ci.nsITimer.TYPE_ONE_SHOT);
+ },
+
+ /** Indicates whether we're waiting for pongs or requests. */
+ _hasPendingRequests() {
+ return this._lastPingTime > 0 || this._pendingRequests.size > 0;
+ },
+
+ /**
+ * Starts the request timeout timer unless we're already waiting for a pong
+ * or register request.
+ */
+ _startRequestTimeoutTimer() {
+ if (this._hasPendingRequests()) {
+ return;
+ }
+ if (!this._requestTimeoutTimer) {
+ this._requestTimeoutTimer = Cc["@mozilla.org/timer;1"]
+ .createInstance(Ci.nsITimer);
+ }
+ this._requestTimeoutTimer.init(this,
+ this._requestTimeout,
+ Ci.nsITimer.TYPE_REPEATING_SLACK);
+ },
+
+ /** Starts or resets the ping timer. */
+ _startPingTimer() {
+ if (!this._pingTimer) {
+ this._pingTimer = Cc["@mozilla.org/timer;1"]
+ .createInstance(Ci.nsITimer);
+ }
+ this._pingTimer.init(this, prefs.get("pingInterval"),
+ Ci.nsITimer.TYPE_ONE_SHOT);
+ },
+
+ _makeWebSocket: function(uri) {
+ if (!prefs.get("connection.enabled")) {
+ console.warn("makeWebSocket: connection.enabled is not set to true.",
+ "Aborting.");
+ return null;
+ }
+ if (Services.io.offline) {
+ console.warn("makeWebSocket: Network is offline.");
+ return null;
+ }
+ let contractId = uri.scheme == "ws" ?
+ "@mozilla.org/network/protocol;1?name=ws" :
+ "@mozilla.org/network/protocol;1?name=wss";
+ let socket = Cc[contractId].createInstance(Ci.nsIWebSocketChannel);
+
+ socket.initLoadInfo(null, // aLoadingNode
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null, // aTriggeringPrincipal
+ Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_DATA_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_WEBSOCKET);
+
+ return socket;
+ },
+
+ _beginWSSetup: function() {
+ console.debug("beginWSSetup()");
+ if (this._currentState != STATE_SHUT_DOWN) {
+ console.error("_beginWSSetup: Not in shutdown state! Current state",
+ this._currentState);
+ return;
+ }
+
+ // Stop any pending reconnects scheduled for the near future.
+ if (this._backoffTimer) {
+ this._backoffTimer.cancel();
+ }
+
+ let uri = this._serverURI;
+ if (!uri) {
+ return;
+ }
+ let socket = this._makeWebSocket(uri);
+ if (!socket) {
+ return;
+ }
+ this._ws = socket.QueryInterface(Ci.nsIWebSocketChannel);
+
+ console.debug("beginWSSetup: Connecting to", uri.spec);
+ this._wsListener = new PushWebSocketListener(this);
+ this._ws.protocol = "push-notification";
+
+ try {
+ // Grab a wakelock before we open the socket to ensure we don't go to
+ // sleep before connection the is opened.
+ this._ws.asyncOpen(uri, uri.spec, 0, this._wsListener, null);
+ this._currentState = STATE_WAITING_FOR_WS_START;
+ } catch(e) {
+ console.error("beginWSSetup: Error opening websocket.",
+ "asyncOpen failed", e);
+ this._reconnect();
+ }
+ },
+
+ connect: function(records) {
+ console.debug("connect()");
+ // Check to see if we need to do anything.
+ if (records.length > 0) {
+ this._beginWSSetup();
+ }
+ },
+
+ isConnected: function() {
+ return !!this._ws;
+ },
+
+ /**
+ * Protocol handler invoked by server message.
+ */
+ _handleHelloReply: function(reply) {
+ console.debug("handleHelloReply()");
+ if (this._currentState != STATE_WAITING_FOR_HELLO) {
+ console.error("handleHelloReply: Unexpected state", this._currentState,
+ "(expected STATE_WAITING_FOR_HELLO)");
+ this._shutdownWS();
+ return;
+ }
+
+ if (typeof reply.uaid !== "string") {
+ console.error("handleHelloReply: Received invalid UAID", reply.uaid);
+ this._shutdownWS();
+ return;
+ }
+
+ if (reply.uaid === "") {
+ console.error("handleHelloReply: Received empty UAID");
+ this._shutdownWS();
+ return;
+ }
+
+ // To avoid sticking extra large values sent by an evil server into prefs.
+ if (reply.uaid.length > 128) {
+ console.error("handleHelloReply: UAID received from server was too long",
+ reply.uaid);
+ this._shutdownWS();
+ return;
+ }
+
+ let sendRequests = () => {
+ if (this._notifyRequestQueue) {
+ this._notifyRequestQueue();
+ this._notifyRequestQueue = null;
+ }
+ this._sendPendingRequests();
+ };
+
+ function finishHandshake() {
+ this._UAID = reply.uaid;
+ this._currentState = STATE_READY;
+ prefs.observe("userAgentID", this);
+
+ this._dataEnabled = !!reply.use_webpush;
+ if (this._dataEnabled) {
+ this._mainPushService.getAllUnexpired().then(records =>
+ Promise.all(records.map(record =>
+ this._mainPushService.ensureCrypto(record).catch(error => {
+ console.error("finishHandshake: Error updating record",
+ record.keyID, error);
+ })
+ ))
+ ).then(sendRequests);
+ } else {
+ sendRequests();
+ }
+ }
+
+ // By this point we've got a UAID from the server that we are ready to
+ // accept.
+ //
+ // We unconditionally drop all existing registrations and notify service
+ // workers if we receive a new UAID. This ensures we expunge all stale
+ // registrations if the `userAgentID` pref is reset.
+ if (this._UAID != reply.uaid) {
+ console.debug("handleHelloReply: Received new UAID");
+
+ this._mainPushService.dropUnexpiredRegistrations()
+ .then(finishHandshake.bind(this));
+
+ return;
+ }
+
+ // otherwise we are good to go
+ finishHandshake.bind(this)();
+ },
+
+ /**
+ * Protocol handler invoked by server message.
+ */
+ _handleRegisterReply: function(reply) {
+ console.debug("handleRegisterReply()");
+
+ let tmp = this._takeRequestForReply(reply);
+ if (!tmp) {
+ return;
+ }
+
+ if (reply.status == 200) {
+ try {
+ Services.io.newURI(reply.pushEndpoint, null, null);
+ }
+ catch (e) {
+ tmp.reject(new Error("Invalid push endpoint: " + reply.pushEndpoint));
+ return;
+ }
+
+ let record = new PushRecordWebSocket({
+ channelID: reply.channelID,
+ pushEndpoint: reply.pushEndpoint,
+ scope: tmp.record.scope,
+ originAttributes: tmp.record.originAttributes,
+ version: null,
+ systemRecord: tmp.record.systemRecord,
+ appServerKey: tmp.record.appServerKey,
+ ctime: Date.now(),
+ });
+ Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_WS_TIME").add(Date.now() - tmp.ctime);
+ tmp.resolve(record);
+ } else {
+ console.error("handleRegisterReply: Unexpected server response", reply);
+ tmp.reject(new Error("Wrong status code for register reply: " +
+ reply.status));
+ }
+ },
+
+ _handleUnregisterReply(reply) {
+ console.debug("handleUnregisterReply()");
+
+ let request = this._takeRequestForReply(reply);
+ if (!request) {
+ return;
+ }
+
+ let success = reply.status === 200;
+ request.resolve(success);
+ },
+
+ _handleDataUpdate: function(update) {
+ let promise;
+ if (typeof update.channelID != "string") {
+ console.warn("handleDataUpdate: Discarding update without channel ID",
+ update);
+ return;
+ }
+ function updateRecord(record) {
+ // Ignore messages that we've already processed. This can happen if the
+ // connection drops between notifying the service worker and acking the
+ // the message. In that case, the server will re-send the message on
+ // reconnect.
+ if (record.hasRecentMessageID(update.version)) {
+ console.warn("handleDataUpdate: Ignoring duplicate message",
+ update.version);
+ return null;
+ }
+ record.noteRecentMessageID(update.version);
+ return record;
+ }
+ if (typeof update.data != "string") {
+ promise = this._mainPushService.receivedPushMessage(
+ update.channelID,
+ update.version,
+ null,
+ null,
+ updateRecord
+ );
+ } else {
+ let message = ChromeUtils.base64URLDecode(update.data, {
+ // The Push server may append padding.
+ padding: "ignore",
+ });
+ promise = this._mainPushService.receivedPushMessage(
+ update.channelID,
+ update.version,
+ update.headers,
+ message,
+ updateRecord
+ );
+ }
+ promise.then(status => {
+ this._sendAck(update.channelID, update.version, status);
+ }, err => {
+ console.error("handleDataUpdate: Error delivering message", update, err);
+ this._sendAck(update.channelID, update.version,
+ Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR);
+ }).catch(err => {
+ console.error("handleDataUpdate: Error acknowledging message", update,
+ err);
+ });
+ },
+
+ /**
+ * Protocol handler invoked by server message.
+ */
+ _handleNotificationReply: function(reply) {
+ console.debug("handleNotificationReply()");
+ if (this._dataEnabled) {
+ this._handleDataUpdate(reply);
+ return;
+ }
+
+ if (typeof reply.updates !== 'object') {
+ console.warn("handleNotificationReply: Missing updates", reply.updates);
+ return;
+ }
+
+ console.debug("handleNotificationReply: Got updates", reply.updates);
+ for (let i = 0; i < reply.updates.length; i++) {
+ let update = reply.updates[i];
+ console.debug("handleNotificationReply: Handling update", update);
+ if (typeof update.channelID !== "string") {
+ console.debug("handleNotificationReply: Invalid update at index",
+ i, update);
+ continue;
+ }
+
+ if (update.version === undefined) {
+ console.debug("handleNotificationReply: Missing version", update);
+ continue;
+ }
+
+ let version = update.version;
+
+ if (typeof version === "string") {
+ version = parseInt(version, 10);
+ }
+
+ if (typeof version === "number" && version >= 0) {
+ // FIXME(nsm): this relies on app update notification being infallible!
+ // eventually fix this
+ this._receivedUpdate(update.channelID, version);
+ }
+ }
+ },
+
+ reportDeliveryError(messageID, reason) {
+ console.debug("reportDeliveryError()");
+ let code = kDELIVERY_REASON_TO_CODE[reason];
+ if (!code) {
+ throw new Error('Invalid delivery error reason');
+ }
+ let data = {messageType: 'nack',
+ version: messageID,
+ code: code};
+ this._queueRequest(data);
+ },
+
+ _sendAck(channelID, version, status) {
+ console.debug("sendAck()");
+ let code = kACK_STATUS_TO_CODE[status];
+ if (!code) {
+ throw new Error('Invalid ack status');
+ }
+ let data = {messageType: 'ack',
+ updates: [{channelID: channelID,
+ version: version,
+ code: code}]};
+ this._queueRequest(data);
+ },
+
+ _generateID: function() {
+ let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"]
+ .getService(Ci.nsIUUIDGenerator);
+ // generateUUID() gives a UUID surrounded by {...}, slice them off.
+ return uuidGenerator.generateUUID().toString().slice(1, -1);
+ },
+
+ register(record) {
+ console.debug("register() ", record);
+
+ let data = {channelID: this._generateID(),
+ messageType: "register"};
+
+ if (record.appServerKey) {
+ data.key = ChromeUtils.base64URLEncode(record.appServerKey, {
+ // The Push server requires padding.
+ pad: true,
+ });
+ }
+
+ return this._sendRequestForReply(record, data).then(record => {
+ if (!this._dataEnabled) {
+ return record;
+ }
+ return PushCrypto.generateKeys()
+ .then(([publicKey, privateKey]) => {
+ record.p256dhPublicKey = publicKey;
+ record.p256dhPrivateKey = privateKey;
+ record.authenticationSecret = PushCrypto.generateAuthenticationSecret();
+ return record;
+ });
+ });
+ },
+
+ unregister(record, reason) {
+ console.debug("unregister() ", record, reason);
+
+ return Promise.resolve().then(_ => {
+ let code = kUNREGISTER_REASON_TO_CODE[reason];
+ if (!code) {
+ throw new Error('Invalid unregister reason');
+ }
+ let data = {channelID: record.channelID,
+ messageType: "unregister",
+ code: code};
+
+ return this._sendRequestForReply(record, data);
+ });
+ },
+
+ _queueStart: Promise.resolve(),
+ _notifyRequestQueue: null,
+ _queue: null,
+ _enqueue: function(op) {
+ console.debug("enqueue()");
+ if (!this._queue) {
+ this._queue = this._queueStart;
+ }
+ this._queue = this._queue
+ .then(op)
+ .catch(_ => {});
+ },
+
+ /** Sends a request to the server. */
+ _send(data) {
+ if (this._currentState != STATE_READY) {
+ console.warn("send: Unexpected state; ignoring message",
+ this._currentState);
+ return;
+ }
+ if (!this._requestHasReply(data)) {
+ this._wsSendMessage(data);
+ return;
+ }
+ // If we're expecting a reply, check that we haven't cancelled the request.
+ let key = this._makePendingRequestKey(data);
+ if (!this._pendingRequests.has(key)) {
+ console.log("send: Request cancelled; ignoring message", key);
+ return;
+ }
+ this._wsSendMessage(data);
+ },
+
+ /** Indicates whether a request has a corresponding reply from the server. */
+ _requestHasReply(data) {
+ return data.messageType == "register" || data.messageType == "unregister";
+ },
+
+ /**
+ * Sends all pending requests that expect replies. Called after the connection
+ * is established and the handshake is complete.
+ */
+ _sendPendingRequests() {
+ this._enqueue(_ => {
+ for (let request of this._pendingRequests.values()) {
+ this._send(request.data);
+ }
+ });
+ },
+
+ /** Queues an outgoing request, establishing a connection if necessary. */
+ _queueRequest(data) {
+ console.debug("queueRequest()", data);
+
+ if (this._currentState == STATE_READY) {
+ // If we're ready, no need to queue; just send the request.
+ this._send(data);
+ return;
+ }
+
+ // Otherwise, we're still setting up. If we don't have a request queue,
+ // make one now.
+ if (!this._notifyRequestQueue) {
+ let promise = new Promise((resolve, reject) => {
+ this._notifyRequestQueue = resolve;
+ });
+ this._enqueue(_ => promise);
+ }
+
+ let isRequest = this._requestHasReply(data);
+ if (!isRequest) {
+ // Don't queue requests, since they're stored in `_pendingRequests`, and
+ // `_sendPendingRequests` will send them after reconnecting. Without this
+ // check, we'd send requests twice.
+ this._enqueue(_ => this._send(data));
+ }
+
+ if (!this._ws) {
+ // This will end up calling notifyRequestQueue().
+ this._beginWSSetup();
+ // If beginWSSetup does not succeed to make ws, notifyRequestQueue will
+ // not be call.
+ if (!this._ws && this._notifyRequestQueue) {
+ this._notifyRequestQueue();
+ this._notifyRequestQueue = null;
+ }
+ }
+ },
+
+ _receivedUpdate: function(aChannelID, aLatestVersion) {
+ console.debug("receivedUpdate: Updating", aChannelID, "->", aLatestVersion);
+
+ this._mainPushService.receivedPushMessage(aChannelID, "", null, null, record => {
+ if (record.version === null ||
+ record.version < aLatestVersion) {
+ console.debug("receivedUpdate: Version changed for", aChannelID,
+ aLatestVersion);
+ record.version = aLatestVersion;
+ return record;
+ }
+ console.debug("receivedUpdate: No significant version change for",
+ aChannelID, aLatestVersion);
+ return null;
+ }).then(status => {
+ this._sendAck(aChannelID, aLatestVersion, status);
+ }).catch(err => {
+ console.error("receivedUpdate: Error acknowledging message", aChannelID,
+ aLatestVersion, err);
+ });
+ },
+
+ // begin Push protocol handshake
+ _wsOnStart: function(context) {
+ console.debug("wsOnStart()");
+
+ if (this._currentState != STATE_WAITING_FOR_WS_START) {
+ console.error("wsOnStart: NOT in STATE_WAITING_FOR_WS_START. Current",
+ "state", this._currentState, "Skipping");
+ return;
+ }
+
+ let data = {
+ messageType: "hello",
+ use_webpush: true,
+ };
+
+ if (this._UAID) {
+ data.uaid = this._UAID;
+ }
+
+ this._wsSendMessage(data);
+ this._currentState = STATE_WAITING_FOR_HELLO;
+ },
+
+ /**
+ * This statusCode is not the websocket protocol status code, but the TCP
+ * connection close status code.
+ *
+ * If we do not explicitly call ws.close() then statusCode is always
+ * NS_BASE_STREAM_CLOSED, even on a successful close.
+ */
+ _wsOnStop: function(context, statusCode) {
+ console.debug("wsOnStop()");
+
+ if (statusCode != Cr.NS_OK && !this._skipReconnect) {
+ console.debug("wsOnStop: Reconnecting after socket error", statusCode);
+ this._reconnect();
+ return;
+ }
+
+ this._shutdownWS();
+ },
+
+ _wsOnMessageAvailable: function(context, message) {
+ console.debug("wsOnMessageAvailable()", message);
+
+ // Clearing the last ping time indicates we're no longer waiting for a pong.
+ this._lastPingTime = 0;
+
+ let reply;
+ try {
+ reply = JSON.parse(message);
+ } catch(e) {
+ console.warn("wsOnMessageAvailable: Invalid JSON", message, e);
+ return;
+ }
+
+ // If we receive a message, we know the connection succeeded. Reset the
+ // connection attempt and ping interval counters.
+ this._retryFailCount = 0;
+
+ let doNotHandle = false;
+ if ((message === '{}') ||
+ (reply.messageType === undefined) ||
+ (reply.messageType === "ping") ||
+ (typeof reply.messageType != "string")) {
+ console.debug("wsOnMessageAvailable: Pong received");
+ doNotHandle = true;
+ }
+
+ // Reset the ping timer. Note: This path is executed at every step of the
+ // handshake, so this timer does not need to be set explicitly at startup.
+ this._startPingTimer();
+
+ // If it is a ping, do not handle the message.
+ if (doNotHandle) {
+ return;
+ }
+
+ // A whitelist of protocol handlers. Add to these if new messages are added
+ // in the protocol.
+ let handlers = ["Hello", "Register", "Unregister", "Notification"];
+
+ // Build up the handler name to call from messageType.
+ // e.g. messageType == "register" -> _handleRegisterReply.
+ let handlerName = reply.messageType[0].toUpperCase() +
+ reply.messageType.slice(1).toLowerCase();
+
+ if (handlers.indexOf(handlerName) == -1) {
+ console.warn("wsOnMessageAvailable: No whitelisted handler", handlerName,
+ "for message", reply.messageType);
+ return;
+ }
+
+ let handler = "_handle" + handlerName + "Reply";
+
+ if (typeof this[handler] !== "function") {
+ console.warn("wsOnMessageAvailable: Handler", handler,
+ "whitelisted but not implemented");
+ return;
+ }
+
+ this[handler](reply);
+ },
+
+ /**
+ * The websocket should never be closed. Since we don't call ws.close(),
+ * _wsOnStop() receives error code NS_BASE_STREAM_CLOSED (see comment in that
+ * function), which calls reconnect and re-establishes the WebSocket
+ * connection.
+ *
+ * If the server requested that we back off, we won't reconnect until the
+ * next network state change event, or until we need to send a new register
+ * request.
+ */
+ _wsOnServerClose: function(context, aStatusCode, aReason) {
+ console.debug("wsOnServerClose()", aStatusCode, aReason);
+
+ if (aStatusCode == kBACKOFF_WS_STATUS_CODE) {
+ console.debug("wsOnServerClose: Skipping automatic reconnect");
+ this._skipReconnect = true;
+ }
+ },
+
+ /**
+ * Rejects all pending register requests with errors.
+ */
+ _cancelPendingRequests() {
+ for (let request of this._pendingRequests.values()) {
+ request.reject(new Error("Request aborted"));
+ }
+ this._pendingRequests.clear();
+ },
+
+ /** Creates a case-insensitive map key for a request that expects a reply. */
+ _makePendingRequestKey(data) {
+ return (data.messageType + "|" + data.channelID).toLowerCase();
+ },
+
+ /** Sends a request and waits for a reply from the server. */
+ _sendRequestForReply(record, data) {
+ return Promise.resolve().then(_ => {
+ // start the timer since we now have at least one request
+ this._startRequestTimeoutTimer();
+
+ let key = this._makePendingRequestKey(data);
+ if (!this._pendingRequests.has(key)) {
+ let request = {
+ data: data,
+ record: record,
+ ctime: Date.now(),
+ };
+ request.promise = new Promise((resolve, reject) => {
+ request.resolve = resolve;
+ request.reject = reject;
+ });
+ this._pendingRequests.set(key, request);
+ this._queueRequest(data);
+ }
+
+ return this._pendingRequests.get(key).promise;
+ });
+ },
+
+ /** Removes and returns a pending request for a server reply. */
+ _takeRequestForReply(reply) {
+ if (typeof reply.channelID !== "string") {
+ return null;
+ }
+ let key = this._makePendingRequestKey(reply);
+ let request = this._pendingRequests.get(key);
+ if (!request) {
+ return null;
+ }
+ this._pendingRequests.delete(key);
+ if (!this._hasPendingRequests()) {
+ this._requestTimeoutTimer.cancel();
+ }
+ return request;
+ },
+};
+
+function PushRecordWebSocket(record) {
+ PushRecord.call(this, record);
+ this.channelID = record.channelID;
+ this.version = record.version;
+}
+
+PushRecordWebSocket.prototype = Object.create(PushRecord.prototype, {
+ keyID: {
+ get() {
+ return this.channelID;
+ },
+ },
+});
+
+PushRecordWebSocket.prototype.toSubscription = function() {
+ let subscription = PushRecord.prototype.toSubscription.call(this);
+ subscription.version = this.version;
+ return subscription;
+};
diff --git a/dom/push/PushSubscription.cpp b/dom/push/PushSubscription.cpp
new file mode 100644
index 0000000000..bfe8b5dd97
--- /dev/null
+++ b/dom/push/PushSubscription.cpp
@@ -0,0 +1,398 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/dom/PushSubscription.h"
+
+#include "nsIPushService.h"
+#include "nsIScriptObjectPrincipal.h"
+
+#include "mozilla/Base64.h"
+#include "mozilla/Unused.h"
+
+#include "mozilla/dom/Promise.h"
+#include "mozilla/dom/PromiseWorkerProxy.h"
+#include "mozilla/dom/PushSubscriptionOptions.h"
+#include "mozilla/dom/PushUtil.h"
+#include "mozilla/dom/WorkerPrivate.h"
+#include "mozilla/dom/WorkerScope.h"
+#include "mozilla/dom/workers/Workers.h"
+
+namespace mozilla {
+namespace dom {
+
+using namespace workers;
+
+namespace {
+
+class UnsubscribeResultCallback final : public nsIUnsubscribeResultCallback
+{
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit UnsubscribeResultCallback(Promise* aPromise)
+ : mPromise(aPromise)
+ {
+ AssertIsOnMainThread();
+ }
+
+ NS_IMETHOD
+ OnUnsubscribe(nsresult aStatus, bool aSuccess) override
+ {
+ if (NS_SUCCEEDED(aStatus)) {
+ mPromise->MaybeResolve(aSuccess);
+ } else {
+ mPromise->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
+ }
+
+ return NS_OK;
+ }
+
+private:
+ ~UnsubscribeResultCallback()
+ {}
+
+ RefPtr<Promise> mPromise;
+};
+
+NS_IMPL_ISUPPORTS(UnsubscribeResultCallback, nsIUnsubscribeResultCallback)
+
+class UnsubscribeResultRunnable final : public WorkerRunnable
+{
+public:
+ UnsubscribeResultRunnable(WorkerPrivate* aWorkerPrivate,
+ already_AddRefed<PromiseWorkerProxy>&& aProxy,
+ nsresult aStatus,
+ bool aSuccess)
+ : WorkerRunnable(aWorkerPrivate)
+ , mProxy(Move(aProxy))
+ , mStatus(aStatus)
+ , mSuccess(aSuccess)
+ {
+ AssertIsOnMainThread();
+ }
+
+ bool
+ WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override
+ {
+ MOZ_ASSERT(aWorkerPrivate);
+ aWorkerPrivate->AssertIsOnWorkerThread();
+
+ RefPtr<Promise> promise = mProxy->WorkerPromise();
+ if (NS_SUCCEEDED(mStatus)) {
+ promise->MaybeResolve(mSuccess);
+ } else {
+ promise->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
+ }
+
+ mProxy->CleanUp();
+
+ return true;
+ }
+private:
+ ~UnsubscribeResultRunnable()
+ {}
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsresult mStatus;
+ bool mSuccess;
+};
+
+class WorkerUnsubscribeResultCallback final : public nsIUnsubscribeResultCallback
+{
+public:
+ NS_DECL_ISUPPORTS
+
+ explicit WorkerUnsubscribeResultCallback(PromiseWorkerProxy* aProxy)
+ : mProxy(aProxy)
+ {
+ AssertIsOnMainThread();
+ }
+
+ NS_IMETHOD
+ OnUnsubscribe(nsresult aStatus, bool aSuccess) override
+ {
+ AssertIsOnMainThread();
+ MOZ_ASSERT(mProxy, "OnUnsubscribe() called twice?");
+
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+
+ WorkerPrivate* worker = mProxy->GetWorkerPrivate();
+ RefPtr<UnsubscribeResultRunnable> r =
+ new UnsubscribeResultRunnable(worker, mProxy.forget(), aStatus, aSuccess);
+ MOZ_ALWAYS_TRUE(r->Dispatch());
+
+ return NS_OK;
+ }
+
+private:
+ ~WorkerUnsubscribeResultCallback()
+ {
+ }
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+};
+
+NS_IMPL_ISUPPORTS(WorkerUnsubscribeResultCallback, nsIUnsubscribeResultCallback)
+
+class UnsubscribeRunnable final : public Runnable
+{
+public:
+ UnsubscribeRunnable(PromiseWorkerProxy* aProxy,
+ const nsAString& aScope)
+ : mProxy(aProxy)
+ , mScope(aScope)
+ {
+ MOZ_ASSERT(aProxy);
+ MOZ_ASSERT(!aScope.IsEmpty());
+ }
+
+ NS_IMETHOD
+ Run() override
+ {
+ AssertIsOnMainThread();
+
+ nsCOMPtr<nsIPrincipal> principal;
+
+ {
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+ principal = mProxy->GetWorkerPrivate()->GetPrincipal();
+ }
+
+ MOZ_ASSERT(principal);
+
+ RefPtr<WorkerUnsubscribeResultCallback> callback =
+ new WorkerUnsubscribeResultCallback(mProxy);
+
+ nsCOMPtr<nsIPushService> service =
+ do_GetService("@mozilla.org/push/Service;1");
+ if (NS_WARN_IF(!service)) {
+ callback->OnUnsubscribe(NS_ERROR_FAILURE, false);
+ return NS_OK;
+ }
+
+ if (NS_WARN_IF(NS_FAILED(service->Unsubscribe(mScope, principal, callback)))) {
+ callback->OnUnsubscribe(NS_ERROR_FAILURE, false);
+ return NS_OK;
+ }
+
+ return NS_OK;
+ }
+
+private:
+ ~UnsubscribeRunnable()
+ {}
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsString mScope;
+};
+
+} // anonymous namespace
+
+PushSubscription::PushSubscription(nsIGlobalObject* aGlobal,
+ const nsAString& aEndpoint,
+ const nsAString& aScope,
+ nsTArray<uint8_t>&& aRawP256dhKey,
+ nsTArray<uint8_t>&& aAuthSecret,
+ nsTArray<uint8_t>&& aAppServerKey)
+ : mEndpoint(aEndpoint)
+ , mScope(aScope)
+ , mRawP256dhKey(Move(aRawP256dhKey))
+ , mAuthSecret(Move(aAuthSecret))
+{
+ if (NS_IsMainThread()) {
+ mGlobal = aGlobal;
+ } else {
+#ifdef DEBUG
+ // There's only one global on a worker, so we don't need to pass a global
+ // object to the constructor.
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+#endif
+ }
+ mOptions = new PushSubscriptionOptions(mGlobal, Move(aAppServerKey));
+}
+
+PushSubscription::~PushSubscription()
+{}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(PushSubscription, mGlobal, mOptions)
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushSubscription)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushSubscription)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushSubscription)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+JSObject*
+PushSubscription::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto)
+{
+ return PushSubscriptionBinding::Wrap(aCx, this, aGivenProto);
+}
+
+// static
+already_AddRefed<PushSubscription>
+PushSubscription::Constructor(GlobalObject& aGlobal,
+ const PushSubscriptionInit& aInitDict,
+ ErrorResult& aRv)
+{
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+
+ nsTArray<uint8_t> rawKey;
+ if (aInitDict.mP256dhKey.WasPassed() &&
+ !aInitDict.mP256dhKey.Value().IsNull() &&
+ !PushUtil::CopyArrayBufferToArray(aInitDict.mP256dhKey.Value().Value(),
+ rawKey)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+
+ nsTArray<uint8_t> authSecret;
+ if (aInitDict.mAuthSecret.WasPassed() &&
+ !aInitDict.mAuthSecret.Value().IsNull() &&
+ !PushUtil::CopyArrayBufferToArray(aInitDict.mAuthSecret.Value().Value(),
+ authSecret)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+
+ nsTArray<uint8_t> appServerKey;
+ if (aInitDict.mAppServerKey.WasPassed() &&
+ !aInitDict.mAppServerKey.Value().IsNull()) {
+ const OwningArrayBufferViewOrArrayBuffer& bufferSource =
+ aInitDict.mAppServerKey.Value().Value();
+ if (!PushUtil::CopyBufferSourceToArray(bufferSource, appServerKey)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+ }
+
+ RefPtr<PushSubscription> sub = new PushSubscription(global,
+ aInitDict.mEndpoint,
+ aInitDict.mScope,
+ Move(rawKey),
+ Move(authSecret),
+ Move(appServerKey));
+
+ return sub.forget();
+}
+
+already_AddRefed<Promise>
+PushSubscription::Unsubscribe(ErrorResult& aRv)
+{
+ if (!NS_IsMainThread()) {
+ RefPtr<Promise> p = UnsubscribeFromWorker(aRv);
+ return p.forget();
+ }
+
+ MOZ_ASSERT(mGlobal);
+
+ nsCOMPtr<nsIPushService> service =
+ do_GetService("@mozilla.org/push/Service;1");
+ if (NS_WARN_IF(!service)) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return nullptr;
+ }
+
+ nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(mGlobal);
+ if (!sop) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return nullptr;
+ }
+
+ RefPtr<Promise> p = Promise::Create(mGlobal, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<UnsubscribeResultCallback> callback =
+ new UnsubscribeResultCallback(p);
+ Unused << NS_WARN_IF(NS_FAILED(
+ service->Unsubscribe(mScope, sop->GetPrincipal(), callback)));
+
+ return p.forget();
+}
+
+void
+PushSubscription::GetKey(JSContext* aCx,
+ PushEncryptionKeyName aType,
+ JS::MutableHandle<JSObject*> aKey,
+ ErrorResult& aRv)
+{
+ if (aType == PushEncryptionKeyName::P256dh) {
+ PushUtil::CopyArrayToArrayBuffer(aCx, mRawP256dhKey, aKey, aRv);
+ } else if (aType == PushEncryptionKeyName::Auth) {
+ PushUtil::CopyArrayToArrayBuffer(aCx, mAuthSecret, aKey, aRv);
+ } else {
+ aKey.set(nullptr);
+ }
+}
+
+void
+PushSubscription::ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv)
+{
+ aJSON.mEndpoint.Construct();
+ aJSON.mEndpoint.Value() = mEndpoint;
+
+ aJSON.mKeys.mP256dh.Construct();
+ nsresult rv = Base64URLEncode(mRawP256dhKey.Length(),
+ mRawP256dhKey.Elements(),
+ Base64URLEncodePaddingPolicy::Omit,
+ aJSON.mKeys.mP256dh.Value());
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ aRv.Throw(rv);
+ return;
+ }
+
+ aJSON.mKeys.mAuth.Construct();
+ rv = Base64URLEncode(mAuthSecret.Length(), mAuthSecret.Elements(),
+ Base64URLEncodePaddingPolicy::Omit,
+ aJSON.mKeys.mAuth.Value());
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ aRv.Throw(rv);
+ return;
+ }
+}
+
+already_AddRefed<PushSubscriptionOptions>
+PushSubscription::Options()
+{
+ RefPtr<PushSubscriptionOptions> options = mOptions;
+ return options.forget();
+}
+
+already_AddRefed<Promise>
+PushSubscription::UnsubscribeFromWorker(ErrorResult& aRv)
+{
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+
+ nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
+ RefPtr<Promise> p = Promise::Create(global, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
+ if (!proxy) {
+ p->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
+ return p.forget();
+ }
+
+ RefPtr<UnsubscribeRunnable> r =
+ new UnsubscribeRunnable(proxy, mScope);
+ MOZ_ALWAYS_SUCCEEDS(NS_DispatchToMainThread(r));
+
+ return p.forget();
+}
+
+} // namespace dom
+} // namespace mozilla
diff --git a/dom/push/PushSubscription.h b/dom/push/PushSubscription.h
new file mode 100644
index 0000000000..fd9a4a5243
--- /dev/null
+++ b/dom/push/PushSubscription.h
@@ -0,0 +1,99 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_PushSubscription_h
+#define mozilla_dom_PushSubscription_h
+
+#include "jsapi.h"
+#include "nsCOMPtr.h"
+#include "nsWrapperCache.h"
+#include "nsContentUtils.h" // Required for nsContentUtils::PushEnabled
+
+#include "mozilla/AlreadyAddRefed.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/RefPtr.h"
+
+#include "mozilla/dom/BindingDeclarations.h"
+#include "mozilla/dom/PushSubscriptionBinding.h"
+#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
+#include "mozilla/dom/TypedArray.h"
+
+class nsIGlobalObject;
+
+namespace mozilla {
+namespace dom {
+
+namespace workers {
+class WorkerPrivate;
+}
+
+class Promise;
+
+class PushSubscription final : public nsISupports
+ , public nsWrapperCache
+{
+public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushSubscription)
+
+ PushSubscription(nsIGlobalObject* aGlobal,
+ const nsAString& aEndpoint,
+ const nsAString& aScope,
+ nsTArray<uint8_t>&& aP256dhKey,
+ nsTArray<uint8_t>&& aAuthSecret,
+ nsTArray<uint8_t>&& aAppServerKey);
+
+ JSObject*
+ WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
+
+ nsIGlobalObject*
+ GetParentObject() const
+ {
+ return mGlobal;
+ }
+
+ void
+ GetEndpoint(nsAString& aEndpoint) const
+ {
+ aEndpoint = mEndpoint;
+ }
+
+ void
+ GetKey(JSContext* cx,
+ PushEncryptionKeyName aType,
+ JS::MutableHandle<JSObject*> aKey,
+ ErrorResult& aRv);
+
+ static already_AddRefed<PushSubscription>
+ Constructor(GlobalObject& aGlobal,
+ const PushSubscriptionInit& aInitDict,
+ ErrorResult& aRv);
+
+ already_AddRefed<Promise>
+ Unsubscribe(ErrorResult& aRv);
+
+ void
+ ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv);
+
+ already_AddRefed<PushSubscriptionOptions>
+ Options();
+
+private:
+ ~PushSubscription();
+
+ already_AddRefed<Promise>
+ UnsubscribeFromWorker(ErrorResult& aRv);
+
+ nsString mEndpoint;
+ nsString mScope;
+ nsTArray<uint8_t> mRawP256dhKey;
+ nsTArray<uint8_t> mAuthSecret;
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+ RefPtr<PushSubscriptionOptions> mOptions;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushSubscription_h
diff --git a/dom/push/PushSubscriptionOptions.cpp b/dom/push/PushSubscriptionOptions.cpp
new file mode 100644
index 0000000000..bc4fead1e4
--- /dev/null
+++ b/dom/push/PushSubscriptionOptions.cpp
@@ -0,0 +1,79 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/dom/PushSubscriptionOptions.h"
+
+#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
+#include "mozilla/HoldDropJSObjects.h"
+
+namespace mozilla {
+namespace dom {
+
+PushSubscriptionOptions::PushSubscriptionOptions(nsIGlobalObject* aGlobal,
+ nsTArray<uint8_t>&& aRawAppServerKey)
+ : mGlobal(aGlobal)
+ , mRawAppServerKey(Move(aRawAppServerKey))
+ , mAppServerKey(nullptr)
+{
+ // There's only one global on a worker, so we don't need to pass a global
+ // object to the constructor.
+ MOZ_ASSERT_IF(NS_IsMainThread(), mGlobal);
+ mozilla::HoldJSObjects(this);
+}
+
+PushSubscriptionOptions::~PushSubscriptionOptions()
+{
+ mAppServerKey = nullptr;
+ mozilla::DropJSObjects(this);
+}
+
+NS_IMPL_CYCLE_COLLECTION_CLASS(PushSubscriptionOptions)
+NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(PushSubscriptionOptions)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK(mGlobal)
+ NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
+ tmp->mAppServerKey = nullptr;
+NS_IMPL_CYCLE_COLLECTION_UNLINK_END
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(PushSubscriptionOptions)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mGlobal)
+ NS_IMPL_CYCLE_COLLECTION_TRAVERSE_SCRIPT_OBJECTS
+NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
+NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(PushSubscriptionOptions)
+ NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER
+ NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mAppServerKey)
+NS_IMPL_CYCLE_COLLECTION_TRACE_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushSubscriptionOptions)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushSubscriptionOptions)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushSubscriptionOptions)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+JSObject*
+PushSubscriptionOptions::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto)
+{
+ return PushSubscriptionOptionsBinding::Wrap(aCx, this, aGivenProto);
+}
+
+void
+PushSubscriptionOptions::GetApplicationServerKey(JSContext* aCx,
+ JS::MutableHandle<JSObject*> aKey,
+ ErrorResult& aRv)
+{
+ if (!mRawAppServerKey.IsEmpty() && !mAppServerKey) {
+ JS::Rooted<JSObject*> appServerKey(aCx);
+ PushUtil::CopyArrayToArrayBuffer(aCx, mRawAppServerKey, &appServerKey, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ MOZ_ASSERT(appServerKey);
+ mAppServerKey = appServerKey;
+ }
+ aKey.set(mAppServerKey);
+}
+
+} // namespace dom
+} // namespace mozilla
diff --git a/dom/push/PushSubscriptionOptions.h b/dom/push/PushSubscriptionOptions.h
new file mode 100644
index 0000000000..1df8ebe7a1
--- /dev/null
+++ b/dom/push/PushSubscriptionOptions.h
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_PushSubscriptionOptions_h
+#define mozilla_dom_PushSubscriptionOptions_h
+
+#include "nsCycleCollectionParticipant.h"
+#include "nsContentUtils.h" // Required for nsContentUtils::PushEnabled
+#include "nsTArray.h"
+#include "nsWrapperCache.h"
+
+class nsIGlobalObject;
+
+namespace mozilla {
+
+class ErrorResult;
+
+namespace dom {
+
+class PushSubscriptionOptions final : public nsISupports
+ , public nsWrapperCache
+{
+public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushSubscriptionOptions)
+
+ PushSubscriptionOptions(nsIGlobalObject* aGlobal,
+ nsTArray<uint8_t>&& aRawAppServerKey);
+
+ nsIGlobalObject*
+ GetParentObject() const
+ {
+ return mGlobal;
+ }
+
+ JSObject*
+ WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
+
+ void
+ GetApplicationServerKey(JSContext* aCx,
+ JS::MutableHandle<JSObject*> aKey,
+ ErrorResult& aRv);
+
+private:
+ ~PushSubscriptionOptions();
+
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+ nsTArray<uint8_t> mRawAppServerKey;
+ JS::Heap<JSObject*> mAppServerKey;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushSubscriptionOptions_h
diff --git a/dom/push/PushUtil.cpp b/dom/push/PushUtil.cpp
new file mode 100644
index 0000000000..408b62048d
--- /dev/null
+++ b/dom/push/PushUtil.cpp
@@ -0,0 +1,64 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#include "mozilla/dom/PushUtil.h"
+
+namespace mozilla {
+namespace dom {
+
+/* static */ bool
+PushUtil::CopyArrayBufferToArray(const ArrayBuffer& aBuffer,
+ nsTArray<uint8_t>& aArray)
+{
+ MOZ_ASSERT(aArray.IsEmpty());
+ aBuffer.ComputeLengthAndData();
+ return aArray.SetCapacity(aBuffer.Length(), fallible) &&
+ aArray.InsertElementsAt(0, aBuffer.Data(), aBuffer.Length(), fallible);
+}
+
+/* static */ bool
+PushUtil::CopyArrayBufferViewToArray(const ArrayBufferView& aView,
+ nsTArray<uint8_t>& aArray)
+{
+ MOZ_ASSERT(aArray.IsEmpty());
+ aView.ComputeLengthAndData();
+ return aArray.SetCapacity(aView.Length(), fallible) &&
+ aArray.InsertElementsAt(0, aView.Data(), aView.Length(), fallible);
+}
+
+/* static */ bool
+PushUtil::CopyBufferSourceToArray(
+ const OwningArrayBufferViewOrArrayBuffer& aSource, nsTArray<uint8_t>& aArray)
+{
+ if (aSource.IsArrayBuffer()) {
+ return CopyArrayBufferToArray(aSource.GetAsArrayBuffer(), aArray);
+ }
+ if (aSource.IsArrayBufferView()) {
+ return CopyArrayBufferViewToArray(aSource.GetAsArrayBufferView(), aArray);
+ }
+ MOZ_CRASH("Uninitialized union: expected buffer or view");
+}
+
+/* static */ void
+PushUtil::CopyArrayToArrayBuffer(JSContext* aCx,
+ const nsTArray<uint8_t>& aArray,
+ JS::MutableHandle<JSObject*> aValue,
+ ErrorResult& aRv)
+{
+ if (aArray.IsEmpty()) {
+ aValue.set(nullptr);
+ return;
+ }
+ JS::Rooted<JSObject*> buffer(aCx, ArrayBuffer::Create(aCx,
+ aArray.Length(),
+ aArray.Elements()));
+ if (NS_WARN_IF(!buffer)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return;
+ }
+ aValue.set(buffer);
+}
+
+} // namespace dom
+} // namespace mozilla
diff --git a/dom/push/PushUtil.h b/dom/push/PushUtil.h
new file mode 100644
index 0000000000..548ae23491
--- /dev/null
+++ b/dom/push/PushUtil.h
@@ -0,0 +1,43 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+#ifndef mozilla_dom_PushUtil_h
+#define mozilla_dom_PushUtil_h
+
+#include "nsTArray.h"
+
+#include "mozilla/dom/TypedArray.h"
+
+namespace mozilla {
+namespace dom {
+
+class OwningArrayBufferViewOrArrayBuffer;
+
+class PushUtil final
+{
+private:
+ PushUtil() = delete;
+
+public:
+ static bool
+ CopyArrayBufferToArray(const ArrayBuffer& aBuffer,
+ nsTArray<uint8_t>& aArray);
+
+ static bool
+ CopyArrayBufferViewToArray(const ArrayBufferView& aView,
+ nsTArray<uint8_t>& aArray);
+
+ static bool
+ CopyBufferSourceToArray(const OwningArrayBufferViewOrArrayBuffer& aSource,
+ nsTArray<uint8_t>& aArray);
+
+ static void
+ CopyArrayToArrayBuffer(JSContext* aCx, const nsTArray<uint8_t>& aArray,
+ JS::MutableHandle<JSObject*> aValue, ErrorResult& aRv);
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushUtil_h
diff --git a/dom/push/moz.build b/dom/push/moz.build
new file mode 100644
index 0000000000..b960991610
--- /dev/null
+++ b/dom/push/moz.build
@@ -0,0 +1,65 @@
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_COMPONENTS += [
+ 'Push.js',
+ 'Push.manifest',
+ 'PushComponents.js',
+]
+
+EXTRA_JS_MODULES += [
+ 'PushCrypto.jsm',
+ 'PushDB.jsm',
+ 'PushRecord.jsm',
+ 'PushService.jsm',
+]
+
+if CONFIG['MOZ_BUILD_APP'] != 'mobile/android':
+ # Everything but Fennec.
+ EXTRA_JS_MODULES += [
+ 'PushServiceHttp2.jsm',
+ 'PushServiceWebSocket.jsm',
+ ]
+else:
+ # Fennec only.
+ EXTRA_JS_MODULES += [
+ 'PushServiceAndroidGCM.jsm',
+ ]
+
+MOCHITEST_MANIFESTS += [
+ 'test/mochitest.ini',
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ 'test/xpcshell/xpcshell.ini',
+]
+
+EXPORTS.mozilla.dom += [
+ 'PushManager.h',
+ 'PushNotifier.h',
+ 'PushSubscription.h',
+ 'PushSubscriptionOptions.h',
+ 'PushUtil.h',
+]
+
+UNIFIED_SOURCES += [
+ 'PushManager.cpp',
+ 'PushNotifier.cpp',
+ 'PushSubscription.cpp',
+ 'PushSubscriptionOptions.cpp',
+ 'PushUtil.cpp',
+]
+
+TEST_DIRS += ['test/xpcshell']
+
+include('/ipc/chromium/chromium-config.mozbuild')
+
+LOCAL_INCLUDES += [
+ '../base',
+ '../ipc',
+ '../workers',
+]
+
+FINAL_LIBRARY = 'xul'
diff --git a/dom/push/test/error_worker.js b/dom/push/test/error_worker.js
new file mode 100644
index 0000000000..a50f838045
--- /dev/null
+++ b/dom/push/test/error_worker.js
@@ -0,0 +1,10 @@
+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..46e713f4e5
--- /dev/null
+++ b/dom/push/test/lifetime_worker.js
@@ -0,0 +1,85 @@
+var state = "from_scope";
+var resolvePromiseCallback;
+
+onfetch = function(event) {
+ if (event.request.url.indexOf("lifetime_frame.html") >= 0) {
+ event.respondWith(new Response("iframe_lifetime"));
+ return;
+ }
+
+ var currentState = state;
+ event.waitUntil(
+ clients.matchAll()
+ .then(clients => {
+ clients.forEach(client => {
+ client.postMessage({type: "fetch", state: currentState});
+ });
+ })
+ );
+
+ if (event.request.url.indexOf("update") >= 0) {
+ state = "update";
+ } else if (event.request.url.indexOf("wait") >= 0) {
+ 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.indexOf("release") >= 0) {
+ state = "release";
+ resolvePromise();
+ }
+}
+
+function resolvePromise() {
+ if (resolvePromiseCallback === undefined || resolvePromiseCallback == null) {
+ dump("ERROR: wait promise was not set.\n");
+ return;
+ }
+ resolvePromiseCallback();
+ resolvePromiseCallback = null;
+}
+
+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});
+}
+
+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"
+ clients.matchAll().then(function(client) {
+ if (client.length == 0) {
+ dump("ERROR: no clients to send the response to.\n");
+ }
+
+ client[0].postMessage({type: "push", state: 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..adb1c39d74
--- /dev/null
+++ b/dom/push/test/mochitest.ini
@@ -0,0 +1,24 @@
+[DEFAULT]
+skip-if = os == "android"
+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]
+[test_register_key.html]
+[test_multiple_register.html]
+[test_multiple_register_during_service_activation.html]
+[test_unregister.html]
+[test_multiple_register_different_scope.html]
+[test_subscription_change.html]
+[test_data.html]
+[test_try_registering_offline_disabled.html]
+[test_serviceworker_lifetime.html]
+[test_error_reporting.html]
diff --git a/dom/push/test/mockpushserviceparent.js b/dom/push/test/mockpushserviceparent.js
new file mode 100644
index 0000000000..78cf246ee8
--- /dev/null
+++ b/dom/push/test/mockpushserviceparent.js
@@ -0,0 +1,168 @@
+"use strict";
+
+const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+/**
+ * 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: XPCOMUtils.generateQI([
+ Ci.nsISupports,
+ Ci.nsIWebSocketChannel
+ ]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, 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 > 0) {
+ 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: name,
+ id: id,
+ params: params,
+ });
+ });
+ },
+
+ 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: messageId,
+ reason: reason,
+ });
+ },
+};
+
+addMessageListener("service-replace", function () {
+ pushService.service = MockService;
+});
+
+addMessageListener("service-restore", function () {
+ pushService.service = null;
+});
+
+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..8de873fce9
--- /dev/null
+++ b/dom/push/test/test_data.html
@@ -0,0 +1,218 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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">
+ 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(function* start() {
+ yield setupPrefsAndMockSocket(mockSocket);
+ yield setPushPermission(true);
+
+ var url = "worker.js" + "?" + (Math.random());
+ registration = yield navigator.serviceWorker.register(url, {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(function* createControlledIFrame() {
+ controlledFrame = yield injectControlledFrame();
+ });
+
+ var pushSubscription;
+ add_task(function* subscribe() {
+ pushSubscription = yield registration.pushManager.subscribe();
+ });
+
+ 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;
+ }
+
+ add_task(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(function* comparePublicKey() {
+ var data = yield 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"));
+ ok(authSecret.length, 16, "Auth secret should be 16 octets");
+ isDeeply(
+ authSecret,
+ new Uint8Array(data.auth),
+ "Mismatched auth secret"
+ );
+ });
+
+ var version = 0;
+ function sendEncryptedMsg(pushSubscription, message) {
+ return webPushEncrypt(pushSubscription, message)
+ .then((encryptedData) => {
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: 'notification',
+ version: version++,
+ channelID: channelID,
+ data: encryptedData.data,
+ headers: {
+ encryption: encryptedData.encryption,
+ encryption_key: encryptedData.encryption_key,
+ encoding: encryptedData.encoding
+ }
+ }));
+ });
+ }
+
+ function waitForMessage(pushSubscription, message) {
+ return Promise.all([
+ controlledFrame.waitOnWorkerMessage("finished"),
+ sendEncryptedMsg(pushSubscription, message),
+ ]).then(([message]) => message);
+ }
+
+ add_task(function* sendPushMessageFromPage() {
+ var typedArray = new Uint8Array([226, 130, 40, 240, 40, 140, 188]);
+ var json = { hello: "world" };
+
+ var message = yield waitForMessage(pushSubscription, "Text message from page");
+ is(message.data.text, "Text message from page", "Wrong text message data");
+
+ message = yield waitForMessage(
+ pushSubscription,
+ typedArray
+ );
+ isDeeply(new Uint8Array(message.data.arrayBuffer), typedArray,
+ "Wrong array buffer message data");
+
+ message = yield 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 = yield 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 = yield 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 = yield 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: channelID
+ }));
+
+ var message = yield finishedPromise;
+ ok(!message.data, "Should exclude data for blank messages");
+ });
+
+ add_task(function* unsubscribe() {
+ controlledFrame.remove();
+ yield pushSubscription.unsubscribe();
+ });
+
+ add_task(function* unregister() {
+ yield 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..9564cd5101
--- /dev/null
+++ b/dom/push/test/test_error_reporting.html
@@ -0,0 +1,130 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() {
+ yield setupPrefsAndReplaceService({
+ reportDeliveryError(messageId, reason) {
+ ok(reporters.has(messageId),
+ 'Unexpected error reported for message ' + messageId);
+ var resolve = reporters.get(messageId);
+ reporters.delete(messageId);
+ resolve(reason);
+ },
+ });
+ yield setPushPermission(true);
+
+ var url = "error_worker.js" + "?" + (Math.random());
+ registration = yield navigator.serviceWorker.register(url, {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(function* createControlledIFrame() {
+ controlledFrame = yield injectControlledFrame();
+ });
+
+ var idCounter = 1;
+ function waitForDeliveryError(request) {
+ return new Promise(resolve => {
+ var data = new TextEncoder("utf-8").encode(JSON.stringify(request));
+ var principal = SpecialPowers.wrap(document).nodePrincipal;
+
+ let messageId = "message-" + (idCounter++);
+ reporters.set(messageId, resolve);
+ pushNotifier.notifyPushWithData(registration.scope, principal, messageId,
+ data.length, data);
+ });
+ }
+
+ add_task(function* reportDeliveryErrors() {
+ var reason = yield waitForDeliveryError({ type: "exception" });
+ is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION,
+ "Should report uncaught exceptions");
+
+ reason = yield waitForDeliveryError({ type: "rejection" });
+ is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION,
+ "Should report unhandled rejections");
+ });
+
+ add_task(function* reportDecryptionError() {
+ var message = yield new Promise(resolve => {
+ var consoleService = SpecialPowers.Cc["@mozilla.org/consoleservice;1"]
+ .getService(SpecialPowers.Ci.nsIConsoleService);
+
+ var listener = SpecialPowers.wrapCallbackObject({
+ QueryInterface(iid) {
+ if (!SpecialPowers.Ci.nsISupports.equals(iid) &&
+ !SpecialPowers.Ci.nsIConsoleListener.equals(iid)) {
+ throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE;
+ }
+ return this;
+ },
+
+ observe(message) {
+ let error = message;
+ try {
+ error.QueryInterface(SpecialPowers.Ci.nsIScriptError);
+ } catch (error) {
+ return;
+ }
+ if (message.innerWindowID == controlledFrame.innerWindowId()) {
+ consoleService.unregisterListener(listener);
+ resolve(error);
+ }
+ },
+ });
+ consoleService.registerListener(listener);
+
+ var principal = SpecialPowers.wrap(document).nodePrincipal;
+ 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(function* unsubscribe() {
+ controlledFrame.remove();
+ });
+
+ add_task(function* unregister() {
+ yield 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..00857b5fdf
--- /dev/null
+++ b/dom/push/test/test_has_permissions.html
@@ -0,0 +1,84 @@
+<!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 type="text/javascript" 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");
+ }
+
+ 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 hasPermission(swr) {
+ var p = new Promise(function(res, rej) {
+ swr.pushManager.permissionState().then(
+ function(state) {
+ debug("state: " + state);
+ ok(["granted", "denied", "prompt"].indexOf(state) >= 0, "permissionState() returned a valid state.");
+ res(swr);
+ }, function(error) {
+ ok(false, "permissionState() failed.");
+ res(swr);
+ }
+ );
+ });
+ return p;
+ }
+
+ function runTest() {
+ start()
+ .then(hasPermission)
+ .then(unregister)
+ .catch(function(e) {
+ ok(false, "Some test failed with error " + e);
+ }).then(SimpleTest.finish);
+ }
+
+ SpecialPowers.addPermission("desktop-notification", false, document);
+ SpecialPowers.pushPrefEnv({"set": [
+ ["dom.push.enabled", true],
+ ["dom.push.connection.enabled", true],
+ ["dom.serviceWorkers.exemptFromPerDomainMax", true],
+ ["dom.serviceWorkers.enabled", true],
+ ["dom.serviceWorkers.testing.enabled", true]
+ ]}, runTest);
+ SimpleTest.waitForExplicitFinish();
+
+</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..1834882a5b
--- /dev/null
+++ b/dom/push/test/test_multiple_register.html
@@ -0,0 +1,130 @@
+<!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 type="text/javascript" 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");
+ }
+
+ 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: swr, pushSubscription: 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(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ 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..4540ba2474
--- /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 type="text/javascript" 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: 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(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ 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..98aef4a3a1
--- /dev/null
+++ b/dom/push/test/test_multiple_register_during_service_activation.html
@@ -0,0 +1,111 @@
+<!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 type="text/javascript" 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..1d78e34f84
--- /dev/null
+++ b/dom/push/test/test_permissions.html
@@ -0,0 +1,106 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() {
+ yield setupPrefsAndMockSocket(new MockWebSocket());
+ yield setPushPermission(false);
+
+ var url = "worker.js" + "?" + Math.random();
+ registration = yield navigator.serviceWorker.register(url, {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ add_task(function* denySubscribe() {
+ try {
+ yield 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(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 = yield sendRequestToWorker({
+ type: "denySubscribe",
+ });
+ ok(errorInfo.isDOMException, "Wrong exception type");
+ is(errorInfo.name, "NotAllowedError", "Wrong exception name");
+ });
+
+ add_task(function* getEndpoint() {
+ var pushSubscription = yield registration.pushManager.getSubscription();
+ is(pushSubscription, null, "getSubscription() should return null because no permission for push");
+ });
+
+ add_task(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) {
+ yield setPushPermission(test.action);
+ var state = yield registration.pushManager.permissionState();
+ is(state, test.state, JSON.stringify(test));
+ try {
+ yield SpecialPowers.pushPrefEnv({ set: [
+ ["dom.push.testing.ignorePermission", true]] });
+ state = yield registration.pushManager.permissionState();
+ is(state, "granted", `Should ignore ${
+ test.action} if the override pref is set`);
+ } finally {
+ yield SpecialPowers.flushPrefEnv();
+ }
+ }
+ });
+
+ add_task(function* unregister() {
+ var result = yield 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..70071b09d7
--- /dev/null
+++ b/dom/push/test/test_register.html
@@ -0,0 +1,109 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() {
+ yield setupPrefsAndMockSocket(mockSocket);
+ yield setPushPermission(true);
+
+ var url = "worker.js" + "?" + (Math.random());
+ registration = yield navigator.serviceWorker.register(url, {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(function* createControlledIFrame() {
+ controlledFrame = yield injectControlledFrame();
+ });
+
+ add_task(function* checkPermissionState() {
+ var state = yield registration.pushManager.permissionState();
+ is(state, "granted", "permissionState() should resolve to granted.");
+ });
+
+ var pushSubscription;
+ add_task(function* subscribe() {
+ pushSubscription = yield registration.pushManager.subscribe();
+ is(pushSubscription.options.applicationServerKey, null,
+ "Subscription should not have an app server key");
+ });
+
+ add_task(function* resubscribe() {
+ var data = yield sendRequestToWorker({
+ type: "resubscribe",
+ endpoint: pushSubscription.endpoint,
+ });
+ pushSubscription = yield registration.pushManager.getSubscription();
+ is(data.endpoint, pushSubscription.endpoint,
+ "Subscription endpoints should match after resubscribing in worker");
+ });
+
+ add_task(function* waitForPushNotification() {
+ var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
+
+ // Send a blank message.
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID: channelID
+ }));
+
+ yield finishedPromise;
+ });
+
+ add_task(function* unsubscribe() {
+ controlledFrame.remove();
+ yield pushSubscription.unsubscribe();
+ });
+
+ add_task(function* unregister() {
+ var result = yield 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..23ecf2f015
--- /dev/null
+++ b/dom/push/test/test_register_key.html
@@ -0,0 +1,210 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() {
+ yield setupPrefsAndReplaceService({
+ register(pageRecord) {
+ ok(pageRecord.appServerKey.length > 0,
+ "App server key should not be empty");
+ if (pageRecord.appServerKey.length != 65) {
+ throw { result:
+ SpecialPowers.Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR };
+ }
+ if (isTestingMismatchedKey) {
+ 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,
+ };
+ },
+ });
+ yield setPushPermission(true);
+ testKey = yield generateKey();
+
+ var url = "worker.js" + "?" + (Math.random());
+ registration = yield navigator.serviceWorker.register(url, {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(function* createControlledIFrame() {
+ controlledFrame = yield injectControlledFrame();
+ });
+
+ add_task(function* emptyKey() {
+ try {
+ yield 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(function* invalidKey() {
+ try {
+ yield 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(function* validKey() {
+ var pushSubscription = yield registration.pushManager.subscribe({
+ applicationServerKey: yield 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(function* retrieveKey() {
+ var pushSubscription = yield 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(function* mismatchedKey() {
+ isTestingMismatchedKey = true;
+ try {
+ yield registration.pushManager.subscribe({
+ applicationServerKey: yield 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(function* emptyKeyInWorker() {
+ var errorInfo = yield 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(function* invalidKeyInWorker() {
+ var errorInfo = yield 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(function* validKeyInWorker() {
+ var key = yield generateKey();
+ var data = yield sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: 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(function* mismatchedKeyInWorker() {
+ isTestingMismatchedKey = true;
+ try {
+ var errorInfo = yield sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: yield 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(function* unsubscribe() {
+ is(subscriptions, 2, "Wrong subscription count");
+ controlledFrame.remove();
+ });
+
+ add_task(function* unregister() {
+ yield 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..03f66887ad
--- /dev/null
+++ b/dom/push/test/test_serviceworker_lifetime.html
@@ -0,0 +1,362 @@
+<!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 type="text/javascript" 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.parentNode.removeChild(ctx.iframe);
+ 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(expected, resolve, event) {
+ ok(event.data.type == expected.type, "Received correct message type: " + expected.type);
+ ok(event.data.state == expected.state, "Service worker is in the correct state: " + expected.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);
+ }
+ }
+
+ 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: function(subject, topic, data) {
+ ok((topic == "service-worker-shutdown") && expectingEvent, "Service worker was terminated.");
+ this.remove(ctx);
+ },
+ remove: function(ctx) {
+ SpecialPowers.removeObserver(this, "service-worker-shutdown");
+ ctx.observer = null;
+ res(ctx);
+ }
+ }
+ SpecialPowers.addObserver(ctx.observer, "service-worker-shutdown", false);
+ });
+
+ 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((ctx) => res(ctx));
+ }
+
+ 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: function(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: function(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: function(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: function(ctx) {
+ return new Promise(function(res, rej) {
+ ctx.iframe.contentWindow.fetch("update");
+ res(ctx);
+ });
+ }
+ }
+
+ // 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..3f2e45e5aa
--- /dev/null
+++ b/dom/push/test/test_subscription_change.html
@@ -0,0 +1,69 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() {
+ yield setupPrefsAndMockSocket(new MockWebSocket());
+ yield setPushPermission(true);
+
+ var url = "worker.js" + "?" + (Math.random());
+ registration = yield navigator.serviceWorker.register(url, {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(function* createControlledIFrame() {
+ controlledFrame = yield injectControlledFrame();
+ });
+
+ add_task(function* togglePermission() {
+ var subscription = yield registration.pushManager.subscribe();
+ ok(subscription, "Should create a push subscription");
+
+ yield setPushPermission(false);
+ var permissionState = yield registration.pushManager.permissionState();
+ is(permissionState, "denied", "Should deny push permission");
+
+ var subscription = yield registration.pushManager.getSubscription();
+ is(subscription, null, "Should not return subscription when permission is revoked");
+
+ var changePromise = controlledFrame.waitOnWorkerMessage("changed");
+ yield setPushPermission(true);
+ yield changePromise;
+
+ subscription = yield registration.pushManager.getSubscription();
+ is(subscription, null, "Should drop subscription after reinstating permission");
+ });
+
+ add_task(function* unsubscribe() {
+ controlledFrame.remove();
+ yield 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..d0d16e39cd
--- /dev/null
+++ b/dom/push/test/test_try_registering_offline_disabled.html
@@ -0,0 +1,305 @@
+<!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 type="text/javascript" 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 "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 chromeScript = SpecialPowers.loadChromeScript(_ => {
+ var { classes: Cc, interfaces: Ci } = Components;
+ var ioService = Cc["@mozilla.org/network/io-service;1"]
+ .getService(Ci.nsIIOService);
+ addMessageListener("change-status", function(offline) {
+ ioService.offline = offline;
+ });
+ });
+
+ function offlineObserver(res) {
+ this._res = res;
+ }
+ offlineObserver.prototype = {
+ _res: null,
+
+ observe: function(subject, topic, data) {
+ debug("observe: " + subject + " " + topic + " " + data);
+ if (topic === "network:offline-status-changed") {
+ var 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) {
+ var obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
+ .getService(SpecialPowers.Ci.nsIObserverService);
+ obsService.addObserver(SpecialPowers.wrapCallbackObject(new offlineObserver(res)),
+ "network:offline-status-changed",
+ false);
+ chromeScript.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(sub => unsubscribe(sub))
+ )
+ .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(sub => unsubscribe(sub))
+ )
+ .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(sub => unsubscribe(sub))
+ )
+ .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(sub => unsubscribe(sub))
+ )
+ .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(sub => unsubscribe(sub))
+ )
+ .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(sub => unsubscribe(sub))
+ )
+ .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(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ 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..f15b36c479
--- /dev/null
+++ b/dom/push/test/test_unregister.html
@@ -0,0 +1,81 @@
+<!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 type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/SimpleTest/SpawnTask.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(function* start() {
+ yield setupPrefsAndMockSocket(new MockWebSocket());
+ yield setPushPermission(true);
+
+ registration = yield navigator.serviceWorker.register(
+ generateURL(), {scope: "."});
+ yield waitForActive(registration);
+ });
+
+ var pushSubscription;
+ add_task(function* setupPushNotification() {
+ pushSubscription = yield registration.pushManager.subscribe();
+ ok(pushSubscription, "successful registered for push notification");
+ });
+
+ add_task(function* unregisterPushNotification() {
+ var result = yield pushSubscription.unsubscribe();
+ ok(result, "unsubscribe() on existing subscription should return true.");
+ });
+
+ add_task(function* unregisterAgain() {
+ var result = yield pushSubscription.unsubscribe();
+ ok(!result, "unsubscribe() on previously unsubscribed subscription should return false.");
+ });
+
+ add_task(function* subscribeAgain() {
+ pushSubscription = yield registration.pushManager.subscribe();
+ ok(pushSubscription, "Should create a new push subscription");
+
+ var result = yield registration.unregister();
+ ok(result, "Should unregister the service worker");
+
+ registration = yield navigator.serviceWorker.register(
+ generateURL(), {scope: "."});
+ yield waitForActive(registration);
+ var pushSubscription = yield registration.pushManager.getSubscription();
+ ok(!pushSubscription,
+ "Unregistering a service worker should drop its subscription");
+ });
+
+ add_task(function* unregister() {
+ var result = yield 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..efd2f9dd77
--- /dev/null
+++ b/dom/push/test/test_utils.js
@@ -0,0 +1,245 @@
+(function (g) {
+ "use strict";
+
+ let url = SimpleTest.getTestFileURL("mockpushserviceparent.js");
+ let 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.
+ */
+ function replacePushService(mockService) {
+ chromeScript.sendSyncMessage("service-replace");
+ 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: result,
+ });
+ }, error => {
+ chromeScript.sendAsyncMessage("service-response", {
+ id: msg.id,
+ error: error,
+ });
+ });
+ });
+ }
+
+ function restorePushService() {
+ chromeScript.sendSyncMessage("service-restore");
+ }
+
+ let userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8";
+
+ 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.sendSyncMessage("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.sendSyncMessage("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.
+ */
+ function MockWebSocket() {}
+
+ let registerCount = 0;
+
+ // Default implementation to make the push server work minimally.
+ // Override methods to implement custom functionality.
+ MockWebSocket.prototype = {
+ // 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.
+ _isActive: false,
+
+ onHello(request) {
+ this.serverSendMsg(JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ }));
+ },
+
+ onRegister(request) {
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/" + 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);
+ }
+ },
+ };
+
+ g.MockWebSocket = MockWebSocket;
+ g.setupMockPushSocket = setupMockPushSocket;
+ g.teardownMockPushSocket = teardownMockPushSocket;
+ g.replacePushService = replacePushService;
+ g.restorePushService = restorePushService;
+}(this));
+
+// Remove permissions and prefs when the test finishes.
+SimpleTest.registerCleanupFunction(() => {
+ return new Promise(resolve =>
+ SpecialPowers.flushPermissions(resolve)
+ ).then(_ => SpecialPowers.flushPrefEnv()).then(_ => {
+ restorePushService();
+ return teardownMockPushSocket();
+ });
+});
+
+function setPushPermission(allow) {
+ return new Promise(resolve => {
+ SpecialPowers.pushPermissions([
+ { type: "desktop-notification", allow, context: document },
+ ], resolve);
+ });
+}
+
+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]
+ ]});
+}
+
+function setupPrefsAndReplaceService(mockService) {
+ replacePushService(mockService);
+ return 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() {
+ var utils = SpecialPowers.getDOMWindowUtils(iframe.contentWindow);
+ return utils.currentInnerWindowID;
+ },
+ };
+
+ 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);
+ }
+ });
+ });
+}
diff --git a/dom/push/test/webpush.js b/dom/push/test/webpush.js
new file mode 100644
index 0000000000..6aacc5ae1b
--- /dev/null
+++ b/dom/push/test/webpush.js
@@ -0,0 +1,186 @@
+/*
+ * 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.
+ * Uses the fetch API. Polyfill: https://github.com/github/fetch
+ */
+
+(function (g) {
+ 'use strict';
+
+ var P256DH = {
+ name: 'ECDH',
+ namedCurve: 'P-256'
+ };
+ var webCrypto = g.crypto.subtle;
+ var ENCRYPT_INFO = new TextEncoder('utf-8').encode("Content-Encoding: aesgcm128");
+ var NONCE_INFO = new TextEncoder('utf-8').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: function(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: function(s, i) {
+ return base64url._strmap.indexOf(s.charAt(i));
+ },
+ decode: function(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('utf-8').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..0e26f228d0
--- /dev/null
+++ b/dom/push/test/worker.js
@@ -0,0 +1,152 @@
+// 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.
+
+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/PushServiceHandler.js b/dom/push/test/xpcshell/PushServiceHandler.js
new file mode 100644
index 0000000000..d63f32c978
--- /dev/null
+++ b/dom/push/test/xpcshell/PushServiceHandler.js
@@ -0,0 +1,31 @@
+// An XPCOM service that's registered with the category manager in the parent
+// process for handling push notifications with scope "chrome://test-scope"
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+let pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
+
+function PushServiceHandler() {
+ // So JS code can reach into us.
+ this.wrappedJSObject = this;
+ // Register a push observer.
+ this.observed = [];
+ Services.obs.addObserver(this, pushService.pushTopic, false);
+ Services.obs.addObserver(this, pushService.subscriptionChangeTopic, false);
+ Services.obs.addObserver(this, pushService.subscriptionModifiedTopic, false);
+}
+
+PushServiceHandler.prototype = {
+ classID: Components.ID("{bb7c5199-c0f7-4976-9f6d-1306e32c5591}"),
+ QueryInterface: XPCOMUtils.generateQI([]),
+
+ observe(subject, topic, data) {
+ this.observed.push({ subject, topic, data });
+ },
+}
+
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PushServiceHandler]);
diff --git a/dom/push/test/xpcshell/PushServiceHandler.manifest b/dom/push/test/xpcshell/PushServiceHandler.manifest
new file mode 100644
index 0000000000..f25b498963
--- /dev/null
+++ b/dom/push/test/xpcshell/PushServiceHandler.manifest
@@ -0,0 +1,4 @@
+component {bb7c5199-c0f7-4976-9f6d-1306e32c5591} PushServiceHandler.js
+contract @mozilla.org/dom/push/test/PushServiceHandler;1 {bb7c5199-c0f7-4976-9f6d-1306e32c5591}
+
+category push chrome://test-scope @mozilla.org/dom/push/test/PushServiceHandler;1
diff --git a/dom/push/test/xpcshell/head-http2.js b/dom/push/test/xpcshell/head-http2.js
new file mode 100644
index 0000000000..9c502bdcc1
--- /dev/null
+++ b/dom/push/test/xpcshell/head-http2.js
@@ -0,0 +1,62 @@
+// Returns the test H/2 server port, throwing if it's missing or invalid.
+function getTestServerPort() {
+ let portEnv = Cc["@mozilla.org/process/environment;1"]
+ .getService(Ci.nsIEnvironment).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}`);
+ }
+ do_print(`Using HTTP/2 server on port ${port}`);
+ return port;
+}
+
+// Support for making sure we can talk to the invalid cert the server presents
+var CertOverrideListener = function(host, port, bits) {
+ this.host = host;
+ this.port = port || 443;
+ this.bits = bits;
+};
+
+CertOverrideListener.prototype = {
+ host: null,
+ bits: null,
+
+ getInterface: function(aIID) {
+ return this.QueryInterface(aIID);
+ },
+
+ QueryInterface: function(aIID) {
+ if (aIID.equals(Ci.nsIBadCertListener2) ||
+ aIID.equals(Ci.nsIInterfaceRequestor) ||
+ aIID.equals(Ci.nsISupports))
+ return this;
+ throw Components.results.NS_ERROR_NO_INTERFACE;
+ },
+
+ notifyCertProblem: function(socketInfo, sslStatus, targetHost) {
+ var cert = sslStatus.QueryInterface(Ci.nsISSLStatus).serverCert;
+ var cos = Cc["@mozilla.org/security/certoverride;1"].
+ getService(Ci.nsICertOverrideService);
+ cos.rememberValidityOverride(this.host, this.port, cert, this.bits, false);
+ dump("Certificate Override in place\n");
+ return true;
+ },
+};
+
+function addCertOverride(host, port, bits) {
+ var req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+ .createInstance(Ci.nsIXMLHttpRequest);
+ try {
+ var url;
+ if (port && (port > 0) && (port !== 443)) {
+ url = "https://" + host + ":" + port + "/";
+ } else {
+ url = "https://" + host + "/";
+ }
+ req.open("GET", url, false);
+ req.channel.notificationCallbacks = new CertOverrideListener(host, port, bits);
+ req.send(null);
+ } catch (e) {
+ // This will fail since the server is not trusted yet
+ }
+}
diff --git a/dom/push/test/xpcshell/head.js b/dom/push/test/xpcshell/head.js
new file mode 100644
index 0000000000..9751a1cb17
--- /dev/null
+++ b/dom/push/test/xpcshell/head.js
@@ -0,0 +1,463 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import('resource://gre/modules/XPCOMUtils.jsm');
+Cu.import('resource://gre/modules/Services.jsm');
+Cu.import('resource://gre/modules/Task.jsm');
+Cu.import('resource://gre/modules/Timer.jsm');
+Cu.import('resource://gre/modules/Promise.jsm');
+Cu.import('resource://gre/modules/Preferences.jsm');
+Cu.import('resource://gre/modules/PlacesUtils.jsm');
+Cu.import('resource://gre/modules/ObjectUtils.jsm');
+
+XPCOMUtils.defineLazyModuleGetter(this, 'PlacesTestUtils',
+ 'resource://testing-common/PlacesTestUtils.jsm');
+XPCOMUtils.defineLazyServiceGetter(this, 'PushServiceComponent',
+ '@mozilla.org/push/Service;1', 'nsIPushService');
+
+const serviceExports = Cu.import('resource://gre/modules/PushService.jsm', {});
+const servicePrefs = new Preferences('dom.push.');
+
+const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
+
+const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000;
+
+var isParent = Cc['@mozilla.org/xre/runtime;1']
+ .getService(Ci.nsIXULRuntime).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, false);
+ serviceExports.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) {
+ Cu.reportError(e);
+ }
+ }
+}, 'profile-change-net-teardown', false);
+
+/**
+ * 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) {
+ return 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(Cu.reportError);
+}
+
+/**
+ * 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, topic, data) {
+ let matches = typeof matchFunc != 'function' || matchFunc(subject, data);
+ if (!matches) {
+ return;
+ }
+ Services.obs.removeObserver(observe, topic, false);
+ resolve({subject, data});
+ }, topic, false);
+ });
+}
+
+/**
+ * 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(target, property) {
+ if (!stubs || typeof stubs != 'object' || !(property in stubs)) {
+ return target[property];
+ }
+ let stub = stubs[property];
+ if (typeof stub != 'function') {
+ return stub;
+ }
+ let original = target[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) {
+ return a > b ? 1 : 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;
+}
+
+MockWebSocket.prototype = {
+ _originalURI: null,
+ _onHello: null,
+ _onRegister: null,
+ _onUnregister: null,
+ _onACK: null,
+ _onPing: null,
+
+ _listener: null,
+ _context: null,
+
+ QueryInterface: XPCOMUtils.generateQI([
+ Ci.nsISupports,
+ Ci.nsIWebSocketChannel
+ ]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, 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;
+
+ 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 = Task.async(function* (service, db) {
+ if (!isParent) {
+ return;
+ }
+
+ let userAgentID = 'ce704e41-cb77-4206-b07b-5bf47114791b';
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+
+ yield 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,
+ });
+ yield 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,
+ });
+ yield 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: function(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 = Task.async(function* (db) {
+ if (!isParent) {
+ return;
+ }
+
+ let record = yield db.getByIdentifiers({
+ scope: 'https://example.com/sub/ok',
+ originAttributes: '',
+ });
+ ok(record.pushEndpoint.startsWith('https://example.org/push'),
+ 'Wrong push endpoint in subscription record');
+
+ record = yield db.getByIdentifiers({
+ scope: 'https://example.net/scope/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: 1, inIsolatedMozBrowser: true }),
+ });
+ ok(record.pushEndpoint.startsWith('https://example.org/push'),
+ 'Wrong push endpoint in app record');
+
+ record = yield 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: scope,
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: '',
+ quota: 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..a6f5423186
--- /dev/null
+++ b/dom/push/test/xpcshell/moz.build
@@ -0,0 +1,4 @@
+EXTRA_COMPONENTS += [
+ 'PushServiceHandler.js',
+ 'PushServiceHandler.manifest',
+]
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..b8060a141e
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clearAll_successful.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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: userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* setup() {
+ db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(_ => db.drop().then(_ => db.close()));
+
+ // Active subscriptions; should be expired then dropped.
+ yield putTestRecord(db, 'active-1', 'https://example.info/some-page', 8);
+ yield putTestRecord(db, 'active-2', 'https://example.com/another-page', 16);
+
+ // Expired subscription; should be dropped.
+ yield 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`.
+ yield putTestRecord(db, 'privileged', 'app://chrome/only', Infinity);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => handshakeDone = r);
+ PushService.init({
+ serverURI: 'wss://push.example.org/',
+ db: 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();
+ },
+ });
+ },
+ });
+ yield handshakePromise;
+});
+
+add_task(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;
+ }),
+ ]);
+
+ yield PushService.clear({
+ domain: '*',
+ });
+
+ yield 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 = yield 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..4db75c026d
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
@@ -0,0 +1,128 @@
+'use strict';
+
+const {PushService, PushServiceWebSocket} = serviceExports;
+const {ForgetAboutSite} = Cu.import(
+ 'resource://gre/modules/ForgetAboutSite.jsm', {});
+
+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: userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* setup() {
+ db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(_ => 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.
+ yield putTestRecord(db, 'active-sub', 'https://sub.example.com/sub-page', 4);
+ yield putTestRecord(db, 'expired-sub', 'https://sub.example.com/yet-another-page', 0);
+
+ // Active subscriptions for another subdomain. Should be unsubscribed and
+ // dropped.
+ yield putTestRecord(db, 'active-1', 'https://sub2.example.com/some-page', 8);
+ yield putTestRecord(db, 'active-2', 'https://sub3.example.com/another-page', 16);
+
+ // A privileged subscription with a real URL that should not be affected
+ // because its quota is set to `Infinity`.
+ yield 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: 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.
+ yield handshakePromise;
+});
+
+add_task(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;
+ }
+ ),
+ ]);
+ yield ForgetAboutSite.removeDataFromDomain('sub.example.com');
+ yield promiseForgetSubs;
+
+ deepEqual(modifiedScopes.sort(compareAscending), [
+ 'https://sub.example.com/sub-page',
+ ], 'Should fire modified notifications for active subscriptions');
+
+ let remainingIDs = yield getAllKeyIDs(db);
+ deepEqual(remainingIDs, ['active-1', 'active-2', 'privileged'],
+ 'Should only forget subscriptions for subdomain');
+});
+
+add_task(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;
+ }
+ ),
+ ]);
+
+ yield ForgetAboutSite.removeDataFromDomain('example.com');
+ yield 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 = yield 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..6bb5007824
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_origin_data.js
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = 'bd744428-f125-436a-b6d0-dd0c9845837f';
+
+let clearForPattern = Task.async(function* (testRecords, pattern) {
+ let patternString = JSON.stringify(pattern);
+ yield PushService._clearOriginData(patternString);
+
+ for (let length = testRecords.length; length--;) {
+ let test = testRecords[length];
+ let originSuffix = ChromeUtils.originAttributesToSuffix(
+ test.originAttributes);
+
+ let registration = yield 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(function* test_webapps_cleardata() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ let testRecords = [{
+ scope: 'https://example.org/1',
+ originAttributes: { appId: 1 },
+ clearIf: { appId: 1, inIsolatedMozBrowser: false },
+ }, {
+ scope: 'https://example.org/1',
+ originAttributes: { appId: 1, inIsolatedMozBrowser: true },
+ clearIf: { appId: 1 },
+ }, {
+ scope: 'https://example.org/1',
+ originAttributes: { appId: 2, inIsolatedMozBrowser: true },
+ clearIf: { appId: 2, inIsolatedMozBrowser: true },
+ }, {
+ scope: 'https://example.org/2',
+ originAttributes: { appId: 1 },
+ clearIf: { appId: 1, inIsolatedMozBrowser: false },
+ }, {
+ scope: 'https://example.org/2',
+ originAttributes: { appId: 2, inIsolatedMozBrowser: true },
+ clearIf: { appId: 2, inIsolatedMozBrowser: true },
+ }, {
+ scope: 'https://example.org/3',
+ originAttributes: { appId: 3, inIsolatedMozBrowser: true },
+ clearIf: { inIsolatedMozBrowser: true },
+ }, {
+ scope: 'https://example.org/3',
+ originAttributes: { appId: 4, inIsolatedMozBrowser: true },
+ clearIf: { inIsolatedMozBrowser: true },
+ }];
+
+ 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');
+ equal(data.uaid, userAgentID, 'Handshake: wrong device ID');
+ 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();
+ },
+ });
+ }
+ });
+
+ yield Promise.all(testRecords.map(test =>
+ PushService.register({
+ scope: test.scope,
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ test.originAttributes),
+ })
+ ));
+
+ // Removes records for all scopes with the same app ID. Excludes records
+ // where `inIsolatedMozBrowser` is true.
+ yield clearForPattern(testRecords, { appId: 1, inIsolatedMozBrowser: false });
+
+ // Removes the remaining record for app ID 1, where `inIsolatedMozBrowser` is true.
+ yield clearForPattern(testRecords, { appId: 1 });
+
+ // Removes all records for all scopes with the same app ID, where
+ // `inIsolatedMozBrowser` is true.
+ yield clearForPattern(testRecords, { appId: 2, inIsolatedMozBrowser: true });
+
+ // Removes all records where `inIsolatedMozBrowser` is true.
+ yield clearForPattern(testRecords, { inIsolatedMozBrowser: true });
+
+ equal(testRecords.length, 0, 'Should remove all test records');
+ yield 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..e32f50260c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -0,0 +1,249 @@
+'use strict';
+
+const {
+ getCryptoParams,
+ PushCrypto,
+} = Cu.import('resource://gre/modules/PushCrypto.jsm', {});
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_crypto_getCryptoParams() {
+ // 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: {
+ dh: 'Iy1Je2Kv11A',
+ salt: 'upk1yFkp1xI',
+ rs: 4096,
+ padSize: 2,
+ },
+ }, {
+ desc: 'aesgcm with quoted key param',
+ headers: {
+ encoding: 'aesgcm',
+ crypto_key: 'dh="byfHbUffc-k"',
+ encryption: 'salt=C11AvAsp6Gc',
+ },
+ params: {
+ dh: 'byfHbUffc-k',
+ salt: 'C11AvAsp6Gc',
+ rs: 4096,
+ padSize: 2,
+ },
+ }, {
+ desc: 'aesgcm with Crypto-Key and rs = 24',
+ headers: {
+ encoding: 'aesgcm',
+ crypto_key: 'dh="ybuT4VDz-Bg"',
+ encryption: 'salt=H7U7wcIoIKs; rs=24',
+ },
+ params: {
+ dh: 'ybuT4VDz-Bg',
+ salt: 'H7U7wcIoIKs',
+ rs: 24,
+ padSize: 2,
+ },
+ }, {
+ 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: {
+ dh: 'LqrDQuVl9lY',
+ salt: 'YngI8B7YapM',
+ rs: 2,
+ padSize: 1,
+ },
+ }, {
+ desc: 'aesgcm128 with Encryption-Key',
+ headers: {
+ encoding: 'aesgcm128',
+ encryption_key: 'keyid=v2; dh=VA6wmY1IpiE',
+ encryption: 'keyid=v2; salt=khtpyXhpDKM',
+ },
+ params: {
+ dh: 'VA6wmY1IpiE',
+ salt: 'khtpyXhpDKM',
+ rs: 4096,
+ padSize: 1,
+ }
+ }];
+ for (let test of shouldParse) {
+ let params = getCryptoParams(test.headers);
+ deepEqual(params, test.params, 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',
+ },
+ }, {
+ desc: 'Invalid encoding',
+ headers: {
+ encoding: 'nonexistent',
+ },
+ }, {
+ desc: 'Invalid record size',
+ headers: {
+ encoding: 'aesgcm',
+ crypto_key: 'dh=pbmv1QkcEDY',
+ encryption: 'dh=Esao8aTBfIk;rs=bad',
+ },
+ }, {
+ desc: 'Insufficiently large record size',
+ headers: {
+ encoding: 'aesgcm',
+ crypto_key: 'dh=fK0EXaw5IU8',
+ encryption: 'salt=orbLLmlbJfM;rs=1',
+ },
+ }, {
+ desc: 'aesgcm with Encryption-Key',
+ headers: {
+ encoding: 'aesgcm',
+ encryption_key: 'dh=FplK5KkvUF0',
+ encryption: 'salt=p6YHhFF3BQY',
+ },
+ }];
+ for (let test of shouldThrow) {
+ throws(() => getCryptoParams(test.headers), test.desc);
+ }
+});
+
+add_task(function* test_crypto_decodeMsg() {
+ let privateKey = {
+ crv: 'P-256',
+ d: '4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg',
+ ext: true,
+ key_ops: ['deriveBits'],
+ kty: 'EC',
+ x: 'sd85ZCbEG6dEkGMCmDyGBIt454Qy-Yo-1xhbaT2Jlk4',
+ y: 'vr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs',
+ };
+ let publicKey = ChromeUtils.base64URLDecode('BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs', {
+ padding: "reject",
+ });
+
+ let expectedSuccesses = [{
+ desc: 'padSize = 2, rs = 24, pad = 0',
+ result: 'Some message',
+ data: 'Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU',
+ authSecret: 'aTDc6JebzR6eScy2oLo4RQ',
+ 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',
+ headers: {
+ crypto_key: 'dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ',
+ encryption: 'salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8',
+ encoding: 'aesgcm',
+ },
+ }, {
+ desc: 'padSize = 1, rs = 4096, pad = 2',
+ result: 'aesgcm128 encrypted message',
+ data: 'ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE',
+ headers: {
+ encryption_key: 'dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI',
+ encryption: 'salt=btxxUtclbmgcc30b9rT3Bg; rs=4096',
+ encoding: 'aesgcm128',
+ },
+ }, {
+ 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',
+ headers: {
+ crypto_key: 'dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk',
+ encryption: 'salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3',
+ encoding: 'aesgcm',
+ },
+ }];
+ for (let test of expectedSuccesses) {
+ let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, {
+ padding: "reject",
+ }) : null;
+ let data = ChromeUtils.base64URLDecode(test.data, {
+ padding: "reject",
+ });
+ let result = yield PushCrypto.decrypt(privateKey, publicKey, authSecret,
+ test.headers, data);
+ let decoder = new TextDecoder('utf-8');
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+ }
+
+ let expectedFailures = [{
+ desc: 'padSize = 1, rs = 4096, auth secret, pad = 8',
+ data: 'h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r',
+ senderPublicKey: '',
+ authSecret: 'Sxb6u0gJIhGEogyLawjmCw',
+ headers: {
+ crypto_key: 'dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM',
+ encryption: 'salt=aGBpoKklLtrLcAUCcCr7JQ',
+ encoding: 'aesgcm128',
+ },
+ }, {
+ desc: 'Missing padding',
+ data: 'anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg',
+ headers: {
+ crypto_key: 'dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4',
+ encryption: 'salt=Czx2i18rar8XWOXAVDnUuw',
+ encoding: 'aesgcm128',
+ },
+ }, {
+ desc: 'padSize > rs',
+ data: 'Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu',
+ headers: {
+ crypto_key: 'dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls',
+ encryption: 'salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1',
+ encoding: 'aesgcm',
+ },
+ }, {
+ desc: 'Encrypted with padSize = 1, decrypted with padSize = 2 and auth secret',
+ data: 'fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8',
+ authSecret: 'BhbpNTWyO5wVJmVKTV6XaA',
+ headers: {
+ crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
+ encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q',
+ encoding: 'aesgcm',
+ },
+ }, {
+ desc: 'Truncated input',
+ data: 'AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0',
+ headers: {
+ crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
+ encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25',
+ encoding: 'aesgcm',
+ },
+ }];
+ for (let test of expectedFailures) {
+ let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, {
+ padding: "reject",
+ }) : null;
+ let data = ChromeUtils.base64URLDecode(test.data, {
+ padding: "reject",
+ });
+ yield rejects(
+ PushCrypto.decrypt(privateKey, publicKey, authSecret,
+ test.headers, data),
+ test.desc
+ );
+ }
+});
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..4444753e8f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_drop_expired.js
@@ -0,0 +1,154 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '2c43af06-ab6e-476a-adc4-16cbda54fb89';
+
+var db;
+var quotaURI;
+var permURI;
+
+function visitURI(uri, timestamp) {
+ return PlacesTestUtils.addVisits({
+ uri: uri,
+ title: uri.spec,
+ visitDate: timestamp * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK
+ });
+}
+
+var putRecord = Task.async(function* ({scope, perm, quota, lastPush, lastVisit}) {
+ let uri = Services.io.newURI(scope, null, null);
+
+ Services.perms.add(uri, 'desktop-notification',
+ Ci.nsIPermissionManager[perm]);
+ do_register_cleanup(() => {
+ Services.perms.remove(uri, 'desktop-notification');
+ });
+
+ yield visitURI(uri, lastVisit);
+
+ yield db.put({
+ channelID: uri.path,
+ pushEndpoint: 'https://example.org/push' + uri.path,
+ scope: uri.spec,
+ pushCount: 0,
+ lastPush: lastPush,
+ version: null,
+ originAttributes: '',
+ quota: quota,
+ });
+
+ return uri;
+});
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ run_next_test();
+}
+
+add_task(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.
+ yield 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 = yield 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 = yield 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.
+ yield 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,
+ }));
+ },
+ });
+ },
+ });
+
+ yield subChangePromise;
+});
+
+add_task(function* test_site_visited() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == 'https://example.xyz/expired-quota-exceeded'
+ );
+
+ yield visitURI(quotaURI, Date.now());
+ PushService.observe(null, 'idle-daily', '');
+
+ yield subChangePromise;
+});
+
+add_task(function* test_perm_restored() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == 'https://example.info/expired-perm-revoked'
+ );
+
+ Services.perms.add(permURI, 'desktop-notification',
+ Ci.nsIPermissionManager.ALLOW_ACTION);
+
+ yield 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..fd80d506d3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_handler_service.js
@@ -0,0 +1,47 @@
+"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.
+
+// This component is registered for "chrome://test-scope"
+const kServiceContractID = "@mozilla.org/dom/push/test/PushServiceHandler;1";
+
+let pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
+
+add_test(function test_service_instantiation() {
+ do_load_manifest("PushServiceHandler.manifest");
+
+ let scope = "chrome://test-scope";
+ let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(Ci.nsIPushNotifier);
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ pushNotifier.notifyPush(scope, principal, "");
+
+ // Now get a handle to our service and check it received the notification.
+ let handlerService = Cc[kServiceContractID]
+ .getService(Ci.nsISupports)
+ .wrappedJSObject;
+
+ 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..19c5a158ad
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_ack.js
@@ -0,0 +1,125 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+var userAgentID = '5ab1d1df-7a3d-4024-a469-b9e1bb399fad';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({userAgentID});
+ run_next_test();
+}
+
+add_task(function* test_notification_ack() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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) {
+ yield 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);
+ }
+ }
+ });
+ }
+ });
+
+ yield notifyPromise;
+ yield 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..1969bcbd3d
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_data.js
@@ -0,0 +1,280 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+let db;
+let userAgentID = 'f5b47f8d-771f-4ea3-b999-91c135f8766d';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+ run_next_test();
+}
+
+function putRecord(channelID, scope, publicKey, privateKey, authSecret) {
+ return db.put({
+ channelID: channelID,
+ pushEndpoint: 'https://example.org/push/' + channelID,
+ scope: 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(function* test_notification_ack_data_setup() {
+ db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ yield 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'
+ );
+ yield 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'
+ );
+ yield 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);
+ }
+ }
+ });
+ }
+ });
+ yield setupDonePromise;
+});
+
+add_task(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]);
+ };
+
+ yield 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..3f48f71e01
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_duplicate.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '1500e7d9-8cbe-4ee6-98da-7fa5d6a39852';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ maxRecentMessageIDsPerSubscription: 4,
+ userAgentID: userAgentID,
+ });
+ run_next_test();
+}
+
+// Should acknowledge duplicate notifications, but not notify apps.
+add_task(function* test_notification_duplicate() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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) {
+ yield 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: channelID,
+ version: 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(expectedAck =>
+ expectedAck.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();
+ },
+ });
+ }
+ });
+
+ yield notifyPromise;
+ yield ackPromise;
+
+ for (let {channelID, recents} of testData) {
+ let record = yield 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..74631f4f81
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_error.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '3c7462fc-270f-45be-a459-b9d631b0d093';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* test_notification_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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: 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: 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: originAttributes,
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ }];
+ for (let record of records) {
+ yield 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
+ });
+ }
+ });
+
+ yield 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');
+
+ yield ackPromise;
+
+ let aRecord = yield db.getByIdentifiers({scope: 'https://example.com/a',
+ originAttributes: 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 = yield db.getByIdentifiers({scope: 'https://example.com/b',
+ originAttributes: 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 = yield db.getByIdentifiers({scope: 'https://example.com/c',
+ originAttributes: 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_http2.js b/dom/push/test/xpcshell/test_notification_http2.js
new file mode 100644
index 0000000000..1b334bfba5
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_http2.js
@@ -0,0 +1,189 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+var prefs;
+var tlsProfile;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ setPrefs({
+ 'testing.allowInsecureServerURL': true,
+ });
+ prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
+
+ tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
+
+ // Set to allow the cert presented by our H2 server
+ var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
+ prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
+ prefs.setBoolPref("dom.push.enabled", true);
+ prefs.setBoolPref("dom.push.connection.enabled", true);
+
+ addCertOverride("localhost", serverPort,
+ Ci.nsICertOverrideService.ERROR_UNTRUSTED |
+ Ci.nsICertOverrideService.ERROR_MISMATCH |
+ Ci.nsICertOverrideService.ERROR_TIME);
+
+ prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+
+ run_next_test();
+}
+
+add_task(function* test_pushNotifications() {
+
+ // /pushNotifications/subscription1 will send a message with no rs and padding
+ // length 1.
+ // /pushNotifications/subscription2 will send a message with no rs and padding
+ // length 16.
+ // /pushNotifications/subscription3 will send a message with rs equal 24 and
+ // padding length 16.
+ // /pushNotifications/subscription4 will send a message with no rs and padding
+ // length 256.
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ let records = [{
+ subscriptionUri: serverURL + '/pushNotifications/subscription1',
+ pushEndpoint: serverURL + '/pushEndpoint1',
+ pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint1',
+ scope: 'https://example.com/page/1',
+ 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: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ systemRecord: true,
+ }, {
+ subscriptionUri: serverURL + '/pushNotifications/subscription2',
+ pushEndpoint: serverURL + '/pushEndpoint2',
+ pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint2',
+ scope: 'https://example.com/page/2',
+ p256dhPublicKey: 'BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E',
+ p256dhPrivateKey: {
+ crv: 'P-256',
+ d: 'lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4',
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: 'EC',
+ x: '-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE',
+ y: '5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E'
+ },
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ systemRecord: true,
+ }, {
+ subscriptionUri: serverURL + '/pushNotifications/subscription3',
+ pushEndpoint: serverURL + '/pushEndpoint3',
+ pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint3',
+ scope: 'https://example.com/page/3',
+ p256dhPublicKey: 'BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI',
+ p256dhPrivateKey: {
+ crv: 'P-256',
+ d: 'Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8',
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: 'EC',
+ x: 'OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po',
+ y: 'Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI'
+ },
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ systemRecord: true,
+ }, {
+ subscriptionUri: serverURL + '/pushNotifications/subscription4',
+ pushEndpoint: serverURL + '/pushEndpoint4',
+ pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint4',
+ scope: 'https://example.com/page/4',
+ p256dhPublicKey: ChromeUtils.base64URLDecode('BEcvDzkWCrUtjU_wygL98sbQCQrW1lY9irtgGnlCc4B0JJXLCHB9MTM73qD6GZYfL0YOvKo8XLOflh-J4dMGklU', {
+ padding: "reject",
+ }),
+ p256dhPrivateKey: {
+ crv: 'P-256',
+ d: 'fWi7tZaX0Pk6WnLrjQ3kiRq_g5XStL5pdH4pllNCqXw',
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: 'EC',
+ x: 'Ry8PORYKtS2NT_DKAv3yxtAJCtbWVj2Ku2AaeUJzgHQ',
+ y: 'JJXLCHB9MTM73qD6GZYfL0YOvKo8XLOflh-J4dMGklU'
+ },
+ authenticationSecret: ChromeUtils.base64URLDecode('cwDVC1iwAn8E37mkR3tMSg', {
+ padding: "reject",
+ }),
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ systemRecord: true,
+ }];
+
+ for (let record of records) {
+ yield db.put(record);
+ }
+
+ let notifyPromise = Promise.all([
+ promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
+ var message = subject.QueryInterface(Ci.nsIPushMessage).data;
+ if (message && (data == "https://example.com/page/1")){
+ equal(message.text(), "Some message", "decoded message is incorrect");
+ return true;
+ }
+ }),
+ promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
+ var message = subject.QueryInterface(Ci.nsIPushMessage).data;
+ if (message && (data == "https://example.com/page/2")){
+ equal(message.text(), "Some message", "decoded message is incorrect");
+ return true;
+ }
+ }),
+ promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
+ var message = subject.QueryInterface(Ci.nsIPushMessage).data;
+ if (message && (data == "https://example.com/page/3")){
+ equal(message.text(), "Some message", "decoded message is incorrect");
+ return true;
+ }
+ }),
+ promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
+ var message = subject.QueryInterface(Ci.nsIPushMessage).data;
+ if (message && (data == "https://example.com/page/4")){
+ equal(message.text(), "Yet another message", "decoded message is incorrect");
+ return true;
+ }
+ }),
+ ]);
+
+ PushService.init({
+ serverURI: serverURL,
+ db
+ });
+
+ yield notifyPromise;
+});
+
+add_task(function* test_complete() {
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
+});
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..ed2cec9863
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_incomplete.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '1ca1cf66-eeb4-4df7-87c1-d5c92906ab90';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* test_notification_incomplete() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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) {
+ yield db.put(record);
+ }
+
+ function observeMessage(subject, topic, data) {
+ ok(false, 'Should not deliver malformed updates');
+ }
+ do_register_cleanup(() =>
+ Services.obs.removeObserver(observeMessage, PushServiceComponent.pushTopic));
+ Services.obs.addObserver(observeMessage, PushServiceComponent.pushTopic, false);
+
+ 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');
+ }
+ });
+ }
+ });
+
+ yield notificationPromise;
+
+ let storeRecords = yield 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..aa39c2f896
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_version_string.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = 'ba31ac13-88d4-4984-8e6b-8731315a7cf8';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* test_notification_version_string() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+ yield 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, data: scope} = yield notifyPromise;
+ equal(message.QueryInterface(Ci.nsIPushMessage).data, null,
+ 'Unexpected data for Simple Push message');
+
+ yield ackPromise;
+
+ let storeRecord = yield 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..2a610475a5
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_data.js
@@ -0,0 +1,42 @@
+'use strict';
+
+var pushNotifier = Cc['@mozilla.org/push/Notifier;1']
+ .getService(Ci.nsIPushNotifier);
+var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* test_notifyWithData() {
+ let textData = '{"hello":"world"}';
+ let payload = new TextEncoder('utf-8').encode(textData);
+
+ let notifyPromise =
+ promiseObserverNotification(PushServiceComponent.pushTopic);
+ pushNotifier.notifyPushWithData('chrome://notify-test', systemPrincipal,
+ '' /* messageId */, payload.length, payload);
+
+ let data = (yield 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(function* test_empty_notifyWithData() {
+ let notifyPromise =
+ promiseObserverNotification(PushServiceComponent.pushTopic);
+ pushNotifier.notifyPushWithData('chrome://notify-test', systemPrincipal,
+ '' /* messageId */, 0, null);
+
+ let data = (yield notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage).data;
+ throws(_ => data.json(),
+ '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..80903bed37
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_remoting.js
@@ -0,0 +1,111 @@
+'use strict';
+
+const pushNotifier = Cc['@mozilla.org/push/Notifier;1']
+ .getService(Ci.nsIPushNotifier);
+
+add_task(function* test_observer_remoting() {
+ if (isParent) {
+ yield testInParent();
+ } else {
+ yield testInChild();
+ }
+});
+
+const childTests = [{
+ text: 'Hello from child!',
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+}];
+
+const parentTests = [{
+ text: 'Hello from parent!',
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+}];
+
+function* testInParent() {
+ // 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');
+ yield promiseNotifications;
+
+ // Wait until the child is listening for notifications from the parent.
+ yield do_await_remote_message('push_test_observer_remoting_child_ready');
+
+ // Fire an observer notification in the parent that should be forwarded to
+ // the child.
+ yield parentTests.reduce(
+ (p, test) => p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Wait for the child to exit.
+ yield promiseFinished;
+}
+
+function* testInChild() {
+ // Fire an observer notification in the child that should be forwarded to
+ // the parent.
+ yield 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');
+ yield promiseNotifierObservers;
+}
+
+var waitForNotifierObservers = Task.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('utf-8').encode(text);
+
+ if (shouldNotify) {
+ pushNotifier.notifyPushWithData(scope, principal, '', data.length, data);
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ }
+
+ let {
+ data: notifyScope,
+ subject: notifySubject,
+ } = yield 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,
+ } = yield 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,
+ } = yield 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..ff9de26ad8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_permissions.js
@@ -0,0 +1,296 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '2c43af06-ab6e-476a-adc4-16cbda54fb89';
+
+let db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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: XPCOMUtils.generateQI([Ci.nsIPermission]),
+ capability: Ci.nsIPermissionManager[capability],
+ expireTime: 0,
+ expireType: Ci.nsIPermissionManager.EXPIRE_NEVER,
+ principal: Services.scriptSecurityManager.getCodebasePrincipal(
+ Services.io.newURI(url, null, null)
+ ),
+ 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(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.
+ yield putTestRecord(db, 'active-allow', 'https://example.info/page/1', 8);
+
+ // Expired registration; should be dropped.
+ yield putTestRecord(db, 'expired-allow', 'https://example.info/page/2', 0);
+
+ // Active registration; should be expired when we change the permission
+ // to "deny".
+ yield 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.
+ yield putTestRecord(db, 'active-deny-added-1', 'https://example.net/ham', 16);
+ yield 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.
+ yield 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`.
+ yield putTestRecord(db, 'never-expires', 'app://chrome/only', Infinity);
+
+ // A registration that should be dropped when we clear the permission
+ // list.
+ yield 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) {},
+ });
+ }
+ });
+ yield handshakePromise;
+});
+
+add_task(function* test_permissions_allow_added() {
+ let subChangePromise = promiseSubscriptionChanges(1);
+
+ yield PushService._onPermissionChange(
+ makePushPermission('https://example.info', 'ALLOW_ACTION'),
+ 'added'
+ );
+ let notifiedScopes = yield subChangePromise;
+
+ deepEqual(notifiedScopes, [
+ 'https://example.info/page/2',
+ ], 'Wrong scopes after adding allow');
+
+ let record = yield db.getByKeyID('active-allow');
+ equal(record.quota, 16,
+ 'Should reset quota for active records after adding allow');
+
+ record = yield db.getByKeyID('expired-allow');
+ ok(!record, 'Should drop expired records after adding allow');
+});
+
+add_task(function* test_permissions_allow_deleted() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister('active-allow');
+
+ yield PushService._onPermissionChange(
+ makePushPermission('https://example.info', 'ALLOW_ACTION'),
+ 'deleted'
+ );
+
+ yield unregisterPromise;
+
+ let notifiedScopes = yield subModifiedPromise;
+ deepEqual(notifiedScopes, [
+ 'https://example.info/page/1',
+ ], 'Wrong scopes modified after deleting allow');
+
+ let record = yield db.getByKeyID('active-allow');
+ ok(record.isExpired(),
+ 'Should expire active record after deleting allow');
+});
+
+add_task(function* test_permissions_deny_added() {
+ let subModifiedPromise = promiseSubscriptionModifications(2);
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister('active-deny-added-1'),
+ promiseUnregister('active-deny-added-2'),
+ ]);
+
+ yield PushService._onPermissionChange(
+ makePushPermission('https://example.net', 'DENY_ACTION'),
+ 'added'
+ );
+ yield unregisterPromise;
+
+ let notifiedScopes = yield subModifiedPromise;
+ deepEqual(notifiedScopes, [
+ 'https://example.net/green',
+ 'https://example.net/ham',
+ ], 'Wrong scopes modified after adding deny');
+
+ let isExpired = yield allExpired(
+ 'active-deny-added-1',
+ 'expired-deny-added'
+ );
+ ok(isExpired, 'Should expire all registrations after adding deny');
+});
+
+add_task(function* test_permissions_deny_deleted() {
+ yield PushService._onPermissionChange(
+ makePushPermission('https://example.net', 'DENY_ACTION'),
+ 'deleted'
+ );
+
+ let isExpired = yield allExpired(
+ 'active-deny-added-1',
+ 'expired-deny-added'
+ );
+ ok(isExpired, 'Should retain expired registrations after deleting deny');
+});
+
+add_task(function* test_permissions_allow_changed() {
+ let subChangePromise = promiseSubscriptionChanges(3);
+
+ yield PushService._onPermissionChange(
+ makePushPermission('https://example.net', 'ALLOW_ACTION'),
+ 'changed'
+ );
+
+ let notifiedScopes = yield subChangePromise;
+
+ deepEqual(notifiedScopes, [
+ 'https://example.net/eggs',
+ 'https://example.net/green',
+ 'https://example.net/ham'
+ ], 'Wrong scopes after changing to allow');
+
+ let droppedRecords = yield 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(function* test_permissions_deny_changed() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister('active-deny-changed');
+
+ yield PushService._onPermissionChange(
+ makePushPermission('https://example.xyz', 'DENY_ACTION'),
+ 'changed'
+ );
+
+ yield unregisterPromise;
+
+ let notifiedScopes = yield subModifiedPromise;
+ deepEqual(notifiedScopes, [
+ 'https://example.xyz/page/1',
+ ], 'Wrong scopes modified after changing to deny');
+
+ let record = yield db.getByKeyID('active-deny-changed');
+ ok(record.isExpired(),
+ 'Should expire active record after changing to deny');
+});
+
+add_task(function* test_permissions_clear() {
+ let subModifiedPromise = promiseSubscriptionModifications(3);
+
+ deepEqual(yield 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'),
+ ]);
+
+ yield PushService._onPermissionChange(null, 'cleared');
+
+ yield unregisterPromise;
+
+ let notifiedScopes = yield subModifiedPromise;
+ deepEqual(notifiedScopes, [
+ 'https://example.edu/lonely',
+ 'https://example.info/page/1',
+ 'https://example.xyz/page/1',
+ ], 'Wrong scopes modified after clearing registrations');
+
+ deepEqual(yield 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..1982fe04ce
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_exceeded.js
@@ -0,0 +1,141 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+const userAgentID = '7eb873f9-8d47-4218-804b-fff78dc04e88';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ 'testing.ignorePermission': true,
+ });
+ run_next_test();
+}
+
+add_task(function* test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => db.drop().then(_ => db.close()));
+
+ yield 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,
+ });
+ yield 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.
+ yield 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) {},
+ });
+ },
+ });
+
+ yield unregisterPromise;
+
+ yield notifyPromise;
+
+ let expiredRecord = yield 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..9401a5c869
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_observer.js
@@ -0,0 +1,183 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '28cd09e2-7506-42d8-9e50-b02785adc7ef';
+
+var db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+let putRecord = Task.async(function* (perm, record) {
+ let uri = Services.io.newURI(record.scope, null, null);
+
+ Services.perms.add(uri, 'desktop-notification',
+ Ci.nsIPermissionManager[perm]);
+ do_register_cleanup(() => {
+ Services.perms.remove(uri, 'desktop-notification');
+ });
+
+ yield db.put(record);
+});
+
+add_task(function* test_expiration_history_observer() {
+ db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => db.drop().then(_ => db.close()));
+
+ // A registration that we'll expire...
+ yield 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.
+ yield 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,
+ });
+
+ yield 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) {},
+ });
+ }
+ });
+
+ yield subChangePromise;
+ yield unregisterPromise;
+
+ let expiredRecord = yield 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.
+ yield 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.
+ yield 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...
+ yield 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.
+ yield subChangePromise;
+ deepEqual(notifiedScopes.sort(), [
+ 'https://example.com/auctions',
+ 'https://example.com/deals'
+ ], 'Wrong scopes for subscription changes');
+
+ let aRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
+ ok(!aRecord, 'Should drop expired record');
+
+ let bRecord = yield 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.
+ yield 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.isCodebasePrincipal,
+ 'Should pass subscription principal as the subject');
+ return true;
+ }
+ });
+ let record = yield PushService.registration({
+ scope: 'https://example.net/sales',
+ originAttributes: '',
+ });
+ ok(!record, 'Should not return evicted record');
+ ok(!(yield db.getByKeyID('6b2d13fe-d848-4c5f-bdda-e9fc89727dca')),
+ 'Should drop evicted record on fetch');
+ yield 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..556cc9d0c8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_with_notification.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+const userAgentID = 'aaabf1f8-2f68-44f1-a920-b88e9e7d7559';
+const nsIPushQuotaManager = Components.interfaces.nsIPushQuotaManager;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ 'testing.ignorePermission': true,
+ });
+ run_next_test();
+}
+
+add_task(function* test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {
+ 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");
+
+ yield 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.
+ yield 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) {},
+ });
+ },
+ });
+
+ yield notifyPromise;
+
+ yield updateQuotaPromise;
+ yield modifiedPromise;
+
+ let expiredRecord = yield 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..d8a21789da
--- /dev/null
+++ b/dom/push/test/xpcshell/test_reconnect_retry.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 10000,
+ retryBaseInterval: 150
+ });
+ run_next_test();
+}
+
+add_task(function* test_reconnect_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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 = yield PushService.register({
+ scope: 'https://example.com/page/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+ let retryEndpoint = 'https://example.org/push/' + channelID;
+ equal(registration.endpoint, retryEndpoint, 'Wrong endpoint for retried request');
+
+ registration = yield PushService.register({
+ scope: 'https://example.com/page/2',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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..7807fb9d3f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_record.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const {PushRecord} = Cu.import('resource://gre/modules/PushRecord.jsm', {});
+
+function run_test() {
+ run_next_test();
+}
+
+add_task(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(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);
+ equal(record.uri.spec, props.scope,
+ `Record URI should match scope URL for ${JSON.stringify(props)}`);
+ if (props.originAttributes) {
+ let 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`);
+ let permURI = Services.io.newURI(props.scope, null, null);
+ Services.perms.add(permURI, 'desktop-notification',
+ Ci.nsIPermissionManager.ALLOW_ACTION);
+ try {
+ ok(record.hasPermission(), `Record ${
+ JSON.stringify(props)} should have permission`);
+ } finally {
+ Services.perms.remove(permURI, 'desktop-notification');
+ }
+}
+
+add_task(function* test_principal_permissions() {
+ let testProps = [{
+ scope: 'https://example.com/',
+ }, {
+ scope: 'https://example.com/',
+ originAttributes: '^userContextId=1',
+ }, {
+ scope: 'https://блог.фанфрог.рф/',
+ }, {
+ scope: 'https://блог.фанфрог.рф/',
+ 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..8199481e4e
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+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) {
+ do_check_true(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(function* test1() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ 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({
+ appId: Ci.nsIScriptSecurityManager.NO_APP_ID,
+ inIsolatedMozBrowser: false,
+ });
+ let newRecord = yield PushService.register({
+ scope: 'https://example.com/retry5xxCode',
+ originAttributes: 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 = yield 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..98670c742d
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_case.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '1760b1f5-c3ba-40e3-9344-adef7c18ab12';
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(function* test_register_case() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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 = yield PushService.register({
+ scope: 'https://example.net/case',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+ equal(newRecord.endpoint, 'https://example.com/update/case',
+ 'Wrong push endpoint in registration record');
+
+ let record = yield 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_error_http2.js b/dom/push/test/xpcshell/test_register_error_http2.js
new file mode 100644
index 0000000000..eeb3b64b09
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_error_http2.js
@@ -0,0 +1,201 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+var prefs;
+var tlsProfile;
+var serverURL;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
+
+ tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
+
+ serverURL = "https://localhost:" + serverPort;
+
+ run_next_test();
+}
+
+// Connection will fail because of the certificates.
+add_task(function* test_pushSubscriptionNoConnection() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionNoConnection/subscribe",
+ db
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-response',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for not being able to establish connecion.'
+ );
+
+ let record = yield db.getAllKeyIDs();
+ ok(record.length === 0, "Should not store records when connection couldn't be established.");
+ PushService.uninit();
+});
+
+add_task(function* test_TLS() {
+ // Set to allow the cert presented by our H2 server
+ var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
+ prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
+
+ addCertOverride("localhost", serverPort,
+ Ci.nsICertOverrideService.ERROR_UNTRUSTED |
+ Ci.nsICertOverrideService.ERROR_MISMATCH |
+ Ci.nsICertOverrideService.ERROR_TIME);
+
+ prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+});
+
+add_task(function* test_pushSubscriptionMissingLocation() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionMissingLocation/subscribe",
+ db
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-response',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for the missing location header.'
+ );
+
+ let record = yield db.getAllKeyIDs();
+ ok(record.length === 0, 'Should not store records when the location header is missing.');
+ PushService.uninit();
+});
+
+add_task(function* test_pushSubscriptionMissingLink() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionMissingLink/subscribe",
+ db
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-response',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for the missing link header.'
+ );
+
+ let record = yield db.getAllKeyIDs();
+ ok(record.length === 0, 'Should not store records when a link header is missing.');
+ PushService.uninit();
+});
+
+add_task(function* test_pushSubscriptionMissingLink1() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionMissingLink1/subscribe",
+ db
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-response',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for the missing push endpoint.'
+ );
+
+ let record = yield db.getAllKeyIDs();
+ ok(record.length === 0, 'Should not store records when the push endpoint is missing.');
+ PushService.uninit();
+});
+
+add_task(function* test_pushSubscriptionLocationBogus() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionLocationBogus/subscribe",
+ db
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-response',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for the bogus location'
+ );
+
+ let record = yield db.getAllKeyIDs();
+ ok(record.length === 0, 'Should not store records when location header is bogus.');
+ PushService.uninit();
+});
+
+add_task(function* test_pushSubscriptionNot2xxCode() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionNot201Code/subscribe",
+ db
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-response',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for not 201 responce code.'
+ );
+
+ let record = yield db.getAllKeyIDs();
+ ok(record.length === 0, 'Should not store records when respons code is not 201.');
+});
+
+add_task(function* test_complete() {
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
+});
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..49d2fe6745
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_flush.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '9ce1e6d3-7bdb-4fe9-90a5-def1d64716f1';
+const channelID = 'c26892c5-6e08-4c16-9f0c-0044697b4d85';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150
+ });
+ run_next_test();
+}
+
+add_task(function* test_register_flush() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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,
+ };
+ yield 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 = yield 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} = yield notifyPromise;
+ equal(scope, 'https://example.com/page/1', 'Wrong notification scope');
+
+ yield ackPromise;
+
+ let prevRecord = yield 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 = yield 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..cd82ebef37
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_channel.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '52b2b04c-b6cc-42c6-abdf-bef9cbdbea00';
+const channelID = 'cafed00d';
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(function* test_register_invalid_channel() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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'
+ }));
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.com/invalid-channel',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for invalid channel ID'
+ );
+
+ let record = yield 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..03b9efbaf1
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(function* test_register_invalid_endpoint() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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: '!@#$%^&*'
+ }));
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-endpoint',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for invalid endpoint'
+ );
+
+ let record = yield 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..a2ec515885
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_json.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(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++;
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/invalid-json',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for invalid JSON response'
+ );
+
+ yield 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..815dff1dd2
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_no_id.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(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
+ }));
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.com/incomplete',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for incomplete register response'
+ );
+
+ yield 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..75ca1d3485
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_request_queue.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150
+ });
+ run_next_test();
+}
+
+add_task(function* test_register_request_queue() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ let onHello;
+ let helloPromise = new Promise(resolve => onHello = after(2, function onHello(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(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+ let secondRegister = PushService.register({
+ scope: 'https://example.com/page/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+
+ yield Promise.all([
+ rejects(firstRegister, 'Should time out the first request'),
+ rejects(secondRegister, 'Should time out the second request')
+ ]);
+
+ yield 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..5a316257b3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_rollback.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(function* test_register_rollback() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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++;
+ equal(request.uaid, userAgentID, 'Handshake: wrong device ID');
+ 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.
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.com/storage-error',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for unregister database failure'
+ );
+
+ // Should send an out-of-band unregister request.
+ yield 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..94d09546ae
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_success.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(function* test_register_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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');
+ equal(data.uaid, userAgentID, 'Handshake: wrong device ID');
+ 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: channelID,
+ uaid: userAgentID,
+ pushEndpoint: 'https://example.com/update/1',
+ }));
+ }
+ });
+ }
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic);
+
+ let newRecord = yield PushService.register({
+ scope: 'https://example.org/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+ equal(newRecord.endpoint, 'https://example.com/update/1',
+ 'Wrong push endpoint in registration record');
+
+ let {data: subModifiedScope} = yield subModifiedPromise;
+ equal(subModifiedScope, 'https://example.org/1',
+ 'Should fire a subscription modified event after subscribing');
+
+ let record = yield 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_success_http2.js b/dom/push/test/xpcshell/test_register_success_http2.js
new file mode 100644
index 0000000000..b4dbb09e37
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_success_http2.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+var prefs;
+var tlsProfile;
+var serverURL;
+var serverPort = -1;
+var pushEnabled;
+var pushConnectionEnabled;
+var db;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
+
+ tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
+ pushEnabled = prefs.getBoolPref("dom.push.enabled");
+ pushConnectionEnabled = prefs.getBoolPref("dom.push.connection.enabled");
+
+ // Set to allow the cert presented by our H2 server
+ var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
+ prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
+ prefs.setBoolPref("dom.push.enabled", true);
+ prefs.setBoolPref("dom.push.connection.enabled", true);
+
+ addCertOverride("localhost", serverPort,
+ Ci.nsICertOverrideService.ERROR_UNTRUSTED |
+ Ci.nsICertOverrideService.ERROR_MISMATCH |
+ Ci.nsICertOverrideService.ERROR_TIME);
+
+ prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+
+ serverURL = "https://localhost:" + serverPort;
+
+ run_next_test();
+}
+
+add_task(function* test_setup() {
+
+ db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+});
+
+add_task(function* test_pushSubscriptionSuccess() {
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionSuccess/subscribe",
+ db
+ });
+
+ let newRecord = yield PushService.register({
+ scope: 'https://example.org/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+
+ var subscriptionUri = serverURL + '/pushSubscriptionSuccesss';
+ var pushEndpoint = serverURL + '/pushEndpointSuccess';
+ var pushReceiptEndpoint = serverURL + '/receiptPushEndpointSuccess';
+ equal(newRecord.endpoint, pushEndpoint,
+ 'Wrong push endpoint in registration record');
+
+ equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint,
+ 'Wrong push endpoint receipt in registration record');
+
+ let record = yield 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.org/1',
+ 'Wrong scope in database record');
+
+ PushService.uninit()
+});
+
+add_task(function* test_pushSubscriptionMissingLink2() {
+
+ PushService.init({
+ serverURI: serverURL + "/pushSubscriptionMissingLink2/subscribe",
+ db
+ });
+
+ let newRecord = yield PushService.register({
+ scope: 'https://example.org/no_receiptEndpoint',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+
+ var subscriptionUri = serverURL + '/subscriptionMissingLink2';
+ var pushEndpoint = serverURL + '/pushEndpointMissingLink2';
+ var pushReceiptEndpoint = '';
+ equal(newRecord.endpoint, pushEndpoint,
+ 'Wrong push endpoint in registration record');
+
+ equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint,
+ 'Wrong push endpoint receipt in registration record');
+
+ let record = yield 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.org/no_receiptEndpoint',
+ 'Wrong scope in database record');
+});
+
+add_task(function* test_complete() {
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
+ prefs.setBoolPref("dom.push.enabled", pushEnabled);
+ prefs.setBoolPref("dom.push.connection.enabled", pushConnectionEnabled);
+});
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..c2da107f86
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_timeout.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(function* test_register_timeout() {
+ let handshakes = 0;
+ let timeoutDone;
+ let timeoutPromise = new Promise(resolve => timeoutDone = resolve);
+ let registers = 0;
+
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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 (handshakes === 0) {
+ equal(request.uaid, null, 'Should not include device ID');
+ } else if (handshakes === 1) {
+ // Should use the previously-issued device ID when reconnecting,
+ // but should not include the timed-out channel ID.
+ equal(request.uaid, userAgentID,
+ 'Should include device ID on reconnect');
+ } else {
+ 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');
+ setTimeout(() => {
+ // Should ignore replies for timed-out requests.
+ this.serverSendMsg(JSON.stringify({
+ messageType: 'register',
+ status: 200,
+ channelID: channelID,
+ uaid: userAgentID,
+ pushEndpoint: 'https://example.com/update/timeout',
+ }));
+ timeoutDone();
+ }, 2000);
+ registers++;
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.net/page/timeout',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for request timeout'
+ );
+
+ let record = yield db.getByKeyID(channelID);
+ ok(!record, 'Should not store records for timed-out responses');
+
+ yield 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..a929ada039
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_id.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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(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
+ }));
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.com/mismatched',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for mismatched register reply'
+ );
+
+ yield 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..ade84ed76a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_type.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService} = serviceExports;
+
+const userAgentID = 'c293fdc5-a75e-4eb1-af88-a203991c0787';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150
+ });
+ run_next_test();
+}
+
+add_task(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'
+ }));
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: 'https://example.com/mistyped',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for non-string channel ID'
+ );
+
+ yield 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..bdade78ccc
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error.js
@@ -0,0 +1,43 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: '6faed1f0-1439-4aac-a978-db21c81cd5eb'
+ });
+ run_next_test();
+}
+
+add_task(function* test_registrations_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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);
+ }
+ });
+
+ yield rejects(
+ PushService.registration({
+ scope: 'https://example.net/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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..d4935787cd
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error_http2.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+add_task(function* test_registrations_error() {
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ PushService.init({
+ serverURI: "https://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers() {
+ return Promise.reject('Database error');
+ }
+ }),
+ });
+
+ yield rejects(
+ PushService.registration({
+ scope: 'https://example.net/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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..a30fad9ebd
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_missing_scope.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(function* test_registration_missing_scope() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ }
+ });
+ yield rejects(
+ PushService.registration({ scope: '', originAttributes: '' }),
+ '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..7c5b7118c6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_none.js
@@ -0,0 +1,31 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService} = serviceExports;
+
+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(function* test_registration_none() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ }
+ });
+
+ let registration = yield PushService.registration({
+ scope: 'https://example.net/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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..8c579dfc4b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '997ee7ba-36b1-4526-ae9e-2d3f38d6efe8';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({userAgentID});
+ run_next_test();
+}
+
+add_task(function* test_registration_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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,
+ }, {
+ channelID: 'f6edfbcd-79d6-49b8-9766-48b9dcfeff0f',
+ pushEndpoint: 'https://example.com/update/same-manifest/2',
+ scope: 'https://example.net/b',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: 42 }),
+ version: 10,
+ quota: Infinity,
+ }, {
+ channelID: 'b1cf38c9-6836-4d29-8a30-a3e98d59b728',
+ pushEndpoint: 'https://example.org/update/different-manifest',
+ scope: 'https://example.org/c',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: 42, inIsolatedMozBrowser: true }),
+ version: 15,
+ quota: Infinity,
+ }];
+ for (let record of records) {
+ yield 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();
+ }
+ });
+ }
+ });
+
+ yield handshakePromise;
+
+ let registration = yield 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..010108ca3c
--- /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';
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+var prefs;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
+
+ run_next_test();
+}
+
+add_task(function* test_pushNotifications() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ 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(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ }, {
+ subscriptionUri: serverURL + '/subscriptionB',
+ pushEndpoint: serverURL + '/pushEndpointB',
+ pushReceiptEndpoint: serverURL + '/pushReceiptEndpointB',
+ scope: 'https://example.net/b',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ }, {
+ subscriptionUri: serverURL + '/subscriptionC',
+ pushEndpoint: serverURL + '/pushEndpointC',
+ pushReceiptEndpoint: serverURL + '/pushReceiptEndpointC',
+ scope: 'https://example.net/c',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ }];
+
+ for (let record of records) {
+ yield db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL,
+ db
+ });
+
+ let registration = yield PushService.registration({
+ scope: 'https://example.net/a',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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..17db69f0e9
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+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) {
+ do_check_true(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(function* test1() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ 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) {
+ yield db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db
+ });
+
+ yield handlerPromise;
+
+ let record = yield 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..bbe634d90a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+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) {
+ do_check_true(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(function* test1() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ 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) {
+ yield db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db
+ });
+
+ yield handlerPromise;
+
+ let record = yield 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..660e27f116
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
@@ -0,0 +1,105 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+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) {
+ do_check_true(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(function* test1() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ 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) {
+ yield db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db
+ });
+
+ yield handlerPromise;
+
+ let record = yield 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..05f2616298
--- /dev/null
+++ b/dom/push/test/xpcshell/test_retry_ws.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '05f7b940-51b6-4b6f-8032-b83ebb577ded';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ pingInterval: 2000,
+ retryBaseInterval: 25,
+ });
+ run_next_test();
+}
+
+add_task(function* test_ws_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ yield 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();
+ },
+ });
+ },
+ });
+
+ yield 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..8426936b84
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_child.js
@@ -0,0 +1,307 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.importGlobalProperties(["crypto"]);
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+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();
+ do_register_cleanup(() => {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.length,
+ 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.length,
+ 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.length,
+ 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_app_principal() {
+ let principal = Services.scriptSecurityManager.getAppCodebasePrincipal(
+ Services.io.newURI('https://example.net/app/1', null, null),
+ 1, /* appId */
+ true /* browserOnly */
+ );
+
+ do_test_pending();
+ PushServiceComponent.subscribe('https://example.net/scope/1', principal, (result, subscription) => {
+ ok(Components.isSuccessCode(result), 'Error creating subscription');
+ ok(subscription.endpoint.startsWith('https://example.org/push'),
+ 'Wrong push endpoint in app subscription');
+ ok(!subscription.isSystemSubscription,
+ 'Unexpected system subscription for app principal');
+ equal(subscription.quota, 16, 'Wrong quota for app subscription');
+
+ do_test_finished();
+ run_next_test();
+ });
+});
+
+add_test(function test_subscribe_origin_principal() {
+ let scope = 'https://example.net/origin-principal';
+ let principal =
+ Services.scriptSecurityManager.createCodebasePrincipalFromOrigin(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..3b08d641d5
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_parent.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+add_task(function* test_service_parent() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+ yield 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");
+
+ yield run_test_in_child('./test_service_child.js');
+
+ yield tearDownServiceInParent(db);
+});
diff --git a/dom/push/test/xpcshell/test_startup_error.js b/dom/push/test/xpcshell/test_startup_error.js
new file mode 100644
index 0000000000..b01b8a917b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_startup_error.js
@@ -0,0 +1,71 @@
+'use strict';
+
+const {PushService, PushServiceWebSocket} = serviceExports;
+
+function run_test() {
+ setPrefs();
+ do_get_profile();
+ run_next_test();
+}
+
+add_task(function* test_startup_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ PushService.init({
+ serverURI: 'wss://push.example.org/',
+ db: makeStub(db, {
+ getAllExpired(prev) {
+ return Promise.reject('database corruption on startup');
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ ok(false, 'Unexpected handshake');
+ },
+ onRegister(request) {
+ ok(false, 'Unexpected register request');
+ },
+ });
+ },
+ });
+
+ yield rejects(
+ PushService.register({
+ scope: `https://example.net/1`,
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Should not register if startup failed'
+ );
+
+ PushService.uninit();
+
+ PushService.init({
+ serverURI: 'wss://push.example.org/',
+ db: makeStub(db, {
+ getAllUnexpired(prev) {
+ return Promise.reject('database corruption on connect');
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ ok(false, 'Unexpected handshake');
+ },
+ onRegister(request) {
+ ok(false, 'Unexpected register request');
+ },
+ });
+ },
+ });
+ yield rejects(
+ PushService.registration({
+ scope: `https://example.net/1`,
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Should not return registration if connection failed'
+ );
+});
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..32b12f9e4b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(function* test_unregister_empty_scope() {
+ 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'
+ }));
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.unregister({
+ scope: '',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ '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..53d5929185
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_error.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const channelID = '00c7fa13-7b71-447d-bd27-a91abc09d1b2';
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(function* test_unregister_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+ yield db.put({
+ channelID: 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();
+ }
+ });
+ }
+ });
+
+ yield PushService.unregister({
+ scope: 'https://example.net/page/failure',
+ originAttributes: '',
+ });
+
+ let result = yield db.getByKeyID(channelID);
+ ok(!result, 'Deleted push record exists');
+
+ // Make sure we send a request to the server.
+ yield 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..28c10e9996
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_invalid_json.js
@@ -0,0 +1,92 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = '7f0af1bb-7e1f-4fb8-8e4a-e8de434abde3';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 150,
+ retryBaseInterval: 150
+ });
+ run_next_test();
+}
+
+add_task(function* test_unregister_invalid_json() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {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) {
+ yield 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();
+ }
+ });
+ }
+ });
+
+ yield rejects(
+ PushService.unregister({
+ scope: 'https://example.edu/page/1',
+ originAttributes: '',
+ }),
+ 'Expected error for first invalid JSON response'
+ );
+
+ let record = yield db.getByKeyID(
+ '87902e90-c57e-4d18-8354-013f4a556559');
+ ok(!record, 'Failed to delete unregistered record');
+
+ yield rejects(
+ PushService.unregister({
+ scope: 'https://example.net/page/1',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ }),
+ 'Expected error for second invalid JSON response'
+ );
+
+ record = yield db.getByKeyID(
+ '057caa8f-9b99-47ff-891c-adad18ce603e');
+ ok(!record,
+ 'Failed to delete unregistered record after receiving invalid JSON');
+
+ yield 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..4bd6776131
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_not_found.js
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService} = serviceExports;
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(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 = yield PushService.unregister({
+ scope: 'https://example.net/nonexistent',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, 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..6bf6dff3f9
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
+
+const userAgentID = 'fbe865a6-aeb8-446f-873c-aeebdb8d493c';
+const channelID = 'db0a7021-ec2d-4bd3-8802-7a6966f10ed8';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* test_unregister_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+ yield 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);
+
+ yield PushService.unregister({
+ scope: 'https://example.com/page/unregister-success',
+ originAttributes: '',
+ });
+
+ let {data: subModifiedScope} = yield subModifiedPromise;
+ equal(subModifiedScope, 'https://example.com/page/unregister-success',
+ 'Should fire a subscription modified event after unsubscribing');
+
+ let record = yield db.getByKeyID(channelID);
+ ok(!record, 'Unregister did not remove record');
+
+ yield 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..f2eb353318
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success_http2.js
@@ -0,0 +1,81 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+var prefs;
+var tlsProfile;
+var pushEnabled;
+var pushConnectionEnabled;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
+
+ tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
+ pushEnabled = prefs.getBoolPref("dom.push.enabled");
+ pushConnectionEnabled = prefs.getBoolPref("dom.push.connection.enabled");
+
+ // Set to allow the cert presented by our H2 server
+ var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
+ prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
+ prefs.setBoolPref("dom.push.enabled", true);
+ prefs.setBoolPref("dom.push.connection.enabled", true);
+
+ addCertOverride("localhost", serverPort,
+ Ci.nsICertOverrideService.ERROR_UNTRUSTED |
+ Ci.nsICertOverrideService.ERROR_MISMATCH |
+ Ci.nsICertOverrideService.ERROR_TIME);
+
+ prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+
+ run_next_test();
+}
+
+add_task(function* test_pushUnsubscriptionSuccess() {
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ yield db.put({
+ subscriptionUri: serverURL + '/subscriptionUnsubscriptionSuccess',
+ pushEndpoint: serverURL + '/pushEndpointUnsubscriptionSuccess',
+ pushReceiptEndpoint: serverURL + '/receiptPushEndpointUnsubscriptionSuccess',
+ scope: 'https://example.com/page/unregister-success',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ quota: Infinity,
+ });
+
+ PushService.init({
+ serverURI: serverURL,
+ db
+ });
+
+ yield PushService.unregister({
+ scope: 'https://example.com/page/unregister-success',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+ let record = yield db.getByKeyID(serverURL + '/subscriptionUnsubscriptionSuccess');
+ ok(!record, 'Unregister did not remove record');
+
+});
+
+add_task(function* test_complete() {
+ prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
+ prefs.setBoolPref("dom.push.enabled", pushEnabled);
+ 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..0704344c27
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://testing-common/httpd.js");
+
+const {PushDB, PushService, PushServiceHttp2} = serviceExports;
+
+var httpServer = null;
+
+XPCOMUtils.defineLazyGetter(this, "serverPort", function() {
+ return httpServer.identity.primaryPort;
+});
+
+function listenHandler(metadata, response) {
+ do_check_true(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(function* test1() {
+
+ let db = PushServiceHttp2.newPushDB();
+ do_register_cleanup(_ => {
+ 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,
+ };
+
+ yield db.put(record);
+
+ let notifyPromise = promiseObserverNotification(PushServiceComponent.subscriptionChangeTopic,
+ _ => true);
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db
+ });
+
+ yield notifyPromise;
+
+ let aRecord = yield 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..d135a39a01
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
@@ -0,0 +1,86 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+'use strict';
+
+const {PushDB, PushService, PushServiceWebSocket, PushCrypto} = serviceExports;
+
+const userAgentID = '4dffd396-6582-471d-8c0c-84f394e9f7db';
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(function* test_with_data_enabled() {
+ let db = PushServiceWebSocket.newPushDB();
+ do_register_cleanup(() => {return db.drop().then(_ => db.close());});
+
+ let [publicKey, privateKey] = yield 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) {
+ yield 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 = yield PushService.register({
+ scope: 'https://example.com/page/3',
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ { appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
+ });
+ ok(newRecord.p256dhKey, 'Should generate public keys for new records');
+
+ let record = yield 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 = yield 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..63ddfcc81b
--- /dev/null
+++ b/dom/push/test/xpcshell/xpcshell.ini
@@ -0,0 +1,83 @@
+[DEFAULT]
+head = head.js head-http2.js
+tail =
+# Push notifications and alarms are currently disabled on Android.
+skip-if = toolkit == 'android'
+
+[test_clear_forgetAboutSite.js]
+[test_clear_origin_data.js]
+[test_crypto.js]
+[test_drop_expired.js]
+[test_handler_service.js]
+support-files = PushServiceHandler.js PushServiceHandler.manifest
+[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]
+
+[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]
+[test_unregister_not_found.js]
+[test_unregister_success.js]
+[test_updateRecordNoEncryptionKeys_ws.js]
+[test_reconnect_retry.js]
+[test_retry_ws.js]
+[test_service_parent.js]
+[test_service_child.js]
+[test_startup_error.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_register_success_http2.js]
+skip-if = !hasNode
+run-sequentially = node server exceptions dont replay well
+[test_register_error_http2.js]
+skip-if = !hasNode
+run-sequentially = node server exceptions dont replay well
+[test_unregister_success_http2.js]
+skip-if = !hasNode
+run-sequentially = node server exceptions dont replay well
+[test_notification_http2.js]
+skip-if = !hasNode
+run-sequentially = node server exceptions dont replay well
+[test_registration_success_http2.js]
+skip-if = !hasNode
+run-sequentially = node server exceptions dont replay well
+[test_registration_error_http2.js]
+skip-if = !hasNode
+run-sequentially = node server exceptions dont replay well
+[test_clearAll_successful.js]
+skip-if = !hasNode
+run-sequentially = This will delete all existing push subscriptions.