From 5f8de423f190bbb79a62f804151bc24824fa32d8 Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 2 Feb 2018 04:16:08 -0500 Subject: Add m-esr52 at 52.6.0 --- dom/push/Push.js | 284 ++++ dom/push/Push.manifest | 11 + dom/push/PushComponents.js | 558 ++++++++ dom/push/PushCrypto.jsm | 454 +++++++ dom/push/PushDB.jsm | 440 +++++++ dom/push/PushManager.cpp | 600 +++++++++ dom/push/PushManager.h | 117 ++ dom/push/PushNotifier.cpp | 550 ++++++++ dom/push/PushNotifier.h | 203 +++ dom/push/PushRecord.jsm | 318 +++++ dom/push/PushService.jsm | 1365 ++++++++++++++++++++ dom/push/PushServiceAndroidGCM.jsm | 275 ++++ dom/push/PushServiceHttp2.jsm | 820 ++++++++++++ dom/push/PushServiceWebSocket.jsm | 1145 ++++++++++++++++ dom/push/PushSubscription.cpp | 398 ++++++ dom/push/PushSubscription.h | 99 ++ dom/push/PushSubscriptionOptions.cpp | 79 ++ dom/push/PushSubscriptionOptions.h | 56 + dom/push/PushUtil.cpp | 64 + dom/push/PushUtil.h | 43 + dom/push/moz.build | 65 + dom/push/test/error_worker.js | 10 + dom/push/test/frame.html | 24 + dom/push/test/lifetime_worker.js | 85 ++ dom/push/test/mochitest.ini | 24 + dom/push/test/mockpushserviceparent.js | 168 +++ dom/push/test/test_data.html | 218 ++++ dom/push/test/test_error_reporting.html | 130 ++ dom/push/test/test_has_permissions.html | 84 ++ dom/push/test/test_multiple_register.html | 130 ++ .../test_multiple_register_different_scope.html | 123 ++ ...ultiple_register_during_service_activation.html | 111 ++ dom/push/test/test_permissions.html | 106 ++ dom/push/test/test_register.html | 109 ++ dom/push/test/test_register_key.html | 210 +++ dom/push/test/test_serviceworker_lifetime.html | 362 ++++++ dom/push/test/test_subscription_change.html | 69 + .../test_try_registering_offline_disabled.html | 305 +++++ dom/push/test/test_unregister.html | 81 ++ dom/push/test/test_utils.js | 245 ++++ dom/push/test/webpush.js | 186 +++ dom/push/test/worker.js | 152 +++ dom/push/test/xpcshell/PushServiceHandler.js | 31 + dom/push/test/xpcshell/PushServiceHandler.manifest | 4 + dom/push/test/xpcshell/head-http2.js | 62 + dom/push/test/xpcshell/head.js | 463 +++++++ dom/push/test/xpcshell/moz.build | 4 + dom/push/test/xpcshell/test_clearAll_successful.js | 115 ++ .../test/xpcshell/test_clear_forgetAboutSite.js | 128 ++ dom/push/test/xpcshell/test_clear_origin_data.js | 141 ++ dom/push/test/xpcshell/test_crypto.js | 249 ++++ dom/push/test/xpcshell/test_drop_expired.js | 154 +++ dom/push/test/xpcshell/test_handler_service.js | 47 + dom/push/test/xpcshell/test_notification_ack.js | 125 ++ dom/push/test/xpcshell/test_notification_data.js | 280 ++++ .../test/xpcshell/test_notification_duplicate.js | 140 ++ dom/push/test/xpcshell/test_notification_error.js | 117 ++ dom/push/test/xpcshell/test_notification_http2.js | 189 +++ .../test/xpcshell/test_notification_incomplete.js | 130 ++ .../xpcshell/test_notification_version_string.js | 69 + dom/push/test/xpcshell/test_observer_data.js | 42 + dom/push/test/xpcshell/test_observer_remoting.js | 111 ++ dom/push/test/xpcshell/test_permissions.js | 296 +++++ dom/push/test/xpcshell/test_quota_exceeded.js | 141 ++ dom/push/test/xpcshell/test_quota_observer.js | 183 +++ .../test/xpcshell/test_quota_with_notification.js | 120 ++ dom/push/test/xpcshell/test_reconnect_retry.js | 73 ++ dom/push/test/xpcshell/test_record.js | 93 ++ .../test/xpcshell/test_register_5xxCode_http2.js | 112 ++ dom/push/test/xpcshell/test_register_case.js | 56 + .../test/xpcshell/test_register_error_http2.js | 201 +++ dom/push/test/xpcshell/test_register_flush.js | 96 ++ .../test/xpcshell/test_register_invalid_channel.js | 57 + .../xpcshell/test_register_invalid_endpoint.js | 58 + .../test/xpcshell/test_register_invalid_json.js | 58 + dom/push/test/xpcshell/test_register_no_id.js | 62 + .../test/xpcshell/test_register_request_queue.js | 61 + dom/push/test/xpcshell/test_register_rollback.js | 87 ++ dom/push/test/xpcshell/test_register_success.js | 77 ++ .../test/xpcshell/test_register_success_http2.js | 128 ++ dom/push/test/xpcshell/test_register_timeout.js | 87 ++ dom/push/test/xpcshell/test_register_wrong_id.js | 68 + dom/push/test/xpcshell/test_register_wrong_type.js | 62 + dom/push/test/xpcshell/test_registration_error.js | 43 + .../test/xpcshell/test_registration_error_http2.js | 37 + .../xpcshell/test_registration_missing_scope.js | 25 + dom/push/test/xpcshell/test_registration_none.js | 31 + .../test/xpcshell/test_registration_success.js | 78 ++ .../xpcshell/test_registration_success_http2.js | 77 ++ .../xpcshell/test_resubscribe_4xxCode_http2.js | 103 ++ .../xpcshell/test_resubscribe_5xxCode_http2.js | 106 ++ ...st_resubscribe_listening_for_msg_error_http2.js | 105 ++ dom/push/test/xpcshell/test_retry_ws.js | 69 + dom/push/test/xpcshell/test_service_child.js | 307 +++++ dom/push/test/xpcshell/test_service_parent.js | 28 + dom/push/test/xpcshell/test_startup_error.js | 71 + .../test/xpcshell/test_unregister_empty_scope.js | 38 + dom/push/test/xpcshell/test_unregister_error.js | 68 + .../test/xpcshell/test_unregister_invalid_json.js | 92 ++ .../test/xpcshell/test_unregister_not_found.js | 36 + dom/push/test/xpcshell/test_unregister_success.js | 76 ++ .../test/xpcshell/test_unregister_success_http2.js | 81 ++ .../test_updateRecordNoEncryptionKeys_http2.js | 77 ++ .../test_updateRecordNoEncryptionKeys_ws.js | 86 ++ dom/push/test/xpcshell/xpcshell.ini | 83 ++ 105 files changed, 17370 insertions(+) create mode 100644 dom/push/Push.js create mode 100644 dom/push/Push.manifest create mode 100644 dom/push/PushComponents.js create mode 100644 dom/push/PushCrypto.jsm create mode 100644 dom/push/PushDB.jsm create mode 100644 dom/push/PushManager.cpp create mode 100644 dom/push/PushManager.h create mode 100644 dom/push/PushNotifier.cpp create mode 100644 dom/push/PushNotifier.h create mode 100644 dom/push/PushRecord.jsm create mode 100644 dom/push/PushService.jsm create mode 100644 dom/push/PushServiceAndroidGCM.jsm create mode 100644 dom/push/PushServiceHttp2.jsm create mode 100644 dom/push/PushServiceWebSocket.jsm create mode 100644 dom/push/PushSubscription.cpp create mode 100644 dom/push/PushSubscription.h create mode 100644 dom/push/PushSubscriptionOptions.cpp create mode 100644 dom/push/PushSubscriptionOptions.h create mode 100644 dom/push/PushUtil.cpp create mode 100644 dom/push/PushUtil.h create mode 100644 dom/push/moz.build create mode 100644 dom/push/test/error_worker.js create mode 100644 dom/push/test/frame.html create mode 100644 dom/push/test/lifetime_worker.js create mode 100644 dom/push/test/mochitest.ini create mode 100644 dom/push/test/mockpushserviceparent.js create mode 100644 dom/push/test/test_data.html create mode 100644 dom/push/test/test_error_reporting.html create mode 100644 dom/push/test/test_has_permissions.html create mode 100644 dom/push/test/test_multiple_register.html create mode 100644 dom/push/test/test_multiple_register_different_scope.html create mode 100644 dom/push/test/test_multiple_register_during_service_activation.html create mode 100644 dom/push/test/test_permissions.html create mode 100644 dom/push/test/test_register.html create mode 100644 dom/push/test/test_register_key.html create mode 100644 dom/push/test/test_serviceworker_lifetime.html create mode 100644 dom/push/test/test_subscription_change.html create mode 100644 dom/push/test/test_try_registering_offline_disabled.html create mode 100644 dom/push/test/test_unregister.html create mode 100644 dom/push/test/test_utils.js create mode 100644 dom/push/test/webpush.js create mode 100644 dom/push/test/worker.js create mode 100644 dom/push/test/xpcshell/PushServiceHandler.js create mode 100644 dom/push/test/xpcshell/PushServiceHandler.manifest create mode 100644 dom/push/test/xpcshell/head-http2.js create mode 100644 dom/push/test/xpcshell/head.js create mode 100644 dom/push/test/xpcshell/moz.build create mode 100644 dom/push/test/xpcshell/test_clearAll_successful.js create mode 100644 dom/push/test/xpcshell/test_clear_forgetAboutSite.js create mode 100644 dom/push/test/xpcshell/test_clear_origin_data.js create mode 100644 dom/push/test/xpcshell/test_crypto.js create mode 100644 dom/push/test/xpcshell/test_drop_expired.js create mode 100644 dom/push/test/xpcshell/test_handler_service.js create mode 100644 dom/push/test/xpcshell/test_notification_ack.js create mode 100644 dom/push/test/xpcshell/test_notification_data.js create mode 100644 dom/push/test/xpcshell/test_notification_duplicate.js create mode 100644 dom/push/test/xpcshell/test_notification_error.js create mode 100644 dom/push/test/xpcshell/test_notification_http2.js create mode 100644 dom/push/test/xpcshell/test_notification_incomplete.js create mode 100644 dom/push/test/xpcshell/test_notification_version_string.js create mode 100644 dom/push/test/xpcshell/test_observer_data.js create mode 100644 dom/push/test/xpcshell/test_observer_remoting.js create mode 100644 dom/push/test/xpcshell/test_permissions.js create mode 100644 dom/push/test/xpcshell/test_quota_exceeded.js create mode 100644 dom/push/test/xpcshell/test_quota_observer.js create mode 100644 dom/push/test/xpcshell/test_quota_with_notification.js create mode 100644 dom/push/test/xpcshell/test_reconnect_retry.js create mode 100644 dom/push/test/xpcshell/test_record.js create mode 100644 dom/push/test/xpcshell/test_register_5xxCode_http2.js create mode 100644 dom/push/test/xpcshell/test_register_case.js create mode 100644 dom/push/test/xpcshell/test_register_error_http2.js create mode 100644 dom/push/test/xpcshell/test_register_flush.js create mode 100644 dom/push/test/xpcshell/test_register_invalid_channel.js create mode 100644 dom/push/test/xpcshell/test_register_invalid_endpoint.js create mode 100644 dom/push/test/xpcshell/test_register_invalid_json.js create mode 100644 dom/push/test/xpcshell/test_register_no_id.js create mode 100644 dom/push/test/xpcshell/test_register_request_queue.js create mode 100644 dom/push/test/xpcshell/test_register_rollback.js create mode 100644 dom/push/test/xpcshell/test_register_success.js create mode 100644 dom/push/test/xpcshell/test_register_success_http2.js create mode 100644 dom/push/test/xpcshell/test_register_timeout.js create mode 100644 dom/push/test/xpcshell/test_register_wrong_id.js create mode 100644 dom/push/test/xpcshell/test_register_wrong_type.js create mode 100644 dom/push/test/xpcshell/test_registration_error.js create mode 100644 dom/push/test/xpcshell/test_registration_error_http2.js create mode 100644 dom/push/test/xpcshell/test_registration_missing_scope.js create mode 100644 dom/push/test/xpcshell/test_registration_none.js create mode 100644 dom/push/test/xpcshell/test_registration_success.js create mode 100644 dom/push/test/xpcshell/test_registration_success_http2.js create mode 100644 dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js create mode 100644 dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js create mode 100644 dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js create mode 100644 dom/push/test/xpcshell/test_retry_ws.js create mode 100644 dom/push/test/xpcshell/test_service_child.js create mode 100644 dom/push/test/xpcshell/test_service_parent.js create mode 100644 dom/push/test/xpcshell/test_startup_error.js create mode 100644 dom/push/test/xpcshell/test_unregister_empty_scope.js create mode 100644 dom/push/test/xpcshell/test_unregister_error.js create mode 100644 dom/push/test/xpcshell/test_unregister_invalid_json.js create mode 100644 dom/push/test/xpcshell/test_unregister_not_found.js create mode 100644 dom/push/test/xpcshell/test_unregister_success.js create mode 100644 dom/push/test/xpcshell/test_unregister_success_http2.js create mode 100644 dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js create mode 100644 dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js create mode 100644 dom/push/test/xpcshell/xpcshell.ini (limited to 'dom/push') diff --git a/dom/push/Push.js b/dom/push/Push.js new file mode 100644 index 000000000..134f0a470 --- /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 000000000..1d467d821 --- /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 000000000..214e9fc47 --- /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 000000000..5a669875c --- /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: " 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: \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 000000000..02f623fa7 --- /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 000000000..2cb5a3877 --- /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 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& 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& aRawP256dhKey, + nsTArray& aAuthSecret, + nsTArray& 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&& aProxy, + nsresult aStatus, + const nsAString& aEndpoint, + const nsAString& aScope, + nsTArray&& aRawP256dhKey, + nsTArray&& aAuthSecret, + nsTArray&& 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 = mProxy->WorkerPromise(); + if (NS_SUCCEEDED(mStatus)) { + if (mEndpoint.IsEmpty()) { + promise->MaybeResolve(JS::NullHandleValue); + } else { + RefPtr 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 mProxy; + nsresult mStatus; + nsString mEndpoint; + nsString mScope; + nsTArray mRawP256dhKey; + nsTArray mAuthSecret; + nsTArray 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 rawP256dhKey, authSecret, appServerKey; + if (NS_SUCCEEDED(aStatus)) { + aStatus = GetSubscriptionParams(aSubscription, endpoint, rawP256dhKey, + authSecret, appServerKey); + } + + WorkerPrivate* worker = mProxy->GetWorkerPrivate(); + RefPtr 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 mProxy; + nsString mScope; +}; + +NS_IMPL_ISUPPORTS(GetSubscriptionCallback, nsIPushSubscriptionCallback) + +class GetSubscriptionRunnable final : public Runnable +{ +public: + GetSubscriptionRunnable(PromiseWorkerProxy* aProxy, + const nsAString& aScope, + PushManager::SubscriptionAction aAction, + nsTArray&& aAppServerKey) + : mProxy(aProxy) + , mScope(aScope) + , mAction(aAction) + , mAppServerKey(Move(aAppServerKey)) + {} + + NS_IMETHOD + Run() override + { + AssertIsOnMainThread(); + + nsCOMPtr 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 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 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 mProxy; + nsString mScope; + PushManager::SubscriptionAction mAction; + nsTArray 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 = mProxy->WorkerPromise(); + if (NS_SUCCEEDED(mStatus)) { + promise->MaybeResolve(mState); + } else { + promise->MaybeReject(aCx, JS::UndefinedHandleValue); + } + + mProxy->CleanUp(); + + return true; + } + +private: + ~PermissionResultRunnable() + {} + + RefPtr 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 r = + new PermissionResultRunnable(mProxy, rv, state); + MOZ_ALWAYS_TRUE(r->Dispatch()); + + return NS_OK; + } + +private: + ~PermissionStateRunnable() + {} + + RefPtr 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 aGivenProto) +{ + return PushManagerBinding::Wrap(aCx, this, aGivenProto); +} + +// static +already_AddRefed +PushManager::Constructor(GlobalObject& aGlobal, + const nsAString& aScope, + ErrorResult& aRv) +{ + if (!NS_IsMainThread()) { + RefPtr ret = new PushManager(aScope); + return ret.forget(); + } + + RefPtr impl = PushManagerImpl::Constructor(aGlobal, + aGlobal.Context(), + aScope, aRv); + if (aRv.Failed()) { + return nullptr; + } + + nsCOMPtr global = do_QueryInterface(aGlobal.GetAsSupports()); + RefPtr ret = new PushManager(global, impl); + + return ret.forget(); +} + +already_AddRefed +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 +PushManager::GetSubscription(ErrorResult& aRv) +{ + if (mImpl) { + MOZ_ASSERT(NS_IsMainThread()); + return mImpl->GetSubscription(aRv); + } + + return PerformSubscriptionActionFromWorker(GetSubscriptionAction, aRv); +} + +already_AddRefed +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 global = worker->GlobalScope(); + RefPtr p = Promise::Create(global, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + RefPtr proxy = PromiseWorkerProxy::Create(worker, p); + if (!proxy) { + p->MaybeReject(worker->GetJSContext(), JS::UndefinedHandleValue); + return p.forget(); + } + + RefPtr r = + new PermissionStateRunnable(proxy); + NS_DispatchToMainThread(r); + + return p.forget(); +} + +already_AddRefed +PushManager::PerformSubscriptionActionFromWorker(SubscriptionAction aAction, + ErrorResult& aRv) +{ + PushSubscriptionOptionsInit options; + return PerformSubscriptionActionFromWorker(aAction, options, aRv); +} + +already_AddRefed +PushManager::PerformSubscriptionActionFromWorker(SubscriptionAction aAction, + const PushSubscriptionOptionsInit& aOptions, + ErrorResult& aRv) +{ + WorkerPrivate* worker = GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(worker); + worker->AssertIsOnWorkerThread(); + + nsCOMPtr global = worker->GlobalScope(); + RefPtr p = Promise::Create(global, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + RefPtr proxy = PromiseWorkerProxy::Create(worker, p); + if (!proxy) { + p->MaybeReject(NS_ERROR_DOM_PUSH_ABORT_ERR); + return p.forget(); + } + + nsTArray 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 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 000000000..8b4753648 --- /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 aGivenProto) override; + + static already_AddRefed + Constructor(GlobalObject& aGlobal, const nsAString& aScope, + ErrorResult& aRv); + + already_AddRefed + PerformSubscriptionActionFromWorker(SubscriptionAction aAction, + ErrorResult& aRv); + + already_AddRefed + PerformSubscriptionActionFromWorker(SubscriptionAction aAction, + const PushSubscriptionOptionsInit& aOptions, + ErrorResult& aRv); + + already_AddRefed + Subscribe(const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv); + + already_AddRefed + GetSubscription(ErrorResult& aRv); + + already_AddRefed + PermissionState(const PushSubscriptionOptionsInit& aOptions, + ErrorResult& aRv); + +private: + ~PushManager(); + + // The following are only set and accessed on the main thread. + nsCOMPtr mGlobal; + RefPtr 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 000000000..e60db2d97 --- /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 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 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& 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(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 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(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 principal = mPrincipal; + principal.forget(aPrincipal); + return NS_OK; +} + +NS_IMETHODIMP +PushMessage::GetData(nsIPushData** aData) +{ + NS_ENSURE_ARG_POINTER(aData); + + nsCOMPtr 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 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 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 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>& aData) + : PushDispatcher(aScope, aPrincipal) + , mMessageId(aMessageId) + , mData(aData) +{} + +PushMessageDispatcher::~PushMessageDispatcher() +{} + +nsresult +PushMessageDispatcher::NotifyObservers() +{ + nsCOMPtr data; + if (mData) { + data = new PushData(mData.ref()); + } + nsCOMPtr message = new PushMessage(mPrincipal, data); + return DoNotifyObservers(message, OBSERVER_TOPIC_PUSH, mScope); +} + +nsresult +PushMessageDispatcher::NotifyWorkers() +{ + if (!ShouldNotifyWorkers()) { + return NS_OK; + } + RefPtr 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 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 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 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 000000000..878e601df --- /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 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& aData); + + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushData, nsIPushData) + NS_DECL_NSIPUSHDATA + +private: + ~PushData(); + + nsresult EnsureDecodedText(); + + nsTArray 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 mPrincipal; + nsCOMPtr mData; +}; + +class PushMessageDispatcher final : public PushDispatcher +{ +public: + PushMessageDispatcher(const nsACString& aScope, + nsIPrincipal* aPrincipal, + const nsAString& aMessageId, + const Maybe>& aData); + ~PushMessageDispatcher(); + + nsresult NotifyObservers() override; + nsresult NotifyWorkers() override; + bool SendToParent(ContentChild* aParentActor) override; + bool SendToChild(ContentParent* aContentActor) override; + +private: + const nsString mMessageId; + const Maybe> 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 000000000..08a7678e0 --- /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 000000000..373807024 --- /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 000000000..ed07be339 --- /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 000000000..ce7e325ae --- /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 000000000..46b12b8f0 --- /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 000000000..bfe8b5dd9 --- /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 mPromise; +}; + +NS_IMPL_ISUPPORTS(UnsubscribeResultCallback, nsIUnsubscribeResultCallback) + +class UnsubscribeResultRunnable final : public WorkerRunnable +{ +public: + UnsubscribeResultRunnable(WorkerPrivate* aWorkerPrivate, + already_AddRefed&& 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 = 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 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 r = + new UnsubscribeResultRunnable(worker, mProxy.forget(), aStatus, aSuccess); + MOZ_ALWAYS_TRUE(r->Dispatch()); + + return NS_OK; + } + +private: + ~WorkerUnsubscribeResultCallback() + { + } + + RefPtr 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 principal; + + { + MutexAutoLock lock(mProxy->Lock()); + if (mProxy->CleanedUp()) { + return NS_OK; + } + principal = mProxy->GetWorkerPrivate()->GetPrincipal(); + } + + MOZ_ASSERT(principal); + + RefPtr callback = + new WorkerUnsubscribeResultCallback(mProxy); + + nsCOMPtr 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 mProxy; + nsString mScope; +}; + +} // anonymous namespace + +PushSubscription::PushSubscription(nsIGlobalObject* aGlobal, + const nsAString& aEndpoint, + const nsAString& aScope, + nsTArray&& aRawP256dhKey, + nsTArray&& aAuthSecret, + nsTArray&& 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 aGivenProto) +{ + return PushSubscriptionBinding::Wrap(aCx, this, aGivenProto); +} + +// static +already_AddRefed +PushSubscription::Constructor(GlobalObject& aGlobal, + const PushSubscriptionInit& aInitDict, + ErrorResult& aRv) +{ + nsCOMPtr global = do_QueryInterface(aGlobal.GetAsSupports()); + + nsTArray 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 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 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 sub = new PushSubscription(global, + aInitDict.mEndpoint, + aInitDict.mScope, + Move(rawKey), + Move(authSecret), + Move(appServerKey)); + + return sub.forget(); +} + +already_AddRefed +PushSubscription::Unsubscribe(ErrorResult& aRv) +{ + if (!NS_IsMainThread()) { + RefPtr p = UnsubscribeFromWorker(aRv); + return p.forget(); + } + + MOZ_ASSERT(mGlobal); + + nsCOMPtr service = + do_GetService("@mozilla.org/push/Service;1"); + if (NS_WARN_IF(!service)) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + + nsCOMPtr sop = do_QueryInterface(mGlobal); + if (!sop) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + + RefPtr p = Promise::Create(mGlobal, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + RefPtr 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 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 +PushSubscription::Options() +{ + RefPtr options = mOptions; + return options.forget(); +} + +already_AddRefed +PushSubscription::UnsubscribeFromWorker(ErrorResult& aRv) +{ + WorkerPrivate* worker = GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(worker); + worker->AssertIsOnWorkerThread(); + + nsCOMPtr global = worker->GlobalScope(); + RefPtr p = Promise::Create(global, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + RefPtr proxy = PromiseWorkerProxy::Create(worker, p); + if (!proxy) { + p->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE); + return p.forget(); + } + + RefPtr 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 000000000..fd9a4a524 --- /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&& aP256dhKey, + nsTArray&& aAuthSecret, + nsTArray&& aAppServerKey); + + JSObject* + WrapObject(JSContext* aCx, JS::Handle aGivenProto) override; + + nsIGlobalObject* + GetParentObject() const + { + return mGlobal; + } + + void + GetEndpoint(nsAString& aEndpoint) const + { + aEndpoint = mEndpoint; + } + + void + GetKey(JSContext* cx, + PushEncryptionKeyName aType, + JS::MutableHandle aKey, + ErrorResult& aRv); + + static already_AddRefed + Constructor(GlobalObject& aGlobal, + const PushSubscriptionInit& aInitDict, + ErrorResult& aRv); + + already_AddRefed + Unsubscribe(ErrorResult& aRv); + + void + ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv); + + already_AddRefed + Options(); + +private: + ~PushSubscription(); + + already_AddRefed + UnsubscribeFromWorker(ErrorResult& aRv); + + nsString mEndpoint; + nsString mScope; + nsTArray mRawP256dhKey; + nsTArray mAuthSecret; + nsCOMPtr mGlobal; + RefPtr 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 000000000..bc4fead1e --- /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&& 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 aGivenProto) +{ + return PushSubscriptionOptionsBinding::Wrap(aCx, this, aGivenProto); +} + +void +PushSubscriptionOptions::GetApplicationServerKey(JSContext* aCx, + JS::MutableHandle aKey, + ErrorResult& aRv) +{ + if (!mRawAppServerKey.IsEmpty() && !mAppServerKey) { + JS::Rooted 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 000000000..1df8ebe7a --- /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&& aRawAppServerKey); + + nsIGlobalObject* + GetParentObject() const + { + return mGlobal; + } + + JSObject* + WrapObject(JSContext* aCx, JS::Handle aGivenProto) override; + + void + GetApplicationServerKey(JSContext* aCx, + JS::MutableHandle aKey, + ErrorResult& aRv); + +private: + ~PushSubscriptionOptions(); + + nsCOMPtr mGlobal; + nsTArray mRawAppServerKey; + JS::Heap 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 000000000..408b62048 --- /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& 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& 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& 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& aArray, + JS::MutableHandle aValue, + ErrorResult& aRv) +{ + if (aArray.IsEmpty()) { + aValue.set(nullptr); + return; + } + JS::Rooted 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 000000000..548ae2349 --- /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& aArray); + + static bool + CopyArrayBufferViewToArray(const ArrayBufferView& aView, + nsTArray& aArray); + + static bool + CopyBufferSourceToArray(const OwningArrayBufferViewOrArrayBuffer& aSource, + nsTArray& aArray); + + static void + CopyArrayToArrayBuffer(JSContext* aCx, const nsTArray& aArray, + JS::MutableHandle 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 000000000..b96099161 --- /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 000000000..a50f83804 --- /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 000000000..50036db15 --- /dev/null +++ b/dom/push/test/frame.html @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/dom/push/test/lifetime_worker.js b/dom/push/test/lifetime_worker.js new file mode 100644 index 000000000..46e713f4e --- /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 000000000..adb1c39d7 --- /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 000000000..78cf246ee --- /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 000000000..8de873fce --- /dev/null +++ b/dom/push/test/test_data.html @@ -0,0 +1,218 @@ + + + + + Test for Bug 1185544 + + + + + + + +Mozilla Bug 1185544 +

+ +
+
+ + + + diff --git a/dom/push/test/test_error_reporting.html b/dom/push/test/test_error_reporting.html new file mode 100644 index 000000000..9564cd510 --- /dev/null +++ b/dom/push/test/test_error_reporting.html @@ -0,0 +1,130 @@ + + + + + Test for Bug 1246341 + + + + + + +Mozilla Bug 1246341 +

+ +
+
+ + + + + diff --git a/dom/push/test/test_has_permissions.html b/dom/push/test/test_has_permissions.html new file mode 100644 index 000000000..00857b5fd --- /dev/null +++ b/dom/push/test/test_has_permissions.html @@ -0,0 +1,84 @@ + + + + + Test for Bug 1038811 + + + + + +Mozilla Bug 1038811 +

+ +
+
+ + + + diff --git a/dom/push/test/test_multiple_register.html b/dom/push/test/test_multiple_register.html new file mode 100644 index 000000000..1834882a5 --- /dev/null +++ b/dom/push/test/test_multiple_register.html @@ -0,0 +1,130 @@ + + + + + Test for Bug 1038811 + + + + + +Mozilla Bug 1038811 +

+ +
+
+ + + + 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 000000000..4540ba247 --- /dev/null +++ b/dom/push/test/test_multiple_register_different_scope.html @@ -0,0 +1,123 @@ + + + + + Test for Bug 1150812 + + + + + +Mozilla Bug 1150812 +

+ +
+
+ + + + 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 000000000..98aef4a3a --- /dev/null +++ b/dom/push/test/test_multiple_register_during_service_activation.html @@ -0,0 +1,111 @@ + + + + + Test for Bug 1150812 + + + + + +Mozilla Bug 1150812 +

+ +
+
+ + + + diff --git a/dom/push/test/test_permissions.html b/dom/push/test/test_permissions.html new file mode 100644 index 000000000..1d78e34f8 --- /dev/null +++ b/dom/push/test/test_permissions.html @@ -0,0 +1,106 @@ + + + + + Test for Bug 1038811 + + + + + + +Mozilla Bug 1038811 +

+ +
+
+ + + + diff --git a/dom/push/test/test_register.html b/dom/push/test/test_register.html new file mode 100644 index 000000000..70071b09d --- /dev/null +++ b/dom/push/test/test_register.html @@ -0,0 +1,109 @@ + + + + + Test for Bug 1038811 + + + + + + +Mozilla Bug 1038811 +

+ +
+
+ + + + diff --git a/dom/push/test/test_register_key.html b/dom/push/test/test_register_key.html new file mode 100644 index 000000000..23ecf2f01 --- /dev/null +++ b/dom/push/test/test_register_key.html @@ -0,0 +1,210 @@ + + + + + Test for Bug 1247685 + + + + + + +Mozilla Bug 1247685 +

+ +
+
+ + + + + diff --git a/dom/push/test/test_serviceworker_lifetime.html b/dom/push/test/test_serviceworker_lifetime.html new file mode 100644 index 000000000..03f66887a --- /dev/null +++ b/dom/push/test/test_serviceworker_lifetime.html @@ -0,0 +1,362 @@ + + + + + Test for Bug 1188545 + + + + + +Mozilla Bug 118845 +

+ +
+
+ + + + diff --git a/dom/push/test/test_subscription_change.html b/dom/push/test/test_subscription_change.html new file mode 100644 index 000000000..3f2e45e5a --- /dev/null +++ b/dom/push/test/test_subscription_change.html @@ -0,0 +1,69 @@ + + + + + Test for Bug 1205109 + + + + + + +Mozilla Bug 1205109 +

+ +
+
+ + + + 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 000000000..d0d16e39c --- /dev/null +++ b/dom/push/test/test_try_registering_offline_disabled.html @@ -0,0 +1,305 @@ + + + + + Test for Bug 1150812 + + + + + +Mozilla Bug 1150812 +

+ +
+
+ + + + diff --git a/dom/push/test/test_unregister.html b/dom/push/test/test_unregister.html new file mode 100644 index 000000000..f15b36c47 --- /dev/null +++ b/dom/push/test/test_unregister.html @@ -0,0 +1,81 @@ + + + + + Test for Bug 1170817 + + + + + + +Mozilla Bug 1170817 +

+ +
+
+ + + + + diff --git a/dom/push/test/test_utils.js b/dom/push/test/test_utils.js new file mode 100644 index 000000000..efd2f9dd7 --- /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 000000000..6aacc5ae1 --- /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 000000000..0e26f228d --- /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 000000000..d63f32c97 --- /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 000000000..f25b49896 --- /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 000000000..9c502bdcc --- /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 000000000..9751a1cb1 --- /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 000000000..a6f542318 --- /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 000000000..b8060a141 --- /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 000000000..4db75c026 --- /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 000000000..6bb500782 --- /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 000000000..e32f50260 --- /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 000000000..4444753e8 --- /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 000000000..fd80d506d --- /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 000000000..19c5a158a --- /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 000000000..1969bcbd3 --- /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 000000000..3f48f71e0 --- /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 000000000..74631f4f8 --- /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 000000000..1b334bfba --- /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 000000000..ed2cec986 --- /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 000000000..aa39c2f89 --- /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 000000000..2a610475a --- /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 000000000..80903bed3 --- /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 000000000..ff9de26ad --- /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 000000000..1982fe04c --- /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 000000000..9401a5c86 --- /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 000000000..556cc9d0c --- /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 000000000..d8a21789d --- /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 000000000..7807fb9d3 --- /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 000000000..8199481e4 --- /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", + '; rel="urn:ietf:params:push", ' + + '; 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 000000000..98670c742 --- /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 000000000..eeb3b64b0 --- /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 000000000..49d2fe674 --- /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 000000000..cd82ebef3 --- /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 000000000..03b9efbaf --- /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 000000000..a2ec51588 --- /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 000000000..815dff1dd --- /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 000000000..75ca1d348 --- /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 000000000..5a316257b --- /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 000000000..94d09546a --- /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 000000000..b4dbb09e3 --- /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 000000000..c2da107f8 --- /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 000000000..a929ada03 --- /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 000000000..ade84ed76 --- /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 000000000..bdade78cc --- /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 000000000..d4935787c --- /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 000000000..a30fad9eb --- /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 000000000..7c5b7118c --- /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 000000000..8c579dfc4 --- /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 000000000..010108ca3 --- /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 000000000..17db69f0e --- /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", + '; rel="urn:ietf:params:push", ' + + '; 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 000000000..bbe634d90 --- /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", + '; rel="urn:ietf:params:push", ' + + '; 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 000000000..660e27f11 --- /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", + '; rel="urn:ietf:params:push", ' + + '; 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 000000000..05f261629 --- /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 000000000..8426936b8 --- /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 000000000..3b08d641d --- /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 000000000..b01b8a917 --- /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 000000000..32b12f9e4 --- /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 000000000..53d592918 --- /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 000000000..28c10e999 --- /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 000000000..4bd677613 --- /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 000000000..6bf6dff3f --- /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 000000000..f2eb35331 --- /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 000000000..0704344c2 --- /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 000000000..d135a39a0 --- /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 000000000..63ddfcc81 --- /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. -- cgit v1.2.3