summaryrefslogtreecommitdiffstats
path: root/dom/push
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /dom/push
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--dom/push/Push.manifest2
-rw-r--r--dom/push/Push.sys.mjs335
-rw-r--r--dom/push/PushBroadcastService.sys.mjs297
-rw-r--r--dom/push/PushComponents.sys.mjs585
-rw-r--r--dom/push/PushCrypto.sys.mjs879
-rw-r--r--dom/push/PushDB.sys.mjs461
-rw-r--r--dom/push/PushManager.cpp536
-rw-r--r--dom/push/PushManager.h111
-rw-r--r--dom/push/PushNotifier.cpp437
-rw-r--r--dom/push/PushNotifier.h193
-rw-r--r--dom/push/PushRecord.sys.mjs305
-rw-r--r--dom/push/PushService.sys.mjs1485
-rw-r--r--dom/push/PushServiceHttp2.sys.mjs824
-rw-r--r--dom/push/PushServiceWebSocket.sys.mjs1310
-rw-r--r--dom/push/PushSubscription.cpp372
-rw-r--r--dom/push/PushSubscription.h83
-rw-r--r--dom/push/PushSubscriptionOptions.cpp65
-rw-r--r--dom/push/PushSubscriptionOptions.h56
-rw-r--r--dom/push/PushUtil.cpp36
-rw-r--r--dom/push/PushUtil.h39
-rw-r--r--dom/push/components.conf24
-rw-r--r--dom/push/moz.build67
-rw-r--r--dom/push/test/error_worker.js9
-rw-r--r--dom/push/test/frame.html24
-rw-r--r--dom/push/test/lifetime_worker.js90
-rw-r--r--dom/push/test/mochitest.toml82
-rw-r--r--dom/push/test/mockpushserviceparent.js207
-rw-r--r--dom/push/test/test_data.html191
-rw-r--r--dom/push/test/test_error_reporting.html112
-rw-r--r--dom/push/test/test_has_permissions.html53
-rw-r--r--dom/push/test/test_multiple_register.html132
-rw-r--r--dom/push/test/test_multiple_register_different_scope.html123
-rw-r--r--dom/push/test/test_multiple_register_during_service_activation.html110
-rw-r--r--dom/push/test/test_permission_granted.html46
-rw-r--r--dom/push/test/test_permissions.html104
-rw-r--r--dom/push/test/test_register.html107
-rw-r--r--dom/push/test/test_register_key.html280
-rw-r--r--dom/push/test/test_serviceworker_lifetime.html364
-rw-r--r--dom/push/test/test_subscription_change.html67
-rw-r--r--dom/push/test/test_try_registering_offline_disabled.html307
-rw-r--r--dom/push/test/test_unregister.html78
-rw-r--r--dom/push/test/test_utils.js304
-rw-r--r--dom/push/test/webpush.js228
-rw-r--r--dom/push/test/worker.js174
-rw-r--r--dom/push/test/xpcshell/broadcast_handler.sys.mjs12
-rw-r--r--dom/push/test/xpcshell/head-http2.js44
-rw-r--r--dom/push/test/xpcshell/head.js497
-rw-r--r--dom/push/test/xpcshell/moz.build3
-rw-r--r--dom/push/test/xpcshell/test_broadcast_success.js428
-rw-r--r--dom/push/test/xpcshell/test_clearAll_successful.js130
-rw-r--r--dom/push/test/xpcshell/test_clear_forgetAboutSite.js225
-rw-r--r--dom/push/test/xpcshell/test_clear_origin_data.js132
-rw-r--r--dom/push/test/xpcshell/test_crypto.js666
-rw-r--r--dom/push/test/xpcshell/test_crypto_encrypt.js199
-rw-r--r--dom/push/test/xpcshell/test_drop_expired.js164
-rw-r--r--dom/push/test/xpcshell/test_handler_service.js74
-rw-r--r--dom/push/test/xpcshell/test_notification_ack.js163
-rw-r--r--dom/push/test/xpcshell/test_notification_data.js310
-rw-r--r--dom/push/test/xpcshell/test_notification_duplicate.js175
-rw-r--r--dom/push/test/xpcshell/test_notification_error.js151
-rw-r--r--dom/push/test/xpcshell/test_notification_incomplete.js151
-rw-r--r--dom/push/test/xpcshell/test_notification_version_string.js79
-rw-r--r--dom/push/test/xpcshell/test_observer_data.js61
-rw-r--r--dom/push/test/xpcshell/test_observer_remoting.js139
-rw-r--r--dom/push/test/xpcshell/test_permissions.js332
-rw-r--r--dom/push/test/xpcshell/test_quota_exceeded.js156
-rw-r--r--dom/push/test/xpcshell/test_quota_observer.js210
-rw-r--r--dom/push/test/xpcshell/test_quota_with_notification.js129
-rw-r--r--dom/push/test/xpcshell/test_reconnect_retry.js90
-rw-r--r--dom/push/test/xpcshell/test_record.js132
-rw-r--r--dom/push/test/xpcshell/test_register_5xxCode_http2.js129
-rw-r--r--dom/push/test/xpcshell/test_register_case.js67
-rw-r--r--dom/push/test/xpcshell/test_register_flush.js116
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_channel.js63
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_endpoint.js64
-rw-r--r--dom/push/test/xpcshell/test_register_invalid_json.js60
-rw-r--r--dom/push/test/xpcshell/test_register_no_id.js66
-rw-r--r--dom/push/test/xpcshell/test_register_request_queue.js78
-rw-r--r--dom/push/test/xpcshell/test_register_rollback.js102
-rw-r--r--dom/push/test/xpcshell/test_register_success.js93
-rw-r--r--dom/push/test/xpcshell/test_register_timeout.js102
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_id.js75
-rw-r--r--dom/push/test/xpcshell/test_register_wrong_type.js66
-rw-r--r--dom/push/test/xpcshell/test_registration_error.js44
-rw-r--r--dom/push/test/xpcshell/test_registration_error_http2.js38
-rw-r--r--dom/push/test/xpcshell/test_registration_missing_scope.js24
-rw-r--r--dom/push/test/xpcshell/test_registration_none.js30
-rw-r--r--dom/push/test/xpcshell/test_registration_success.js66
-rw-r--r--dom/push/test/xpcshell/test_registration_success_http2.js77
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js113
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js116
-rw-r--r--dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js116
-rw-r--r--dom/push/test/xpcshell/test_retry_ws.js73
-rw-r--r--dom/push/test/xpcshell/test_service_child.js352
-rw-r--r--dom/push/test/xpcshell/test_service_parent.js35
-rw-r--r--dom/push/test/xpcshell/test_unregister_empty_scope.js44
-rw-r--r--dom/push/test/xpcshell/test_unregister_error.js72
-rw-r--r--dom/push/test/xpcshell/test_unregister_invalid_json.js102
-rw-r--r--dom/push/test/xpcshell/test_unregister_not_found.js41
-rw-r--r--dom/push/test/xpcshell/test_unregister_success.js84
-rw-r--r--dom/push/test/xpcshell/test_unregister_success_http2.js78
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js72
-rw-r--r--dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js102
-rw-r--r--dom/push/test/xpcshell/xpcshell.toml121
104 files changed, 19427 insertions, 0 deletions
diff --git a/dom/push/Push.manifest b/dom/push/Push.manifest
new file mode 100644
index 0000000000..b2465080cf
--- /dev/null
+++ b/dom/push/Push.manifest
@@ -0,0 +1,2 @@
+# For immediate loading of PushService instead of delayed loading.
+category android-push-service PushServiceParent @mozilla.org/push/Service;1 process=main
diff --git a/dom/push/Push.sys.mjs b/dom/push/Push.sys.mjs
new file mode 100644
index 0000000000..07f763040f
--- /dev/null
+++ b/dom/push/Push.sys.mjs
@@ -0,0 +1,335 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { DOMRequestIpcHelper } from "resource://gre/modules/DOMRequestHelper.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "Push",
+ });
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "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.
+ */
+export function Push() {
+ lazy.console.debug("Push()");
+}
+
+Push.prototype = {
+ __proto__: DOMRequestIpcHelper.prototype,
+
+ contractID: "@mozilla.org/push/PushManager;1",
+
+ classID: PUSH_CID,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIDOMGlobalPropertyInitializer",
+ "nsISupportsWeakReference",
+ "nsIObserver",
+ ]),
+
+ init(win) {
+ lazy.console.debug("init()");
+
+ this._window = win;
+
+ this.initDOMRequestHelper(win);
+
+ // Get the client principal from the window. This won't be null because the
+ // service worker should be available when accessing the push manager.
+ this._principal = win.clientPrincipal;
+
+ if (!this._principal) {
+ throw new Error(" The client principal of the window is not available");
+ }
+
+ try {
+ this._topLevelPrincipal = win.top.document.nodePrincipal;
+ } catch (error) {
+ // Accessing the top-level document might fails if cross-origin
+ this._topLevelPrincipal = undefined;
+ }
+ },
+
+ __init(scope) {
+ this._scope = scope;
+ },
+
+ askPermission() {
+ lazy.console.debug("askPermission()");
+
+ let hasValidTransientUserGestureActivation =
+ this._window.document.hasValidTransientUserGestureActivation;
+
+ return this.createPromise((resolve, reject) => {
+ // Test permission before requesting to support GeckoView:
+ // * GeckoViewPermissionChild wants to return early when requested without user activation
+ // before doing actual permission check:
+ // https://searchfox.org/mozilla-central/rev/0ba4632ee85679a1ccaf652df79c971fa7e9b9f7/mobile/android/actors/GeckoViewPermissionChild.sys.mjs#46-56
+ // which is partly because:
+ // * GeckoView test runner has no real permission check but just returns VALUE_ALLOW.
+ // https://searchfox.org/mozilla-central/rev/6e5b9a5a1edab13a1b2e2e90944b6e06b4d8149c/mobile/android/test_runner/src/main/java/org/mozilla/geckoview/test_runner/TestRunnerActivity.java#108-123
+ if (this._testPermission() === Ci.nsIPermissionManager.ALLOW_ACTION) {
+ resolve();
+ return;
+ }
+
+ let permissionDenied = () => {
+ reject(
+ new this._window.DOMException(
+ "User denied permission to use the Push API.",
+ "NotAllowedError"
+ )
+ );
+ };
+
+ if (
+ Services.prefs.getBoolPref("dom.push.testing.ignorePermission", false)
+ ) {
+ resolve();
+ return;
+ }
+
+ this._requestPermission(
+ hasValidTransientUserGestureActivation,
+ resolve,
+ permissionDenied
+ );
+ });
+ },
+
+ subscribe(options) {
+ lazy.console.debug("subscribe()", this._scope);
+
+ return this.askPermission().then(() =>
+ this.createPromise((resolve, reject) => {
+ let callback = new PushSubscriptionCallback(this, resolve, reject);
+
+ if (!options || options.applicationServerKey === null) {
+ lazy.PushService.subscribe(this._scope, this._principal, callback);
+ return;
+ }
+
+ let keyView = this._normalizeAppServerKey(options.applicationServerKey);
+ if (keyView.byteLength === 0) {
+ callback._rejectWithError(Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
+ return;
+ }
+ lazy.PushService.subscribeWithKey(
+ this._scope,
+ this._principal,
+ keyView,
+ callback
+ );
+ })
+ );
+ },
+
+ _normalizeAppServerKey(appServerKey) {
+ let key;
+ if (typeof appServerKey == "string") {
+ try {
+ key = Cu.cloneInto(
+ ChromeUtils.base64URLDecode(appServerKey, {
+ padding: "reject",
+ }),
+ this._window
+ );
+ } catch (e) {
+ throw new this._window.DOMException(
+ "String contains an invalid character",
+ "InvalidCharacterError"
+ );
+ }
+ } else if (this._window.ArrayBuffer.isView(appServerKey)) {
+ key = appServerKey.buffer;
+ } else {
+ // `appServerKey` is an array buffer.
+ key = appServerKey;
+ }
+ return new this._window.Uint8Array(key);
+ },
+
+ getSubscription() {
+ lazy.console.debug("getSubscription()", this._scope);
+
+ return this.createPromise((resolve, reject) => {
+ let callback = new PushSubscriptionCallback(this, resolve, reject);
+ lazy.PushService.getSubscription(this._scope, this._principal, callback);
+ });
+ },
+
+ permissionState() {
+ lazy.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() {
+ 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(
+ hasValidTransientUserGestureActivation,
+ allowCallback,
+ cancelCallback
+ ) {
+ // Create an array with a single nsIContentPermissionType element.
+ let type = {
+ type: "desktop-notification",
+ options: [],
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionType"]),
+ };
+ let typeArray = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ typeArray.appendElement(type);
+
+ // create a nsIContentPermissionRequest
+ let request = {
+ QueryInterface: ChromeUtils.generateQI(["nsIContentPermissionRequest"]),
+ types: typeArray,
+ principal: this._principal,
+ hasValidTransientUserGestureActivation,
+ topLevelPrincipal: this._topLevelPrincipal,
+ allow: allowCallback,
+ cancel: cancelCallback,
+ window: this._window,
+ };
+
+ // Using askPermission from nsIDOMWindowUtils that takes care of the
+ // remoting if needed.
+ let windowUtils = this._window.windowUtils;
+ windowUtils.askPermission(request);
+ },
+};
+
+function PushSubscriptionCallback(pushManager, resolve, reject) {
+ this.pushManager = pushManager;
+ this.resolve = resolve;
+ this.reject = reject;
+}
+
+PushSubscriptionCallback.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPushSubscriptionCallback"]),
+
+ onPushSubscription(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,
+ 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(subscription, name) {
+ let rawKey = Cu.cloneInto(
+ subscription.getKey(name),
+ this.pushManager._window
+ );
+ if (!rawKey.length) {
+ return null;
+ }
+
+ let key = new this.pushManager._window.ArrayBuffer(rawKey.length);
+ let keyView = new this.pushManager._window.Uint8Array(key);
+ keyView.set(rawKey);
+ return key;
+ },
+
+ _rejectWithError(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);
+ },
+};
diff --git a/dom/push/PushBroadcastService.sys.mjs b/dom/push/PushBroadcastService.sys.mjs
new file mode 100644
index 0000000000..eea31ef192
--- /dev/null
+++ b/dom/push/PushBroadcastService.sys.mjs
@@ -0,0 +1,297 @@
+/* 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/. */
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ JSONFile: "resource://gre/modules/JSONFile.sys.mjs",
+ PushService: "resource://gre/modules/PushService.sys.mjs",
+});
+
+// BroadcastService is exported for test purposes.
+// We are supposed to ignore any updates with this version.
+const DUMMY_VERSION_STRING = "____NOP____";
+
+ChromeUtils.defineLazyGetter(lazy, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "BroadcastService",
+ });
+});
+
+class InvalidSourceInfo extends Error {
+ constructor(message) {
+ super(message);
+ this.name = "InvalidSourceInfo";
+ }
+}
+
+const BROADCAST_SERVICE_VERSION = 1;
+
+export var BroadcastService = class {
+ constructor(pushService, path) {
+ this.PHASES = {
+ HELLO: "hello",
+ REGISTER: "register",
+ BROADCAST: "broadcast",
+ };
+
+ this.pushService = pushService;
+ this.jsonFile = new lazy.JSONFile({
+ path,
+ dataPostProcessor: this._initializeJSONFile,
+ });
+ this.initializePromise = this.jsonFile.load();
+ }
+
+ /**
+ * Convert the listeners from our on-disk format to the format
+ * needed by a hello message.
+ */
+ async getListeners() {
+ await this.initializePromise;
+ return Object.entries(this.jsonFile.data.listeners).reduce(
+ (acc, [k, v]) => {
+ acc[k] = v.version;
+ return acc;
+ },
+ {}
+ );
+ }
+
+ _initializeJSONFile(data) {
+ if (!data.version) {
+ data.version = BROADCAST_SERVICE_VERSION;
+ }
+ if (!data.hasOwnProperty("listeners")) {
+ data.listeners = {};
+ }
+ return data;
+ }
+
+ /**
+ * Reset to a state akin to what you would get in a new profile.
+ * In particular, wipe anything from storage.
+ *
+ * Used mainly for testing.
+ */
+ async _resetListeners() {
+ await this.initializePromise;
+ this.jsonFile.data = this._initializeJSONFile({});
+ this.initializePromise = Promise.resolve();
+ }
+
+ /**
+ * Ensure that a sourceInfo is correct (has the expected fields).
+ */
+ _validateSourceInfo(sourceInfo) {
+ const { moduleURI, symbolName } = sourceInfo;
+ if (typeof moduleURI !== "string") {
+ throw new InvalidSourceInfo(
+ `moduleURI must be a string (got ${typeof moduleURI})`
+ );
+ }
+ if (typeof symbolName !== "string") {
+ throw new InvalidSourceInfo(
+ `symbolName must be a string (got ${typeof symbolName})`
+ );
+ }
+ }
+
+ /**
+ * Add an entry for a given listener if it isn't present, or update
+ * one if it is already present.
+ *
+ * Note that this means only a single listener can be set for a
+ * given subscription. This is a limitation in the current API that
+ * stems from the fact that there can only be one source of truth
+ * for the subscriber's version. As a workaround, you can define a
+ * listener which calls multiple other listeners.
+ *
+ * @param {string} broadcastId The broadcastID to listen for
+ * @param {string} version The most recent version we have for
+ * updates from this broadcastID
+ * @param {Object} sourceInfo A description of the handler for
+ * updates on this broadcastID
+ */
+ async addListener(broadcastId, version, sourceInfo) {
+ lazy.console.info(
+ "addListener: adding listener",
+ broadcastId,
+ version,
+ sourceInfo
+ );
+ await this.initializePromise;
+ this._validateSourceInfo(sourceInfo);
+ if (typeof version !== "string") {
+ throw new TypeError("version should be a string");
+ }
+ if (!version) {
+ throw new TypeError("version should not be an empty string");
+ }
+
+ const isNew = !this.jsonFile.data.listeners.hasOwnProperty(broadcastId);
+ const oldVersion =
+ !isNew && this.jsonFile.data.listeners[broadcastId].version;
+ if (!isNew && oldVersion != version) {
+ lazy.console.warn(
+ "Versions differ while adding listener for",
+ broadcastId,
+ ". Got",
+ version,
+ "but JSON file says",
+ oldVersion,
+ "."
+ );
+ }
+
+ // Update listeners before telling the pushService to subscribe,
+ // in case it would disregard the update in the small window
+ // between getting listeners and setting state to RUNNING.
+ //
+ // Keep the old version (if we have it) because Megaphone is
+ // really the source of truth for the current version of this
+ // broadcaster, and the old version is whatever we've either
+ // gotten from Megaphone or what we've told to Megaphone and
+ // haven't been corrected.
+ this.jsonFile.data.listeners[broadcastId] = {
+ version: oldVersion || version,
+ sourceInfo,
+ };
+ this.jsonFile.saveSoon();
+
+ if (isNew) {
+ await this.pushService.subscribeBroadcast(broadcastId, version);
+ }
+ }
+
+ /**
+ * Call the listeners of the specified broadcasts.
+ *
+ * @param {Array<Object>} broadcasts Map between broadcast ids and versions.
+ * @param {Object} context Additional information about the context in which the
+ * broadcast notification was originally received. This is transmitted to listeners.
+ * @param {String} context.phase One of `BroadcastService.PHASES`
+ */
+ async receivedBroadcastMessage(broadcasts, context) {
+ lazy.console.info("receivedBroadcastMessage:", broadcasts, context);
+ await this.initializePromise;
+ for (const broadcastId in broadcasts) {
+ const version = broadcasts[broadcastId];
+ if (version === DUMMY_VERSION_STRING) {
+ lazy.console.info(
+ "Ignoring",
+ version,
+ "because it's the dummy version"
+ );
+ continue;
+ }
+ // We don't know this broadcastID. This is probably a bug?
+ if (!this.jsonFile.data.listeners.hasOwnProperty(broadcastId)) {
+ lazy.console.warn(
+ "receivedBroadcastMessage: unknown broadcastId",
+ broadcastId
+ );
+ continue;
+ }
+
+ const { sourceInfo } = this.jsonFile.data.listeners[broadcastId];
+ try {
+ this._validateSourceInfo(sourceInfo);
+ } catch (e) {
+ lazy.console.error(
+ "receivedBroadcastMessage: malformed sourceInfo",
+ sourceInfo,
+ e
+ );
+ continue;
+ }
+
+ const { moduleURI, symbolName } = sourceInfo;
+
+ let module;
+ try {
+ module = ChromeUtils.importESModule(moduleURI);
+ } catch (e) {
+ lazy.console.error(
+ "receivedBroadcastMessage: couldn't invoke",
+ broadcastId,
+ "because import of module",
+ moduleURI,
+ "failed",
+ e
+ );
+ continue;
+ }
+
+ if (!module[symbolName]) {
+ lazy.console.error(
+ "receivedBroadcastMessage: couldn't invoke",
+ broadcastId,
+ "because module",
+ moduleURI,
+ "missing attribute",
+ symbolName
+ );
+ continue;
+ }
+
+ const handler = module[symbolName];
+
+ if (!handler.receivedBroadcastMessage) {
+ lazy.console.error(
+ "receivedBroadcastMessage: couldn't invoke",
+ broadcastId,
+ "because handler returned by",
+ `${moduleURI}.${symbolName}`,
+ "has no receivedBroadcastMessage method"
+ );
+ continue;
+ }
+
+ try {
+ await handler.receivedBroadcastMessage(version, broadcastId, context);
+ } catch (e) {
+ lazy.console.error(
+ "receivedBroadcastMessage: handler for",
+ broadcastId,
+ "threw error:",
+ e
+ );
+ continue;
+ }
+
+ // Broadcast message applied successfully. Update the version we
+ // received if it's different than the one we had. We don't
+ // enforce an ordering here (i.e. we use != instead of <)
+ // because we don't know what the ordering of the service's
+ // versions is going to be.
+ if (this.jsonFile.data.listeners[broadcastId].version != version) {
+ this.jsonFile.data.listeners[broadcastId].version = version;
+ this.jsonFile.saveSoon();
+ }
+ }
+ }
+
+ // For test only.
+ _saveImmediately() {
+ return this.jsonFile._save();
+ }
+};
+
+function initializeBroadcastService() {
+ // Fallback path for xpcshell tests.
+ let path = "broadcast-listeners.json";
+ try {
+ if (PathUtils.profileDir) {
+ // Real path for use in a real profile.
+ path = PathUtils.join(PathUtils.profileDir, path);
+ }
+ } catch (e) {}
+ return new BroadcastService(lazy.PushService, path);
+}
+
+export var pushBroadcastService = initializeBroadcastService();
diff --git a/dom/push/PushComponents.sys.mjs b/dom/push/PushComponents.sys.mjs
new file mode 100644
index 0000000000..0ad0505851
--- /dev/null
+++ b/dom/push/PushComponents.sys.mjs
@@ -0,0 +1,585 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * This file exports XPCOM components for C++ and chrome JavaScript callers to
+ * interact with the Push service.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+var isParent =
+ Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+const lazy = {};
+
+// The default Push service implementation.
+ChromeUtils.defineLazyGetter(lazy, "PushService", function () {
+ if (Services.prefs.getBoolPref("dom.push.enabled")) {
+ const { PushService } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushService.sys.mjs"
+ );
+ PushService.init();
+ return PushService;
+ }
+
+ throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE);
+});
+
+// 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: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ "nsIPushService",
+ "nsIPushQuotaManager",
+ "nsIPushErrorReporter",
+ ]),
+
+ pushTopic: OBSERVER_TOPIC_PUSH,
+ subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
+ subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
+
+ ensureReady() {},
+
+ _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 === "android-push-service") {
+ // Load PushService immediately.
+ this.ensureReady();
+ }
+ },
+
+ _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.
+ */
+let parentInstance;
+function PushServiceParent() {
+ if (parentInstance) {
+ return parentInstance;
+ }
+ parentInstance = this;
+
+ PushServiceBase.call(this);
+}
+
+PushServiceParent.prototype = Object.create(PushServiceBase.prototype);
+
+XPCOMUtils.defineLazyServiceGetter(
+ PushServiceParent.prototype,
+ "_mm",
+ "@mozilla.org/parentprocessmessagemanager;1",
+ "nsISupports"
+);
+
+Object.assign(PushServiceParent.prototype, {
+ _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, [], callback);
+ },
+
+ subscribeWithKey(scope, principal, key, callback) {
+ this._handleRequest("Push:Register", principal, {
+ scope,
+ appServerKey: key,
+ })
+ .then(
+ result => {
+ this._deliverSubscription(callback, result);
+ },
+ error => {
+ this._deliverSubscriptionError(callback, error);
+ }
+ )
+ .catch(console.error);
+ },
+
+ unsubscribe(scope, principal, callback) {
+ this._handleRequest("Push:Unregister", principal, {
+ scope,
+ })
+ .then(
+ result => {
+ callback.onUnsubscribe(Cr.NS_OK, result);
+ },
+ error => {
+ callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
+ }
+ )
+ .catch(console.error);
+ },
+
+ getSubscription(scope, principal, callback) {
+ return this._handleRequest("Push:Registration", principal, {
+ scope,
+ })
+ .then(
+ result => {
+ this._deliverSubscription(callback, result);
+ },
+ error => {
+ this._deliverSubscriptionError(callback, error);
+ }
+ )
+ .catch(console.error);
+ },
+
+ clearForDomain(domain, callback) {
+ return this._handleRequest("Push:Clear", null, {
+ domain,
+ })
+ .then(
+ result => {
+ callback.onClear(Cr.NS_OK);
+ },
+ error => {
+ callback.onClear(Cr.NS_ERROR_FAILURE);
+ }
+ )
+ .catch(console.error);
+ },
+
+ // 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, target, data } = message;
+ if (name === "Push:NotificationForOriginShown") {
+ this.notificationForOriginShown(data);
+ return;
+ }
+ if (name === "Push:NotificationForOriginClosed") {
+ this.notificationForOriginClosed(data);
+ return;
+ }
+ if (name === "Push:ReportError") {
+ this.reportDeliveryError(data.messageId, data.reason);
+ return;
+ }
+ this._handleRequest(name, data.principal, data)
+ .then(
+ result => {
+ target.sendAsyncMessage(this._getResponseName(name, "OK"), {
+ requestID: data.requestID,
+ result,
+ });
+ },
+ error => {
+ target.sendAsyncMessage(this._getResponseName(name, "KO"), {
+ requestID: data.requestID,
+ result: error.result,
+ });
+ }
+ )
+ .catch(console.error);
+ },
+
+ ensureReady() {
+ 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;
+ },
+
+ async _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 || lazy.PushService;
+ },
+ set(impl) {
+ this._service = impl;
+ },
+});
+
+let contentInstance;
+/**
+ * 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() {
+ if (contentInstance) {
+ return contentInstance;
+ }
+ contentInstance = this;
+
+ 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",
+ "nsISupports"
+);
+
+Object.assign(PushServiceContent.prototype, {
+ _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, [], callback);
+ },
+
+ subscribeWithKey(scope, principal, key, callback) {
+ let requestID = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Register", {
+ scope,
+ appServerKey: key,
+ requestID,
+ principal,
+ });
+ },
+
+ unsubscribe(scope, principal, callback) {
+ let requestID = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Unregister", {
+ scope,
+ requestID,
+ principal,
+ });
+ },
+
+ getSubscription(scope, principal, callback) {
+ let requestID = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Registration", {
+ scope,
+ requestID,
+ principal,
+ });
+ },
+
+ clearForDomain(domain, callback) {
+ let requestID = this._addRequest(callback);
+ this._mm.sendAsyncMessage("Push:Clear", {
+ domain,
+ 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,
+ 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: ChromeUtils.generateQI(["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) {
+ switch (name) {
+ case "p256dh":
+ return this._getRawKey(this._props.p256dhKey);
+
+ case "auth":
+ return this._getRawKey(this._props.authenticationSecret);
+
+ case "appServer":
+ return this._getRawKey(this._props.appServerKey);
+ }
+ return [];
+ },
+
+ _getRawKey(key) {
+ if (!key) {
+ return [];
+ }
+ return new Uint8Array(key);
+ },
+};
+
+// Export the correct implementation depending on whether we're running in
+// the parent or content process.
+export let Service = isParent ? PushServiceParent : PushServiceContent;
diff --git a/dom/push/PushCrypto.sys.mjs b/dom/push/PushCrypto.sys.mjs
new file mode 100644
index 0000000000..384901f925
--- /dev/null
+++ b/dom/push/PushCrypto.sys.mjs
@@ -0,0 +1,879 @@
+/* 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/. */
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "gDOMBundle", () =>
+ Services.strings.createBundle("chrome://global/locale/dom/dom.properties")
+);
+
+// getCryptoParamsFromHeaders is exported for test purposes.
+const UTF8 = new TextEncoder();
+
+const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
+const ECDSA_KEY = { name: "ECDSA", namedCurve: "P-256" };
+const HMAC_SHA256 = { name: "HMAC", hash: "SHA-256" };
+const NONCE_INFO = UTF8.encode("Content-Encoding: nonce");
+
+// A default keyid with a name that won't conflict with a real keyid.
+const 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 lazy.gDOMBundle.formatStringFromName(this.property, params);
+ }
+}
+
+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, {});
+}
+
+// Extracts the sender public key, salt, and record size from the payload for the
+// aes128gcm scheme.
+function getCryptoParamsFromPayload(payload) {
+ if (payload.byteLength < 21) {
+ throw new CryptoError("Truncated header", BAD_CRYPTO);
+ }
+ let rs =
+ (payload[16] << 24) |
+ (payload[17] << 16) |
+ (payload[18] << 8) |
+ payload[19];
+ let keyIdLen = payload[20];
+ if (keyIdLen != 65) {
+ throw new CryptoError("Invalid sender public key", BAD_DH_PARAM);
+ }
+ if (payload.byteLength <= 21 + keyIdLen) {
+ throw new CryptoError("Truncated payload", BAD_CRYPTO);
+ }
+ return {
+ salt: payload.slice(0, 16),
+ rs,
+ senderKey: payload.slice(21, 21 + keyIdLen),
+ ciphertext: payload.slice(21 + keyIdLen),
+ };
+}
+
+// Extracts the sender public key, salt, and record size from the `Crypto-Key`,
+// `Encryption-Key`, and `Encryption` headers for the aesgcm and aesgcm128
+// schemes.
+export function getCryptoParamsFromHeaders(headers) {
+ if (!headers) {
+ return null;
+ }
+
+ var keymap;
+ 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);
+ }
+ } 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
+ );
+ }
+ }
+
+ var enc = getEncryptionParams(headers.encryption);
+ var dh = keymap[enc.keyid || DEFAULT_KEYID];
+ var senderKey = base64URLDecode(dh);
+ if (!senderKey) {
+ throw new CryptoError("Invalid dh parameter", BAD_DH_PARAM);
+ }
+
+ var salt = base64URLDecode(enc.salt);
+ if (!salt) {
+ throw new CryptoError("Invalid 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);
+ }
+ return {
+ salt,
+ rs,
+ senderKey,
+ };
+}
+
+// Decodes an unpadded, base64url-encoded string.
+function base64URLDecode(string) {
+ if (!string) {
+ return null;
+ }
+ 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;
+}
+
+function concatArray(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 = 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;
+}
+
+function encodeLength(buffer) {
+ return new Uint8Array([0, buffer.byteLength]);
+}
+
+class Decoder {
+ /**
+ * Creates a decoder for decrypting an incoming push message.
+ *
+ * @param {JsonWebKey} privateKey The static subscription private key.
+ * @param {BufferSource} publicKey The static subscription public key.
+ * @param {BufferSource} authenticationSecret The subscription authentication
+ * secret, or `null` if not used by the scheme.
+ * @param {Object} cryptoParams An object containing the ephemeral sender
+ * public key, salt, and record size.
+ * @param {BufferSource} ciphertext The encrypted message data.
+ */
+ constructor(
+ privateKey,
+ publicKey,
+ authenticationSecret,
+ cryptoParams,
+ ciphertext
+ ) {
+ this.privateKey = privateKey;
+ this.publicKey = publicKey;
+ this.authenticationSecret = authenticationSecret;
+ this.senderKey = cryptoParams.senderKey;
+ this.salt = cryptoParams.salt;
+ this.rs = cryptoParams.rs;
+ this.ciphertext = ciphertext;
+ }
+
+ /**
+ * Derives the decryption keys and decodes the push message.
+ *
+ * @throws {CryptoError} if decryption fails.
+ * @returns {Uint8Array} The decrypted message data.
+ */
+ async decode() {
+ if (this.ciphertext.byteLength === 0) {
+ // Zero length messages will be passed as null.
+ return null;
+ }
+ try {
+ let ikm = await this.computeSharedSecret();
+ let [gcmBits, nonce] = await this.deriveKeyAndNonce(ikm);
+ let key = await crypto.subtle.importKey(
+ "raw",
+ gcmBits,
+ "AES-GCM",
+ false,
+ ["decrypt"]
+ );
+
+ let r = await Promise.all(
+ chunkArray(this.ciphertext, this.chunkSize).map(
+ (slice, index, chunks) =>
+ this.decodeChunk(
+ slice,
+ index,
+ nonce,
+ key,
+ index >= chunks.length - 1
+ )
+ )
+ );
+
+ return concatArray(r);
+ } 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);
+ }
+ }
+
+ /**
+ * Computes the ECDH shared secret, used as the input key material for HKDF.
+ *
+ * @throws if the static or ephemeral ECDH keys are invalid.
+ * @returns {ArrayBuffer} The shared secret.
+ */
+ async computeSharedSecret() {
+ let [appServerKey, subscriptionPrivateKey] = await Promise.all([
+ crypto.subtle.importKey("raw", this.senderKey, ECDH_KEY, false, [
+ "deriveBits",
+ ]),
+ crypto.subtle.importKey("jwk", this.privateKey, ECDH_KEY, false, [
+ "deriveBits",
+ ]),
+ ]);
+ return crypto.subtle.deriveBits(
+ { name: "ECDH", public: appServerKey },
+ subscriptionPrivateKey,
+ 256
+ );
+ }
+
+ /**
+ * Derives the content encryption key and nonce.
+ *
+ * @param {BufferSource} ikm The ECDH shared secret.
+ * @returns {Array} A `[gcmBits, nonce]` tuple.
+ */
+ async deriveKeyAndNonce(ikm) {
+ throw new Error("Missing `deriveKeyAndNonce` implementation");
+ }
+
+ /**
+ * Decrypts and removes padding from an encrypted record.
+ *
+ * @throws {CryptoError} if decryption fails or padding is incorrect.
+ * @param {Uint8Array} slice The encrypted record.
+ * @param {Number} index The record sequence number.
+ * @param {Uint8Array} nonce The nonce base, used to generate the IV.
+ * @param {Uint8Array} key The content encryption key.
+ * @param {Boolean} last Indicates if this is the final record.
+ * @returns {Uint8Array} The decrypted block with padding removed.
+ */
+ async decodeChunk(slice, index, nonce, key, last) {
+ let params = {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, index),
+ };
+ let decoded = await crypto.subtle.decrypt(params, key, slice);
+ return this.unpadChunk(new Uint8Array(decoded), last);
+ }
+
+ /**
+ * Removes padding from a decrypted block.
+ *
+ * @throws {CryptoError} if padding is missing or invalid.
+ * @param {Uint8Array} chunk The decrypted block with padding.
+ * @returns {Uint8Array} The block with padding removed.
+ */
+ unpadChunk(chunk, last) {
+ throw new Error("Missing `unpadChunk` implementation");
+ }
+
+ /** The record chunking size. */
+ get chunkSize() {
+ throw new Error("Missing `chunkSize` implementation");
+ }
+}
+
+class OldSchemeDecoder extends Decoder {
+ async decode() {
+ // For aesgcm and aesgcm128, the ciphertext length can't fall on a record
+ // boundary.
+ if (
+ this.ciphertext.byteLength > 0 &&
+ this.ciphertext.byteLength % this.chunkSize === 0
+ ) {
+ throw new CryptoError("Encrypted data truncated", BAD_CRYPTO);
+ }
+ return super.decode();
+ }
+
+ /**
+ * For aesgcm, the padding length is a 16-bit unsigned big endian integer.
+ * For aesgcm128, the padding is an 8-bit integer.
+ */
+ unpadChunk(decoded) {
+ if (decoded.length < this.padSize) {
+ throw new CryptoError("Decoded array is too short!", BAD_PADDING);
+ }
+ var pad = decoded[0];
+ if (this.padSize == 2) {
+ pad = (pad << 8) | decoded[1];
+ }
+ if (pad > decoded.length - this.padSize) {
+ throw new CryptoError("Padding is wrong!", BAD_PADDING);
+ }
+ // All padded bytes must be zero except the first one.
+ for (var i = this.padSize; i < this.padSize + pad; i++) {
+ if (decoded[i] !== 0) {
+ throw new CryptoError("Padding is wrong!", BAD_PADDING);
+ }
+ }
+ return decoded.slice(pad + this.padSize);
+ }
+
+ /**
+ * aesgcm and aesgcm128 don't account for the authentication tag as part of
+ * the record size.
+ */
+ get chunkSize() {
+ return this.rs + 16;
+ }
+
+ get padSize() {
+ throw new Error("Missing `padSize` implementation");
+ }
+}
+
+/** New encryption scheme (draft-ietf-httpbis-encryption-encoding-06). */
+
+const AES128GCM_ENCODING = "aes128gcm";
+const AES128GCM_KEY_INFO = UTF8.encode("Content-Encoding: aes128gcm\0");
+const AES128GCM_AUTH_INFO = UTF8.encode("WebPush: info\0");
+const AES128GCM_NONCE_INFO = UTF8.encode("Content-Encoding: nonce\0");
+
+class aes128gcmDecoder extends Decoder {
+ /**
+ * Derives the aes128gcm decryption key and nonce. The PRK info string for
+ * HKDF is "WebPush: info\0", followed by the unprefixed receiver and sender
+ * public keys.
+ */
+ async deriveKeyAndNonce(ikm) {
+ let authKdf = new hkdf(this.authenticationSecret, ikm);
+ let authInfo = concatArray([
+ AES128GCM_AUTH_INFO,
+ this.publicKey,
+ this.senderKey,
+ ]);
+ let prk = await authKdf.extract(authInfo, 32);
+ let prkKdf = new hkdf(this.salt, prk);
+ return Promise.all([
+ prkKdf.extract(AES128GCM_KEY_INFO, 16),
+ prkKdf.extract(AES128GCM_NONCE_INFO, 12),
+ ]);
+ }
+
+ unpadChunk(decoded, last) {
+ let length = decoded.length;
+ while (length--) {
+ if (decoded[length] === 0) {
+ continue;
+ }
+ let recordPad = last ? 2 : 1;
+ if (decoded[length] != recordPad) {
+ throw new CryptoError("Padding is wrong!", BAD_PADDING);
+ }
+ return decoded.slice(0, length);
+ }
+ throw new CryptoError("Zero plaintext", BAD_PADDING);
+ }
+
+ /** aes128gcm accounts for the authentication tag in the record size. */
+ get chunkSize() {
+ return this.rs;
+ }
+}
+
+/** Older encryption scheme (draft-ietf-httpbis-encryption-encoding-01). */
+
+const AESGCM_ENCODING = "aesgcm";
+const AESGCM_KEY_INFO = UTF8.encode("Content-Encoding: aesgcm\0");
+const AESGCM_AUTH_INFO = UTF8.encode("Content-Encoding: auth\0"); // note nul-terminus
+const AESGCM_P256DH_INFO = UTF8.encode("P-256\0");
+
+class aesgcmDecoder extends OldSchemeDecoder {
+ /**
+ * Derives the aesgcm decryption key and nonce. We mix the authentication
+ * secret with the ikm using HKDF. The context string for the PRK is
+ * "Content-Encoding: auth\0". The context string for the key and nonce is
+ * "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
+ * receiver key and sender key.
+ */
+ async deriveKeyAndNonce(ikm) {
+ // Since we are using an authentication secret, we need to run an extra
+ // round of HKDF with the authentication secret as salt.
+ let authKdf = new hkdf(this.authenticationSecret, ikm);
+ let prk = await authKdf.extract(AESGCM_AUTH_INFO, 32);
+ let prkKdf = new hkdf(this.salt, prk);
+ let keyInfo = concatArray([
+ AESGCM_KEY_INFO,
+ AESGCM_P256DH_INFO,
+ encodeLength(this.publicKey),
+ this.publicKey,
+ encodeLength(this.senderKey),
+ this.senderKey,
+ ]);
+ let nonceInfo = concatArray([
+ NONCE_INFO,
+ new Uint8Array([0]),
+ AESGCM_P256DH_INFO,
+ encodeLength(this.publicKey),
+ this.publicKey,
+ encodeLength(this.senderKey),
+ this.senderKey,
+ ]);
+ return Promise.all([
+ prkKdf.extract(keyInfo, 16),
+ prkKdf.extract(nonceInfo, 12),
+ ]);
+ }
+
+ get padSize() {
+ return 2;
+ }
+}
+
+/** Oldest encryption scheme (draft-thomson-http-encryption-02). */
+
+const AESGCM128_ENCODING = "aesgcm128";
+const AESGCM128_KEY_INFO = UTF8.encode("Content-Encoding: aesgcm128");
+
+class aesgcm128Decoder extends OldSchemeDecoder {
+ constructor(privateKey, publicKey, cryptoParams, ciphertext) {
+ super(privateKey, publicKey, null, cryptoParams, ciphertext);
+ }
+
+ /**
+ * The aesgcm128 scheme ignores the authentication secret, and uses
+ * "Content-Encoding: <blah>" for the context string. It should eventually
+ * be removed: bug 1230038.
+ */
+ deriveKeyAndNonce(ikm) {
+ let prkKdf = new hkdf(this.salt, ikm);
+ return Promise.all([
+ prkKdf.extract(AESGCM128_KEY_INFO, 16),
+ prkKdf.extract(NONCE_INFO, 12),
+ ]);
+ }
+
+ get padSize() {
+ return 1;
+ }
+}
+
+export var PushCrypto = {
+ concatArray,
+
+ 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.
+ *
+ * @throws {CryptoError} if decryption fails.
+ * @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 from the push server.
+ * @param {BufferSource} payload The encrypted message payload.
+ * @returns {Uint8Array} The decrypted message data.
+ */
+ async decrypt(privateKey, publicKey, authenticationSecret, headers, payload) {
+ if (!headers) {
+ return null;
+ }
+
+ let encoding = headers.encoding;
+ if (!headers.encoding) {
+ throw new CryptoError(
+ "Missing Content-Encoding header",
+ BAD_ENCODING_HEADER
+ );
+ }
+
+ let decoder;
+ if (encoding == AES128GCM_ENCODING) {
+ // aes128gcm includes the salt, record size, and sender public key in a
+ // binary header preceding the ciphertext.
+ let cryptoParams = getCryptoParamsFromPayload(new Uint8Array(payload));
+ decoder = new aes128gcmDecoder(
+ privateKey,
+ publicKey,
+ authenticationSecret,
+ cryptoParams,
+ cryptoParams.ciphertext
+ );
+ } else if (encoding == AESGCM128_ENCODING || encoding == AESGCM_ENCODING) {
+ // aesgcm and aesgcm128 include the salt, record size, and sender public
+ // key in the `Crypto-Key` and `Encryption` HTTP headers.
+ let cryptoParams = getCryptoParamsFromHeaders(headers);
+ if (headers.encoding == AESGCM_ENCODING) {
+ decoder = new aesgcmDecoder(
+ privateKey,
+ publicKey,
+ authenticationSecret,
+ cryptoParams,
+ payload
+ );
+ } else {
+ decoder = new aesgcm128Decoder(
+ privateKey,
+ publicKey,
+ cryptoParams,
+ payload
+ );
+ }
+ }
+
+ if (!decoder) {
+ throw new CryptoError(
+ "Unsupported Content-Encoding: " + encoding,
+ BAD_ENCODING_HEADER
+ );
+ }
+
+ return decoder.decode();
+ },
+
+ /**
+ * Encrypts a payload suitable for using in a push message. The encryption
+ * is always done with a record size of 4096 and no padding.
+ *
+ * @throws {CryptoError} if encryption fails.
+ * @param {plaintext} Uint8Array The plaintext to encrypt.
+ * @param {receiverPublicKey} Uint8Array The public key of the recipient
+ * of the message as a buffer.
+ * @param {receiverAuthSecret} Uint8Array The auth secret of the of the
+ * message recipient as a buffer.
+ * @param {options} Object Encryption options, used for tests.
+ * @returns {ciphertext, encoding} The encrypted payload and encoding.
+ */
+ async encrypt(
+ plaintext,
+ receiverPublicKey,
+ receiverAuthSecret,
+ options = {}
+ ) {
+ const encoding = options.encoding || AES128GCM_ENCODING;
+ // We only support one encoding type.
+ if (encoding != AES128GCM_ENCODING) {
+ throw new CryptoError(
+ `Only ${AES128GCM_ENCODING} is supported`,
+ BAD_ENCODING_HEADER
+ );
+ }
+ // We typically use an ephemeral key for this message, but for testing
+ // purposes we allow it to be specified.
+ const senderKeyPair =
+ options.senderKeyPair ||
+ (await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveBits"]));
+ // allowing a salt to be specified is useful for tests.
+ const salt = options.salt || crypto.getRandomValues(new Uint8Array(16));
+ const rs = options.rs === undefined ? 4096 : options.rs;
+
+ const encoder = new aes128gcmEncoder(
+ plaintext,
+ receiverPublicKey,
+ receiverAuthSecret,
+ senderKeyPair,
+ salt,
+ rs
+ );
+ return encoder.encode();
+ },
+};
+
+// A class for aes128gcm encryption - the only kind we support.
+class aes128gcmEncoder {
+ constructor(
+ plaintext,
+ receiverPublicKey,
+ receiverAuthSecret,
+ senderKeyPair,
+ salt,
+ rs
+ ) {
+ this.receiverPublicKey = receiverPublicKey;
+ this.receiverAuthSecret = receiverAuthSecret;
+ this.senderKeyPair = senderKeyPair;
+ this.salt = salt;
+ this.rs = rs;
+ this.plaintext = plaintext;
+ }
+
+ async encode() {
+ const sharedSecret = await this.computeSharedSecret(
+ this.receiverPublicKey,
+ this.senderKeyPair.privateKey
+ );
+
+ const rawSenderPublicKey = await crypto.subtle.exportKey(
+ "raw",
+ this.senderKeyPair.publicKey
+ );
+ const [gcmBits, nonce] = await this.deriveKeyAndNonce(
+ sharedSecret,
+ rawSenderPublicKey
+ );
+
+ const contentEncryptionKey = await crypto.subtle.importKey(
+ "raw",
+ gcmBits,
+ "AES-GCM",
+ false,
+ ["encrypt"]
+ );
+ const payloadHeader = this.createHeader(rawSenderPublicKey);
+
+ const ciphertextChunks = await this.encrypt(contentEncryptionKey, nonce);
+ return {
+ ciphertext: concatArray([payloadHeader, ...ciphertextChunks]),
+ encoding: "aes128gcm",
+ };
+ }
+
+ // Perform the actual encryption of the payload.
+ async encrypt(key, nonce) {
+ if (this.rs < 18) {
+ throw new CryptoError("recordsize is too small", BAD_RS_PARAM);
+ }
+
+ let chunks;
+ if (this.plaintext.byteLength === 0) {
+ // Send an authentication tag for empty messages.
+ chunks = [
+ await crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, 0),
+ },
+ key,
+ new Uint8Array([2])
+ ),
+ ];
+ } else {
+ // Use specified recordsize, though we burn 1 for padding and 16 byte
+ // overhead.
+ let inChunks = chunkArray(this.plaintext, this.rs - 1 - 16);
+ chunks = await Promise.all(
+ inChunks.map(async function (slice, index) {
+ let isLast = index == inChunks.length - 1;
+ let padding = new Uint8Array([isLast ? 2 : 1]);
+ let input = concatArray([slice, padding]);
+ return crypto.subtle.encrypt(
+ {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, index),
+ },
+ key,
+ input
+ );
+ })
+ );
+ }
+ return chunks;
+ }
+
+ // Note: this is a dupe of aes128gcmDecoder.deriveKeyAndNonce, but tricky
+ // to rationalize without a larger refactor.
+ async deriveKeyAndNonce(sharedSecret, senderPublicKey) {
+ const authKdf = new hkdf(this.receiverAuthSecret, sharedSecret);
+ const authInfo = concatArray([
+ AES128GCM_AUTH_INFO,
+ this.receiverPublicKey,
+ senderPublicKey,
+ ]);
+ const prk = await authKdf.extract(authInfo, 32);
+ const prkKdf = new hkdf(this.salt, prk);
+ return Promise.all([
+ prkKdf.extract(AES128GCM_KEY_INFO, 16),
+ prkKdf.extract(AES128GCM_NONCE_INFO, 12),
+ ]);
+ }
+
+ // Note: this duplicates some of Decoder.computeSharedSecret, but the key
+ // management is slightly different.
+ async computeSharedSecret(receiverPublicKey, senderPrivateKey) {
+ const receiverPublicCryptoKey = await crypto.subtle.importKey(
+ "raw",
+ receiverPublicKey,
+ ECDH_KEY,
+ false,
+ ["deriveBits"]
+ );
+
+ return crypto.subtle.deriveBits(
+ { name: "ECDH", public: receiverPublicCryptoKey },
+ senderPrivateKey,
+ 256
+ );
+ }
+
+ // create aes128gcm's header.
+ createHeader(key) {
+ // layout is "salt|32-bit-int|8-bit-int|key"
+ if (key.byteLength != 65) {
+ throw new CryptoError("Invalid key length for header", BAD_DH_PARAM);
+ }
+ // the 2 ints
+ let ints = new Uint8Array(5);
+ let intsv = new DataView(ints.buffer);
+ intsv.setUint32(0, this.rs); // bigendian
+ intsv.setUint8(4, key.byteLength);
+ return concatArray([this.salt, ints, key]);
+ }
+}
diff --git a/dom/push/PushDB.sys.mjs b/dom/push/PushDB.sys.mjs
new file mode 100644
index 0000000000..d6eab52040
--- /dev/null
+++ b/dom/push/PushDB.sys.mjs
@@ -0,0 +1,461 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { IndexedDBHelper } from "resource://gre/modules/IndexedDBHelper.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushDB",
+ });
+});
+
+export function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) {
+ lazy.console.debug("PushDB()");
+ this._dbStoreName = dbStoreName;
+ this._keyPath = keyPath;
+ this._model = model;
+
+ // set the indexeddb database
+ this.initDBHelper(dbName, dbVersion, [dbStoreName]);
+}
+
+PushDB.prototype = {
+ __proto__: IndexedDBHelper.prototype,
+
+ toPushRecord(record) {
+ if (!record) {
+ return null;
+ }
+ return new this._model(record);
+ },
+
+ isValidRecord(record) {
+ return (
+ record &&
+ typeof record.scope == "string" &&
+ typeof record.originAttributes == "string" &&
+ record.quota >= 0 &&
+ typeof record[this._keyPath] == "string"
+ );
+ },
+
+ upgradeSchema(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(aRecord) {
+ lazy.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 => {
+ lazy.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(aKeyID) {
+ lazy.console.debug("delete()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ (aTxn, aStore) => {
+ lazy.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(testFn) {
+ lazy.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 => {
+ lazy.console.error(
+ "clearIf: Error removing record",
+ record.keyID,
+ e
+ );
+ };
+ }
+ cursor.continue();
+ }
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ getByPushEndpoint(aPushEndpoint) {
+ lazy.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);
+ lazy.console.debug("getByPushEndpoint: Got record", record);
+ aTxn.result = record;
+ };
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+
+ getByKeyID(aKeyID) {
+ lazy.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);
+ lazy.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(origin, originAttributes, callback) {
+ lazy.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(aPageRecord) {
+ lazy.console.debug("getByIdentifiers()", aPageRecord);
+ if (!aPageRecord.scope || aPageRecord.originAttributes == undefined) {
+ lazy.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(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(aOriginAttributes) {
+ if (typeof aOriginAttributes !== "string") {
+ return Promise.reject("Expected string!");
+ }
+ return this._getAllByKey("originAttributes", aOriginAttributes);
+ },
+
+ getAllKeyIDs() {
+ lazy.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(range) {
+ lazy.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() {
+ lazy.console.debug("getAllUnexpired()");
+ return this._getAllByPushQuota(IDBKeyRange.lowerBound(1));
+ },
+
+ getAllExpired() {
+ lazy.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(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)) {
+ lazy.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 => {
+ lazy.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() {
+ lazy.console.debug("drop()");
+
+ return new Promise((resolve, reject) =>
+ this.newTxn(
+ "readwrite",
+ this._dbStoreName,
+ function txnCb(aTxn, aStore) {
+ aStore.clear();
+ },
+ resolve,
+ reject
+ )
+ );
+ },
+};
diff --git a/dom/push/PushManager.cpp b/dom/push/PushManager.cpp
new file mode 100644
index 0000000000..74bb4a3d7c
--- /dev/null
+++ b/dom/push/PushManager.cpp
@@ -0,0 +1,536 @@
+/* -*- 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/Base64.h"
+#include "mozilla/Preferences.h"
+#include "mozilla/Components.h"
+#include "mozilla/Unused.h"
+#include "mozilla/dom/PermissionStatusBinding.h"
+#include "mozilla/dom/PushManagerBinding.h"
+#include "mozilla/dom/PushSubscription.h"
+#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
+#include "mozilla/dom/PushUtil.h"
+#include "mozilla/dom/RootedDictionary.h"
+#include "mozilla/dom/ServiceWorker.h"
+#include "mozilla/dom/WorkerRunnable.h"
+#include "mozilla/dom/WorkerScope.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 "nsServiceManagerUtils.h"
+
+namespace mozilla::dom {
+
+namespace {
+
+nsresult GetPermissionState(nsIPrincipal* aPrincipal, PermissionState& aState) {
+ nsCOMPtr<nsIPermissionManager> permManager =
+ mozilla::components::PermissionManager::Service();
+
+ if (!permManager) {
+ return NS_ERROR_FAILURE;
+ }
+ uint32_t permission = nsIPermissionManager::UNKNOWN_ACTION;
+ nsresult rv = permManager->TestExactPermissionFromPrincipal(
+ aPrincipal, "desktop-notification"_ns, &permission);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ if (permission == nsIPermissionManager::ALLOW_ACTION ||
+ Preferences::GetBool("dom.push.testing.ignorePermission", false)) {
+ aState = PermissionState::Granted;
+ } else if (permission == nsIPermissionManager::DENY_ACTION) {
+ aState = PermissionState::Denied;
+ } else {
+ aState = PermissionState::Prompt;
+ }
+
+ return NS_OK;
+}
+
+nsresult GetSubscriptionParams(nsIPushSubscription* aSubscription,
+ nsAString& aEndpoint,
+ nsTArray<uint8_t>& aRawP256dhKey,
+ nsTArray<uint8_t>& aAuthSecret,
+ nsTArray<uint8_t>& aAppServerKey) {
+ if (!aSubscription) {
+ return NS_OK;
+ }
+
+ nsresult rv = aSubscription->GetEndpoint(aEndpoint);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ rv = aSubscription->GetKey(u"p256dh"_ns, aRawP256dhKey);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ rv = aSubscription->GetKey(u"auth"_ns, aAuthSecret);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ rv = aSubscription->GetKey(u"appServer"_ns, aAppServerKey);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+
+ return NS_OK;
+}
+
+class GetSubscriptionResultRunnable final : public WorkerRunnable {
+ public:
+ GetSubscriptionResultRunnable(WorkerPrivate* aWorkerPrivate,
+ RefPtr<PromiseWorkerProxy>&& aProxy,
+ nsresult aStatus, const nsAString& aEndpoint,
+ const nsAString& aScope,
+ Nullable<EpochTimeStamp>&& aExpirationTime,
+ nsTArray<uint8_t>&& aRawP256dhKey,
+ nsTArray<uint8_t>&& aAuthSecret,
+ nsTArray<uint8_t>&& aAppServerKey)
+ : WorkerRunnable(aWorkerPrivate, "GetSubscriptionResultRunnable"),
+ mProxy(std::move(aProxy)),
+ mStatus(aStatus),
+ mEndpoint(aEndpoint),
+ mScope(aScope),
+ mExpirationTime(std::move(aExpirationTime)),
+ mRawP256dhKey(std::move(aRawP256dhKey)),
+ mAuthSecret(std::move(aAuthSecret)),
+ mAppServerKey(std::move(aAppServerKey)) {}
+
+ bool WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override {
+ RefPtr<Promise> promise = mProxy->GetWorkerPromise();
+ // Once Worker had already started shutdown, workerPromise would be nullptr
+ if (!promise) {
+ return true;
+ }
+ if (NS_SUCCEEDED(mStatus)) {
+ if (mEndpoint.IsEmpty()) {
+ promise->MaybeResolve(JS::NullHandleValue);
+ } else {
+ RefPtr<PushSubscription> sub = new PushSubscription(
+ nullptr, mEndpoint, mScope, std::move(mExpirationTime),
+ std::move(mRawP256dhKey), std::move(mAuthSecret),
+ std::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() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsresult mStatus;
+ nsString mEndpoint;
+ nsString mScope;
+ Nullable<EpochTimeStamp> mExpirationTime;
+ nsTArray<uint8_t> mRawP256dhKey;
+ nsTArray<uint8_t> mAuthSecret;
+ nsTArray<uint8_t> mAppServerKey;
+};
+
+class GetSubscriptionCallback final : public nsIPushSubscriptionCallback {
+ public:
+ NS_DECL_ISUPPORTS
+
+ explicit GetSubscriptionCallback(PromiseWorkerProxy* aProxy,
+ const nsAString& aScope)
+ : mProxy(aProxy), mScope(aScope) {}
+
+ NS_IMETHOD
+ OnPushSubscription(nsresult aStatus,
+ nsIPushSubscription* aSubscription) override {
+ AssertIsOnMainThread();
+ MOZ_ASSERT(mProxy, "OnPushSubscription() called twice?");
+
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+
+ nsAutoString endpoint;
+ nsTArray<uint8_t> rawP256dhKey, authSecret, appServerKey;
+ if (NS_SUCCEEDED(aStatus)) {
+ aStatus = GetSubscriptionParams(aSubscription, endpoint, rawP256dhKey,
+ authSecret, appServerKey);
+ }
+
+ WorkerPrivate* worker = mProxy->GetWorkerPrivate();
+ RefPtr<GetSubscriptionResultRunnable> r = new GetSubscriptionResultRunnable(
+ worker, std::move(mProxy), aStatus, endpoint, mScope,
+ std::move(mExpirationTime), std::move(rawP256dhKey),
+ std::move(authSecret), std::move(appServerKey));
+ if (!r->Dispatch()) {
+ return NS_ERROR_UNEXPECTED;
+ }
+
+ 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() = default;
+
+ private:
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsString mScope;
+ Nullable<EpochTimeStamp> mExpirationTime;
+};
+
+NS_IMPL_ISUPPORTS(GetSubscriptionCallback, nsIPushSubscriptionCallback)
+
+class GetSubscriptionRunnable final : public Runnable {
+ public:
+ GetSubscriptionRunnable(PromiseWorkerProxy* aProxy, const nsAString& aScope,
+ PushManager::SubscriptionAction aAction,
+ nsTArray<uint8_t>&& aAppServerKey)
+ : Runnable("dom::GetSubscriptionRunnable"),
+ mProxy(aProxy),
+ mScope(aScope),
+ mAction(aAction),
+ mAppServerKey(std::move(aAppServerKey)) {}
+
+ NS_IMETHOD
+ Run() override {
+ AssertIsOnMainThread();
+
+ nsCOMPtr<nsIPrincipal> principal;
+
+ {
+ // Bug 1228723: If permission is revoked or an error occurs, the
+ // subscription callback will be called synchronously. This causes
+ // `GetSubscriptionCallback::OnPushSubscription` to deadlock when
+ // it tries to acquire the lock.
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+ principal = mProxy->GetWorkerPrivate()->GetPrincipal();
+ }
+
+ MOZ_ASSERT(principal);
+
+ RefPtr<GetSubscriptionCallback> callback =
+ new GetSubscriptionCallback(mProxy, mScope);
+
+ PermissionState state;
+ nsresult rv = GetPermissionState(principal, state);
+ if (NS_FAILED(rv)) {
+ callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
+ return NS_OK;
+ }
+
+ if (state != PermissionState::Granted) {
+ if (mAction == PushManager::GetSubscriptionAction) {
+ callback->OnPushSubscriptionError(NS_OK);
+ return NS_OK;
+ }
+ callback->OnPushSubscriptionError(NS_ERROR_DOM_PUSH_DENIED_ERR);
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIPushService> service =
+ do_GetService("@mozilla.org/push/Service;1");
+ if (NS_WARN_IF(!service)) {
+ callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
+ return NS_OK;
+ }
+
+ if (mAction == PushManager::SubscribeAction) {
+ if (mAppServerKey.IsEmpty()) {
+ rv = service->Subscribe(mScope, principal, callback);
+ } else {
+ rv = service->SubscribeWithKey(mScope, principal, mAppServerKey,
+ 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() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsString mScope;
+ PushManager::SubscriptionAction mAction;
+ nsTArray<uint8_t> mAppServerKey;
+};
+
+class PermissionResultRunnable final : public WorkerRunnable {
+ public:
+ PermissionResultRunnable(PromiseWorkerProxy* aProxy, nsresult aStatus,
+ PermissionState aState)
+ : WorkerRunnable(aProxy->GetWorkerPrivate(), "PermissionResultRunnable"),
+ mProxy(aProxy),
+ mStatus(aStatus),
+ mState(aState) {
+ AssertIsOnMainThread();
+ }
+
+ bool WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override {
+ MOZ_ASSERT(aWorkerPrivate);
+ aWorkerPrivate->AssertIsOnWorkerThread();
+ RefPtr<Promise> promise = mProxy->GetWorkerPromise();
+ if (!promise) {
+ return true;
+ }
+ if (NS_SUCCEEDED(mStatus)) {
+ promise->MaybeResolve(mState);
+ } else {
+ promise->MaybeRejectWithUndefined();
+ }
+
+ mProxy->CleanUp();
+
+ return true;
+ }
+
+ private:
+ ~PermissionResultRunnable() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsresult mStatus;
+ PermissionState mState;
+};
+
+class PermissionStateRunnable final : public Runnable {
+ public:
+ explicit PermissionStateRunnable(PromiseWorkerProxy* aProxy)
+ : Runnable("dom::PermissionStateRunnable"), mProxy(aProxy) {}
+
+ NS_IMETHOD
+ Run() override {
+ AssertIsOnMainThread();
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+
+ PermissionState state;
+ nsresult rv =
+ GetPermissionState(mProxy->GetWorkerPrivate()->GetPrincipal(), state);
+
+ RefPtr<PermissionResultRunnable> r =
+ new PermissionResultRunnable(mProxy, rv, state);
+
+ // This can fail if the worker thread is already shutting down, but there's
+ // nothing we can do in that case.
+ Unused << NS_WARN_IF(!r->Dispatch());
+
+ return NS_OK;
+ }
+
+ private:
+ ~PermissionStateRunnable() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+};
+
+} // anonymous namespace
+
+PushManager::PushManager(nsIGlobalObject* aGlobal, PushManagerImpl* aImpl)
+ : mGlobal(aGlobal), mImpl(aImpl) {
+ AssertIsOnMainThread();
+ MOZ_ASSERT(aImpl);
+}
+
+PushManager::PushManager(const nsAString& aScope) : mScope(aScope) {
+#ifdef DEBUG
+ // There's only one global on a worker, so we don't need to pass a global
+ // object to the constructor.
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+#endif
+}
+
+PushManager::~PushManager() = default;
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(PushManager, mGlobal, mImpl)
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushManager)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushManager)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushManager)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+JSObject* PushManager::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return PushManager_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+// static
+already_AddRefed<PushManager> PushManager::Constructor(GlobalObject& aGlobal,
+ const nsAString& aScope,
+ ErrorResult& aRv) {
+ if (!NS_IsMainThread()) {
+ RefPtr<PushManager> ret = new PushManager(aScope);
+ return ret.forget();
+ }
+
+ RefPtr<PushManagerImpl> impl =
+ PushManagerImpl::Constructor(aGlobal, aGlobal.Context(), aScope, aRv);
+ if (aRv.Failed()) {
+ return nullptr;
+ }
+
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+ RefPtr<PushManager> ret = new PushManager(global, impl);
+
+ return ret.forget();
+}
+
+bool PushManager::IsEnabled(JSContext* aCx, JSObject* aGlobal) {
+ return StaticPrefs::dom_push_enabled() && ServiceWorkerVisible(aCx, aGlobal);
+}
+
+already_AddRefed<Promise> PushManager::Subscribe(
+ const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv) {
+ if (mImpl) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mImpl->Subscribe(aOptions, aRv);
+ }
+
+ return PerformSubscriptionActionFromWorker(SubscribeAction, aOptions, aRv);
+}
+
+already_AddRefed<Promise> PushManager::GetSubscription(ErrorResult& aRv) {
+ if (mImpl) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mImpl->GetSubscription(aRv);
+ }
+
+ return PerformSubscriptionActionFromWorker(GetSubscriptionAction, aRv);
+}
+
+already_AddRefed<Promise> PushManager::PermissionState(
+ const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv) {
+ if (mImpl) {
+ MOZ_ASSERT(NS_IsMainThread());
+ return mImpl->PermissionState(aOptions, aRv);
+ }
+
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+
+ nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
+ RefPtr<Promise> p = Promise::Create(global, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
+ if (!proxy) {
+ p->MaybeRejectWithUndefined();
+ return p.forget();
+ }
+
+ RefPtr<PermissionStateRunnable> r = new PermissionStateRunnable(proxy);
+ NS_DispatchToMainThread(r);
+
+ return p.forget();
+}
+
+already_AddRefed<Promise> PushManager::PerformSubscriptionActionFromWorker(
+ SubscriptionAction aAction, ErrorResult& aRv) {
+ RootedDictionary<PushSubscriptionOptionsInit> options(RootingCx());
+ return PerformSubscriptionActionFromWorker(aAction, options, aRv);
+}
+
+already_AddRefed<Promise> PushManager::PerformSubscriptionActionFromWorker(
+ SubscriptionAction aAction, const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv) {
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+
+ nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
+ RefPtr<Promise> p = Promise::Create(global, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
+ if (!proxy) {
+ p->MaybeReject(NS_ERROR_DOM_PUSH_ABORT_ERR);
+ return p.forget();
+ }
+
+ nsTArray<uint8_t> appServerKey;
+ if (!aOptions.mApplicationServerKey.IsNull()) {
+ nsresult rv = NormalizeAppServerKey(aOptions.mApplicationServerKey.Value(),
+ appServerKey);
+ if (NS_FAILED(rv)) {
+ p->MaybeReject(rv);
+ return p.forget();
+ }
+ }
+
+ RefPtr<GetSubscriptionRunnable> r = new GetSubscriptionRunnable(
+ proxy, mScope, aAction, std::move(appServerKey));
+ MOZ_ALWAYS_SUCCEEDS(NS_DispatchToMainThread(r));
+
+ return p.forget();
+}
+
+nsresult PushManager::NormalizeAppServerKey(
+ const OwningArrayBufferViewOrArrayBufferOrString& aSource,
+ nsTArray<uint8_t>& aAppServerKey) {
+ if (aSource.IsString()) {
+ NS_ConvertUTF16toUTF8 base64Key(aSource.GetAsString());
+ FallibleTArray<uint8_t> decodedKey;
+ nsresult rv = Base64URLDecode(
+ base64Key, Base64URLDecodePaddingPolicy::Reject, decodedKey);
+ if (NS_FAILED(rv)) {
+ return NS_ERROR_DOM_INVALID_CHARACTER_ERR;
+ }
+ aAppServerKey = decodedKey;
+ } else {
+ if (!AppendTypedArrayDataTo(aSource, aAppServerKey)) {
+ return NS_ERROR_DOM_PUSH_INVALID_KEY_ERR;
+ }
+ }
+ if (aAppServerKey.IsEmpty()) {
+ return NS_ERROR_DOM_PUSH_INVALID_KEY_ERR;
+ }
+ return NS_OK;
+}
+
+} // namespace mozilla::dom
diff --git a/dom/push/PushManager.h b/dom/push/PushManager.h
new file mode 100644
index 0000000000..f951e0a2ba
--- /dev/null
+++ b/dom/push/PushManager.h
@@ -0,0 +1,111 @@
+/* -*- 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/dom/BindingDeclarations.h"
+#include "mozilla/dom/TypedArray.h"
+
+#include "nsCOMPtr.h"
+#include "mozilla/RefPtr.h"
+
+class nsIGlobalObject;
+class nsIPrincipal;
+
+namespace mozilla {
+class ErrorResult;
+
+namespace dom {
+
+class OwningArrayBufferViewOrArrayBufferOrString;
+class Promise;
+class PushManagerImpl;
+struct PushSubscriptionOptionsInit;
+class WorkerPrivate;
+
+class PushManager final : public nsISupports, public nsWrapperCache {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(PushManager)
+
+ enum SubscriptionAction {
+ SubscribeAction,
+ GetSubscriptionAction,
+ };
+
+ // The main thread constructor.
+ PushManager(nsIGlobalObject* aGlobal, PushManagerImpl* aImpl);
+
+ // The worker thread constructor.
+ explicit PushManager(const nsAString& aScope);
+
+ nsIGlobalObject* GetParentObject() const { return mGlobal; }
+
+ JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ static already_AddRefed<PushManager> Constructor(GlobalObject& aGlobal,
+ const nsAString& aScope,
+ ErrorResult& aRv);
+
+ static bool IsEnabled(JSContext* aCx, JSObject* aGlobal);
+
+ already_AddRefed<Promise> PerformSubscriptionActionFromWorker(
+ SubscriptionAction aAction, ErrorResult& aRv);
+
+ already_AddRefed<Promise> PerformSubscriptionActionFromWorker(
+ SubscriptionAction aAction, const PushSubscriptionOptionsInit& aOptions,
+ ErrorResult& aRv);
+
+ already_AddRefed<Promise> Subscribe(
+ const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv);
+
+ already_AddRefed<Promise> GetSubscription(ErrorResult& aRv);
+
+ already_AddRefed<Promise> PermissionState(
+ const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv);
+
+ private:
+ ~PushManager();
+
+ nsresult NormalizeAppServerKey(
+ const OwningArrayBufferViewOrArrayBufferOrString& aSource,
+ nsTArray<uint8_t>& aAppServerKey);
+
+ // The following are only set and accessed on the main thread.
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+ RefPtr<PushManagerImpl> mImpl;
+
+ // Only used on the worker thread.
+ nsString mScope;
+};
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushManager_h
diff --git a/dom/push/PushNotifier.cpp b/dom/push/PushNotifier.cpp
new file mode 100644
index 0000000000..458be9f331
--- /dev/null
+++ b/dom/push/PushNotifier.cpp
@@ -0,0 +1,437 @@
+/* -*- 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 "PushNotifier.h"
+
+#include "nsContentUtils.h"
+#include "nsCOMPtr.h"
+#include "nsICategoryManager.h"
+#include "nsIXULRuntime.h"
+#include "nsNetUtil.h"
+#include "nsXPCOM.h"
+#include "mozilla/dom/ServiceWorkerManager.h"
+#include "mozilla/BasePrincipal.h"
+#include "mozilla/Preferences.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::dom {
+
+PushNotifier::PushNotifier() = default;
+
+PushNotifier::~PushNotifier() = default;
+
+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,
+ const nsTArray<uint8_t>& aData) {
+ NS_ENSURE_ARG(aPrincipal);
+ // We still need to do this copying business, if we want the copy to be
+ // fallible. Just passing Some(aData) would do an infallible copy at the
+ // point where the Some() call happens.
+ nsTArray<uint8_t> data;
+ if (!data.AppendElements(aData, fallible)) {
+ return NS_ERROR_OUT_OF_MEMORY;
+ }
+ PushMessageDispatcher dispatcher(aScope, aPrincipal, aMessageId,
+ Some(std::move(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()));
+
+ // e10s is disabled; notify workers in the parent.
+ return aDispatcher.NotifyWorkers();
+ }
+
+ // Otherwise, we're in the content process, so e10s must be enabled. Notify
+ // observers and workers, then send a message to notify observers in the
+ // parent.
+ MOZ_ASSERT(XRE_IsContentProcess());
+
+ nsresult rv = aDispatcher.NotifyObserversAndWorkers();
+
+ ContentChild* parentActor = ContentChild::GetSingleton();
+ if (!NS_WARN_IF(!parentActor)) {
+ Unused << NS_WARN_IF(!aDispatcher.SendToParent(parentActor));
+ }
+
+ return rv;
+}
+
+PushData::PushData(const nsTArray<uint8_t>& aData) : mData(aData.Clone()) {}
+
+PushData::~PushData() = default;
+
+NS_IMPL_CYCLE_COLLECTION_0(PushData)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushData)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushData)
+ NS_INTERFACE_MAP_ENTRY(nsIPushData)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushData)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushData)
+
+nsresult PushData::EnsureDecodedText() {
+ if (mData.IsEmpty() || !mDecodedText.IsEmpty()) {
+ return NS_OK;
+ }
+ nsresult rv = BodyUtil::ConsumeText(
+ mData.Length(), reinterpret_cast<uint8_t*>(mData.Elements()),
+ mDecodedText);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ mDecodedText.Truncate();
+ return rv;
+ }
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PushData::Text(nsAString& aText) {
+ nsresult rv = EnsureDecodedText();
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ aText = mDecodedText;
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PushData::Json(JSContext* aCx, JS::MutableHandle<JS::Value> aResult) {
+ nsresult rv = EnsureDecodedText();
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ ErrorResult error;
+ BodyUtil::ConsumeJson(aCx, aResult, mDecodedText, error);
+ return error.StealNSResult();
+}
+
+NS_IMETHODIMP
+PushData::Binary(nsTArray<uint8_t>& aData) {
+ aData = mData.Clone();
+ return NS_OK;
+}
+
+PushMessage::PushMessage(nsIPrincipal* aPrincipal, nsIPushData* aData)
+ : mPrincipal(aPrincipal), mData(aData) {}
+
+PushMessage::~PushMessage() = default;
+
+NS_IMPL_CYCLE_COLLECTION(PushMessage, mPrincipal, mData)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushMessage)
+ NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushMessage)
+ NS_INTERFACE_MAP_ENTRY(nsIPushMessage)
+NS_INTERFACE_MAP_END
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushMessage)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushMessage)
+
+NS_IMETHODIMP
+PushMessage::GetPrincipal(nsIPrincipal** aPrincipal) {
+ NS_ENSURE_ARG_POINTER(aPrincipal);
+
+ nsCOMPtr<nsIPrincipal> principal = mPrincipal;
+ principal.forget(aPrincipal);
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+PushMessage::GetData(nsIPushData** aData) {
+ NS_ENSURE_ARG_POINTER(aData);
+
+ nsCOMPtr<nsIPushData> data = mData;
+ data.forget(aData);
+ return NS_OK;
+}
+
+PushDispatcher::PushDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal)
+ : mScope(aScope), mPrincipal(aPrincipal) {}
+
+PushDispatcher::~PushDispatcher() = default;
+
+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.
+ if (mPrincipal->IsSystemPrincipal() ||
+ !Preferences::GetBool("dom.push.testing.notifyWorkers", true)) {
+ return false;
+ }
+
+ // If e10s is off, no need to worry about processes.
+ if (!BrowserTabsRemoteAutostart()) {
+ return true;
+ }
+
+ // We only want to notify in the parent process.
+ bool isContentProcess = XRE_GetProcessType() == GeckoProcessType_Content;
+ return !isContentProcess;
+}
+
+nsresult PushDispatcher::DoNotifyObservers(nsISupports* aSubject,
+ const char* aTopic,
+ const nsACString& aScope) {
+ nsCOMPtr<nsIObserverService> obsService =
+ mozilla::services::GetObserverService();
+ if (!obsService) {
+ return NS_ERROR_FAILURE;
+ }
+ // If there's a service for this push category, make sure it is alive.
+ nsCOMPtr<nsICategoryManager> catMan =
+ do_GetService(NS_CATEGORYMANAGER_CONTRACTID);
+ if (catMan) {
+ nsCString contractId;
+ nsresult rv = catMan->GetCategoryEntry("push", mScope, contractId);
+ if (NS_SUCCEEDED(rv)) {
+ // Ensure the service is created - we don't need to do anything with
+ // it though - we assume the service constructor attaches a listener.
+ nsCOMPtr<nsISupports> service = do_GetService(contractId.get());
+ }
+ }
+ return obsService->NotifyObservers(aSubject, aTopic,
+ NS_ConvertUTF8toUTF16(mScope).get());
+}
+
+PushMessageDispatcher::PushMessageDispatcher(
+ const nsACString& aScope, nsIPrincipal* aPrincipal,
+ const nsAString& aMessageId, const Maybe<nsTArray<uint8_t>>& aData)
+ : PushDispatcher(aScope, aPrincipal),
+ mMessageId(aMessageId),
+ mData(aData ? Some(aData->Clone()) : Nothing()) {}
+
+PushMessageDispatcher::~PushMessageDispatcher() = default;
+
+nsresult PushMessageDispatcher::NotifyObservers() {
+ nsCOMPtr<nsIPushData> data;
+ if (mData) {
+ data = new PushData(mData.ref());
+ }
+ nsCOMPtr<nsIPushMessage> message = new PushMessage(mPrincipal, data);
+ return DoNotifyObservers(message, OBSERVER_TOPIC_PUSH, mScope);
+}
+
+nsresult PushMessageDispatcher::NotifyWorkers() {
+ if (!ShouldNotifyWorkers()) {
+ return NS_OK;
+ }
+ RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
+ if (!swm) {
+ return NS_ERROR_FAILURE;
+ }
+ nsAutoCString originSuffix;
+ nsresult rv = mPrincipal->GetOriginSuffix(originSuffix);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return swm->SendPushEvent(originSuffix, mScope, mMessageId, mData);
+}
+
+bool PushMessageDispatcher::SendToParent(ContentChild* aParentActor) {
+ if (mData) {
+ return aParentActor->SendNotifyPushObserversWithData(
+ mScope, mPrincipal, mMessageId, mData.ref());
+ }
+ return aParentActor->SendNotifyPushObservers(mScope, mPrincipal, mMessageId);
+}
+
+bool PushMessageDispatcher::SendToChild(ContentParent* aContentActor) {
+ if (mData) {
+ return aContentActor->SendPushWithData(mScope, mPrincipal, mMessageId,
+ mData.ref());
+ }
+ return aContentActor->SendPush(mScope, mPrincipal, mMessageId);
+}
+
+PushSubscriptionChangeDispatcher::PushSubscriptionChangeDispatcher(
+ const nsACString& aScope, nsIPrincipal* aPrincipal)
+ : PushDispatcher(aScope, aPrincipal) {}
+
+PushSubscriptionChangeDispatcher::~PushSubscriptionChangeDispatcher() = default;
+
+nsresult PushSubscriptionChangeDispatcher::NotifyObservers() {
+ return DoNotifyObservers(mPrincipal, OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
+ mScope);
+}
+
+nsresult PushSubscriptionChangeDispatcher::NotifyWorkers() {
+ if (!ShouldNotifyWorkers()) {
+ return NS_OK;
+ }
+ RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
+ if (!swm) {
+ return NS_ERROR_FAILURE;
+ }
+ nsAutoCString originSuffix;
+ nsresult rv = mPrincipal->GetOriginSuffix(originSuffix);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return swm->SendPushSubscriptionChangeEvent(originSuffix, mScope);
+}
+
+bool PushSubscriptionChangeDispatcher::SendToParent(
+ ContentChild* aParentActor) {
+ return aParentActor->SendNotifyPushSubscriptionChangeObservers(mScope,
+ mPrincipal);
+}
+
+bool PushSubscriptionChangeDispatcher::SendToChild(
+ ContentParent* aContentActor) {
+ return aContentActor->SendPushSubscriptionChange(mScope, mPrincipal);
+}
+
+PushSubscriptionModifiedDispatcher::PushSubscriptionModifiedDispatcher(
+ const nsACString& aScope, nsIPrincipal* aPrincipal)
+ : PushDispatcher(aScope, aPrincipal) {}
+
+PushSubscriptionModifiedDispatcher::~PushSubscriptionModifiedDispatcher() =
+ default;
+
+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,
+ mPrincipal);
+}
+
+bool PushSubscriptionModifiedDispatcher::SendToChild(
+ ContentParent* aContentActor) {
+ return aContentActor->SendNotifyPushSubscriptionModifiedObservers(mScope,
+ mPrincipal);
+}
+
+PushErrorDispatcher::PushErrorDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal,
+ const nsAString& aMessage,
+ uint32_t aFlags)
+ : PushDispatcher(aScope, aPrincipal), mMessage(aMessage), mFlags(aFlags) {}
+
+PushErrorDispatcher::~PushErrorDispatcher() = default;
+
+nsresult PushErrorDispatcher::NotifyObservers() { return NS_OK; }
+
+nsresult PushErrorDispatcher::NotifyWorkers() {
+ if (!ShouldNotifyWorkers() &&
+ (!mPrincipal || mPrincipal->IsSystemPrincipal())) {
+ // For system subscriptions, log the error directly to the browser console.
+ return nsContentUtils::ReportToConsoleNonLocalized(
+ mMessage, mFlags, "Push"_ns, nullptr, /* aDocument */
+ nullptr, /* aURI */
+ u""_ns, /* aLine */
+ 0, /* aLineNumber */
+ 0, /* aColumnNumber */
+ nsContentUtils::eOMIT_LOCATION);
+ }
+
+ // For service worker subscriptions, report the error to all clients.
+ RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
+ if (swm) {
+ swm->ReportToAllClients(mScope, mMessage,
+ NS_ConvertUTF8toUTF16(mScope), /* aFilename */
+ u""_ns, /* aLine */
+ 0, /* aLineNumber */
+ 0, /* aColumnNumber */
+ mFlags);
+ }
+ return NS_OK;
+}
+
+bool PushErrorDispatcher::SendToParent(ContentChild* aContentActor) {
+ return aContentActor->SendPushError(mScope, mPrincipal, mMessage, mFlags);
+}
+
+bool PushErrorDispatcher::SendToChild(ContentParent* aContentActor) {
+ return aContentActor->SendPushError(mScope, mPrincipal, mMessage, mFlags);
+}
+
+nsresult PushErrorDispatcher::HandleNoChildProcesses() {
+ // Report to the console if no content processes are active.
+ nsCOMPtr<nsIURI> scopeURI;
+ nsresult rv = NS_NewURI(getter_AddRefs(scopeURI), mScope);
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ return rv;
+ }
+ return nsContentUtils::ReportToConsoleNonLocalized(
+ mMessage, mFlags, "Push"_ns, nullptr, /* aDocument */
+ scopeURI, /* aURI */
+ u""_ns, /* aLine */
+ 0, /* aLineNumber */
+ 0, /* aColumnNumber */
+ nsContentUtils::eOMIT_LOCATION);
+}
+
+} // namespace mozilla::dom
diff --git a/dom/push/PushNotifier.h b/dom/push/PushNotifier.h
new file mode 100644
index 0000000000..3718c7674e
--- /dev/null
+++ b/dom/push/PushNotifier.h
@@ -0,0 +1,193 @@
+/* -*- 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/. */
+
+#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::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();
+
+ nsIPrincipal* GetPrincipal() { return mPrincipal; }
+
+ protected:
+ PushDispatcher(const nsACString& aScope, nsIPrincipal* aPrincipal);
+
+ virtual ~PushDispatcher();
+
+ bool ShouldNotifyWorkers();
+ nsresult DoNotifyObservers(nsISupports* aSubject, const char* aTopic,
+ const nsACString& aScope);
+
+ const nsCString mScope;
+ nsCOMPtr<nsIPrincipal> mPrincipal;
+};
+
+/**
+ * `PushNotifier` implements the `nsIPushNotifier` interface. This service
+ * broadcasts XPCOM observer notifications for incoming push messages, then
+ * forwards incoming push messages to service workers.
+ *
+ * All scriptable methods on this interface may be called from the parent or
+ * content process. Observer notifications are broadcasted to both processes.
+ */
+class PushNotifier final : public nsIPushNotifier {
+ public:
+ PushNotifier();
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushNotifier, nsIPushNotifier)
+ NS_DECL_NSIPUSHNOTIFIER
+
+ private:
+ ~PushNotifier();
+
+ nsresult Dispatch(PushDispatcher& aDispatcher);
+};
+
+/**
+ * `PushData` provides methods for retrieving push message data in different
+ * formats. This class is similar to the `PushMessageData` WebIDL interface.
+ */
+class PushData final : public nsIPushData {
+ public:
+ explicit PushData(const nsTArray<uint8_t>& aData);
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushData, nsIPushData)
+ NS_DECL_NSIPUSHDATA
+
+ private:
+ ~PushData();
+
+ nsresult EnsureDecodedText();
+
+ nsTArray<uint8_t> mData;
+ nsString mDecodedText;
+};
+
+/**
+ * `PushMessage` exposes the subscription principal and data for a push
+ * message. Each `push-message` observer receives an instance of this class
+ * as the subject.
+ */
+class PushMessage final : public nsIPushMessage {
+ public:
+ PushMessage(nsIPrincipal* aPrincipal, nsIPushData* aData);
+
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushMessage, nsIPushMessage)
+ NS_DECL_NSIPUSHMESSAGE
+
+ private:
+ ~PushMessage();
+
+ nsCOMPtr<nsIPrincipal> mPrincipal;
+ nsCOMPtr<nsIPushData> mData;
+};
+
+class PushMessageDispatcher final : public PushDispatcher {
+ public:
+ PushMessageDispatcher(const nsACString& aScope, nsIPrincipal* aPrincipal,
+ const nsAString& aMessageId,
+ const Maybe<nsTArray<uint8_t>>& aData);
+ ~PushMessageDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+
+ private:
+ const nsString mMessageId;
+ const Maybe<nsTArray<uint8_t>> mData;
+};
+
+class PushSubscriptionChangeDispatcher final : public PushDispatcher {
+ public:
+ PushSubscriptionChangeDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal);
+ ~PushSubscriptionChangeDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+};
+
+class PushSubscriptionModifiedDispatcher : public PushDispatcher {
+ public:
+ PushSubscriptionModifiedDispatcher(const nsACString& aScope,
+ nsIPrincipal* aPrincipal);
+ ~PushSubscriptionModifiedDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+};
+
+class PushErrorDispatcher final : public PushDispatcher {
+ public:
+ PushErrorDispatcher(const nsACString& aScope, nsIPrincipal* aPrincipal,
+ const nsAString& aMessage, uint32_t aFlags);
+ ~PushErrorDispatcher();
+
+ nsresult NotifyObservers() override;
+ nsresult NotifyWorkers() override;
+ bool SendToParent(ContentChild* aParentActor) override;
+ bool SendToChild(ContentParent* aContentActor) override;
+
+ private:
+ nsresult HandleNoChildProcesses() override;
+
+ const nsString mMessage;
+ uint32_t mFlags;
+};
+
+} // namespace mozilla::dom
+
+#endif // mozilla_dom_PushNotifier_h
diff --git a/dom/push/PushRecord.sys.mjs b/dom/push/PushRecord.sys.mjs
new file mode 100644
index 0000000000..aa69a2b22c
--- /dev/null
+++ b/dom/push/PushRecord.sys.mjs
@@ -0,0 +1,305 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ EventDispatcher: "resource://gre/modules/Messaging.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+});
+
+const prefs = Services.prefs.getBranch("dom.push.");
+
+/**
+ * The push subscription record, stored in IndexedDB.
+ */
+export 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.getIntPref("maxQuotaPerSubscription");
+ } else {
+ this.quota = Infinity;
+ }
+ },
+
+ resetQuota() {
+ this.quota = this.quotaApplies()
+ ? prefs.getIntPref("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.getIntPref("maxQuotaPerSubscription")
+ );
+ }
+ },
+
+ 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.getIntPref("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);
+ },
+
+ /**
+ * 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.
+ */
+ async getLastVisit() {
+ 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 = await lazy.EventDispatcher.instance.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 = await lazy.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 with favicons, and an `ORDER BY`
+ // clause that emits a suboptimal index warning.
+ let rows = await 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() {
+ for (let window of Services.wm.getEnumerator("navigator:browser")) {
+ if (window.closed || lazy.PrivateBrowsingUtils.isWindowPrivate(window)) {
+ continue;
+ }
+ for (let tab of window.gBrowser.tabs) {
+ let tabURI = tab.linkedBrowser.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.getBoolPref("testing.ignorePermission", false)
+ ) {
+ 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);
+ // Allow tests to omit origin attributes.
+ let originSuffix = this.originAttributes || "";
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ 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.sys.mjs b/dom/push/PushService.sys.mjs
new file mode 100644
index 0000000000..7314e3df54
--- /dev/null
+++ b/dom/push/PushService.sys.mjs
@@ -0,0 +1,1485 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+export var PushServiceWebSocket;
+export var PushServiceHttp2;
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gPushNotifier",
+ "@mozilla.org/push/Notifier;1",
+ "nsIPushNotifier"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
+ pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
+});
+
+const CONNECTION_PROTOCOLS = (function () {
+ if ("android" != AppConstants.MOZ_WIDGET_TOOLKIT) {
+ ({ PushServiceWebSocket } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushServiceWebSocket.sys.mjs"
+ ));
+ ({ PushServiceHttp2 } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushServiceHttp2.sys.mjs"
+ ));
+ return [PushServiceWebSocket, PushServiceHttp2];
+ }
+ return [];
+})();
+
+ChromeUtils.defineLazyGetter(lazy, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushService",
+ });
+});
+
+const prefs = Services.prefs.getBranch("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;
+
+/**
+ * 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;
+
+// Returns the backend for the given server URI.
+function getServiceForServerURI(uri) {
+ // Insecure server URLs are allowed for development and testing.
+ let allowInsecure = prefs.getBoolPref(
+ "testing.allowInsecureServerURL",
+ false
+ );
+ if (AppConstants.MOZ_WIDGET_TOOLKIT == "android") {
+ if (uri.scheme == "https" || (allowInsecure && uri.scheme == "http")) {
+ return CONNECTION_PROTOCOLS;
+ }
+ return null;
+ }
+ if (uri.scheme == "wss" || (allowInsecure && uri.scheme == "ws")) {
+ return PushServiceWebSocket;
+ }
+ if (uri.scheme == "https" || (allowInsecure && uri.scheme == "http")) {
+ return PushServiceHttp2;
+ }
+ return null;
+}
+
+/**
+ * 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;
+}
+
+/**
+ * The implementation of the push system. It uses WebSockets
+ * (PushServiceWebSocket) to communicate with the server and PushDB (IndexedDB)
+ * for persistence.
+ */
+export var 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(op) {
+ if (!this._stateChangeProcessQueue) {
+ this._stateChangeProcessQueue = Promise.resolve();
+ }
+
+ this._stateChangeProcessQueue = this._stateChangeProcessQueue
+ .then(op)
+ .catch(error => {
+ lazy.console.error(
+ "stateChangeProcessEnqueue: Error transitioning state",
+ error
+ );
+ return this._shutdownService();
+ })
+ .catch(error => {
+ lazy.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() {
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ return Promise.reject(new Error("Push service not active"));
+ }
+ if (this._state > PUSH_SERVICE_ACTIVATING) {
+ return Promise.resolve();
+ }
+ if (!this._activated) {
+ this._activated = new Promise((resolve, reject) => {
+ this._notifyActivated = { resolve, reject };
+ });
+ }
+ return this._activated;
+ },
+
+ _makePendingKey(aPageRecord) {
+ return aPageRecord.scope + "|" + aPageRecord.originAttributes;
+ },
+
+ _lookupOrPutPendingRequest(aPageRecord) {
+ let key = this._makePendingKey(aPageRecord);
+ if (this._pendingRegisterRequest[key]) {
+ return this._pendingRegisterRequest[key];
+ }
+
+ return (this._pendingRegisterRequest[key] =
+ this._registerWithServer(aPageRecord));
+ },
+
+ _deletePendingRequest(aPageRecord) {
+ let key = this._makePendingKey(aPageRecord);
+ if (this._pendingRegisterRequest[key]) {
+ delete this._pendingRegisterRequest[key];
+ }
+ },
+
+ _setState(aNewState) {
+ lazy.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;
+ },
+
+ async _changeStateOfflineEvent(offline, calledFromConnEnabledEvent) {
+ lazy.console.debug("changeStateOfflineEvent()", offline);
+
+ if (
+ this._state < PUSH_SERVICE_ACTIVE_OFFLINE &&
+ this._state != PUSH_SERVICE_ACTIVATING &&
+ !calledFromConnEnabledEvent
+ ) {
+ return;
+ }
+
+ if (offline) {
+ if (this._state == PUSH_SERVICE_RUNNING) {
+ this._service.disconnect();
+ }
+ this._setState(PUSH_SERVICE_ACTIVE_OFFLINE);
+ return;
+ }
+
+ 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();
+ }
+
+ let broadcastListeners = await lazy.pushBroadcastService.getListeners();
+
+ // In principle, a listener could be added to the
+ // pushBroadcastService here, after we have gotten listeners and
+ // before we're RUNNING, but this can't happen in practice because
+ // the only caller that can add listeners is PushBroadcastService,
+ // and it waits on the same promise we are before it can add
+ // listeners. If PushBroadcastService gets woken first, it will
+ // update the value that is eventually returned from
+ // getListeners.
+ this._setState(PUSH_SERVICE_RUNNING);
+
+ this._service.connect(broadcastListeners);
+ },
+
+ _changeStateConnectionEnabledEvent(enabled) {
+ lazy.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 = {}) {
+ lazy.console.debug("changeTestServer()");
+
+ return this._stateChangeProcessEnqueue(_ => {
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ lazy.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 == "serverURL") {
+ lazy.console.debug(
+ "observe: dom.push.serverURL changed for websocket",
+ prefs.getStringPref("serverURL")
+ );
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeServerURL(
+ prefs.getStringPref("serverURL"),
+ CHANGING_SERVICE_EVENT
+ )
+ );
+ } else if (aData == "connection.enabled") {
+ this._stateChangeProcessEnqueue(_ =>
+ this._changeStateConnectionEnabledEvent(
+ prefs.getBoolPref("connection.enabled")
+ )
+ );
+ }
+ break;
+
+ case "idle-daily":
+ this._dropExpiredRegistrations().catch(error => {
+ lazy.console.error(
+ "Failed to drop expired registrations on idle",
+ error
+ );
+ });
+ break;
+
+ case "perm-changed":
+ this._onPermissionChange(aSubject, aData).catch(error => {
+ lazy.console.error(
+ "onPermissionChange: Error updating registrations:",
+ error
+ );
+ });
+ break;
+
+ case "clear-origin-attributes-data":
+ this._clearOriginData(aData).catch(error => {
+ lazy.console.error(
+ "clearOriginData: Error clearing origin data:",
+ error
+ );
+ });
+ break;
+ }
+ },
+
+ _clearOriginData(data) {
+ lazy.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) {
+ lazy.console.debug("backgroundUnregister()");
+
+ if (!this._service.isConnected() || !record) {
+ return;
+ }
+
+ lazy.console.debug("backgroundUnregister: Notifying server", record);
+ this._sendUnregister(record, reason)
+ .then(() => {
+ lazy.gPushNotifier.notifySubscriptionModified(
+ record.scope,
+ record.principal
+ );
+ })
+ .catch(e => {
+ lazy.console.error("backgroundUnregister: Error notifying server", e);
+ });
+ },
+
+ _findService(serverURL) {
+ lazy.console.debug("findService()");
+
+ if (!serverURL) {
+ lazy.console.warn("findService: No dom.push.serverURL found");
+ return [];
+ }
+
+ let uri;
+ try {
+ uri = Services.io.newURI(serverURL);
+ } catch (e) {
+ lazy.console.warn(
+ "findService: Error creating valid URI from",
+ "dom.push.serverURL",
+ serverURL
+ );
+ return [];
+ }
+
+ let service = getServiceForServerURI(uri);
+ return [service, uri];
+ },
+
+ _changeServerURL(serverURI, event, options = {}) {
+ lazy.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.getBoolPref("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.getBoolPref("connection.enabled")
+ )
+ );
+ }
+ 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.getBoolPref("connection.enabled")
+ )
+ );
+ }
+ if (this._state == PUSH_SERVICE_INIT) {
+ return Promise.resolve();
+ }
+ // The new serverUri is empty or misconfigured - stop service.
+ this._setState(PUSH_SERVICE_INIT);
+ return this._stopService(STOPPING_SERVICE_EVENT);
+
+ default:
+ lazy.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.
+ */
+ async init(options = {}) {
+ lazy.console.debug("init()");
+
+ if (this._state > PUSH_SERVICE_UNINIT) {
+ return;
+ }
+
+ this._setState(PUSH_SERVICE_ACTIVATING);
+
+ prefs.addObserver("serverURL", this);
+ Services.obs.addObserver(this, "quit-application");
+
+ if (options.serverURI) {
+ // this is use for xpcshell test.
+
+ await 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.
+ await this._stateChangeProcessEnqueue(_ =>
+ this._changeServerURL(
+ prefs.getStringPref("serverURL"),
+ STARTING_SERVICE_EVENT
+ )
+ );
+ }
+ },
+
+ _startObservers() {
+ lazy.console.debug("startObservers()");
+
+ if (this._state != PUSH_SERVICE_ACTIVATING) {
+ return;
+ }
+
+ Services.obs.addObserver(this, "clear-origin-attributes-data");
+
+ // 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");
+
+ // Used to monitor if the user wishes to disable Push.
+ prefs.addObserver("connection.enabled", this);
+
+ // Prunes expired registrations and notifies dormant service workers.
+ Services.obs.addObserver(this, "idle-daily");
+
+ // Prunes registrations for sites for which the user revokes push
+ // permissions.
+ Services.obs.addObserver(this, "perm-changed");
+ },
+
+ _startService(service, serverURI, options) {
+ lazy.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(event) {
+ lazy.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() {
+ lazy.console.debug("stopObservers()");
+
+ if (this._state < PUSH_SERVICE_ACTIVATING) {
+ return;
+ }
+
+ prefs.removeObserver("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);
+ lazy.console.debug("shutdownService: shutdown complete!");
+ return promiseChangeURL;
+ },
+
+ async uninit() {
+ lazy.console.debug("uninit()");
+
+ if (this._state == PUSH_SERVICE_UNINIT) {
+ return;
+ }
+
+ prefs.removeObserver("serverURL", this);
+ Services.obs.removeObserver(this, "quit-application");
+
+ await 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() {
+ return this._db.clearIf(record => {
+ if (record.isExpired()) {
+ return false;
+ }
+ this._notifySubscriptionChangeObservers(record);
+ return true;
+ });
+ },
+
+ _notifySubscriptionChangeObservers(record) {
+ if (!record) {
+ return;
+ }
+ lazy.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(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(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(aKeyID, aUpdateFunc) {
+ return this._db.update(aKeyID, aUpdateFunc).then(record => {
+ this._notifySubscriptionChangeObservers(record);
+ return record;
+ });
+ },
+
+ ensureCrypto(record) {
+ if (
+ record.hasAuthenticationSecret() &&
+ record.p256dhPublicKey &&
+ record.p256dhPrivateKey
+ ) {
+ return Promise.resolve(record);
+ }
+
+ let keygen = Promise.resolve([]);
+ if (!record.p256dhPublicKey || !record.p256dhPrivateKey) {
+ keygen = lazy.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 =
+ lazy.PushCrypto.generateAuthenticationSecret();
+ }
+ return record;
+ });
+ },
+ error => {
+ return this.dropRegistrationAndNotifyApp(record.keyID).then(() =>
+ Promise.reject(error)
+ );
+ }
+ );
+ },
+
+ /**
+ * 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) {
+ lazy.console.debug("receivedPushMessage()");
+
+ 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)) {
+ lazy.console.debug(
+ "receivedPushMessage: quota update timeout missing?"
+ );
+ }
+ }, prefs.getIntPref("quotaUpdateDelay"));
+ this._updateQuotaTimeouts.add(timeoutID);
+ }
+ return this._decryptAndNotifyApp(record, messageID, headers, data);
+ })
+ .catch(error => {
+ lazy.console.error("receivedPushMessage: Error notifying app", error);
+ return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
+ });
+ },
+
+ /**
+ * Dispatches a broadcast notification to the BroadcastService.
+ *
+ * @param {Object} message The reply received by PushServiceWebSocket
+ * @param {Object} context Additional information about the context in which the
+ * notification was received.
+ */
+ receivedBroadcastMessage(message, context) {
+ lazy.pushBroadcastService
+ .receivedBroadcastMessage(message.broadcasts, context)
+ .catch(e => {
+ lazy.console.error(e);
+ });
+ },
+
+ /**
+ * 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) {
+ 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)) {
+ 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) {
+ 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 => {
+ lazy.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 lazy.PushCrypto.decrypt(
+ record.p256dhPrivateKey,
+ record.p256dhPublicKey,
+ record.authenticationSecret,
+ headers,
+ data
+ ).then(
+ message => this._notifyApp(record, messageID, message),
+ error => {
+ lazy.console.warn(
+ "decryptAndNotifyApp: Error decrypting message",
+ record.scope,
+ messageID,
+ error
+ );
+
+ let message = error.format(record.scope);
+ lazy.gPushNotifier.notifyError(
+ record.scope,
+ record.principal,
+ message,
+ Ci.nsIScriptError.errorFlag
+ );
+ return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
+ }
+ );
+ },
+
+ _updateQuota(keyID) {
+ lazy.console.debug("updateQuota()");
+
+ this._db
+ .update(keyID, record => {
+ // Record may have expired from an earlier quota update.
+ if (record.isExpired()) {
+ lazy.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()) {
+ // 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 {
+ lazy.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 => {
+ lazy.console.debug(
+ "updateQuota: Error while trying to update quota",
+ error
+ );
+ });
+ },
+
+ notificationForOriginShown(origin) {
+ lazy.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) {
+ lazy.console.debug("notificationForOriginClosed()", origin);
+ let count;
+ if (this._visibleNotifications.has(origin)) {
+ count = this._visibleNotifications.get(origin);
+ } else {
+ lazy.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) {
+ lazy.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
+ ) {
+ lazy.console.error("notifyApp: Invalid record", aPushRecord);
+ return Ci.nsIPushErrorReporter.ACK_NOT_DELIVERED;
+ }
+
+ lazy.console.debug("notifyApp()", aPushRecord.scope);
+
+ // If permission has been revoked, trash the message.
+ if (!aPushRecord.hasPermission()) {
+ lazy.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) {
+ lazy.gPushNotifier.notifyPushWithData(
+ aPushRecord.scope,
+ aPushRecord.principal,
+ messageID,
+ payload
+ );
+ } else {
+ lazy.gPushNotifier.notifyPush(
+ aPushRecord.scope,
+ aPushRecord.principal,
+ messageID
+ );
+ }
+
+ return Ci.nsIPushErrorReporter.ACK_DELIVERED;
+ },
+
+ getByKeyID(aKeyID) {
+ return this._db.getByKeyID(aKeyID);
+ },
+
+ getAllUnexpired() {
+ 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(aPageRecord) {
+ lazy.console.debug("registerWithServer()", aPageRecord);
+
+ return this._sendRequest("register", aPageRecord)
+ .then(
+ record => this._onRegisterSuccess(record),
+ err => this._onRegisterError(err)
+ )
+ .then(
+ record => {
+ this._deletePendingRequest(aPageRecord);
+ lazy.gPushNotifier.notifySubscriptionModified(
+ record.scope,
+ record.principal
+ );
+ return record.toSubscription();
+ },
+ err => {
+ this._deletePendingRequest(aPageRecord);
+ throw err;
+ }
+ );
+ },
+
+ _sendUnregister(aRecord, aReason) {
+ return this._sendRequest("unregister", aRecord, aReason);
+ },
+
+ /**
+ * Exceptions thrown in _onRegisterSuccess are caught by the promise obtained
+ * from _service.request, causing the promise to be rejected instead.
+ */
+ _onRegisterSuccess(aRecord) {
+ lazy.console.debug("_onRegisterSuccess()");
+
+ return this._db.put(aRecord).catch(error => {
+ // 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(reply) {
+ lazy.console.debug("_onRegisterError()");
+
+ if (!reply.error) {
+ lazy.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(aPageRecord) {
+ lazy.console.debug("register()", aPageRecord);
+
+ let keyPromise;
+ if (aPageRecord.appServerKey && aPageRecord.appServerKey.length) {
+ let keyView = new Uint8Array(aPageRecord.appServerKey);
+ keyPromise = lazy.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 only by the PushBroadcastService on the receipt of a new
+ * subscription. Don't call this directly. Go through PushBroadcastService.
+ */
+ async subscribeBroadcast(broadcastId, version) {
+ if (this._state != PUSH_SERVICE_RUNNING) {
+ // Ignore any request to subscribe before we send a hello.
+ // We'll send all the broadcast listeners as part of the hello
+ // anyhow.
+ return;
+ }
+
+ await this._service.sendSubscribeBroadcast(broadcastId, version);
+ },
+
+ /**
+ * 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(aPageRecord) {
+ lazy.console.debug("unregister()", aPageRecord);
+
+ return this._getByPageRecord(aPageRecord).then(record => {
+ if (record === null) {
+ return false;
+ }
+
+ let reason = Ci.nsIPushErrorReporter.UNSUBSCRIBE_MANUAL;
+ return Promise.all([
+ this._sendUnregister(record, reason),
+ this._db.delete(record.keyID).then(rec => {
+ if (rec) {
+ lazy.gPushNotifier.notifySubscriptionModified(
+ rec.scope,
+ rec.principal
+ );
+ }
+ }),
+ ]).then(([success]) => success);
+ });
+ },
+
+ clear(info) {
+ return this._checkActivated()
+ .then(_ => {
+ return this._dropRegistrationsIf(
+ record =>
+ info.domain == "*" ||
+ (record.uri &&
+ Services.eTLD.hasRootDomain(record.uri.prePath, info.domain))
+ );
+ })
+ .catch(e => {
+ lazy.console.warn(
+ "clear: Error dropping subscriptions for domain",
+ info.domain,
+ e
+ );
+ return Promise.resolve();
+ });
+ },
+
+ registration(aPageRecord) {
+ lazy.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() {
+ lazy.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.
+ this.dropRegistrationAndNotifyApp(record.keyID);
+ }
+ })
+ .catch(error => {
+ lazy.console.error(
+ "dropExpiredRegistrations: Error dropping registration",
+ record.keyID,
+ error
+ );
+ })
+ )
+ );
+ });
+ },
+
+ _onPermissionChange(subject, data) {
+ lazy.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() {
+ lazy.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(permission, type) {
+ lazy.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(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(record, cursor) {
+ lazy.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) {
+ lazy.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/PushServiceHttp2.sys.mjs b/dom/push/PushServiceHttp2.sys.mjs
new file mode 100644
index 0000000000..76ac85d7b1
--- /dev/null
+++ b/dom/push/PushServiceHttp2.sys.mjs
@@ -0,0 +1,824 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { PushDB } from "resource://gre/modules/PushDB.sys.mjs";
+import { PushRecord } from "resource://gre/modules/PushRecord.sys.mjs";
+
+import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs";
+import { clearTimeout, setTimeout } from "resource://gre/modules/Timer.sys.mjs";
+
+import { PushCrypto } from "resource://gre/modules/PushCrypto.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineLazyGetter(lazy, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ return new ConsoleAPI({
+ maxLogLevelPref: "dom.push.loglevel",
+ prefix: "PushServiceHttp2",
+ });
+});
+
+const prefs = Services.prefs.getBranch("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) {
+ lazy.console.debug("PushSubscriptionListener()");
+ this._pushService = pushService;
+ this.uri = uri;
+};
+
+PushSubscriptionListener.prototype = {
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIHttpPushListener",
+ "nsIStreamListener",
+ ]),
+
+ getInterface(aIID) {
+ return this.QueryInterface(aIID);
+ },
+
+ onStartRequest(aRequest) {
+ lazy.console.debug("PushSubscriptionListener: onStartRequest()");
+ // We do not do anything here.
+ },
+
+ onDataAvailable(aRequest, aStream, aOffset, aCount) {
+ lazy.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);
+ inputStream.read(aCount);
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ lazy.console.debug("PushSubscriptionListener: onStopRequest()");
+ if (!this._pushService) {
+ return;
+ }
+
+ this._pushService.connOnStop(
+ aRequest,
+ Components.isSuccessCode(aStatusCode),
+ this.uri
+ );
+ },
+
+ onPush(associatedChannel, pushChannel) {
+ lazy.console.debug("PushSubscriptionListener: onPush()");
+ var pushChannelListener = new PushChannelListener(this);
+ pushChannel.asyncOpen(pushChannelListener);
+ },
+
+ disconnect() {
+ 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) {
+ lazy.console.debug("PushChannelListener()");
+ this._mainListener = pushSubscriptionListener;
+ this._message = [];
+ this._ackUri = null;
+};
+
+PushChannelListener.prototype = {
+ onStartRequest(aRequest) {
+ this._ackUri = aRequest.URI.spec;
+ },
+
+ onDataAvailable(aRequest, aStream, aOffset, aCount) {
+ lazy.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(aRequest, aStatusCode) {
+ lazy.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 = PushCrypto.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(aRequest) {},
+
+ onDataAvailable(aRequest, 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);
+ inputStream.read(aCount);
+ },
+
+ onStopRequest(aRequest, aStatusCode) {
+ if (Components.isSuccessCode(aStatusCode)) {
+ this._resolve();
+ } else {
+ this._reject(new Error("Error removing subscription: " + aStatusCode));
+ }
+ },
+};
+
+var SubscriptionListener = function (
+ aSubInfo,
+ aResolve,
+ aReject,
+ aServerURI,
+ aPushServiceHttp2
+) {
+ lazy.console.debug("SubscriptionListener()");
+ this._subInfo = aSubInfo;
+ this._resolve = aResolve;
+ this._reject = aReject;
+ this._serverURI = aServerURI;
+ this._service = aPushServiceHttp2;
+ this._ctime = Date.now();
+ this._retryTimeoutID = null;
+};
+
+SubscriptionListener.prototype = {
+ onStartRequest(aRequest) {},
+
+ onDataAvailable(aRequest, aStream, aOffset, aCount) {},
+
+ onStopRequest(aRequest, aStatus) {
+ lazy.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.getIntPref("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;
+ }
+
+ lazy.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 {
+ Services.io.newURI(subscriptionUri);
+ } catch (e) {
+ lazy.console.error(
+ "onStopRequest: Invalid subscription URI",
+ subscriptionUri
+ );
+ this._reject(
+ new Error("Invalid subscription endpoint: " + subscriptionUri)
+ );
+ return;
+ }
+
+ let reply = new PushRecordHttp2({
+ 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(),
+ });
+
+ this._resolve(reply);
+ },
+
+ abortRetry() {
+ if (this._retryTimeoutID != null) {
+ clearTimeout(this._retryTimeoutID);
+ this._retryTimeoutID = null;
+ } else {
+ lazy.console.debug(
+ "SubscriptionListener.abortRetry: aborting non-existent retry?"
+ );
+ }
+ },
+};
+
+function retryAfterParser(aRequest) {
+ let retryAfter = 0;
+ try {
+ let 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) {
+ let linkList = linkHeader.split(",");
+ if (linkList.length < 1) {
+ throw new Error("Invalid Link header");
+ }
+
+ let pushEndpoint;
+ let pushReceiptEndpoint;
+
+ linkList.forEach(link => {
+ let 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(">")
+ );
+ }
+ }
+ });
+
+ lazy.console.debug("linkParser: pushEndpoint", pushEndpoint);
+ lazy.console.debug("linkParser: pushReceiptEndpoint", pushReceiptEndpoint);
+ // Missing pushReceiptEndpoint is allowed.
+ if (!pushEndpoint) {
+ throw new Error("Missing push endpoint");
+ }
+
+ const pushURI = Services.io.newURI(pushEndpoint, null, serverURI);
+ let pushReceiptURI;
+ if (pushReceiptEndpoint) {
+ pushReceiptURI = Services.io.newURI(pushReceiptEndpoint, null, serverURI);
+ }
+
+ return {
+ pushEndpoint: pushURI.spec,
+ pushReceiptEndpoint: pushReceiptURI ? pushReceiptURI.spec : "",
+ };
+}
+
+/**
+ * The implementation of the WebPush.
+ */
+export var 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() {
+ return new PushDB(
+ kPUSHHTTP2DB_DB_NAME,
+ kPUSHHTTP2DB_DB_VERSION,
+ kPUSHHTTP2DB_STORE_NAME,
+ "subscriptionUri",
+ PushRecordHttp2
+ );
+ },
+
+ hasmainPushService() {
+ return this._mainPushService !== null;
+ },
+
+ async connect(broadcastListeners) {
+ let subscriptions = await this._mainPushService.getAllUnexpired();
+ this.startConnections(subscriptions);
+ },
+
+ async sendSubscribeBroadcast(serviceId, version) {
+ // Not implemented yet
+ },
+
+ isConnected() {
+ return this._mainPushService != null;
+ },
+
+ disconnect() {
+ this._shutdownConnections(false);
+ },
+
+ _makeChannel(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(aRecord) {
+ lazy.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(aSubInfo) {
+ lazy.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.asyncOpen(listener);
+ }).catch(err => {
+ if ("retry" in err) {
+ return this._subscribeResourceInternal(err.subInfo);
+ }
+ throw err;
+ });
+ },
+
+ _deleteResource(aUri) {
+ return new Promise((resolve, reject) => {
+ var chan = this._makeChannel(aUri);
+ chan.requestMethod = "DELETE";
+ chan.asyncOpen(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(aSubscriptionUri) {
+ lazy.console.debug("unsubscribeResource()");
+
+ return this._deleteResource(aSubscriptionUri);
+ },
+
+ /**
+ * Start listening for messages.
+ */
+ _listenForMsgs(aSubscriptionUri) {
+ lazy.console.debug("listenForMsgs()", aSubscriptionUri);
+ if (!this._conns[aSubscriptionUri]) {
+ lazy.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.asyncOpen(listener);
+ } catch (e) {
+ lazy.console.error(
+ "listenForMsgs: Error connecting to push server.",
+ "asyncOpen 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(aAckUri) {
+ lazy.console.debug("ackMsgRecv()", aAckUri);
+ return this._deleteResource(aAckUri);
+ },
+
+ init(aOptions, aMainPushService, aServerURL) {
+ lazy.console.debug("init()");
+ this._mainPushService = aMainPushService;
+ this._serverURI = aServerURL;
+
+ return Promise.resolve();
+ },
+
+ _retryAfterBackoff(aSubscriptionUri, retryAfter) {
+ lazy.console.debug("retryAfterBackoff()");
+
+ var resetRetryCount = prefs.getIntPref("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.getIntPref("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.getIntPref("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
+ );
+
+ lazy.console.debug("retryAfterBackoff: Retry in", retryAfter);
+ },
+
+ // Close connections.
+ _shutdownConnections(deleteInfo) {
+ lazy.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(aSubscriptions) {
+ lazy.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 => {
+ lazy.console.error(
+ "startConnections: Error updating record",
+ record.keyID,
+ error
+ );
+ }
+ );
+ }
+ },
+
+ _startSingleConnection(record) {
+ lazy.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(aSubscriptionUri) {
+ lazy.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() {
+ lazy.console.debug("uninit()");
+ this._abortPendingSubscriptionRetries();
+ this._shutdownConnections(true);
+ this._mainPushService = null;
+ },
+
+ _abortPendingSubscriptionRetries() {
+ this._listenersPendingRetry.forEach(listener => listener.abortRetry());
+ this._listenersPendingRetry.clear();
+ },
+
+ unregister(aRecord) {
+ this._shutdownSubscription(aRecord.subscriptionUri);
+ return this._unsubscribeResource(aRecord.subscriptionUri);
+ },
+
+ reportDeliveryError(messageID, reason) {
+ lazy.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(aSubscriptionUri) {
+ this._mainPushService.getByKeyID(aSubscriptionUri).then(record =>
+ this.register(record).then(
+ recordNew => {
+ if (this._mainPushService) {
+ this._mainPushService
+ .updateRegistrationAndNotifyApp(aSubscriptionUri, recordNew)
+ .catch(console.error);
+ }
+ },
+ error => {
+ if (this._mainPushService) {
+ this._mainPushService
+ .dropRegistrationAndNotifyApp(aSubscriptionUri)
+ .catch(console.error);
+ }
+ }
+ )
+ );
+ },
+
+ connOnStop(aRequest, aSuccess, aSubscriptionUri) {
+ lazy.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(aListener) {
+ this._listenersPendingRetry.add(aListener);
+ },
+
+ removeListenerPendingRetry(aListener) {
+ if (!this._listenersPendingRetry.remove(aListener)) {
+ lazy.console.debug("removeListenerPendingRetry: listener not in list?");
+ }
+ },
+
+ _pushChannelOnStop(aUri, aAckUri, aHeaders, aMessage) {
+ lazy.console.debug("pushChannelOnStop()");
+
+ this._mainPushService
+ .receivedPushMessage(aUri, "", aHeaders, aMessage, record => {
+ // Always update the stored record.
+ return record;
+ })
+ .then(_ => this._ackMsgRecv(aAckUri))
+ .catch(err => {
+ lazy.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.sys.mjs b/dom/push/PushServiceWebSocket.sys.mjs
new file mode 100644
index 0000000000..72d8791a96
--- /dev/null
+++ b/dom/push/PushServiceWebSocket.sys.mjs
@@ -0,0 +1,1310 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import { PushDB } from "resource://gre/modules/PushDB.sys.mjs";
+import { PushRecord } from "resource://gre/modules/PushRecord.sys.mjs";
+import { PushCrypto } from "resource://gre/modules/PushCrypto.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
+ pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
+});
+
+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 = Services.prefs.getBranch("dom.push.");
+
+ChromeUtils.defineLazyGetter(lazy, "console", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ 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(context) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnStart(context);
+ },
+
+ onStop(context, statusCode) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnStop(context, statusCode);
+ },
+
+ onAcknowledge(context, size) {
+ // EMPTY
+ },
+
+ onBinaryMessageAvailable(context, message) {
+ // EMPTY
+ },
+
+ onMessageAvailable(context, message) {
+ if (!this._pushService) {
+ return;
+ }
+ this._pushService._wsOnMessageAvailable(context, message);
+ },
+
+ onServerClose(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;
+
+export var PushServiceWebSocket = {
+ QueryInterface: ChromeUtils.generateQI(["nsINamed", "nsIObserver"]),
+ name: "PushServiceWebSocket",
+
+ _mainPushService: null,
+ _serverURI: null,
+ _currentlyRegistering: new Set(),
+
+ newPushDB() {
+ return new PushDB(
+ kPUSHWSDB_DB_NAME,
+ kPUSHWSDB_DB_VERSION,
+ kPUSHWSDB_STORE_NAME,
+ "channelID",
+ PushRecordWebSocket
+ );
+ },
+
+ disconnect() {
+ this._shutdownWS();
+ },
+
+ observe(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() {
+ lazy.console.debug("onUAIDChanged()");
+
+ this._shutdownWS();
+ this._startBackoffTimer();
+ },
+
+ /** Handles a ping, backoff, or request timeout timer event. */
+ _onTimerFired(timer) {
+ lazy.console.debug("onTimerFired()");
+
+ if (timer == this._pingTimer) {
+ this._sendPing();
+ return;
+ }
+
+ if (timer == this._backoffTimer) {
+ lazy.console.debug("onTimerFired: Reconnecting after backoff");
+ this._beginWSSetup();
+ return;
+ }
+
+ if (timer == this._requestTimeoutTimer) {
+ this._timeOutRequests();
+ }
+ },
+
+ /**
+ * 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() {
+ lazy.console.debug("sendPing()");
+
+ this._startRequestTimeoutTimer();
+ try {
+ this._wsSendMessage({});
+ this._lastPingTime = Date.now();
+ } catch (e) {
+ lazy.console.debug("sendPing: Error sending ping", e);
+ this._reconnect();
+ }
+ },
+
+ /** Times out any pending requests. */
+ _timeOutRequests() {
+ lazy.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
+ ) {
+ lazy.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();
+ }
+ },
+
+ get _UAID() {
+ return prefs.getStringPref("userAgentID");
+ },
+
+ set _UAID(newID) {
+ if (typeof newID !== "string") {
+ lazy.console.warn(
+ "Got invalid, non-string UAID",
+ newID,
+ "Not updating userAgentID"
+ );
+ return;
+ }
+ lazy.console.debug("New _UAID", newID);
+ prefs.setStringPref("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(msg) {
+ if (!this._ws) {
+ lazy.console.warn(
+ "wsSendMessage: No WebSocket initialized.",
+ "Cannot send a message"
+ );
+ return;
+ }
+ msg = JSON.stringify(msg);
+ lazy.console.debug("wsSendMessage: Sending message", msg);
+ this._ws.sendMsg(msg);
+ },
+
+ init(options, mainPushService, serverURI) {
+ lazy.console.debug("init()");
+
+ this._mainPushService = mainPushService;
+ this._serverURI = serverURI;
+ // Filled in at connect() time
+ this._broadcastListeners = null;
+
+ // 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.getIntPref("requestTimeout");
+
+ return Promise.resolve();
+ },
+
+ _reconnect() {
+ lazy.console.debug("reconnect()");
+ this._shutdownWS(false);
+ this._startBackoffTimer();
+ },
+
+ _shutdownWS(shouldCancelPending = true) {
+ lazy.console.debug("shutdownWS()");
+
+ if (this._currentState == STATE_READY) {
+ prefs.removeObserver("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() {
+ // 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() {
+ lazy.console.debug("startBackoffTimer()");
+
+ // Calculate new timeout, but cap it to pingInterval.
+ let retryTimeout =
+ prefs.getIntPref("retryBaseInterval") * Math.pow(2, this._retryFailCount);
+ retryTimeout = Math.min(retryTimeout, prefs.getIntPref("pingInterval"));
+
+ this._retryFailCount++;
+
+ lazy.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.getIntPref("pingInterval"),
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ _makeWebSocket(uri) {
+ if (!prefs.getBoolPref("connection.enabled")) {
+ lazy.console.warn(
+ "makeWebSocket: connection.enabled is not set to true.",
+ "Aborting."
+ );
+ return null;
+ }
+ if (Services.io.offline) {
+ lazy.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_SEC_CONTEXT_IS_NULL,
+ Ci.nsIContentPolicy.TYPE_WEBSOCKET
+ );
+ // Allow deprecated HTTP request from SystemPrincipal
+ socket.loadInfo.allowDeprecatedSystemRequests = true;
+
+ return socket;
+ },
+
+ _beginWSSetup() {
+ lazy.console.debug("beginWSSetup()");
+ if (this._currentState != STATE_SHUT_DOWN) {
+ lazy.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);
+
+ lazy.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) {
+ lazy.console.error(
+ "beginWSSetup: Error opening websocket.",
+ "asyncOpen failed",
+ e
+ );
+ this._reconnect();
+ }
+ },
+
+ connect(broadcastListeners) {
+ lazy.console.debug("connect()", broadcastListeners);
+ this._broadcastListeners = broadcastListeners;
+ this._beginWSSetup();
+ },
+
+ isConnected() {
+ return !!this._ws;
+ },
+
+ /**
+ * Protocol handler invoked by server message.
+ */
+ _handleHelloReply(reply) {
+ lazy.console.debug("handleHelloReply()");
+ if (this._currentState != STATE_WAITING_FOR_HELLO) {
+ lazy.console.error(
+ "handleHelloReply: Unexpected state",
+ this._currentState,
+ "(expected STATE_WAITING_FOR_HELLO)"
+ );
+ this._shutdownWS();
+ return;
+ }
+
+ if (typeof reply.uaid !== "string") {
+ lazy.console.error("handleHelloReply: Received invalid UAID", reply.uaid);
+ this._shutdownWS();
+ return;
+ }
+
+ if (reply.uaid === "") {
+ lazy.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) {
+ lazy.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.addObserver("userAgentID", this);
+
+ // Handle broadcasts received in response to the "hello" message.
+ if (!lazy.ObjectUtils.isEmpty(reply.broadcasts)) {
+ // The reply isn't technically a broadcast message, but it has
+ // the shape of a broadcast message (it has a broadcasts field).
+ const context = { phase: lazy.pushBroadcastService.PHASES.HELLO };
+ this._mainPushService.receivedBroadcastMessage(reply, context);
+ }
+
+ this._dataEnabled = !!reply.use_webpush;
+ if (this._dataEnabled) {
+ this._mainPushService
+ .getAllUnexpired()
+ .then(records =>
+ Promise.all(
+ records.map(record =>
+ this._mainPushService.ensureCrypto(record).catch(error => {
+ lazy.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) {
+ lazy.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(reply) {
+ lazy.console.debug("handleRegisterReply()");
+
+ let tmp = this._takeRequestForReply(reply);
+ if (!tmp) {
+ return;
+ }
+
+ if (reply.status == 200) {
+ try {
+ Services.io.newURI(reply.pushEndpoint);
+ } 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(),
+ });
+ tmp.resolve(record);
+ } else {
+ lazy.console.error(
+ "handleRegisterReply: Unexpected server response",
+ reply
+ );
+ tmp.reject(
+ new Error("Wrong status code for register reply: " + reply.status)
+ );
+ }
+ },
+
+ _handleUnregisterReply(reply) {
+ lazy.console.debug("handleUnregisterReply()");
+
+ let request = this._takeRequestForReply(reply);
+ if (!request) {
+ return;
+ }
+
+ let success = reply.status === 200;
+ request.resolve(success);
+ },
+
+ _handleDataUpdate(update) {
+ let promise;
+ if (typeof update.channelID != "string") {
+ lazy.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)) {
+ lazy.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 => {
+ lazy.console.error(
+ "handleDataUpdate: Error delivering message",
+ update,
+ err
+ );
+ this._sendAck(
+ update.channelID,
+ update.version,
+ Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR
+ );
+ }
+ )
+ .catch(err => {
+ lazy.console.error(
+ "handleDataUpdate: Error acknowledging message",
+ update,
+ err
+ );
+ });
+ },
+
+ /**
+ * Protocol handler invoked by server message.
+ */
+ _handleNotificationReply(reply) {
+ lazy.console.debug("handleNotificationReply()");
+ if (this._dataEnabled) {
+ this._handleDataUpdate(reply);
+ return;
+ }
+
+ if (typeof reply.updates !== "object") {
+ lazy.console.warn(
+ "handleNotificationReply: Missing updates",
+ reply.updates
+ );
+ return;
+ }
+
+ lazy.console.debug("handleNotificationReply: Got updates", reply.updates);
+ for (let i = 0; i < reply.updates.length; i++) {
+ let update = reply.updates[i];
+ lazy.console.debug("handleNotificationReply: Handling update", update);
+ if (typeof update.channelID !== "string") {
+ lazy.console.debug(
+ "handleNotificationReply: Invalid update at index",
+ i,
+ update
+ );
+ continue;
+ }
+
+ if (update.version === undefined) {
+ lazy.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);
+ }
+ }
+ },
+
+ _handleBroadcastReply(reply) {
+ let phase = lazy.pushBroadcastService.PHASES.BROADCAST;
+ // Check if this reply is the result of registration.
+ for (const id of Object.keys(reply.broadcasts)) {
+ const wasRegistering = this._currentlyRegistering.delete(id);
+ if (wasRegistering) {
+ // If we get multiple broadcasts and only one is "registering",
+ // then we consider the phase to be REGISTER for all of them.
+ // It is acceptable since registrations do not happen so often,
+ // and are all very likely to occur soon after browser startup.
+ phase = lazy.pushBroadcastService.PHASES.REGISTER;
+ }
+ }
+ const context = { phase };
+ this._mainPushService.receivedBroadcastMessage(reply, context);
+ },
+
+ reportDeliveryError(messageID, reason) {
+ lazy.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 };
+ this._queueRequest(data);
+ },
+
+ _sendAck(channelID, version, status) {
+ lazy.console.debug("sendAck()");
+ let code = kACK_STATUS_TO_CODE[status];
+ if (!code) {
+ throw new Error("Invalid ack status");
+ }
+ let data = { messageType: "ack", updates: [{ channelID, version, code }] };
+ this._queueRequest(data);
+ },
+
+ _generateID() {
+ // generateUUID() gives a UUID surrounded by {...}, slice them off.
+ return Services.uuid.generateUUID().toString().slice(1, -1);
+ },
+
+ register(record) {
+ lazy.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) {
+ lazy.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,
+ };
+
+ return this._sendRequestForReply(record, data);
+ });
+ },
+
+ _queueStart: Promise.resolve(),
+ _notifyRequestQueue: null,
+ _queue: null,
+ _enqueue(op) {
+ lazy.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) {
+ lazy.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)) {
+ lazy.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) {
+ lazy.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(aChannelID, aLatestVersion) {
+ lazy.console.debug(
+ "receivedUpdate: Updating",
+ aChannelID,
+ "->",
+ aLatestVersion
+ );
+
+ this._mainPushService
+ .receivedPushMessage(aChannelID, "", null, null, record => {
+ if (record.version === null || record.version < aLatestVersion) {
+ lazy.console.debug(
+ "receivedUpdate: Version changed for",
+ aChannelID,
+ aLatestVersion
+ );
+ record.version = aLatestVersion;
+ return record;
+ }
+ lazy.console.debug(
+ "receivedUpdate: No significant version change for",
+ aChannelID,
+ aLatestVersion
+ );
+ return null;
+ })
+ .then(status => {
+ this._sendAck(aChannelID, aLatestVersion, status);
+ })
+ .catch(err => {
+ lazy.console.error(
+ "receivedUpdate: Error acknowledging message",
+ aChannelID,
+ aLatestVersion,
+ err
+ );
+ });
+ },
+
+ // begin Push protocol handshake
+ _wsOnStart(context) {
+ lazy.console.debug("wsOnStart()");
+
+ if (this._currentState != STATE_WAITING_FOR_WS_START) {
+ lazy.console.error(
+ "wsOnStart: NOT in STATE_WAITING_FOR_WS_START. Current",
+ "state",
+ this._currentState,
+ "Skipping"
+ );
+ return;
+ }
+
+ this._mainPushService
+ .getAllUnexpired()
+ .then(
+ records => this._sendHello(records),
+ err => {
+ lazy.console.warn(
+ "Error fetching existing records before handshake; assuming none",
+ err
+ );
+ this._sendHello([]);
+ }
+ )
+ .catch(err => {
+ // If we failed to send the handshake, back off and reconnect.
+ lazy.console.warn("Failed to send handshake; reconnecting", err);
+ this._reconnect();
+ });
+ },
+
+ /**
+ * Sends a `hello` handshake to the server.
+ *
+ * @param {Array<PushRecordWebSocket>} An array of records for existing
+ * subscriptions, used to determine whether to rotate our UAID.
+ */
+ _sendHello(records) {
+ let data = {
+ messageType: "hello",
+ broadcasts: this._broadcastListeners,
+ use_webpush: true,
+ };
+
+ if (records.length && this._UAID) {
+ // Only send our UAID if we have existing push subscriptions, to
+ // avoid tying a persistent identifier to the connection (bug
+ // 1617136). The push server will issue our client a new UAID in
+ // the `hello` response, which we'll store until either the next
+ // time we reconnect, or the user subscribes to push. Once we have a
+ // push subscription, we'll stop rotating the UAID when we connect,
+ // so that we can receive push messages for them.
+ 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(context, statusCode) {
+ lazy.console.debug("wsOnStop()");
+
+ if (statusCode != Cr.NS_OK && !this._skipReconnect) {
+ lazy.console.debug(
+ "wsOnStop: Reconnecting after socket error",
+ statusCode
+ );
+ this._reconnect();
+ return;
+ }
+
+ this._shutdownWS();
+ },
+
+ _wsOnMessageAvailable(context, message) {
+ lazy.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) {
+ lazy.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"
+ ) {
+ lazy.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) {
+ if (!this._hasPendingRequests()) {
+ this._requestTimeoutTimer.cancel();
+ }
+ return;
+ }
+
+ // An allowlist of protocol handlers. Add to these if new messages are added
+ // in the protocol.
+ let handlers = [
+ "Hello",
+ "Register",
+ "Unregister",
+ "Notification",
+ "Broadcast",
+ ];
+
+ // 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.includes(handlerName)) {
+ lazy.console.warn(
+ "wsOnMessageAvailable: No allowlisted handler",
+ handlerName,
+ "for message",
+ reply.messageType
+ );
+ return;
+ }
+
+ let handler = "_handle" + handlerName + "Reply";
+
+ if (typeof this[handler] !== "function") {
+ lazy.console.warn(
+ "wsOnMessageAvailable: Handler",
+ handler,
+ "allowlisted 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(context, aStatusCode, aReason) {
+ lazy.console.debug("wsOnServerClose()", aStatusCode, aReason);
+
+ if (aStatusCode == kBACKOFF_WS_STATUS_CODE) {
+ lazy.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,
+ 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;
+ },
+
+ sendSubscribeBroadcast(serviceId, version) {
+ this._currentlyRegistering.add(serviceId);
+ let data = {
+ messageType: "broadcast_subscribe",
+ broadcasts: {
+ [serviceId]: version,
+ },
+ };
+
+ this._queueRequest(data);
+ },
+};
+
+function PushRecordWebSocket(record) {
+ PushRecord.call(this, record);
+ this.channelID = record.channelID;
+ this.version = record.version;
+}
+
+PushRecordWebSocket.prototype = Object.create(PushRecord.prototype, {
+ keyID: {
+ get() {
+ return this.channelID;
+ },
+ },
+});
+
+PushRecordWebSocket.prototype.toSubscription = function () {
+ let subscription = PushRecord.prototype.toSubscription.call(this);
+ subscription.version = this.version;
+ return subscription;
+};
diff --git a/dom/push/PushSubscription.cpp b/dom/push/PushSubscription.cpp
new file mode 100644
index 0000000000..0afb63eee8
--- /dev/null
+++ b/dom/push/PushSubscription.cpp
@@ -0,0 +1,372 @@
+/* -*- 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/PushSubscription.h"
+
+#include "nsGlobalWindowInner.h"
+#include "nsIPushService.h"
+#include "nsIScriptObjectPrincipal.h"
+#include "nsServiceManagerUtils.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/WorkerCommon.h"
+#include "mozilla/dom/WorkerPrivate.h"
+#include "mozilla/dom/WorkerRunnable.h"
+#include "mozilla/dom/WorkerScope.h"
+
+namespace mozilla::dom {
+
+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() = default;
+
+ RefPtr<Promise> mPromise;
+};
+
+NS_IMPL_ISUPPORTS(UnsubscribeResultCallback, nsIUnsubscribeResultCallback)
+
+class UnsubscribeResultRunnable final : public WorkerRunnable {
+ public:
+ UnsubscribeResultRunnable(WorkerPrivate* aWorkerPrivate,
+ RefPtr<PromiseWorkerProxy>&& aProxy,
+ nsresult aStatus, bool aSuccess)
+ : WorkerRunnable(aWorkerPrivate, "UnsubscribeResultRunnable"),
+ mProxy(std::move(aProxy)),
+ mStatus(aStatus),
+ mSuccess(aSuccess) {
+ AssertIsOnMainThread();
+ }
+
+ bool WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override {
+ MOZ_ASSERT(aWorkerPrivate);
+ aWorkerPrivate->AssertIsOnWorkerThread();
+
+ RefPtr<Promise> promise = mProxy->GetWorkerPromise();
+ // Once Worker had already started shutdown, workerPromise would be nullptr
+ if (!promise) {
+ return true;
+ }
+ if (NS_SUCCEEDED(mStatus)) {
+ promise->MaybeResolve(mSuccess);
+ } else {
+ promise->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
+ }
+
+ mProxy->CleanUp();
+
+ return true;
+ }
+
+ private:
+ ~UnsubscribeResultRunnable() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsresult mStatus;
+ bool mSuccess;
+};
+
+class WorkerUnsubscribeResultCallback final
+ : public nsIUnsubscribeResultCallback {
+ public:
+ NS_DECL_ISUPPORTS
+
+ explicit WorkerUnsubscribeResultCallback(PromiseWorkerProxy* aProxy)
+ : mProxy(aProxy) {
+ AssertIsOnMainThread();
+ }
+
+ NS_IMETHOD
+ OnUnsubscribe(nsresult aStatus, bool aSuccess) override {
+ AssertIsOnMainThread();
+ MOZ_ASSERT(mProxy, "OnUnsubscribe() called twice?");
+
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+
+ WorkerPrivate* worker = mProxy->GetWorkerPrivate();
+ RefPtr<UnsubscribeResultRunnable> r = new UnsubscribeResultRunnable(
+ worker, std::move(mProxy), aStatus, aSuccess);
+ MOZ_ALWAYS_TRUE(r->Dispatch());
+
+ return NS_OK;
+ }
+
+ private:
+ ~WorkerUnsubscribeResultCallback() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+};
+
+NS_IMPL_ISUPPORTS(WorkerUnsubscribeResultCallback, nsIUnsubscribeResultCallback)
+
+class UnsubscribeRunnable final : public Runnable {
+ public:
+ UnsubscribeRunnable(PromiseWorkerProxy* aProxy, const nsAString& aScope)
+ : Runnable("dom::UnsubscribeRunnable"), mProxy(aProxy), mScope(aScope) {
+ MOZ_ASSERT(aProxy);
+ MOZ_ASSERT(!aScope.IsEmpty());
+ }
+
+ NS_IMETHOD
+ Run() override {
+ AssertIsOnMainThread();
+
+ nsCOMPtr<nsIPrincipal> principal;
+
+ {
+ MutexAutoLock lock(mProxy->Lock());
+ if (mProxy->CleanedUp()) {
+ return NS_OK;
+ }
+ principal = mProxy->GetWorkerPrivate()->GetPrincipal();
+ }
+
+ MOZ_ASSERT(principal);
+
+ RefPtr<WorkerUnsubscribeResultCallback> callback =
+ new WorkerUnsubscribeResultCallback(mProxy);
+
+ nsCOMPtr<nsIPushService> service =
+ do_GetService("@mozilla.org/push/Service;1");
+ if (NS_WARN_IF(!service)) {
+ callback->OnUnsubscribe(NS_ERROR_FAILURE, false);
+ return NS_OK;
+ }
+
+ if (NS_WARN_IF(
+ NS_FAILED(service->Unsubscribe(mScope, principal, callback)))) {
+ callback->OnUnsubscribe(NS_ERROR_FAILURE, false);
+ return NS_OK;
+ }
+
+ return NS_OK;
+ }
+
+ private:
+ ~UnsubscribeRunnable() = default;
+
+ RefPtr<PromiseWorkerProxy> mProxy;
+ nsString mScope;
+};
+
+} // anonymous namespace
+
+PushSubscription::PushSubscription(nsIGlobalObject* aGlobal,
+ const nsAString& aEndpoint,
+ const nsAString& aScope,
+ Nullable<EpochTimeStamp>&& aExpirationTime,
+ nsTArray<uint8_t>&& aRawP256dhKey,
+ nsTArray<uint8_t>&& aAuthSecret,
+ nsTArray<uint8_t>&& aAppServerKey)
+ : mEndpoint(aEndpoint),
+ mScope(aScope),
+ mExpirationTime(std::move(aExpirationTime)),
+ mRawP256dhKey(std::move(aRawP256dhKey)),
+ mAuthSecret(std::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, std::move(aAppServerKey));
+}
+
+PushSubscription::~PushSubscription() = default;
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(PushSubscription, mGlobal, mOptions)
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushSubscription)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushSubscription)
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushSubscription)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+JSObject* PushSubscription::WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) {
+ return PushSubscription_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+// static
+already_AddRefed<PushSubscription> PushSubscription::Constructor(
+ GlobalObject& aGlobal, const PushSubscriptionInit& aInitDict,
+ ErrorResult& aRv) {
+ nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
+
+ nsTArray<uint8_t> rawKey;
+ if (aInitDict.mP256dhKey.WasPassed() &&
+ !aInitDict.mP256dhKey.Value().IsNull() &&
+ !aInitDict.mP256dhKey.Value().Value().AppendDataTo(rawKey)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+
+ nsTArray<uint8_t> authSecret;
+ if (aInitDict.mAuthSecret.WasPassed() &&
+ !aInitDict.mAuthSecret.Value().IsNull() &&
+ !aInitDict.mAuthSecret.Value().Value().AppendDataTo(authSecret)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+
+ nsTArray<uint8_t> appServerKey;
+ if (aInitDict.mAppServerKey.WasPassed() &&
+ !aInitDict.mAppServerKey.Value().IsNull()) {
+ const OwningArrayBufferViewOrArrayBuffer& bufferSource =
+ aInitDict.mAppServerKey.Value().Value();
+ if (!PushUtil::CopyBufferSourceToArray(bufferSource, appServerKey)) {
+ aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
+ return nullptr;
+ }
+ }
+
+ Nullable<EpochTimeStamp> expirationTime;
+ if (aInitDict.mExpirationTime.IsNull()) {
+ expirationTime.SetNull();
+ } else {
+ expirationTime.SetValue(aInitDict.mExpirationTime.Value());
+ }
+
+ RefPtr<PushSubscription> sub = new PushSubscription(
+ global, aInitDict.mEndpoint, aInitDict.mScope, std::move(expirationTime),
+ std::move(rawKey), std::move(authSecret), std::move(appServerKey));
+
+ return sub.forget();
+}
+
+already_AddRefed<Promise> PushSubscription::Unsubscribe(ErrorResult& aRv) {
+ if (!NS_IsMainThread()) {
+ RefPtr<Promise> p = UnsubscribeFromWorker(aRv);
+ return p.forget();
+ }
+
+ MOZ_ASSERT(mGlobal);
+
+ nsCOMPtr<nsIPushService> service =
+ do_GetService("@mozilla.org/push/Service;1");
+ if (NS_WARN_IF(!service)) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return nullptr;
+ }
+
+ nsCOMPtr<nsPIDOMWindowInner> window = do_QueryInterface(mGlobal);
+ if (!window) {
+ aRv.Throw(NS_ERROR_FAILURE);
+ return nullptr;
+ }
+
+ RefPtr<Promise> p = Promise::Create(mGlobal, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<UnsubscribeResultCallback> callback = new UnsubscribeResultCallback(p);
+ Unused << NS_WARN_IF(NS_FAILED(service->Unsubscribe(
+ mScope, nsGlobalWindowInner::Cast(window)->GetClientPrincipal(),
+ callback)));
+
+ return p.forget();
+}
+
+void PushSubscription::GetKey(JSContext* aCx, PushEncryptionKeyName aType,
+ JS::MutableHandle<JSObject*> aKey,
+ ErrorResult& aRv) {
+ if (aType == PushEncryptionKeyName::P256dh) {
+ PushUtil::CopyArrayToArrayBuffer(aCx, mRawP256dhKey, aKey, aRv);
+ } else if (aType == PushEncryptionKeyName::Auth) {
+ PushUtil::CopyArrayToArrayBuffer(aCx, mAuthSecret, aKey, aRv);
+ } else {
+ aKey.set(nullptr);
+ }
+}
+
+void PushSubscription::ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv) {
+ aJSON.mEndpoint.Construct();
+ aJSON.mEndpoint.Value() = mEndpoint;
+
+ aJSON.mKeys.mP256dh.Construct();
+ nsresult rv = Base64URLEncode(
+ mRawP256dhKey.Length(), mRawP256dhKey.Elements(),
+ Base64URLEncodePaddingPolicy::Omit, aJSON.mKeys.mP256dh.Value());
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ aRv.Throw(rv);
+ return;
+ }
+
+ aJSON.mKeys.mAuth.Construct();
+ rv = Base64URLEncode(mAuthSecret.Length(), mAuthSecret.Elements(),
+ Base64URLEncodePaddingPolicy::Omit,
+ aJSON.mKeys.mAuth.Value());
+ if (NS_WARN_IF(NS_FAILED(rv))) {
+ aRv.Throw(rv);
+ return;
+ }
+ aJSON.mExpirationTime.Construct(mExpirationTime);
+}
+
+already_AddRefed<PushSubscriptionOptions> PushSubscription::Options() {
+ RefPtr<PushSubscriptionOptions> options = mOptions;
+ return options.forget();
+}
+
+already_AddRefed<Promise> PushSubscription::UnsubscribeFromWorker(
+ ErrorResult& aRv) {
+ WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
+ MOZ_ASSERT(worker);
+ worker->AssertIsOnWorkerThread();
+
+ nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
+ RefPtr<Promise> p = Promise::Create(global, aRv);
+ if (NS_WARN_IF(aRv.Failed())) {
+ return nullptr;
+ }
+
+ RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
+ if (!proxy) {
+ p->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
+ return p.forget();
+ }
+
+ RefPtr<UnsubscribeRunnable> r = new UnsubscribeRunnable(proxy, mScope);
+ MOZ_ALWAYS_SUCCEEDS(NS_DispatchToMainThread(r));
+
+ return p.forget();
+}
+
+} // namespace mozilla::dom
diff --git a/dom/push/PushSubscription.h b/dom/push/PushSubscription.h
new file mode 100644
index 0000000000..ea2bed5ad7
--- /dev/null
+++ b/dom/push/PushSubscription.h
@@ -0,0 +1,83 @@
+/* -*- 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/. */
+
+#ifndef mozilla_dom_PushSubscription_h
+#define mozilla_dom_PushSubscription_h
+
+#include "js/RootingAPI.h"
+#include "nsCOMPtr.h"
+#include "nsWrapperCache.h"
+
+#include "mozilla/AlreadyAddRefed.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"
+#include "domstubs.h"
+
+class nsIGlobalObject;
+
+namespace mozilla {
+class ErrorResult;
+
+namespace dom {
+
+class Promise;
+
+class PushSubscription final : public nsISupports, public nsWrapperCache {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(PushSubscription)
+
+ PushSubscription(nsIGlobalObject* aGlobal, const nsAString& aEndpoint,
+ const nsAString& aScope,
+ Nullable<EpochTimeStamp>&& aExpirationTime,
+ nsTArray<uint8_t>&& aP256dhKey,
+ nsTArray<uint8_t>&& aAuthSecret,
+ nsTArray<uint8_t>&& aAppServerKey);
+
+ JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ nsIGlobalObject* GetParentObject() const { return mGlobal; }
+
+ void GetEndpoint(nsAString& aEndpoint) const { aEndpoint = mEndpoint; }
+
+ void GetKey(JSContext* cx, PushEncryptionKeyName aType,
+ JS::MutableHandle<JSObject*> aKey, ErrorResult& aRv);
+
+ Nullable<EpochTimeStamp> GetExpirationTime() { return mExpirationTime; };
+
+ static already_AddRefed<PushSubscription> Constructor(
+ GlobalObject& aGlobal, const PushSubscriptionInit& aInitDict,
+ ErrorResult& aRv);
+
+ already_AddRefed<Promise> Unsubscribe(ErrorResult& aRv);
+
+ void ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv);
+
+ already_AddRefed<PushSubscriptionOptions> Options();
+
+ private:
+ ~PushSubscription();
+
+ already_AddRefed<Promise> UnsubscribeFromWorker(ErrorResult& aRv);
+
+ nsString mEndpoint;
+ nsString mScope;
+ Nullable<EpochTimeStamp> mExpirationTime;
+ nsTArray<uint8_t> mRawP256dhKey;
+ nsTArray<uint8_t> mAuthSecret;
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+ RefPtr<PushSubscriptionOptions> mOptions;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushSubscription_h
diff --git a/dom/push/PushSubscriptionOptions.cpp b/dom/push/PushSubscriptionOptions.cpp
new file mode 100644
index 0000000000..1de3d96a2e
--- /dev/null
+++ b/dom/push/PushSubscriptionOptions.cpp
@@ -0,0 +1,65 @@
+/* -*- 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/PushSubscriptionOptions.h"
+
+#include "MainThreadUtils.h"
+#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
+#include "mozilla/ErrorResult.h"
+#include "mozilla/HoldDropJSObjects.h"
+#include "mozilla/dom/PushUtil.h"
+#include "nsIGlobalObject.h"
+#include "nsWrapperCache.h"
+
+namespace mozilla::dom {
+
+PushSubscriptionOptions::PushSubscriptionOptions(
+ nsIGlobalObject* aGlobal, nsTArray<uint8_t>&& aRawAppServerKey)
+ : mGlobal(aGlobal),
+ mRawAppServerKey(std::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() {
+ mozilla::DropJSObjects(this);
+}
+
+NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_WITH_JS_MEMBERS(PushSubscriptionOptions,
+ (mGlobal),
+ (mAppServerKey))
+
+NS_IMPL_CYCLE_COLLECTING_ADDREF(PushSubscriptionOptions)
+NS_IMPL_CYCLE_COLLECTING_RELEASE(PushSubscriptionOptions)
+
+NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushSubscriptionOptions)
+ NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
+ NS_INTERFACE_MAP_ENTRY(nsISupports)
+NS_INTERFACE_MAP_END
+
+JSObject* PushSubscriptionOptions::WrapObject(
+ JSContext* aCx, JS::Handle<JSObject*> aGivenProto) {
+ return PushSubscriptionOptions_Binding::Wrap(aCx, this, aGivenProto);
+}
+
+void PushSubscriptionOptions::GetApplicationServerKey(
+ JSContext* aCx, JS::MutableHandle<JSObject*> aKey, ErrorResult& aRv) {
+ if (!mRawAppServerKey.IsEmpty() && !mAppServerKey) {
+ JS::Rooted<JSObject*> appServerKey(aCx);
+ PushUtil::CopyArrayToArrayBuffer(aCx, mRawAppServerKey, &appServerKey, aRv);
+ if (aRv.Failed()) {
+ return;
+ }
+ MOZ_ASSERT(appServerKey);
+ mAppServerKey = appServerKey;
+ }
+ aKey.set(mAppServerKey);
+}
+
+} // namespace mozilla::dom
diff --git a/dom/push/PushSubscriptionOptions.h b/dom/push/PushSubscriptionOptions.h
new file mode 100644
index 0000000000..03722edab1
--- /dev/null
+++ b/dom/push/PushSubscriptionOptions.h
@@ -0,0 +1,56 @@
+/* -*- 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/. */
+
+#ifndef mozilla_dom_PushSubscriptionOptions_h
+#define mozilla_dom_PushSubscriptionOptions_h
+
+#include "js/RootingAPI.h"
+#include "js/TypeDecls.h"
+#include "nsCOMPtr.h"
+#include "nsCycleCollectionParticipant.h"
+#include "nsTArray.h"
+#include "nsWrapperCache.h"
+
+class nsIGlobalObject;
+
+namespace mozilla {
+
+class ErrorResult;
+
+namespace dom {
+
+bool ServiceWorkerVisible(JSContext* aCx, JSObject* aGlobal);
+
+class PushSubscriptionOptions final : public nsISupports,
+ public nsWrapperCache {
+ public:
+ NS_DECL_CYCLE_COLLECTING_ISUPPORTS
+ NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushSubscriptionOptions)
+
+ PushSubscriptionOptions(nsIGlobalObject* aGlobal,
+ nsTArray<uint8_t>&& aRawAppServerKey);
+
+ nsIGlobalObject* GetParentObject() const { return mGlobal; }
+
+ JSObject* WrapObject(JSContext* aCx,
+ JS::Handle<JSObject*> aGivenProto) override;
+
+ void GetApplicationServerKey(JSContext* aCx,
+ JS::MutableHandle<JSObject*> aKey,
+ ErrorResult& aRv);
+
+ private:
+ ~PushSubscriptionOptions();
+
+ nsCOMPtr<nsIGlobalObject> mGlobal;
+ nsTArray<uint8_t> mRawAppServerKey;
+ JS::Heap<JSObject*> mAppServerKey;
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushSubscriptionOptions_h
diff --git a/dom/push/PushUtil.cpp b/dom/push/PushUtil.cpp
new file mode 100644
index 0000000000..b1373391ea
--- /dev/null
+++ b/dom/push/PushUtil.cpp
@@ -0,0 +1,36 @@
+/* -*- 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/PushUtil.h"
+#include "mozilla/dom/UnionTypes.h"
+
+namespace mozilla::dom {
+
+/* static */
+bool PushUtil::CopyBufferSourceToArray(
+ const OwningArrayBufferViewOrArrayBuffer& aSource,
+ nsTArray<uint8_t>& aArray) {
+ MOZ_ASSERT(aArray.IsEmpty());
+ return AppendTypedArrayDataTo(aSource, aArray);
+}
+
+/* static */
+void PushUtil::CopyArrayToArrayBuffer(JSContext* aCx,
+ const nsTArray<uint8_t>& aArray,
+ JS::MutableHandle<JSObject*> aValue,
+ ErrorResult& aRv) {
+ if (aArray.IsEmpty()) {
+ aValue.set(nullptr);
+ return;
+ }
+ JS::Rooted<JSObject*> buffer(aCx, ArrayBuffer::Create(aCx, aArray, aRv));
+ if (NS_WARN_IF(aRv.Failed())) {
+ return;
+ }
+ aValue.set(buffer);
+}
+
+} // namespace mozilla::dom
diff --git a/dom/push/PushUtil.h b/dom/push/PushUtil.h
new file mode 100644
index 0000000000..1e4ccf33a9
--- /dev/null
+++ b/dom/push/PushUtil.h
@@ -0,0 +1,39 @@
+/* -*- 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/. */
+
+#ifndef mozilla_dom_PushUtil_h
+#define mozilla_dom_PushUtil_h
+
+#include "nsTArray.h"
+
+#include "mozilla/dom/TypedArray.h"
+
+namespace mozilla {
+class ErrorResult;
+
+namespace dom {
+
+class OwningArrayBufferViewOrArrayBuffer;
+
+class PushUtil final {
+ private:
+ PushUtil() = delete;
+
+ public:
+ static bool CopyBufferSourceToArray(
+ const OwningArrayBufferViewOrArrayBuffer& aSource,
+ nsTArray<uint8_t>& aArray);
+
+ static void CopyArrayToArrayBuffer(JSContext* aCx,
+ const nsTArray<uint8_t>& aArray,
+ JS::MutableHandle<JSObject*> aValue,
+ ErrorResult& aRv);
+};
+
+} // namespace dom
+} // namespace mozilla
+
+#endif // mozilla_dom_PushUtil_h
diff --git a/dom/push/components.conf b/dom/push/components.conf
new file mode 100644
index 0000000000..6af27c15fb
--- /dev/null
+++ b/dom/push/components.conf
@@ -0,0 +1,24 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+Classes = [
+ {
+ 'cid': '{cde1d019-fad8-4044-b141-65fb4fb7a245}',
+ 'contract_ids': ['@mozilla.org/push/PushManager;1'],
+ 'esModule': 'resource://gre/modules/Push.sys.mjs',
+ 'constructor': 'Push',
+ },
+]
+
+if buildconfig.substs['MOZ_WIDGET_TOOLKIT'] != 'android':
+ Classes += [
+ {
+ 'cid': '{daaa8d73-677e-4233-8acd-2c404bd01658}',
+ 'contract_ids': ['@mozilla.org/push/Service;1'],
+ 'esModule': 'resource://gre/modules/PushComponents.sys.mjs',
+ 'constructor': 'Service',
+ },
+ ]
diff --git a/dom/push/moz.build b/dom/push/moz.build
new file mode 100644
index 0000000000..0065fd21f6
--- /dev/null
+++ b/dom/push/moz.build
@@ -0,0 +1,67 @@
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Core", "DOM: Notifications")
+
+EXTRA_COMPONENTS += [
+ "Push.manifest",
+]
+
+EXTRA_JS_MODULES += [
+ "Push.sys.mjs",
+ "PushBroadcastService.sys.mjs",
+ "PushComponents.sys.mjs",
+ "PushCrypto.sys.mjs",
+ "PushDB.sys.mjs",
+ "PushRecord.sys.mjs",
+ "PushService.sys.mjs",
+]
+
+if CONFIG["MOZ_BUILD_APP"] != "mobile/android":
+ # Everything but GeckoView.
+ EXTRA_JS_MODULES += [
+ "PushServiceHttp2.sys.mjs",
+ "PushServiceWebSocket.sys.mjs",
+ ]
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+MOCHITEST_MANIFESTS += [
+ "test/mochitest.toml",
+]
+
+XPCSHELL_TESTS_MANIFESTS += [
+ "test/xpcshell/xpcshell.toml",
+]
+
+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",
+]
+
+FINAL_LIBRARY = "xul"
diff --git a/dom/push/test/error_worker.js b/dom/push/test/error_worker.js
new file mode 100644
index 0000000000..f421118d79
--- /dev/null
+++ b/dom/push/test/error_worker.js
@@ -0,0 +1,9 @@
+this.onpush = function (event) {
+ var request = event.data.json();
+ if (request.type == "exception") {
+ throw new Error("Uncaught exception");
+ }
+ if (request.type == "rejection") {
+ event.waitUntil(Promise.reject(new Error("Unhandled rejection")));
+ }
+};
diff --git a/dom/push/test/frame.html b/dom/push/test/frame.html
new file mode 100644
index 0000000000..50036db15e
--- /dev/null
+++ b/dom/push/test/frame.html
@@ -0,0 +1,24 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+ <script>
+
+ function waitOnWorkerMessage(type) {
+ return new Promise(function(res, rej) {
+ function onMessage(e) {
+ if (e.data.type == type) {
+ navigator.serviceWorker.removeEventListener("message", onMessage);
+ (e.data.okay == "yes" ? res : rej)(e.data);
+ }
+ }
+ navigator.serviceWorker.addEventListener("message", onMessage);
+ });
+ }
+
+ </script>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/dom/push/test/lifetime_worker.js b/dom/push/test/lifetime_worker.js
new file mode 100644
index 0000000000..02c09d966e
--- /dev/null
+++ b/dom/push/test/lifetime_worker.js
@@ -0,0 +1,90 @@
+var state = "from_scope";
+var resolvePromiseCallback;
+
+self.onfetch = function (event) {
+ if (event.request.url.includes("lifetime_frame.html")) {
+ event.respondWith(new Response("iframe_lifetime"));
+ return;
+ }
+
+ var currentState = state;
+ event.waitUntil(
+ self.clients.matchAll().then(clients => {
+ clients.forEach(client => {
+ client.postMessage({ type: "fetch", state: currentState });
+ });
+ })
+ );
+
+ if (event.request.url.includes("update")) {
+ state = "update";
+ } else if (event.request.url.includes("wait")) {
+ event.respondWith(
+ new Promise(function (res, rej) {
+ if (resolvePromiseCallback) {
+ dump("ERROR: service worker was already waiting on a promise.\n");
+ }
+ resolvePromiseCallback = function () {
+ res(new Response("resolve_respondWithPromise"));
+ };
+ })
+ );
+ state = "wait";
+ } else if (event.request.url.includes("release")) {
+ state = "release";
+ resolvePromise();
+ }
+};
+
+function resolvePromise() {
+ if (resolvePromiseCallback === undefined || resolvePromiseCallback == null) {
+ dump("ERROR: wait promise was not set.\n");
+ return;
+ }
+ resolvePromiseCallback();
+ resolvePromiseCallback = null;
+}
+
+self.onmessage = function (event) {
+ var lastState = state;
+ state = event.data;
+ if (state === "wait") {
+ event.waitUntil(
+ new Promise(function (res, rej) {
+ if (resolvePromiseCallback) {
+ dump("ERROR: service worker was already waiting on a promise.\n");
+ }
+ resolvePromiseCallback = res;
+ })
+ );
+ } else if (state === "release") {
+ resolvePromise();
+ }
+ event.source.postMessage({ type: "message", state: lastState });
+};
+
+self.onpush = function (event) {
+ var pushResolve;
+ event.waitUntil(
+ new Promise(function (resolve) {
+ pushResolve = resolve;
+ })
+ );
+
+ // FIXME(catalinb): push message carry no data. So we assume the only
+ // push message we get is "wait"
+ self.clients.matchAll().then(function (client) {
+ if (!client.length) {
+ dump("ERROR: no clients to send the response to.\n");
+ }
+
+ client[0].postMessage({ type: "push", state });
+
+ state = "wait";
+ if (resolvePromiseCallback) {
+ dump("ERROR: service worker was already waiting on a promise.\n");
+ } else {
+ resolvePromiseCallback = pushResolve;
+ }
+ });
+};
diff --git a/dom/push/test/mochitest.toml b/dom/push/test/mochitest.toml
new file mode 100644
index 0000000000..d95b8ef8e1
--- /dev/null
+++ b/dom/push/test/mochitest.toml
@@ -0,0 +1,82 @@
+[DEFAULT]
+tags = "condprof"
+support-files = [
+ "worker.js",
+ "frame.html",
+ "webpush.js",
+ "lifetime_worker.js",
+ "test_utils.js",
+ "mockpushserviceparent.js",
+ "error_worker.js",
+]
+
+
+["test_data.html"]
+skip-if = [
+ "os == 'android'",
+ "os == 'win'", # Bug 1373346
+]
+scheme = "https"
+
+["test_error_reporting.html"]
+skip-if = [
+ "serviceworker_e10s",
+ "os == 'android'",
+]
+
+["test_has_permissions.html"]
+skip-if = ["os == 'android'"]
+
+["test_multiple_register.html"]
+skip-if = ["os == 'android'"]
+
+["test_multiple_register_different_scope.html"]
+skip-if = ["os == 'android'"]
+
+["test_multiple_register_during_service_activation.html"]
+skip-if = [
+ "os == 'android'",
+ "os == 'win'",
+ "os == 'linux'",
+ "os == 'mac'", #Bug 1274773
+]
+
+["test_permission_granted.html"]
+scheme = "https"
+
+["test_permissions.html"]
+skip-if = ["os == 'android'"]
+
+["test_register.html"]
+skip-if = [
+ "os == 'android'",
+ "os == 'win'" # Bug 1373346
+]
+
+["test_register_key.html"]
+skip-if = ["os == 'android'"]
+scheme = "https"
+
+["test_serviceworker_lifetime.html"]
+skip-if = [
+ "serviceworker_e10s",
+ "os == 'android'",
+ "os == 'win'", # Bug 1373346
+ "os =='linux' && bits == 64", # Bug 1578374
+ "os =='mac'", # Bug 1578333
+]
+
+["test_subscription_change.html"]
+skip-if = [
+ "os == 'android'",
+ "os == 'win'" # Bug 1373346
+]
+
+["test_try_registering_offline_disabled.html"]
+skip-if = [
+ "os == 'android'",
+ "os == 'win'" # Bug 1373346
+]
+
+["test_unregister.html"]
+skip-if = ["os == 'android'"]
diff --git a/dom/push/test/mockpushserviceparent.js b/dom/push/test/mockpushserviceparent.js
new file mode 100644
index 0000000000..08d93f3aaf
--- /dev/null
+++ b/dom/push/test/mockpushserviceparent.js
@@ -0,0 +1,207 @@
+/* eslint-env mozilla/chrome-script */
+
+"use strict";
+
+/**
+ * Defers one or more callbacks until the next turn of the event loop. Multiple
+ * callbacks are executed in order.
+ *
+ * @param {Function[]} callbacks The callbacks to execute. One callback will be
+ * executed per tick.
+ */
+function waterfall(...callbacks) {
+ callbacks
+ .reduce(
+ (promise, callback) =>
+ promise.then(() => {
+ callback();
+ }),
+ Promise.resolve()
+ )
+ .catch(Cu.reportError);
+}
+
+/**
+ * Minimal implementation of a mock WebSocket connect to be used with
+ * PushService. Forwards and receive messages from the implementation
+ * that lives in the content process.
+ */
+function MockWebSocketParent(originalURI) {
+ this._originalURI = originalURI;
+}
+
+MockWebSocketParent.prototype = {
+ _originalURI: null,
+
+ _listener: null,
+ _context: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, originAttributes, windowId, listener, context) {
+ this._listener = listener;
+ this._context = context;
+ waterfall(() => this._listener.onStart(this._context));
+ },
+
+ sendMsg(msg) {
+ sendAsyncMessage("socket-client-msg", msg);
+ },
+
+ close() {
+ waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
+ },
+
+ serverSendMsg(msg) {
+ waterfall(
+ () => this._listener.onMessageAvailable(this._context, msg),
+ () => this._listener.onAcknowledge(this._context, 0)
+ );
+ },
+};
+
+var pushService = Cc["@mozilla.org/push/Service;1"].getService(
+ Ci.nsIPushService
+).wrappedJSObject;
+
+var mockSocket;
+var serverMsgs = [];
+
+addMessageListener("socket-setup", function () {
+ pushService.replaceServiceBackend({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ mockSocket = new MockWebSocketParent(uri);
+ while (serverMsgs.length) {
+ let msg = serverMsgs.shift();
+ mockSocket.serverSendMsg(msg);
+ }
+ return mockSocket;
+ },
+ });
+});
+
+addMessageListener("socket-teardown", function (msg) {
+ pushService
+ .restoreServiceBackend()
+ .then(_ => {
+ serverMsgs.length = 0;
+ if (mockSocket) {
+ mockSocket.close();
+ mockSocket = null;
+ }
+ sendAsyncMessage("socket-server-teardown");
+ })
+ .catch(error => {
+ Cu.reportError(`Error restoring service backend: ${error}`);
+ });
+});
+
+addMessageListener("socket-server-msg", function (msg) {
+ if (mockSocket) {
+ mockSocket.serverSendMsg(msg);
+ } else {
+ serverMsgs.push(msg);
+ }
+});
+
+var MockService = {
+ requestID: 1,
+ resolvers: new Map(),
+
+ sendRequest(name, params) {
+ return new Promise((resolve, reject) => {
+ let id = this.requestID++;
+ this.resolvers.set(id, { resolve, reject });
+ sendAsyncMessage("service-request", {
+ name,
+ id,
+ // The request params from the real push service may contain a
+ // principal, which cannot be passed to the unprivileged
+ // mochitest scope, and will cause the message to be dropped if
+ // present. The mochitest scope fortunately does not need the
+ // principal, though, so set it to null before sending.
+ params: Object.assign({}, params, { principal: null }),
+ });
+ });
+ },
+
+ handleResponse(response) {
+ if (!this.resolvers.has(response.id)) {
+ Cu.reportError(`Unexpected response for request ${response.id}`);
+ return;
+ }
+ let resolver = this.resolvers.get(response.id);
+ this.resolvers.delete(response.id);
+ if (response.error) {
+ resolver.reject(response.error);
+ } else {
+ resolver.resolve(response.result);
+ }
+ },
+
+ init() {},
+
+ register(pageRecord) {
+ return this.sendRequest("register", pageRecord);
+ },
+
+ registration(pageRecord) {
+ return this.sendRequest("registration", pageRecord);
+ },
+
+ unregister(pageRecord) {
+ return this.sendRequest("unregister", pageRecord);
+ },
+
+ reportDeliveryError(messageId, reason) {
+ sendAsyncMessage("service-delivery-error", {
+ messageId,
+ reason,
+ });
+ },
+
+ uninit() {
+ return Promise.resolve();
+ },
+};
+
+async function replaceService(service) {
+ // `?.` because `service` can be null
+ // (either by calling this function with null, or the push module doesn't have the
+ // field at all e.g. in GeckoView)
+ // Passing null here resets it to the default implementation on desktop
+ // (so `.service` never becomes null there) but not for GeckoView.
+ // XXX(krosylight): we need to remove this deviation.
+ await pushService.service?.uninit();
+ pushService.service = service;
+ await pushService.service?.init();
+}
+
+addMessageListener("service-replace", function () {
+ replaceService(MockService)
+ .then(_ => {
+ sendAsyncMessage("service-replaced");
+ })
+ .catch(error => {
+ Cu.reportError(`Error replacing service: ${error}`);
+ });
+});
+
+addMessageListener("service-restore", function () {
+ replaceService(null)
+ .then(_ => {
+ sendAsyncMessage("service-restored");
+ })
+ .catch(error => {
+ Cu.reportError(`Error restoring service: ${error}`);
+ });
+});
+
+addMessageListener("service-response", function (response) {
+ MockService.handleResponse(response);
+});
diff --git a/dom/push/test/test_data.html b/dom/push/test/test_data.html
new file mode 100644
index 0000000000..a2f043b7d9
--- /dev/null
+++ b/dom/push/test/test_data.html
@@ -0,0 +1,191 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1185544: Add data delivery to the WebSocket backend.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1185544</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/webpush.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1185544">Mozilla Bug 1185544</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ /* globals webPushEncrypt */
+
+ var userAgentID = "ac44402c-85fc-41e4-a0d0-483316d15351";
+ var channelID = null;
+
+ var mockSocket = new MockWebSocket();
+ mockSocket.onRegister = function(request) {
+ channelID = request.channelID;
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: userAgentID,
+ channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/1",
+ }));
+ };
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(mockSocket);
+ await setPushPermission(true);
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ var pushSubscription;
+ add_task(async function subscribe() {
+ pushSubscription = await registration.pushManager.subscribe();
+ });
+
+ add_task(async function compareJSONSubscription() {
+ var json = pushSubscription.toJSON();
+ is(json.endpoint, pushSubscription.endpoint, "Wrong endpoint");
+
+ ["p256dh", "auth"].forEach(keyName => {
+ isDeeply(
+ base64UrlDecode(json.keys[keyName]),
+ new Uint8Array(pushSubscription.getKey(keyName)),
+ "Mismatched Base64-encoded key: " + keyName
+ );
+ });
+ });
+
+ add_task(async function comparePublicKey() {
+ var data = await sendRequestToWorker({ type: "publicKey" });
+ var p256dhKey = new Uint8Array(pushSubscription.getKey("p256dh"));
+ is(p256dhKey.length, 65, "Key share should be 65 octets");
+ isDeeply(
+ p256dhKey,
+ new Uint8Array(data.p256dh),
+ "Mismatched key share"
+ );
+ var authSecret = new Uint8Array(pushSubscription.getKey("auth"));
+ is(authSecret.length, 16, "Auth secret should be 16 octets");
+ isDeeply(
+ authSecret,
+ new Uint8Array(data.auth),
+ "Mismatched auth secret"
+ );
+ });
+
+ var version = 0;
+ function sendEncryptedMsg(pushSub, message) {
+ return webPushEncrypt(pushSub, message)
+ .then((encryptedData) => {
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: version++,
+ channelID,
+ data: encryptedData.data,
+ headers: {
+ encryption: encryptedData.encryption,
+ encryption_key: encryptedData.encryption_key,
+ encoding: encryptedData.encoding,
+ },
+ }));
+ });
+ }
+
+ function waitForMessage(pushSub, message) {
+ return Promise.all([
+ controlledFrame.waitOnWorkerMessage("finished"),
+ sendEncryptedMsg(pushSub, message),
+ ]).then(([msg]) => msg);
+ }
+
+ add_task(async function sendPushMessageFromPage() {
+ var typedArray = new Uint8Array([226, 130, 40, 240, 40, 140, 188]);
+ var json = { hello: "world" };
+
+ var message = await waitForMessage(pushSubscription, "Text message from page");
+ is(message.data.text, "Text message from page", "Wrong text message data");
+
+ message = await waitForMessage(
+ pushSubscription,
+ typedArray
+ );
+ isDeeply(new Uint8Array(message.data.arrayBuffer), typedArray,
+ "Wrong array buffer message data");
+
+ message = await waitForMessage(
+ pushSubscription,
+ JSON.stringify(json)
+ );
+ ok(message.data.json.ok, "Unexpected error parsing JSON");
+ isDeeply(message.data.json.value, json, "Wrong JSON message data");
+
+ message = await waitForMessage(
+ pushSubscription,
+ ""
+ );
+ ok(message, "Should include data for empty messages");
+ is(message.data.text, "", "Wrong text for empty message");
+ is(message.data.arrayBuffer.byteLength, 0, "Wrong buffer length for empty message");
+ ok(!message.data.json.ok, "Expected JSON parse error for empty message");
+
+ message = await waitForMessage(
+ pushSubscription,
+ new Uint8Array([0x48, 0x69, 0x21, 0x20, 0xf0, 0x9f, 0x91, 0x80])
+ );
+ is(message.data.text, "Hi! \ud83d\udc40", "Wrong text for message with emoji");
+ var text = await new Promise((resolve, reject) => {
+ var reader = new FileReader();
+ reader.onloadend = event => {
+ if (reader.error) {
+ reject(reader.error);
+ } else {
+ resolve(reader.result);
+ }
+ };
+ reader.readAsText(message.data.blob);
+ });
+ is(text, "Hi! \ud83d\udc40", "Wrong blob data for message with emoji");
+
+ var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
+ // Send a blank message.
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID,
+ }));
+
+ var msg = await finishedPromise;
+ ok(!msg.data, "Should exclude data for blank messages");
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ await pushSubscription.unsubscribe();
+ });
+
+ add_task(async function unregister() {
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_error_reporting.html b/dom/push/test/test_error_reporting.html
new file mode 100644
index 0000000000..c180a1153d
--- /dev/null
+++ b/dom/push/test/test_error_reporting.html
@@ -0,0 +1,112 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1246341: Report message delivery failures to the Push server.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1246341</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1246341">Mozilla Bug 1246341</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var pushNotifier = SpecialPowers.Cc["@mozilla.org/push/Notifier;1"]
+ .getService(SpecialPowers.Ci.nsIPushNotifier);
+
+ var reporters = new Map();
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndReplaceService({
+ reportDeliveryError(messageId, reason) {
+ ok(reporters.has(messageId),
+ "Unexpected error reported for message " + messageId);
+ var resolve = reporters.get(messageId);
+ reporters.delete(messageId);
+ resolve(reason);
+ },
+ });
+ await setPushPermission(true);
+
+ var url = "error_worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ var idCounter = 1;
+ function waitForDeliveryError(request) {
+ return new Promise(resolve => {
+ var data = new TextEncoder().encode(JSON.stringify(request));
+ var principal = SpecialPowers.wrap(window).clientPrincipal;
+
+ let messageId = "message-" + (idCounter++);
+ reporters.set(messageId, resolve);
+ pushNotifier.notifyPushWithData(registration.scope, principal, messageId,
+ data);
+ });
+ }
+
+ add_task(async function reportDeliveryErrors() {
+ var reason = await waitForDeliveryError({ type: "exception" });
+ is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION,
+ "Should report uncaught exceptions");
+
+ reason = await waitForDeliveryError({ type: "rejection" });
+ is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION,
+ "Should report unhandled rejections");
+ });
+
+ add_task(async function reportDecryptionError() {
+ var message = await new Promise(resolve => {
+ SpecialPowers.registerConsoleListener(msg => {
+ if (!msg.isScriptError && !msg.isConsoleEvent) {
+ return;
+ }
+ const scope = "http://mochi.test:8888/tests/dom/push/test/";
+ if (msg.innerWindowID === "ServiceWorker" &&
+ msg.windowID === scope) {
+ SpecialPowers.postConsoleSentinel();
+ resolve(msg);
+ }
+ });
+
+ var principal = SpecialPowers.wrap(window).clientPrincipal;
+ pushNotifier.notifyError(registration.scope, principal, "Push error",
+ SpecialPowers.Ci.nsIScriptError.errorFlag);
+ });
+
+ is(message.sourceName, registration.scope,
+ "Should use the qualified scope URL as the source");
+ is(message.errorMessage, "Push error",
+ "Should report the given error string");
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ });
+
+ add_task(async function unregister() {
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_has_permissions.html b/dom/push/test/test_has_permissions.html
new file mode 100644
index 0000000000..0bef8fe19f
--- /dev/null
+++ b/dom/push/test/test_has_permissions.html
@@ -0,0 +1,53 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var registration;
+
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+
+ var url = "worker.js?" + Math.random();
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ add_task(async function hasPermission() {
+ var state = await registration.pushManager.permissionState();
+ debug("state: " + state);
+ ok(["granted", "denied", "prompt"].includes(state), "permissionState() returned a valid state.");
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_multiple_register.html b/dom/push/test/test_multiple_register.html
new file mode 100644
index 0000000000..3a963b7cd4
--- /dev/null
+++ b/dom/push/test/test_multiple_register.html
@@ -0,0 +1,132 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var registration;
+
+ function start() {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."})
+ .then((swr) => {
+ registration = swr;
+ return waitForActive(registration);
+ });
+ }
+
+ function unregister() {
+ return registration.unregister().then(function(result) {
+ ok(result, "Unregister should return true.");
+ }, function(e) {
+ dump("Unregistering the SW failed with " + e + "\n");
+ });
+ }
+
+ function setupPushNotification(swr) {
+ var p = new Promise(function(res, rej) {
+ swr.pushManager.subscribe().then(
+ function(pushSubscription) {
+ ok(true, "successful registered for push notification");
+ res({swr, pushSubscription});
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function setupSecondEndpoint(result) {
+ var p = new Promise(function(res, rej) {
+ result.swr.pushManager.subscribe().then(
+ function(pushSubscription) {
+ ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "setupSecondEndpoint - Got the same endpoint back.");
+ res(result);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function getEndpointExpectNull(swr) {
+ var p = new Promise(function(res, rej) {
+ swr.pushManager.getSubscription().then(
+ function(pushSubscription) {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ res(swr);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function getEndpoint(result) {
+ var p = new Promise(function(res, rej) {
+ result.swr.pushManager.getSubscription().then(
+ function(pushSubscription) {
+ ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "getEndpoint - Got the same endpoint back.");
+
+ res(pushSubscription);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(null);
+ }
+ );
+ });
+ return p;
+ }
+
+ function unregisterPushNotification(pushSubscription) {
+ return pushSubscription.unsubscribe();
+ }
+
+ function runTest() {
+ start()
+ .then(getEndpointExpectNull)
+ .then(setupPushNotification)
+ .then(setupSecondEndpoint)
+ .then(getEndpoint)
+ .then(unregisterPushNotification)
+ .then(unregister)
+ .catch(function(e) {
+ ok(false, "Some test failed with error " + e);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket())
+ .then(_ => setPushPermission(true))
+ .then(_ => runTest());
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_multiple_register_different_scope.html b/dom/push/test/test_multiple_register_different_scope.html
new file mode 100644
index 0000000000..b7c5bf1414
--- /dev/null
+++ b/dom/push/test/test_multiple_register_different_scope.html
@@ -0,0 +1,123 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1150812: Test registering for two different scopes.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1150812</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var scopeA = "./a/";
+ var scopeB = "./b/";
+
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ function registerServiceWorker(scope) {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope})
+ .then(swr => waitForActive(swr));
+ }
+
+ function unregister(swr) {
+ return swr.unregister()
+ .then(result => {
+ ok(result, "Unregister should return true.");
+ }, err => {
+ ok(false, "Unregistering the SW failed with " + err + "\n");
+ throw err;
+ });
+ }
+
+ function subscribe(swr) {
+ return swr.pushManager.subscribe()
+ .then(sub => {
+ ok(true, "successful registered for push notification");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+
+ function setupMultipleSubscriptions(swr1, swr2) {
+ return Promise.all([
+ subscribe(swr1),
+ subscribe(swr2),
+ ]).then(a => {
+ ok(a[0].endpoint != a[1].endpoint, "setupMultipleSubscriptions - Got different endpoints.");
+ return a;
+ });
+ }
+
+ function getEndpointExpectNull(swr) {
+ return swr.pushManager.getSubscription()
+ .then(pushSubscription => {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function getEndpoint(swr, results) {
+ return swr.pushManager.getSubscription()
+ .then(sub => {
+ ok((results[0].endpoint == sub.endpoint) ||
+ (results[1].endpoint == sub.endpoint), "getEndpoint - Got the same endpoint back.");
+ return results;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function unsubscribe(result) {
+ return result[0].unsubscribe()
+ .then(_ => result[1].unsubscribe());
+ }
+
+ function runTest() {
+ registerServiceWorker(scopeA)
+ .then(swrA =>
+ registerServiceWorker(scopeB)
+ .then(swrB =>
+ getEndpointExpectNull(swrA)
+ .then(_ => getEndpointExpectNull(swrB))
+ .then(_ => setupMultipleSubscriptions(swrA, swrB))
+ .then(results => getEndpoint(swrA, results))
+ .then(results => getEndpoint(swrB, results))
+ .then(results => unsubscribe(results))
+ .then(_ => unregister(swrA))
+ .then(_ => unregister(swrB))
+ )
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket())
+ .then(_ => setPushPermission(true))
+ .then(_ => runTest());
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_multiple_register_during_service_activation.html b/dom/push/test/test_multiple_register_during_service_activation.html
new file mode 100644
index 0000000000..be043a523e
--- /dev/null
+++ b/dom/push/test/test_multiple_register_during_service_activation.html
@@ -0,0 +1,110 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1150812: If service is in activating or no connection state it can not send
+request immediately, but the requests are queued. This test test the case of
+multiple subscription for the same scope during activation.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1150812</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ function registerServiceWorker() {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."});
+ }
+
+ function unregister(swr) {
+ return swr.unregister()
+ .then(result => {
+ ok(result, "Unregister should return true.");
+ }, err => {
+ dump("Unregistering the SW failed with " + err + "\n");
+ throw err;
+ });
+ }
+
+ function subscribe(swr) {
+ return swr.pushManager.subscribe()
+ .then(sub => {
+ ok(true, "successful registered for push notification");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function setupMultipleSubscriptions(swr) {
+ // We need to do this to restart service so that a queue will be formed.
+ let promiseTeardown = teardownMockPushSocket();
+ setupMockPushSocket(new MockWebSocket());
+
+ var pushSubscription;
+ return Promise.all([
+ subscribe(swr),
+ subscribe(swr),
+ ]).then(a => {
+ ok(a[0].endpoint == a[1].endpoint, "setupMultipleSubscriptions - Got the same endpoint back.");
+ pushSubscription = a[0];
+ return promiseTeardown;
+ }).then(_ => {
+ return pushSubscription;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function getEndpointExpectNull(swr) {
+ return swr.pushManager.getSubscription()
+ .then(pushSubscription => {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function unsubscribe(sub) {
+ return sub.unsubscribe();
+ }
+
+ function runTest() {
+ registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => setupMultipleSubscriptions(swr))
+ .then(sub => unsubscribe(sub))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_permission_granted.html b/dom/push/test/test_permission_granted.html
new file mode 100644
index 0000000000..418b17aada
--- /dev/null
+++ b/dom/push/test/test_permission_granted.html
@@ -0,0 +1,46 @@
+<!DOCTYPE html>
+<!--
+Tests PushManager.subscribe behavior based on permission state.
+https://bugzilla.mozilla.org/show_bug.cgi?id=1847217
+TODO: Move this to WPT when we get test_driver.set_permission (bug 1524074) and Push testing infra in WPT.
+-->
+<meta charset="utf-8">
+<title>Push permission granted test</title>
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+<script src="/tests/dom/push/test/test_utils.js"></script>
+<link rel="stylesheet" href="/tests/SimpleTest/test.css">
+<script>
+ let registration;
+ add_task(async function start() {
+ const url = "worker.js?" + Math.random();
+ registration = await navigator.serviceWorker.register(url, { scope: "." });
+ await waitForActive(registration);
+ });
+
+ add_task(async function test_notifications_permission_error() {
+ await setPushPermission(false);
+
+ try {
+ await registration.pushManager.subscribe();
+ ok(false, "No permission, should never proceed");
+ } catch (err) {
+ is(err.name, "NotAllowedError", "A permission error should occur");
+ }
+ });
+
+ add_task(async function test_notifications_permission_granted() {
+ await setPushPermission(true);
+
+ try {
+ await registration.pushManager.subscribe();
+ ok(false, "For now this should not proceed because of dom.push.connection.enabled=false (default for all tests)");
+ } catch (err) {
+ is(err.name, "AbortError", "For now a connection error should occur");
+ }
+ });
+
+ add_task(async function unregister() {
+ const result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+</script>
diff --git a/dom/push/test/test_permissions.html b/dom/push/test/test_permissions.html
new file mode 100644
index 0000000000..442cfe4a09
--- /dev/null
+++ b/dom/push/test/test_permissions.html
@@ -0,0 +1,104 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+ await setPushPermission(false);
+
+ var url = "worker.js?" + Math.random();
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ add_task(async function denySubscribe() {
+ try {
+ await registration.pushManager.subscribe();
+ ok(false, "subscribe() should fail because no permission for push");
+ } catch (error) {
+ ok(error instanceof DOMException, "Wrong exception type");
+ is(error.name, "NotAllowedError", "Wrong exception name");
+ }
+ });
+
+ add_task(async function denySubscribeInWorker() {
+ // If permission is revoked, `getSubscription()` should return `null`, and
+ // `subscribe()` should reject immediately. Calling these from the worker
+ // should not deadlock the main thread (see bug 1228723).
+ var errorInfo = await sendRequestToWorker({
+ type: "denySubscribe",
+ });
+ ok(errorInfo.isDOMException, "Wrong exception type");
+ is(errorInfo.name, "NotAllowedError", "Wrong exception name");
+ });
+
+ add_task(async function getEndpoint() {
+ var pushSubscription = await registration.pushManager.getSubscription();
+ is(pushSubscription, null, "getSubscription() should return null because no permission for push");
+ });
+
+ add_task(async function checkPermissionState() {
+ var permissionManager = SpecialPowers.Ci.nsIPermissionManager;
+ var tests = [{
+ action: permissionManager.ALLOW_ACTION,
+ state: "granted",
+ }, {
+ action: permissionManager.DENY_ACTION,
+ state: "denied",
+ }, {
+ action: permissionManager.PROMPT_ACTION,
+ state: "prompt",
+ }, {
+ action: permissionManager.UNKNOWN_ACTION,
+ state: "prompt",
+ }];
+ for (var test of tests) {
+ await setPushPermission(test.action);
+ var state = await registration.pushManager.permissionState();
+ is(state, test.state, JSON.stringify(test));
+ try {
+ await SpecialPowers.pushPrefEnv({ set: [
+ ["dom.push.testing.ignorePermission", true]] });
+ state = await registration.pushManager.permissionState();
+ is(state, "granted", `Should ignore ${
+ test.action} if the override pref is set`);
+ } finally {
+ await SpecialPowers.flushPrefEnv();
+ }
+ }
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_register.html b/dom/push/test/test_register.html
new file mode 100644
index 0000000000..541e4a2d8d
--- /dev/null
+++ b/dom/push/test/test_register.html
@@ -0,0 +1,107 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1038811: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1038811</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ var mockSocket = new MockWebSocket();
+
+ var channelID = null;
+
+ mockSocket.onRegister = function(request) {
+ channelID = request.channelID;
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: "c69e2014-9e15-438d-b253-d79cc2df60a8",
+ channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/1",
+ }));
+ };
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(mockSocket);
+ await setPushPermission(true);
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ add_task(async function checkPermissionState() {
+ var state = await registration.pushManager.permissionState();
+ is(state, "granted", "permissionState() should resolve to granted.");
+ });
+
+ var pushSubscription;
+ add_task(async function subscribe() {
+ pushSubscription = await registration.pushManager.subscribe();
+ is(pushSubscription.options.applicationServerKey, null,
+ "Subscription should not have an app server key");
+ });
+
+ add_task(async function resubscribe() {
+ var data = await sendRequestToWorker({
+ type: "resubscribe",
+ endpoint: pushSubscription.endpoint,
+ });
+ pushSubscription = await registration.pushManager.getSubscription();
+ is(data.endpoint, pushSubscription.endpoint,
+ "Subscription endpoints should match after resubscribing in worker");
+ });
+
+ add_task(async function waitForPushNotification() {
+ var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
+
+ // Send a blank message.
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID,
+ }));
+
+ await finishedPromise;
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ await pushSubscription.unsubscribe();
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_register_key.html b/dom/push/test/test_register_key.html
new file mode 100644
index 0000000000..b3e7570770
--- /dev/null
+++ b/dom/push/test/test_register_key.html
@@ -0,0 +1,280 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1247685: Implement `applicationServerKey` for subscription association.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1247685</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1247685">Mozilla Bug 1247685</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var isTestingMismatchedKey = false;
+ var subscriptions = 0;
+ var testKey; // Generated in `start`.
+
+ function generateKey() {
+ return crypto.subtle.generateKey({
+ name: "ECDSA",
+ namedCurve: "P-256",
+ }, true, ["sign", "verify"]).then(cryptoKey =>
+ crypto.subtle.exportKey("raw", cryptoKey.publicKey)
+ ).then(publicKey => new Uint8Array(publicKey));
+ }
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndReplaceService({
+ register(pageRecord) {
+ ok(pageRecord.appServerKey.length,
+ "App server key should not be empty");
+ if (pageRecord.appServerKey.length != 65) {
+ // eslint-disable-next-line no-throw-literal
+ throw { result:
+ SpecialPowers.Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR };
+ }
+ if (isTestingMismatchedKey) {
+ // eslint-disable-next-line no-throw-literal
+ throw { result:
+ SpecialPowers.Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR };
+ }
+ return {
+ endpoint: "https://example.com/push/" + (++subscriptions),
+ appServerKey: pageRecord.appServerKey,
+ };
+ },
+
+ registration(pageRecord) {
+ return {
+ endpoint: "https://example.com/push/subWithKey",
+ appServerKey: testKey,
+ };
+ },
+ });
+ await setPushPermission(true);
+ testKey = await generateKey();
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ add_task(async function validKey() {
+ var pushSubscription = await registration.pushManager.subscribe({
+ applicationServerKey: await generateKey(),
+ });
+ is(pushSubscription.endpoint, "https://example.com/push/1",
+ "Wrong endpoint for subscription with key");
+ is(pushSubscription.options.applicationServerKey,
+ pushSubscription.options.applicationServerKey,
+ "App server key getter should return the same object");
+ });
+
+ add_task(async function retrieveKey() {
+ var pushSubscription = await registration.pushManager.getSubscription();
+ is(pushSubscription.endpoint, "https://example.com/push/subWithKey",
+ "Got wrong endpoint for subscription with key");
+ isDeeply(
+ new Uint8Array(pushSubscription.options.applicationServerKey),
+ testKey,
+ "Got wrong app server key"
+ );
+ });
+
+ add_task(async function mismatchedKey() {
+ isTestingMismatchedKey = true;
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: await generateKey(),
+ });
+ ok(false, "Should reject for mismatched app server keys");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for mismatched key");
+ is(error.name, "InvalidStateError",
+ "Wrong exception name for mismatched key");
+ } finally {
+ isTestingMismatchedKey = false;
+ }
+ });
+
+ add_task(async function emptyKeyInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: new ArrayBuffer(0),
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for empty key");
+ is(errorInfo.name, "InvalidAccessError",
+ "Wrong exception name in worker for empty key");
+ });
+
+ add_task(async function invalidKeyInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: new Uint8Array([1]),
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for invalid key");
+ is(errorInfo.name, "InvalidAccessError",
+ "Wrong exception name in worker for invalid key");
+ });
+
+ add_task(async function validKeyInWorker() {
+ var key = await generateKey();
+ var data = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key,
+ });
+ is(data.endpoint, "https://example.com/push/2",
+ "Wrong endpoint for subscription with key created in worker");
+ isDeeply(new Uint8Array(data.key), key,
+ "Wrong app server key for subscription created in worker");
+ });
+
+ add_task(async function mismatchedKeyInWorker() {
+ isTestingMismatchedKey = true;
+ try {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: await generateKey(),
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for mismatched key");
+ is(errorInfo.name, "InvalidStateError",
+ "Wrong exception name in worker for mismatched key");
+ } finally {
+ isTestingMismatchedKey = false;
+ }
+ });
+
+ add_task(async function validKeyBuffer() {
+ var key = await generateKey();
+ var pushSubscription = await registration.pushManager.subscribe({
+ applicationServerKey: key.buffer,
+ });
+ is(pushSubscription.endpoint, "https://example.com/push/3",
+ "Wrong endpoint for subscription created with key buffer");
+ var subscriptionKey = pushSubscription.options.applicationServerKey;
+ isDeeply(new Uint8Array(subscriptionKey), key,
+ "App server key getter should match given key");
+ });
+
+ add_task(async function validKeyBufferInWorker() {
+ var key = await generateKey();
+ var data = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: key.buffer,
+ });
+ is(data.endpoint, "https://example.com/push/4",
+ "Wrong endpoint for subscription with key buffer created in worker");
+ isDeeply(new Uint8Array(data.key), key,
+ "App server key getter should match given key for subscription created in worker");
+ });
+
+ add_task(async function validKeyString() {
+ var base64Key = "BOp8kf30nj6mKFFSPw_w3JAMS99Bac8zneMJ6B6lmKixUO5XTf4AtdPgYUgWke-XE25JHdcooyLgJML1R57jhKY";
+ var key = base64UrlDecode(base64Key);
+ var pushSubscription = await registration.pushManager.subscribe({
+ applicationServerKey: base64Key,
+ });
+ is(pushSubscription.endpoint, "https://example.com/push/5",
+ "Wrong endpoint for subscription created with Base64-encoded key");
+ isDeeply(new Uint8Array(pushSubscription.options.applicationServerKey), key,
+ "App server key getter should match Base64-decoded key");
+ });
+
+ add_task(async function validKeyStringInWorker() {
+ var base64Key = "BOp8kf30nj6mKFFSPw_w3JAMS99Bac8zneMJ6B6lmKixUO5XTf4AtdPgYUgWke-XE25JHdcooyLgJML1R57jhKY";
+ var key = base64UrlDecode(base64Key);
+ var data = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: base64Key,
+ });
+ is(data.endpoint, "https://example.com/push/6",
+ "Wrong endpoint for subscription created with Base64-encoded key in worker");
+ isDeeply(new Uint8Array(data.key), key,
+ "App server key getter should match decoded key for subscription created in worker");
+ });
+
+ add_task(async function invalidKeyString() {
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: "!@#$^&*",
+ });
+ ok(false, "Should reject for invalid Base64-encoded keys");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for invalid Base64-encoded key");
+ is(error.name, "InvalidCharacterError",
+ "Wrong exception name for invalid Base64-encoded key");
+ }
+ });
+
+ add_task(async function invalidKeyStringInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: "!@#$^&*",
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for invalid Base64-encoded key");
+ is(errorInfo.name, "InvalidCharacterError",
+ "Wrong exception name in worker for invalid Base64-encoded key");
+ });
+
+ add_task(async function emptyKeyString() {
+ try {
+ await registration.pushManager.subscribe({
+ applicationServerKey: "",
+ });
+ ok(false, "Should reject for empty key strings");
+ } catch (error) {
+ ok(error instanceof DOMException,
+ "Wrong exception type for empty key string");
+ is(error.name, "InvalidAccessError",
+ "Wrong exception name for empty key string");
+ }
+ });
+
+ add_task(async function emptyKeyStringInWorker() {
+ var errorInfo = await sendRequestToWorker({
+ type: "subscribeWithKey",
+ key: "",
+ });
+ ok(errorInfo.isDOMException,
+ "Wrong exception type in worker for empty key string");
+ is(errorInfo.name, "InvalidAccessError",
+ "Wrong exception name in worker for empty key string");
+ });
+
+ add_task(async function unsubscribe() {
+ is(subscriptions, 6, "Wrong subscription count");
+ controlledFrame.remove();
+ });
+
+ add_task(async function unregister() {
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_serviceworker_lifetime.html b/dom/push/test/test_serviceworker_lifetime.html
new file mode 100644
index 0000000000..30f191a119
--- /dev/null
+++ b/dom/push/test/test_serviceworker_lifetime.html
@@ -0,0 +1,364 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test the lifetime management of service workers. We keep this test in
+ dom/push/tests to pass the external network check when connecting to
+ the mozilla push service.
+
+ How this test works:
+ - the service worker maintains a state variable and a promise used for
+ extending its lifetime. Note that the terminating the worker will reset
+ these variables to their default values.
+ - we send 3 types of requests to the service worker:
+ |update|, |wait| and |release|. All three requests will cause the sw to update
+ its state to the new value and reply with a message containing
+ its previous state. Furthermore, |wait| will set a waitUntil or a respondWith
+ promise that's not resolved until the next |release| message.
+ - Each subtest will use a combination of values for the timeouts and check
+ if the service worker is in the correct state as we send it different
+ events.
+ - We also wait and assert for service worker termination using an event dispatched
+ through nsIObserverService.
+ -->
+<head>
+ <title>Test for Bug 1188545</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1188545">Mozilla Bug 118845</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function start() {
+ return navigator.serviceWorker.register("lifetime_worker.js", {scope: "./"})
+ .then((swr) => ({registration: swr}));
+ }
+
+ function waitForActiveServiceWorker(ctx) {
+ return waitForActive(ctx.registration).then(function(result) {
+ ok(ctx.registration.active, "Service Worker is active");
+ return ctx;
+ });
+ }
+
+ function unregister(ctx) {
+ return ctx.registration.unregister().then(function(result) {
+ ok(result, "Unregister should return true.");
+ }, function(e) {
+ dump("Unregistering the SW failed with " + e + "\n");
+ });
+ }
+
+ function registerPushNotification(ctx) {
+ var p = new Promise(function(res, rej) {
+ ctx.registration.pushManager.subscribe().then(
+ function(pushSubscription) {
+ ok(true, "successful registered for push notification");
+ ctx.subscription = pushSubscription;
+ res(ctx);
+ }, function(error) {
+ ok(false, "could not register for push notification");
+ res(ctx);
+ });
+ });
+ return p;
+ }
+
+ var mockSocket = new MockWebSocket();
+ var endpoint = "https://example.com/endpoint/1";
+ var channelID = null;
+ mockSocket.onRegister = function(request) {
+ channelID = request.channelID;
+ this.serverSendMsg(JSON.stringify({
+ messageType: "register",
+ uaid: "fa8f2e4b-5ddc-4408-b1e3-5f25a02abff0",
+ channelID,
+ status: 200,
+ pushEndpoint: endpoint,
+ }));
+ };
+
+ function sendPushToPushServer(pushEndpoint) {
+ is(pushEndpoint, endpoint, "Got unexpected endpoint");
+ mockSocket.serverSendMsg(JSON.stringify({
+ messageType: "notification",
+ version: "vDummy",
+ channelID,
+ }));
+ }
+
+ function unregisterPushNotification(ctx) {
+ return ctx.subscription.unsubscribe().then(function(result) {
+ ok(result, "unsubscribe should succeed.");
+ ctx.subscription = null;
+ return ctx;
+ });
+ }
+
+ function createIframe(ctx) {
+ var p = new Promise(function(res, rej) {
+ var iframe = document.createElement("iframe");
+ // This file doesn't exist, the service worker will give us an empty
+ // document.
+ iframe.src = "http://mochi.test:8888/tests/dom/push/test/lifetime_frame.html";
+
+ iframe.onload = function() {
+ ctx.iframe = iframe;
+ res(ctx);
+ };
+ document.body.appendChild(iframe);
+ });
+ return p;
+ }
+
+ function closeIframe(ctx) {
+ ctx.iframe.remove();
+ return new Promise(function(res, rej) {
+ // XXXcatalinb: give the worker more time to "notice" it stopped
+ // controlling documents
+ ctx.iframe = null;
+ setTimeout(res, 0);
+ }).then(() => ctx);
+ }
+
+ function waitAndCheckMessage(contentWindow, expected) {
+ function checkMessage({ type, state }, resolve, event) {
+ ok(event.data.type == type, "Received correct message type: " + type);
+ ok(event.data.state == state, "Service worker is in the correct state: " + state);
+ this.navigator.serviceWorker.onmessage = null;
+ resolve();
+ }
+ return new Promise(function(res, rej) {
+ contentWindow.navigator.serviceWorker.onmessage =
+ checkMessage.bind(contentWindow, expected, res);
+ });
+ }
+
+ function fetchEvent(ctx, expected_state, new_state) {
+ var expected = { type: "fetch", state: expected_state };
+ var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
+ ctx.iframe.contentWindow.fetch(new_state);
+ return p;
+ }
+
+ function pushEvent(ctx, expected_state, new_state) {
+ var expected = {type: "push", state: expected_state};
+ var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
+ sendPushToPushServer(ctx.subscription.endpoint);
+ return p;
+ }
+
+ function messageEventIframe(ctx, expected_state, new_state) {
+ var expected = {type: "message", state: expected_state};
+ var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
+ ctx.iframe.contentWindow.navigator.serviceWorker.controller.postMessage(new_state);
+ return p;
+ }
+
+ function messageEvent(ctx, expected_state, new_state) {
+ var expected = {type: "message", state: expected_state};
+ var p = waitAndCheckMessage(window, expected);
+ ctx.registration.active.postMessage(new_state);
+ return p;
+ }
+
+ function checkStateAndUpdate(eventFunction, expected_state, new_state) {
+ return function(ctx) {
+ return eventFunction(ctx, expected_state, new_state)
+ .then(() => ctx);
+ };
+ }
+
+ let shutdownTopic = "specialpowers-service-worker-shutdown";
+ SpecialPowers.registerObservers("service-worker-shutdown");
+
+ function setShutdownObserver(expectingEvent) {
+ info("Setting shutdown observer: expectingEvent=" + expectingEvent);
+ return function(ctx) {
+ cancelShutdownObserver(ctx);
+
+ ctx.observer_promise = new Promise(function(res, rej) {
+ ctx.observer = {
+ observe(subject, topic, data) {
+ ok((topic == shutdownTopic) && expectingEvent, "Service worker was terminated.");
+ this.remove(ctx);
+ },
+ remove(context) {
+ SpecialPowers.removeObserver(this, shutdownTopic);
+ context.observer = null;
+ res(context);
+ },
+ };
+ SpecialPowers.addObserver(ctx.observer, shutdownTopic);
+ });
+
+ return ctx;
+ };
+ }
+
+ function waitOnShutdownObserver(ctx) {
+ info("Waiting on worker to shutdown.");
+ return ctx.observer_promise;
+ }
+
+ function cancelShutdownObserver(ctx) {
+ if (ctx.observer) {
+ ctx.observer.remove(ctx);
+ }
+ return ctx.observer_promise;
+ }
+
+ function subTest(test) {
+ return function(ctx) {
+ return new Promise(function(res, rej) {
+ function run() {
+ test.steps(ctx).catch(function(e) {
+ ok(false, "Some test failed with error: " + e);
+ }).then(res);
+ }
+
+ SpecialPowers.pushPrefEnv({"set": test.prefs}, run);
+ });
+ };
+ }
+
+ var test1 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 0],
+ ["dom.serviceWorkers.idle_extended_timeout", 2999999],
+ ],
+ // Test that service workers are terminated after the grace period expires
+ // when there are no pending waitUntil or respondWith promises.
+ steps(ctx) {
+ // Test with fetch events and respondWith promises
+ return createIframe(ctx)
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(fetchEvent, "from_scope", "update"))
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(fetchEvent, "from_scope", "wait"))
+ .then(checkStateAndUpdate(fetchEvent, "wait", "update"))
+ .then(checkStateAndUpdate(fetchEvent, "update", "update"))
+ .then(setShutdownObserver(true))
+ // The service worker should be terminated when the promise is resolved.
+ .then(checkStateAndUpdate(fetchEvent, "update", "release"))
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(closeIframe)
+ .then(cancelShutdownObserver)
+
+ // Test with push events and message events
+ .then(setShutdownObserver(true))
+ .then(createIframe)
+ // Make sure we are shutdown before entering our "no shutdown" sequence
+ // to avoid races.
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(pushEvent, "from_scope", "wait"))
+ .then(checkStateAndUpdate(messageEventIframe, "wait", "update"))
+ .then(checkStateAndUpdate(messageEventIframe, "update", "update"))
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(messageEventIframe, "update", "release"))
+ .then(waitOnShutdownObserver)
+ .then(closeIframe);
+ },
+ };
+
+ var test2 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 0],
+ ["dom.serviceWorkers.idle_extended_timeout", 2999999],
+ ],
+ steps(ctx) {
+ // Older versions used to terminate workers when the last controlled
+ // window was closed. This should no longer happen, though. Verify
+ // the new behavior.
+ setShutdownObserver(true)(ctx);
+ return createIframe(ctx)
+ // Make sure we are shutdown before entering our "no shutdown" sequence
+ // to avoid races.
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(fetchEvent, "from_scope", "wait"))
+ .then(closeIframe)
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(messageEvent, "wait", "release"))
+ .then(waitOnShutdownObserver)
+
+ // Push workers were exempt from the old rule and should continue to
+ // survive past the closing of the last controlled window.
+ .then(setShutdownObserver(true))
+ .then(createIframe)
+ // Make sure we are shutdown before entering our "no shutdown" sequence
+ // to avoid races.
+ .then(waitOnShutdownObserver)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(pushEvent, "from_scope", "wait"))
+ .then(closeIframe)
+ .then(setShutdownObserver(true))
+ .then(checkStateAndUpdate(messageEvent, "wait", "release"))
+ .then(waitOnShutdownObserver);
+ },
+ };
+
+ var test3 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 2999999],
+ ["dom.serviceWorkers.idle_extended_timeout", 0],
+ ],
+ steps(ctx) {
+ // set the grace period to 0 and dispatch a message which will reset
+ // the internal sw timer to the new value.
+ var test3_1 = {
+ prefs: [
+ ["dom.serviceWorkers.idle_timeout", 0],
+ ["dom.serviceWorkers.idle_extended_timeout", 0],
+ ],
+ steps(context) {
+ return new Promise(function(res, rej) {
+ context.iframe.contentWindow.navigator.serviceWorker.controller.postMessage("ping");
+ res(context);
+ });
+ },
+ };
+
+ // Test that service worker is closed when the extended timeout expired
+ return createIframe(ctx)
+ .then(setShutdownObserver(false))
+ .then(checkStateAndUpdate(messageEvent, "from_scope", "update"))
+ .then(checkStateAndUpdate(messageEventIframe, "update", "update"))
+ .then(checkStateAndUpdate(fetchEvent, "update", "wait"))
+ .then(setShutdownObserver(true))
+ .then(subTest(test3_1)) // This should cause the internal timer to expire.
+ .then(waitOnShutdownObserver)
+ .then(closeIframe);
+ },
+ };
+
+ function runTest() {
+ start()
+ .then(waitForActiveServiceWorker)
+ .then(registerPushNotification)
+ .then(subTest(test1))
+ .then(subTest(test2))
+ .then(subTest(test3))
+ .then(unregisterPushNotification)
+ .then(unregister)
+ .catch(function(e) {
+ ok(false, "Some test failed with error " + e);
+ }).then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(mockSocket).then(_ => runTest());
+ SpecialPowers.addPermission("desktop-notification", true, document);
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_subscription_change.html b/dom/push/test/test_subscription_change.html
new file mode 100644
index 0000000000..6d8df58364
--- /dev/null
+++ b/dom/push/test/test_subscription_change.html
@@ -0,0 +1,67 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1205109: Make `pushsubscriptionchange` extendable.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1205109</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1205109">Mozilla Bug 1205109</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+ await setPushPermission(true);
+
+ var url = "worker.js?" + (Math.random());
+ registration = await navigator.serviceWorker.register(url, {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var controlledFrame;
+ add_task(async function createControlledIFrame() {
+ controlledFrame = await injectControlledFrame();
+ });
+
+ add_task(async function togglePermission() {
+ var subscription = await registration.pushManager.subscribe();
+ ok(subscription, "Should create a push subscription");
+
+ await setPushPermission(false);
+ var permissionState = await registration.pushManager.permissionState();
+ is(permissionState, "denied", "Should deny push permission");
+
+ subscription = await registration.pushManager.getSubscription();
+ is(subscription, null, "Should not return subscription when permission is revoked");
+
+ var changePromise = controlledFrame.waitOnWorkerMessage("changed");
+ await setPushPermission(true);
+ await changePromise;
+
+ subscription = await registration.pushManager.getSubscription();
+ is(subscription, null, "Should drop subscription after reinstating permission");
+ });
+
+ add_task(async function unsubscribe() {
+ controlledFrame.remove();
+ await registration.unregister();
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_try_registering_offline_disabled.html b/dom/push/test/test_try_registering_offline_disabled.html
new file mode 100644
index 0000000000..d993e73e60
--- /dev/null
+++ b/dom/push/test/test_try_registering_offline_disabled.html
@@ -0,0 +1,307 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1150812: Try to register when serviced if offline or connection is disabled.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1150812</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function debug(str) {
+ // console.log(str + "\n");
+ }
+
+ function registerServiceWorker() {
+ return navigator.serviceWorker.register("worker.js?" + (Math.random()), {scope: "."})
+ .then(swr => waitForActive(swr));
+ }
+
+ function unregister(swr) {
+ return swr.unregister()
+ .then(result => {
+ ok(result, "Unregister should return true.");
+ }, err => {
+ dump("Unregistering the SW failed with " + err + "\n");
+ throw err;
+ });
+ }
+
+ function subscribe(swr) {
+ return swr.pushManager.subscribe()
+ .then(sub => {
+ ok(true, "successful registered for push notification");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function subscribeFail(swr) {
+ return new Promise((res, rej) => {
+ swr.pushManager.subscribe()
+ .then(sub => {
+ ok(false, "successful registered for push notification");
+ throw new Error("Should fail");
+ }, err => {
+ ok(true, "could not register for push notification");
+ res(swr);
+ });
+ });
+ }
+
+ function getEndpointExpectNull(swr) {
+ return swr.pushManager.getSubscription()
+ .then(pushSubscription => {
+ ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ function getEndpoint(swr, subOld) {
+ return swr.pushManager.getSubscription()
+ .then(sub => {
+ ok(subOld.endpoint == sub.endpoint, "getEndpoint - Got the same endpoint back.");
+ return sub;
+ }, err => {
+ ok(false, "could not register for push notification");
+ throw err;
+ });
+ }
+
+ // Load chrome script to change offline status in the
+ // parent process.
+ var offlineChromeScript = SpecialPowers.loadChromeScript(_ => {
+ /* eslint-env mozilla/chrome-script */
+ addMessageListener("change-status", function(offline) {
+ // eslint-disable-next-line mozilla/use-services
+ const ioService = Cc["@mozilla.org/network/io-service;1"]
+ .getService(Ci.nsIIOService);
+ ioService.offline = offline;
+ });
+ });
+
+ function offlineObserver(res) {
+ this._res = res;
+ }
+ offlineObserver.prototype = {
+ _res: null,
+
+ observe(subject, topic, data) {
+ debug("observe: " + subject + " " + topic + " " + data);
+ if (topic === "network:offline-status-changed") {
+ // eslint-disable-next-line mozilla/use-services
+ const obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
+ .getService(SpecialPowers.Ci.nsIObserverService);
+ obsService.removeObserver(this, topic);
+ this._res(null);
+ }
+ },
+ };
+
+ function changeOfflineState(offline) {
+ return new Promise(function(res, rej) {
+ // eslint-disable-next-line mozilla/use-services
+ const obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
+ .getService(SpecialPowers.Ci.nsIObserverService);
+ obsService.addObserver(SpecialPowers.wrapCallbackObject(new offlineObserver(res)),
+ "network:offline-status-changed");
+ offlineChromeScript.sendAsyncMessage("change-status", offline);
+ });
+ }
+
+ function changePushServerConnectionEnabled(enable) {
+ debug("changePushServerConnectionEnabled");
+ SpecialPowers.setBoolPref("dom.push.connection.enabled", enable);
+ }
+
+ function unsubscribe(sub) {
+ return sub.unsubscribe()
+ .then(_ => { ok(true, "Unsubscribed!"); });
+ }
+
+ // go offline then go online
+ function runTest1() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // disable - enable push connection.
+ function runTest2() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // go offline - disable - enable - go online
+ function runTest3() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // disable - offline - online - enable.
+ function runTest4() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // go offline - disable - go online - enable
+ function runTest5() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ // disable - go offline - enable - go online.
+ function runTest6() {
+ return registerServiceWorker()
+ .then(swr =>
+ getEndpointExpectNull(swr)
+ .then(_ => changePushServerConnectionEnabled(false))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changePushServerConnectionEnabled(true))
+ .then(_ => subscribeFail(swr))
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => changeOfflineState(false))
+ .then(_ => subscribe(swr))
+ .then(sub => getEndpoint(swr, sub)
+ .then(s => unsubscribe(s))
+ )
+ .then(_ => getEndpointExpectNull(swr))
+ .then(_ => unregister(swr))
+ )
+ .catch(err => {
+ ok(false, "Some test failed with error " + err);
+ });
+ }
+
+ function runTest() {
+ runTest1()
+ .then(_ => runTest2())
+ .then(_ => runTest3())
+ .then(_ => runTest4())
+ .then(_ => runTest5())
+ .then(_ => runTest6())
+ .then(SimpleTest.finish);
+ }
+
+ setupPrefsAndMockSocket(new MockWebSocket())
+ .then(_ => setPushPermission(true))
+ .then(_ => runTest());
+ SimpleTest.waitForExplicitFinish();
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_unregister.html b/dom/push/test/test_unregister.html
new file mode 100644
index 0000000000..51f215c29b
--- /dev/null
+++ b/dom/push/test/test_unregister.html
@@ -0,0 +1,78 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Bug 1170817: Push tests.
+
+Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/licenses/publicdomain/
+
+-->
+<head>
+ <title>Test for Bug 1170817</title>
+ <script src="/tests/SimpleTest/SimpleTest.js"></script>
+ <script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
+ <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
+ <meta http-equiv="Content-type" content="text/html;charset=UTF-8">
+</head>
+<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1170817">Mozilla Bug 1170817</a>
+<p id="display"></p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+
+<script class="testbody" type="text/javascript">
+ function generateURL() {
+ return "worker.js?" + (Math.random());
+ }
+
+ var registration;
+ add_task(async function start() {
+ await setupPrefsAndMockSocket(new MockWebSocket());
+ await setPushPermission(true);
+
+ registration = await navigator.serviceWorker.register(
+ generateURL(), {scope: "."});
+ await waitForActive(registration);
+ });
+
+ var pushSubscription;
+ add_task(async function setupPushNotification() {
+ pushSubscription = await registration.pushManager.subscribe();
+ ok(pushSubscription, "successful registered for push notification");
+ });
+
+ add_task(async function unregisterPushNotification() {
+ var result = await pushSubscription.unsubscribe();
+ ok(result, "unsubscribe() on existing subscription should return true.");
+ });
+
+ add_task(async function unregisterAgain() {
+ var result = await pushSubscription.unsubscribe();
+ ok(!result, "unsubscribe() on previously unsubscribed subscription should return false.");
+ });
+
+ add_task(async function subscribeAgain() {
+ pushSubscription = await registration.pushManager.subscribe();
+ ok(pushSubscription, "Should create a new push subscription");
+
+ var result = await registration.unregister();
+ ok(result, "Should unregister the service worker");
+
+ registration = await navigator.serviceWorker.register(
+ generateURL(), {scope: "."});
+ await waitForActive(registration);
+ pushSubscription = await registration.pushManager.getSubscription();
+ ok(!pushSubscription,
+ "Unregistering a service worker should drop its subscription");
+ });
+
+ add_task(async function unregister() {
+ var result = await registration.unregister();
+ ok(result, "Unregister should return true.");
+ });
+
+</script>
+</body>
+</html>
diff --git a/dom/push/test/test_utils.js b/dom/push/test/test_utils.js
new file mode 100644
index 0000000000..0214318d09
--- /dev/null
+++ b/dom/push/test/test_utils.js
@@ -0,0 +1,304 @@
+"use strict";
+
+const url = SimpleTest.getTestFileURL("mockpushserviceparent.js");
+const chromeScript = SpecialPowers.loadChromeScript(url);
+
+/**
+ * Replaces `PushService.jsm` with a mock implementation that handles requests
+ * from the DOM API. This allows tests to simulate local errors and error
+ * reporting, bypassing the `PushService.jsm` machinery.
+ */
+async function replacePushService(mockService) {
+ chromeScript.addMessageListener("service-delivery-error", function (msg) {
+ mockService.reportDeliveryError(msg.messageId, msg.reason);
+ });
+ chromeScript.addMessageListener("service-request", function (msg) {
+ let promise;
+ try {
+ let handler = mockService[msg.name];
+ promise = Promise.resolve(handler(msg.params));
+ } catch (error) {
+ promise = Promise.reject(error);
+ }
+ promise.then(
+ result => {
+ chromeScript.sendAsyncMessage("service-response", {
+ id: msg.id,
+ result,
+ });
+ },
+ error => {
+ chromeScript.sendAsyncMessage("service-response", {
+ id: msg.id,
+ error,
+ });
+ }
+ );
+ });
+ await new Promise(resolve => {
+ chromeScript.addMessageListener("service-replaced", function onReplaced() {
+ chromeScript.removeMessageListener("service-replaced", onReplaced);
+ resolve();
+ });
+ chromeScript.sendAsyncMessage("service-replace");
+ });
+}
+
+async function restorePushService() {
+ await new Promise(resolve => {
+ chromeScript.addMessageListener("service-restored", function onRestored() {
+ chromeScript.removeMessageListener("service-restored", onRestored);
+ resolve();
+ });
+ chromeScript.sendAsyncMessage("service-restore");
+ });
+}
+
+let currentMockSocket = null;
+
+/**
+ * Sets up a mock connection for the WebSocket backend. This only replaces
+ * the transport layer; `PushService.jsm` still handles DOM API requests,
+ * observes permission changes, writes to IndexedDB, and notifies service
+ * workers of incoming push messages.
+ */
+function setupMockPushSocket(mockWebSocket) {
+ currentMockSocket = mockWebSocket;
+ currentMockSocket._isActive = true;
+ chromeScript.sendAsyncMessage("socket-setup");
+ chromeScript.addMessageListener("socket-client-msg", function (msg) {
+ mockWebSocket.handleMessage(msg);
+ });
+}
+
+function teardownMockPushSocket() {
+ if (currentMockSocket) {
+ return new Promise(resolve => {
+ currentMockSocket._isActive = false;
+ chromeScript.addMessageListener("socket-server-teardown", resolve);
+ chromeScript.sendAsyncMessage("socket-teardown");
+ });
+ }
+ return Promise.resolve();
+}
+
+/**
+ * Minimal implementation of web sockets for use in testing. Forwards
+ * messages to a mock web socket in the parent process that is used
+ * by the push service.
+ */
+class MockWebSocket {
+ // Default implementation to make the push server work minimally.
+ // Override methods to implement custom functionality.
+ constructor() {
+ this.userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8";
+ this.registerCount = 0;
+ // We only allow one active mock web socket to talk to the parent.
+ // This flag is used to keep track of which mock web socket is active.
+ this._isActive = false;
+ }
+
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: this.userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ }
+
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ uaid: this.userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/endpoint/" + this.registerCount++,
+ })
+ );
+ }
+
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ }
+
+ onAck(request) {
+ // Do nothing.
+ }
+
+ handleMessage(msg) {
+ let request = JSON.parse(msg);
+ let messageType = request.messageType;
+ switch (messageType) {
+ case "hello":
+ this.onHello(request);
+ break;
+ case "register":
+ this.onRegister(request);
+ break;
+ case "unregister":
+ this.onUnregister(request);
+ break;
+ case "ack":
+ this.onAck(request);
+ break;
+ default:
+ throw new Error("Unexpected message: " + messageType);
+ }
+ }
+
+ serverSendMsg(msg) {
+ if (this._isActive) {
+ chromeScript.sendAsyncMessage("socket-server-msg", msg);
+ }
+ }
+}
+
+// Remove permissions and prefs when the test finishes.
+SimpleTest.registerCleanupFunction(async function () {
+ await new Promise(resolve => SpecialPowers.flushPermissions(resolve));
+ await SpecialPowers.flushPrefEnv();
+ await restorePushService();
+ await teardownMockPushSocket();
+});
+
+function setPushPermission(allow) {
+ let permissions = [
+ { type: "desktop-notification", allow, context: document },
+ ];
+
+ if (isXOrigin) {
+ // We need to add permission for the xorigin tests. In xorigin tests, the
+ // test page will be run under third-party context, so we need to use
+ // partitioned principal to add the permission.
+ let partitionedPrincipal =
+ SpecialPowers.wrap(document).partitionedPrincipal;
+
+ permissions.push({
+ type: "desktop-notification",
+ allow,
+ context: {
+ url: partitionedPrincipal.originNoSuffix,
+ originAttributes: {
+ partitionKey: partitionedPrincipal.originAttributes.partitionKey,
+ },
+ },
+ });
+ }
+
+ return SpecialPowers.pushPermissions(permissions);
+}
+
+function setupPrefs() {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.push.enabled", true],
+ ["dom.push.connection.enabled", true],
+ ["dom.push.maxRecentMessageIDsPerSubscription", 0],
+ ["dom.serviceWorkers.exemptFromPerDomainMax", true],
+ ["dom.serviceWorkers.enabled", true],
+ ["dom.serviceWorkers.testing.enabled", true],
+ ],
+ });
+}
+
+async function setupPrefsAndReplaceService(mockService) {
+ await replacePushService(mockService);
+ await setupPrefs();
+}
+
+function setupPrefsAndMockSocket(mockSocket) {
+ setupMockPushSocket(mockSocket);
+ return setupPrefs();
+}
+
+function injectControlledFrame(target = document.body) {
+ return new Promise(function (res, rej) {
+ var iframe = document.createElement("iframe");
+ iframe.src = "/tests/dom/push/test/frame.html";
+
+ var controlledFrame = {
+ remove() {
+ target.removeChild(iframe);
+ iframe = null;
+ },
+ waitOnWorkerMessage(type) {
+ return iframe
+ ? iframe.contentWindow.waitOnWorkerMessage(type)
+ : Promise.reject(new Error("Frame removed from document"));
+ },
+ innerWindowId() {
+ return SpecialPowers.wrap(iframe).browsingContext.currentWindowContext
+ .innerWindowId;
+ },
+ };
+
+ iframe.onload = () => res(controlledFrame);
+ target.appendChild(iframe);
+ });
+}
+
+function sendRequestToWorker(request) {
+ return navigator.serviceWorker.ready.then(registration => {
+ return new Promise((resolve, reject) => {
+ var channel = new MessageChannel();
+ channel.port1.onmessage = e => {
+ (e.data.error ? reject : resolve)(e.data);
+ };
+ registration.active.postMessage(request, [channel.port2]);
+ });
+ });
+}
+
+function waitForActive(swr) {
+ let sw = swr.installing || swr.waiting || swr.active;
+ return new Promise(resolve => {
+ if (sw.state === "activated") {
+ resolve(swr);
+ return;
+ }
+ sw.addEventListener("statechange", function onStateChange(evt) {
+ if (sw.state === "activated") {
+ sw.removeEventListener("statechange", onStateChange);
+ resolve(swr);
+ }
+ });
+ });
+}
+
+function base64UrlDecode(s) {
+ s = s.replace(/-/g, "+").replace(/_/g, "/");
+
+ // Replace padding if it was stripped by the sender.
+ // See http://tools.ietf.org/html/rfc4648#section-4
+ switch (s.length % 4) {
+ case 0:
+ break; // No pad chars in this case
+ case 2:
+ s += "==";
+ break; // Two pad chars
+ case 3:
+ s += "=";
+ break; // One pad char
+ default:
+ throw new Error("Illegal base64url string!");
+ }
+
+ // With correct padding restored, apply the standard base64 decoder
+ var decoded = atob(s);
+
+ var array = new Uint8Array(new ArrayBuffer(decoded.length));
+ for (var i = 0; i < decoded.length; i++) {
+ array[i] = decoded.charCodeAt(i);
+ }
+ return array;
+}
diff --git a/dom/push/test/webpush.js b/dom/push/test/webpush.js
new file mode 100644
index 0000000000..b97e465a8b
--- /dev/null
+++ b/dom/push/test/webpush.js
@@ -0,0 +1,228 @@
+/*
+ * Browser-based Web Push client for the application server piece.
+ *
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/licenses/publicdomain/
+ *
+ * Uses the WebCrypto API.
+ *
+ * Note that this test file uses the old, deprecated aesgcm128 encryption
+ * scheme. PushCrypto.encrypt() exists and uses the later aes128gcm, but
+ * there's no good reason to upgrade this at this time (and having mochitests
+ * use PushCrypto directly is easier said than done.)
+ */
+
+(function (g) {
+ "use strict";
+
+ var P256DH = {
+ name: "ECDH",
+ namedCurve: "P-256",
+ };
+ var webCrypto = g.crypto.subtle;
+ var ENCRYPT_INFO = new TextEncoder().encode("Content-Encoding: aesgcm128");
+ var NONCE_INFO = new TextEncoder().encode("Content-Encoding: nonce");
+
+ function chunkArray(array, size) {
+ var start = array.byteOffset || 0;
+ array = array.buffer || array;
+ var index = 0;
+ var result = [];
+ while (index + size <= array.byteLength) {
+ result.push(new Uint8Array(array, start + index, size));
+ index += size;
+ }
+ if (index < array.byteLength) {
+ result.push(new Uint8Array(array, start + index));
+ }
+ return result;
+ }
+
+ /* I can't believe that this is needed here, in this day and age ...
+ * Note: these are not efficient, merely expedient.
+ */
+ var base64url = {
+ _strmap: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
+ encode(data) {
+ data = new Uint8Array(data);
+ var len = Math.ceil((data.length * 4) / 3);
+ return chunkArray(data, 3)
+ .map(chunk =>
+ [
+ chunk[0] >>> 2,
+ ((chunk[0] & 0x3) << 4) | (chunk[1] >>> 4),
+ ((chunk[1] & 0xf) << 2) | (chunk[2] >>> 6),
+ chunk[2] & 0x3f,
+ ]
+ .map(v => base64url._strmap[v])
+ .join("")
+ )
+ .join("")
+ .slice(0, len);
+ },
+ _lookup(s, i) {
+ return base64url._strmap.indexOf(s.charAt(i));
+ },
+ decode(str) {
+ var v = new Uint8Array(Math.floor((str.length * 3) / 4));
+ var vi = 0;
+ for (var si = 0; si < str.length; ) {
+ var w = base64url._lookup(str, si++);
+ var x = base64url._lookup(str, si++);
+ var y = base64url._lookup(str, si++);
+ var z = base64url._lookup(str, si++);
+ v[vi++] = (w << 2) | (x >>> 4);
+ v[vi++] = (x << 4) | (y >>> 2);
+ v[vi++] = (y << 6) | z;
+ }
+ return v;
+ },
+ };
+
+ g.base64url = base64url;
+
+ /* Coerces data into a Uint8Array */
+ function ensureView(data) {
+ if (typeof data === "string") {
+ return new TextEncoder().encode(data);
+ }
+ if (data instanceof ArrayBuffer) {
+ return new Uint8Array(data);
+ }
+ if (ArrayBuffer.isView(data)) {
+ return new Uint8Array(data.buffer);
+ }
+ throw new Error("webpush() needs a string or BufferSource");
+ }
+
+ function bsConcat(arrays) {
+ var size = arrays.reduce((total, a) => total + a.byteLength, 0);
+ var index = 0;
+ return arrays.reduce((result, a) => {
+ result.set(new Uint8Array(a), index);
+ index += a.byteLength;
+ return result;
+ }, new Uint8Array(size));
+ }
+
+ function hmac(key) {
+ this.keyPromise = webCrypto.importKey(
+ "raw",
+ key,
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ }
+ hmac.prototype.hash = function (input) {
+ return this.keyPromise.then(k => webCrypto.sign("HMAC", k, input));
+ };
+
+ function hkdf(salt, ikm) {
+ this.prkhPromise = new hmac(salt).hash(ikm).then(prk => new hmac(prk));
+ }
+
+ hkdf.prototype.generate = function (info, len) {
+ var input = bsConcat([info, new Uint8Array([1])]);
+ return this.prkhPromise
+ .then(prkh => prkh.hash(input))
+ .then(h => {
+ if (h.byteLength < len) {
+ throw new Error("Length is too long");
+ }
+ return h.slice(0, len);
+ });
+ };
+
+ /* generate a 96-bit IV for use in GCM, 48-bits of which are populated */
+ function generateNonce(base, index) {
+ var nonce = base.slice(0, 12);
+ for (var i = 0; i < 6; ++i) {
+ nonce[nonce.length - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
+ }
+ return nonce;
+ }
+
+ function encrypt(localKey, remoteShare, salt, data) {
+ return webCrypto
+ .importKey("raw", remoteShare, P256DH, false, ["deriveBits"])
+ .then(remoteKey =>
+ webCrypto.deriveBits(
+ { name: P256DH.name, public: remoteKey },
+ localKey,
+ 256
+ )
+ )
+ .then(rawKey => {
+ var kdf = new hkdf(salt, rawKey);
+ return Promise.all([
+ kdf
+ .generate(ENCRYPT_INFO, 16)
+ .then(gcmBits =>
+ webCrypto.importKey("raw", gcmBits, "AES-GCM", false, ["encrypt"])
+ ),
+ kdf.generate(NONCE_INFO, 12),
+ ]);
+ })
+ .then(([key, nonce]) => {
+ if (data.byteLength === 0) {
+ // Send an authentication tag for empty messages.
+ return webCrypto
+ .encrypt(
+ {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, 0),
+ },
+ key,
+ new Uint8Array([0])
+ )
+ .then(value => [value]);
+ }
+ // 4096 is the default size, though we burn 1 for padding
+ return Promise.all(
+ chunkArray(data, 4095).map((slice, index) => {
+ var padded = bsConcat([new Uint8Array([0]), slice]);
+ return webCrypto.encrypt(
+ {
+ name: "AES-GCM",
+ iv: generateNonce(nonce, index),
+ },
+ key,
+ padded
+ );
+ })
+ );
+ })
+ .then(bsConcat);
+ }
+
+ function webPushEncrypt(subscription, data) {
+ data = ensureView(data);
+
+ var salt = g.crypto.getRandomValues(new Uint8Array(16));
+ return webCrypto
+ .generateKey(P256DH, false, ["deriveBits"])
+ .then(localKey => {
+ return Promise.all([
+ encrypt(
+ localKey.privateKey,
+ subscription.getKey("p256dh"),
+ salt,
+ data
+ ),
+ // 1337 p-256 specific haxx to get the raw value out of the spki value
+ webCrypto.exportKey("raw", localKey.publicKey),
+ ]);
+ })
+ .then(([payload, pubkey]) => {
+ return {
+ data: base64url.encode(payload),
+ encryption: "keyid=p256dh;salt=" + base64url.encode(salt),
+ encryption_key: "keyid=p256dh;dh=" + base64url.encode(pubkey),
+ encoding: "aesgcm128",
+ };
+ });
+ }
+
+ g.webPushEncrypt = webPushEncrypt;
+})(this);
diff --git a/dom/push/test/worker.js b/dom/push/test/worker.js
new file mode 100644
index 0000000000..bcdbf0e0ad
--- /dev/null
+++ b/dom/push/test/worker.js
@@ -0,0 +1,174 @@
+// Any copyright is dedicated to the Public Domain.
+// http://creativecommons.org/licenses/publicdomain/
+
+// This worker is used for two types of tests. `handlePush` sends messages to
+// `frame.html`, which verifies that the worker can receive push messages.
+
+// `handleMessage` receives messages from `test_push_manager_worker.html`
+// and `test_data.html`, and verifies that `PushManager` can be used from
+// the worker.
+
+/* globals PushEvent */
+
+this.onpush = handlePush;
+this.onmessage = handleMessage;
+this.onpushsubscriptionchange = handlePushSubscriptionChange;
+
+function getJSON(data) {
+ var result = {
+ ok: false,
+ };
+ try {
+ result.value = data.json();
+ result.ok = true;
+ } catch (e) {
+ // Ignore syntax errors for invalid JSON.
+ }
+ return result;
+}
+
+function assert(value, message) {
+ if (!value) {
+ throw new Error(message);
+ }
+}
+
+function broadcast(event, promise) {
+ event.waitUntil(
+ Promise.resolve(promise).then(message => {
+ return self.clients.matchAll().then(clients => {
+ clients.forEach(client => client.postMessage(message));
+ });
+ })
+ );
+}
+
+function reply(event, promise) {
+ event.waitUntil(
+ Promise.resolve(promise)
+ .then(result => {
+ event.ports[0].postMessage(result);
+ })
+ .catch(error => {
+ event.ports[0].postMessage({
+ error: String(error),
+ });
+ })
+ );
+}
+
+function handlePush(event) {
+ if (event instanceof PushEvent) {
+ if (!("data" in event)) {
+ broadcast(event, { type: "finished", okay: "yes" });
+ return;
+ }
+ var message = {
+ type: "finished",
+ okay: "yes",
+ };
+ if (event.data) {
+ message.data = {
+ text: event.data.text(),
+ arrayBuffer: event.data.arrayBuffer(),
+ json: getJSON(event.data),
+ blob: event.data.blob(),
+ };
+ }
+ broadcast(event, message);
+ return;
+ }
+ broadcast(event, { type: "finished", okay: "no" });
+}
+
+var testHandlers = {
+ publicKey(data) {
+ return self.registration.pushManager
+ .getSubscription()
+ .then(subscription => ({
+ p256dh: subscription.getKey("p256dh"),
+ auth: subscription.getKey("auth"),
+ }));
+ },
+
+ resubscribe(data) {
+ return self.registration.pushManager
+ .getSubscription()
+ .then(subscription => {
+ assert(
+ subscription.endpoint == data.endpoint,
+ "Wrong push endpoint in worker"
+ );
+ return subscription.unsubscribe();
+ })
+ .then(result => {
+ assert(result, "Error unsubscribing in worker");
+ return self.registration.pushManager.getSubscription();
+ })
+ .then(subscription => {
+ assert(!subscription, "Subscription not removed in worker");
+ return self.registration.pushManager.subscribe();
+ })
+ .then(subscription => {
+ return {
+ endpoint: subscription.endpoint,
+ };
+ });
+ },
+
+ denySubscribe(data) {
+ return self.registration.pushManager
+ .getSubscription()
+ .then(subscription => {
+ assert(
+ !subscription,
+ "Should not return worker subscription with revoked permission"
+ );
+ return self.registration.pushManager.subscribe().then(
+ _ => {
+ assert(false, "Expected error subscribing with revoked permission");
+ },
+ error => {
+ return {
+ isDOMException: error instanceof DOMException,
+ name: error.name,
+ };
+ }
+ );
+ });
+ },
+
+ subscribeWithKey(data) {
+ return self.registration.pushManager
+ .subscribe({
+ applicationServerKey: data.key,
+ })
+ .then(
+ subscription => {
+ return {
+ endpoint: subscription.endpoint,
+ key: subscription.options.applicationServerKey,
+ };
+ },
+ error => {
+ return {
+ isDOMException: error instanceof DOMException,
+ name: error.name,
+ };
+ }
+ );
+ },
+};
+
+function handleMessage(event) {
+ var handler = testHandlers[event.data.type];
+ if (handler) {
+ reply(event, handler(event.data));
+ } else {
+ reply(event, Promise.reject("Invalid message type: " + event.data.type));
+ }
+}
+
+function handlePushSubscriptionChange(event) {
+ broadcast(event, { type: "changed", okay: "yes" });
+}
diff --git a/dom/push/test/xpcshell/broadcast_handler.sys.mjs b/dom/push/test/xpcshell/broadcast_handler.sys.mjs
new file mode 100644
index 0000000000..eecf220a6f
--- /dev/null
+++ b/dom/push/test/xpcshell/broadcast_handler.sys.mjs
@@ -0,0 +1,12 @@
+export var broadcastHandler = {
+ reset() {
+ this.notifications = [];
+
+ this.wasNotified = new Promise((resolve, reject) => {
+ this.receivedBroadcastMessage = function () {
+ resolve();
+ this.notifications.push(Array.from(arguments));
+ };
+ });
+ },
+};
diff --git a/dom/push/test/xpcshell/head-http2.js b/dom/push/test/xpcshell/head-http2.js
new file mode 100644
index 0000000000..ef9ec61690
--- /dev/null
+++ b/dom/push/test/xpcshell/head-http2.js
@@ -0,0 +1,44 @@
+const { NetUtil } = ChromeUtils.importESModule(
+ "resource://gre/modules/NetUtil.sys.mjs"
+);
+
+// Returns the test H/2 server port, throwing if it's missing or invalid.
+function getTestServerPort() {
+ let portEnv = Services.env.get("MOZHTTP2_PORT");
+ let port = parseInt(portEnv, 10);
+ if (!Number.isFinite(port) || port < 1 || port > 65535) {
+ throw new Error(`Invalid port in MOZHTTP2_PORT env var: ${portEnv}`);
+ }
+ info(`Using HTTP/2 server on port ${port}`);
+ return port;
+}
+
+function readFile(file) {
+ let fstream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ fstream.init(file, -1, 0, 0);
+ let data = NetUtil.readInputStreamToString(fstream, fstream.available());
+ fstream.close();
+ return data;
+}
+
+function addCertFromFile(certdb, filename, trustString) {
+ let certFile = do_get_file(filename, false);
+ let pem = readFile(certFile)
+ .replace(/-----BEGIN CERTIFICATE-----/, "")
+ .replace(/-----END CERTIFICATE-----/, "")
+ .replace(/[\r\n]/g, "");
+ certdb.addCertFromBase64(pem, trustString);
+}
+
+function trustHttp2CA() {
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ addCertFromFile(
+ certdb,
+ "../../../../netwerk/test/unit/http2-ca.pem",
+ "CTu,u,u"
+ );
+}
diff --git a/dom/push/test/xpcshell/head.js b/dom/push/test/xpcshell/head.js
new file mode 100644
index 0000000000..da50ee3c5c
--- /dev/null
+++ b/dom/push/test/xpcshell/head.js
@@ -0,0 +1,497 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
+ PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Preferences: "resource://gre/modules/Preferences.sys.mjs",
+ PushCrypto: "resource://gre/modules/PushCrypto.sys.mjs",
+ PushService: "resource://gre/modules/PushService.sys.mjs",
+ PushServiceHttp2: "resource://gre/modules/PushService.sys.mjs",
+ PushServiceWebSocket: "resource://gre/modules/PushService.sys.mjs",
+ pushBroadcastService: "resource://gre/modules/PushBroadcastService.sys.mjs",
+});
+
+var {
+ clearInterval,
+ clearTimeout,
+ setInterval,
+ setIntervalWithTarget,
+ setTimeout,
+ setTimeoutWithTarget,
+} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs");
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "PushServiceComponent",
+ "@mozilla.org/push/Service;1",
+ "nsIPushService"
+);
+
+const servicePrefs = new Preferences("dom.push.");
+
+const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
+
+const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000;
+
+var isParent =
+ Services.appinfo.processType == Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
+
+// Stop and clean up after the PushService.
+Services.obs.addObserver(function observe(subject, topic, data) {
+ Services.obs.removeObserver(observe, topic);
+ PushService.uninit();
+ // Occasionally, `profile-change-teardown` and `xpcom-shutdown` will fire
+ // before the PushService and AlarmService finish writing to IndexedDB. This
+ // causes spurious errors and crashes, so we spin the event loop to let the
+ // writes finish.
+ let done = false;
+ setTimeout(() => (done = true), 1000);
+ let thread = Services.tm.mainThread;
+ while (!done) {
+ try {
+ thread.processNextEvent(true);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+}, "profile-change-net-teardown");
+
+/**
+ * Gates a function so that it is called only after the wrapper is called a
+ * given number of times.
+ *
+ * @param {Number} times The number of wrapper calls before |func| is called.
+ * @param {Function} func The function to gate.
+ * @returns {Function} The gated function wrapper.
+ */
+function after(times, func) {
+ return function afterFunc() {
+ if (--times <= 0) {
+ func.apply(this, arguments);
+ }
+ };
+}
+
+/**
+ * Defers one or more callbacks until the next turn of the event loop. Multiple
+ * callbacks are executed in order.
+ *
+ * @param {Function[]} callbacks The callbacks to execute. One callback will be
+ * executed per tick.
+ */
+function waterfall(...callbacks) {
+ callbacks
+ .reduce(
+ (promise, callback) =>
+ promise.then(() => {
+ callback();
+ }),
+ Promise.resolve()
+ )
+ .catch(console.error);
+}
+
+/**
+ * Waits for an observer notification to fire.
+ *
+ * @param {String} topic The notification topic.
+ * @returns {Promise} A promise that fulfills when the notification is fired.
+ */
+function promiseObserverNotification(topic, matchFunc) {
+ return new Promise((resolve, reject) => {
+ Services.obs.addObserver(function observe(subject, aTopic, data) {
+ let matches = typeof matchFunc != "function" || matchFunc(subject, data);
+ if (!matches) {
+ return;
+ }
+ Services.obs.removeObserver(observe, aTopic);
+ resolve({ subject, data });
+ }, topic);
+ });
+}
+
+/**
+ * Wraps an object in a proxy that traps property gets and returns stubs. If
+ * the stub is a function, the original value will be passed as the first
+ * argument. If the original value is a function, the proxy returns a wrapper
+ * that calls the stub; otherwise, the stub is called as a getter.
+ *
+ * @param {Object} target The object to wrap.
+ * @param {Object} stubs An object containing stubbed values and functions.
+ * @returns {Proxy} A proxy that returns stubs for property gets.
+ */
+function makeStub(target, stubs) {
+ return new Proxy(target, {
+ get(aTarget, property) {
+ if (!stubs || typeof stubs != "object" || !(property in stubs)) {
+ return aTarget[property];
+ }
+ let stub = stubs[property];
+ if (typeof stub != "function") {
+ return stub;
+ }
+ let original = aTarget[property];
+ if (typeof original != "function") {
+ return stub.call(this, original);
+ }
+ return function callStub(...params) {
+ return stub.call(this, original, ...params);
+ };
+ },
+ });
+}
+
+/**
+ * Sets default PushService preferences. All pref names are prefixed with
+ * `dom.push.`; any additional preferences will override the defaults.
+ *
+ * @param {Object} [prefs] Additional preferences to set.
+ */
+function setPrefs(prefs = {}) {
+ let defaultPrefs = Object.assign(
+ {
+ loglevel: "all",
+ serverURL: "wss://push.example.org",
+ "connection.enabled": true,
+ userAgentID: "",
+ enabled: true,
+ // Defaults taken from /modules/libpref/init/all.js.
+ requestTimeout: 10000,
+ retryBaseInterval: 5000,
+ pingInterval: 30 * 60 * 1000,
+ // Misc. defaults.
+ "http2.maxRetries": 2,
+ "http2.retryInterval": 500,
+ "http2.reset_retry_count_after_ms": 60000,
+ maxQuotaPerSubscription: 16,
+ quotaUpdateDelay: 3000,
+ "testing.notifyWorkers": false,
+ },
+ prefs
+ );
+ for (let pref in defaultPrefs) {
+ servicePrefs.set(pref, defaultPrefs[pref]);
+ }
+}
+
+function compareAscending(a, b) {
+ if (a > b) {
+ return 1;
+ }
+ return a < b ? -1 : 0;
+}
+
+/**
+ * Creates a mock WebSocket object that implements a subset of the
+ * nsIWebSocketChannel interface used by the PushService.
+ *
+ * The given protocol handlers are invoked for each Simple Push command sent
+ * by the PushService. The ping handler is optional; all others will throw if
+ * the PushService sends a command for which no handler is registered.
+ *
+ * All nsIWebSocketListener methods will be called asynchronously.
+ * serverSendMsg() and serverClose() can be used to respond to client messages
+ * and close the "server" end of the connection, respectively.
+ *
+ * @param {nsIURI} originalURI The original WebSocket URL.
+ * @param {Function} options.onHello The "hello" handshake command handler.
+ * @param {Function} options.onRegister The "register" command handler.
+ * @param {Function} options.onUnregister The "unregister" command handler.
+ * @param {Function} options.onACK The "ack" command handler.
+ * @param {Function} [options.onPing] An optional ping handler.
+ */
+function MockWebSocket(originalURI, handlers = {}) {
+ this._originalURI = originalURI;
+ this._onHello = handlers.onHello;
+ this._onRegister = handlers.onRegister;
+ this._onUnregister = handlers.onUnregister;
+ this._onACK = handlers.onACK;
+ this._onPing = handlers.onPing;
+ this._onBroadcastSubscribe = handlers.onBroadcastSubscribe;
+}
+
+MockWebSocket.prototype = {
+ _originalURI: null,
+ _onHello: null,
+ _onRegister: null,
+ _onUnregister: null,
+ _onACK: null,
+ _onPing: null,
+
+ _listener: null,
+ _context: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebSocketChannel"]),
+
+ get originalURI() {
+ return this._originalURI;
+ },
+
+ asyncOpen(uri, origin, originAttributes, windowId, listener, context) {
+ this._listener = listener;
+ this._context = context;
+ waterfall(() => this._listener.onStart(this._context));
+ },
+
+ _handleMessage(msg) {
+ let messageType, request;
+ if (msg == "{}") {
+ request = {};
+ messageType = "ping";
+ } else {
+ request = JSON.parse(msg);
+ messageType = request.messageType;
+ }
+ switch (messageType) {
+ case "hello":
+ if (typeof this._onHello != "function") {
+ throw new Error("Unexpected handshake request");
+ }
+ this._onHello(request);
+ break;
+
+ case "register":
+ if (typeof this._onRegister != "function") {
+ throw new Error("Unexpected register request");
+ }
+ this._onRegister(request);
+ break;
+
+ case "unregister":
+ if (typeof this._onUnregister != "function") {
+ throw new Error("Unexpected unregister request");
+ }
+ this._onUnregister(request);
+ break;
+
+ case "ack":
+ if (typeof this._onACK != "function") {
+ throw new Error("Unexpected acknowledgement");
+ }
+ this._onACK(request);
+ break;
+
+ case "ping":
+ if (typeof this._onPing == "function") {
+ this._onPing(request);
+ } else {
+ // Echo ping packets.
+ this.serverSendMsg("{}");
+ }
+ break;
+
+ case "broadcast_subscribe":
+ if (typeof this._onBroadcastSubscribe != "function") {
+ throw new Error("Unexpected broadcast_subscribe");
+ }
+ this._onBroadcastSubscribe(request);
+ break;
+
+ default:
+ throw new Error("Unexpected message: " + messageType);
+ }
+ },
+
+ sendMsg(msg) {
+ this._handleMessage(msg);
+ },
+
+ close(code, reason) {
+ waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
+ },
+
+ /**
+ * Responds with the given message, calling onMessageAvailable() and
+ * onAcknowledge() synchronously. Throws if the message is not a string.
+ * Used by the tests to respond to client commands.
+ *
+ * @param {String} msg The message to send to the client.
+ */
+ serverSendMsg(msg) {
+ if (typeof msg != "string") {
+ throw new Error("Invalid response message");
+ }
+ waterfall(
+ () => this._listener.onMessageAvailable(this._context, msg),
+ () => this._listener.onAcknowledge(this._context, 0)
+ );
+ },
+
+ /**
+ * Closes the server end of the connection, calling onServerClose()
+ * followed by onStop(). Used to test abrupt connection termination.
+ *
+ * @param {Number} [statusCode] The WebSocket connection close code.
+ * @param {String} [reason] The connection close reason.
+ */
+ serverClose(statusCode, reason = "") {
+ if (!isFinite(statusCode)) {
+ statusCode = WEBSOCKET_CLOSE_GOING_AWAY;
+ }
+ waterfall(
+ () => this._listener.onServerClose(this._context, statusCode, reason),
+ () => this._listener.onStop(this._context, Cr.NS_BASE_STREAM_CLOSED)
+ );
+ },
+
+ serverInterrupt(result = Cr.NS_ERROR_NET_RESET) {
+ waterfall(() => this._listener.onStop(this._context, result));
+ },
+};
+
+var setUpServiceInParent = async function (service, db) {
+ if (!isParent) {
+ return;
+ }
+
+ let userAgentID = "ce704e41-cb77-4206-b07b-5bf47114791b";
+ setPrefs({
+ userAgentID,
+ });
+
+ await db.put({
+ channelID: "6e2814e1-5f84-489e-b542-855cc1311f09",
+ pushEndpoint: "https://example.org/push/get",
+ scope: "https://example.com/get/ok",
+ originAttributes: "",
+ version: 1,
+ pushCount: 10,
+ lastPush: 1438360548322,
+ quota: 16,
+ });
+ await db.put({
+ channelID: "3a414737-2fd0-44c0-af05-7efc172475fc",
+ pushEndpoint: "https://example.org/push/unsub",
+ scope: "https://example.com/unsub/ok",
+ originAttributes: "",
+ version: 2,
+ pushCount: 10,
+ lastPush: 1438360848322,
+ quota: 4,
+ });
+ await db.put({
+ channelID: "ca3054e8-b59b-4ea0-9c23-4a3c518f3161",
+ pushEndpoint: "https://example.org/push/stale",
+ scope: "https://example.com/unsub/fail",
+ originAttributes: "",
+ version: 3,
+ pushCount: 10,
+ lastPush: 1438362348322,
+ quota: 1,
+ });
+
+ service.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ put(prev, record) {
+ if (record.scope == "https://example.com/sub/fail") {
+ return Promise.reject("synergies not aligned");
+ }
+ return prev.call(this, record);
+ },
+ delete(prev, channelID) {
+ if (channelID == "ca3054e8-b59b-4ea0-9c23-4a3c518f3161") {
+ return Promise.reject("splines not reticulated");
+ }
+ return prev.call(this, channelID);
+ },
+ getByIdentifiers(prev, identifiers) {
+ if (identifiers.scope == "https://example.com/get/fail") {
+ return Promise.reject("qualia unsynchronized");
+ }
+ return prev.call(this, identifiers);
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ if (request.key) {
+ let appServerKey = new Uint8Array(
+ ChromeUtils.base64URLDecode(request.key, {
+ padding: "require",
+ })
+ );
+ equal(appServerKey.length, 65, "Wrong app server key length");
+ equal(appServerKey[0], 4, "Wrong app server key format");
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.org/push/" + request.channelID,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+};
+
+var tearDownServiceInParent = async function (db) {
+ if (!isParent) {
+ return;
+ }
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/sub/ok",
+ originAttributes: "",
+ });
+ ok(
+ record.pushEndpoint.startsWith("https://example.org/push"),
+ "Wrong push endpoint in subscription record"
+ );
+
+ record = await db.getByKeyID("3a414737-2fd0-44c0-af05-7efc172475fc");
+ ok(!record, "Unsubscribed record should not exist");
+};
+
+function putTestRecord(db, keyID, scope, quota) {
+ return db.put({
+ channelID: keyID,
+ pushEndpoint: "https://example.org/push/" + keyID,
+ scope,
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota,
+ systemRecord: quota == Infinity,
+ });
+}
+
+function getAllKeyIDs(db) {
+ return db
+ .getAllKeyIDs()
+ .then(records =>
+ records.map(record => record.keyID).sort(compareAscending)
+ );
+}
diff --git a/dom/push/test/xpcshell/moz.build b/dom/push/test/xpcshell/moz.build
new file mode 100644
index 0000000000..fc154ce0d0
--- /dev/null
+++ b/dom/push/test/xpcshell/moz.build
@@ -0,0 +1,3 @@
+TESTING_JS_MODULES += [
+ "broadcast_handler.sys.mjs",
+]
diff --git a/dom/push/test/xpcshell/test_broadcast_success.js b/dom/push/test/xpcshell/test_broadcast_success.js
new file mode 100644
index 0000000000..16f586081b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_broadcast_success.js
@@ -0,0 +1,428 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Create the profile directory early to ensure pushBroadcastService
+// is initialized with the correct path
+do_get_profile();
+const { BroadcastService } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushBroadcastService.sys.mjs"
+);
+const { JSONFile } = ChromeUtils.importESModule(
+ "resource://gre/modules/JSONFile.sys.mjs"
+);
+
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+const { broadcastHandler } = ChromeUtils.importESModule(
+ "resource://test/broadcast_handler.sys.mjs"
+);
+
+const broadcastService = pushBroadcastService;
+const assert = Assert;
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf";
+
+function run_test() {
+ setPrefs({
+ userAgentID,
+ alwaysConnect: true,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+function getPushServiceMock() {
+ return {
+ subscribed: [],
+ subscribeBroadcast(broadcastId, version) {
+ this.subscribed.push([broadcastId, version]);
+ },
+ };
+}
+
+add_task(async function test_register_success() {
+ await broadcastService._resetListeners();
+ const db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ const notifications = broadcastHandler.notifications;
+ let socket;
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await broadcastService.addListener("broadcast-test", "2018-02-01", {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+
+ var broadcastSubscriptions = [];
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ socket = this;
+ deepEqual(
+ data.broadcasts,
+ { "broadcast-test": "2018-02-01" },
+ "Handshake: doesn't consult listeners"
+ );
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+
+ onBroadcastSubscribe(data) {
+ broadcastSubscriptions.push(data);
+ },
+ });
+ },
+ });
+ await handshakePromise;
+
+ socket.serverSendMsg(
+ JSON.stringify({
+ messageType: "broadcast",
+ broadcasts: {
+ "broadcast-test": "2018-03-02",
+ },
+ })
+ );
+
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ notifications,
+ [
+ [
+ "2018-03-02",
+ "broadcast-test",
+ { phase: broadcastService.PHASES.BROADCAST },
+ ],
+ ],
+ "Broadcast notification didn't get delivered"
+ );
+
+ deepEqual(
+ await broadcastService.getListeners(),
+ {
+ "broadcast-test": "2018-03-02",
+ },
+ "Broadcast version wasn't updated"
+ );
+
+ await broadcastService.addListener("example-listener", "2018-03-01", {
+ moduleURI: "resource://gre/modules/not-real-example.jsm",
+ symbolName: "doesntExist",
+ });
+
+ deepEqual(broadcastSubscriptions, [
+ {
+ messageType: "broadcast_subscribe",
+ broadcasts: { "example-listener": "2018-03-01" },
+ },
+ ]);
+});
+
+add_task(async function test_handle_hello_broadcasts() {
+ PushService.uninit();
+ await broadcastService._resetListeners();
+ let db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ let notifications = broadcastHandler.notifications;
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await broadcastService.addListener("broadcast-test", "2018-02-01", {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ deepEqual(
+ data.broadcasts,
+ { "broadcast-test": "2018-02-01" },
+ "Handshake: doesn't consult listeners"
+ );
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ broadcasts: {
+ "broadcast-test": "2018-02-02",
+ },
+ })
+ );
+ },
+
+ onBroadcastSubscribe(data) {},
+ });
+ },
+ });
+
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ notifications,
+ [
+ [
+ "2018-02-02",
+ "broadcast-test",
+ { phase: broadcastService.PHASES.HELLO },
+ ],
+ ],
+ "Broadcast notification on hello was delivered"
+ );
+
+ deepEqual(
+ await broadcastService.getListeners(),
+ {
+ "broadcast-test": "2018-02-02",
+ },
+ "Broadcast version wasn't updated"
+ );
+});
+
+add_task(async function test_broadcast_context() {
+ await broadcastService._resetListeners();
+ const db = PushServiceWebSocket.newPushDB();
+ broadcastHandler.reset();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+
+ const serviceId = "broadcast-test";
+ const version = "2018-02-01";
+ await broadcastService.addListener(serviceId, version, {
+ moduleURI: "resource://test/broadcast_handler.sys.mjs",
+ symbolName: "broadcastHandler",
+ });
+
+ // PushServiceWebSocket._generateID = () => channelID;
+
+ await PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {},
+ });
+ },
+ });
+
+ // Simulate registration.
+ PushServiceWebSocket.sendSubscribeBroadcast(serviceId, version);
+
+ // Simulate broadcast reply received by PushWebSocketListener.
+ const message = JSON.stringify({
+ messageType: "broadcast",
+ broadcasts: {
+ [serviceId]: version,
+ },
+ });
+ PushServiceWebSocket._wsOnMessageAvailable({}, message);
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ broadcastHandler.notifications,
+ [[version, serviceId, { phase: broadcastService.PHASES.REGISTER }]],
+ "Broadcast passes REGISTER context"
+ );
+
+ // Simulate broadcast reply, without previous registration.
+ broadcastHandler.reset();
+ PushServiceWebSocket._wsOnMessageAvailable({}, message);
+ await broadcastHandler.wasNotified;
+
+ deepEqual(
+ broadcastHandler.notifications,
+ [[version, serviceId, { phase: broadcastService.PHASES.BROADCAST }]],
+ "Broadcast passes BROADCAST context"
+ );
+});
+
+add_task(async function test_broadcast_unit() {
+ const fakeListenersData = {
+ abc: {
+ version: "2018-03-04",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/abc.jsm",
+ symbolName: "getAbc",
+ },
+ },
+ def: {
+ version: "2018-04-05",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/def.jsm",
+ symbolName: "getDef",
+ },
+ },
+ };
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+
+ const jsonFile = new JSONFile({ path });
+ jsonFile.data = {
+ listeners: fakeListenersData,
+ };
+ await jsonFile._save();
+
+ const pushServiceMock = getPushServiceMock();
+
+ const mockBroadcastService = new BroadcastService(pushServiceMock, path);
+ const listeners = await mockBroadcastService.getListeners();
+ deepEqual(listeners, {
+ abc: "2018-03-04",
+ def: "2018-04-05",
+ });
+
+ await mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ });
+
+ deepEqual(pushServiceMock.subscribed, [["ghi", "2018-05-06"]]);
+
+ await mockBroadcastService._saveImmediately();
+
+ const newJSONFile = new JSONFile({ path });
+ await newJSONFile.load();
+
+ deepEqual(newJSONFile.data, {
+ listeners: {
+ ...fakeListenersData,
+ ghi: {
+ version: "2018-05-06",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ },
+ },
+ },
+ version: 1,
+ });
+
+ deepEqual(await mockBroadcastService.getListeners(), {
+ abc: "2018-03-04",
+ def: "2018-04-05",
+ ghi: "2018-05-06",
+ });
+});
+
+add_task(async function test_broadcast_initialize_sane() {
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+ const mockBroadcastService = new BroadcastService(getPushServiceMock(), path);
+ deepEqual(
+ await mockBroadcastService.getListeners(),
+ {},
+ "listeners should start out sane"
+ );
+ await mockBroadcastService._saveImmediately();
+ let onDiskJSONFile = new JSONFile({ path });
+ await onDiskJSONFile.load();
+ deepEqual(
+ onDiskJSONFile.data,
+ { listeners: {}, version: 1 },
+ "written JSON file has listeners and version fields"
+ );
+
+ await mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ });
+
+ await mockBroadcastService._saveImmediately();
+
+ onDiskJSONFile = new JSONFile({ path });
+ await onDiskJSONFile.load();
+
+ deepEqual(
+ onDiskJSONFile.data,
+ {
+ listeners: {
+ ghi: {
+ version: "2018-05-06",
+ sourceInfo: {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ },
+ },
+ },
+ version: 1,
+ },
+ "adding listeners to initial state is written OK"
+ );
+});
+
+add_task(async function test_broadcast_reject_invalid_sourceinfo() {
+ const path = FileTestUtils.getTempFile("broadcast-listeners.json").path;
+ const mockBroadcastService = new BroadcastService(getPushServiceMock(), path);
+
+ await assert.rejects(
+ mockBroadcastService.addListener("ghi", "2018-05-06", {
+ moduleName: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }),
+ /moduleURI must be a string/,
+ "rejects sourceInfo that doesn't have moduleURI"
+ );
+});
+
+add_task(async function test_broadcast_reject_version_not_string() {
+ await assert.rejects(
+ broadcastService.addListener(
+ "ghi",
+ {},
+ {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }
+ ),
+ /version should be a string/,
+ "rejects version that isn't a string"
+ );
+});
+
+add_task(async function test_broadcast_reject_version_empty_string() {
+ await assert.rejects(
+ broadcastService.addListener("ghi", "", {
+ moduleURI: "resource://gre/modules/ghi.jsm",
+ symbolName: "getGhi",
+ }),
+ /version should not be an empty string/,
+ "rejects version that is an empty string"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_clearAll_successful.js b/dom/push/test/xpcshell/test_clearAll_successful.js
new file mode 100644
index 0000000000..a638fffaaf
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clearAll_successful.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var db;
+var unregisterDefers = {};
+var userAgentID = "4ce480ef-55b2-4f83-924c-dcd35ab978b4";
+
+function promiseUnregister(keyID, code) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(() => db.close()));
+
+ // Active subscriptions; should be expired then dropped.
+ await putTestRecord(db, "active-1", "https://example.info/some-page", 8);
+ await putTestRecord(db, "active-2", "https://example.com/another-page", 16);
+
+ // Expired subscription; should be dropped.
+ await putTestRecord(db, "expired", "https://example.net/yet-another-page", 0);
+
+ // A privileged subscription that should not be affected by sanitizing data
+ // because its quota is set to `Infinity`.
+ await putTestRecord(db, "privileged", "app://chrome/only", Infinity);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => (handshakeDone = r));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ resolve();
+ },
+ });
+ },
+ });
+ await handshakePromise;
+});
+
+add_task(async function test_sanitize() {
+ let modifiedScopes = [];
+ let changeScopes = [];
+
+ let promiseCleared = Promise.all([
+ // Active subscriptions should be unregistered.
+ promiseUnregister("active-1"),
+ promiseUnregister("active-2"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 3;
+ }
+ ),
+
+ // Privileged should be recreated.
+ promiseUnregister("privileged"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ changeScopes.push(data);
+ return changeScopes.length == 1;
+ }
+ ),
+ ]);
+
+ await PushService.clear({
+ domain: "*",
+ });
+
+ await promiseCleared;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "app://chrome/only",
+ "https://example.com/another-page",
+ "https://example.info/some-page",
+ ],
+ "Should modify active subscription scopes"
+ );
+
+ deepEqual(
+ changeScopes,
+ ["app://chrome/only"],
+ "Should fire change notification for privileged scope"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(remainingIDs, [], "Should drop all subscriptions");
+});
diff --git a/dom/push/test/xpcshell/test_clear_forgetAboutSite.js b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
new file mode 100644
index 0000000000..27ae57af25
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_forgetAboutSite.js
@@ -0,0 +1,225 @@
+"use strict";
+
+const { ForgetAboutSite } = ChromeUtils.importESModule(
+ "resource://gre/modules/ForgetAboutSite.sys.mjs"
+);
+
+var db;
+var unregisterDefers = {};
+var userAgentID = "4fe01c2d-72ac-4c13-93d2-bb072caf461d";
+
+function promiseUnregister(keyID) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(() => db.close()));
+
+ // Active and expired subscriptions for a subdomain. The active subscription
+ // should be expired, then removed; the expired subscription should be
+ // removed immediately.
+ await putTestRecord(db, "active-sub", "https://sub.example.com/sub-page", 4);
+ await putTestRecord(
+ db,
+ "active-sub-b",
+ "https://sub.example.net/sub-page",
+ 4
+ );
+ await putTestRecord(
+ db,
+ "expired-sub",
+ "https://sub.example.com/yet-another-page",
+ 0
+ );
+
+ // Active subscriptions for another subdomain. Should be unsubscribed and
+ // dropped.
+ await putTestRecord(db, "active-1", "https://sub2.example.com/some-page", 8);
+ await putTestRecord(
+ db,
+ "active-2",
+ "https://sub3.example.com/another-page",
+ 16
+ );
+ await putTestRecord(
+ db,
+ "active-1-b",
+ "https://sub2.example.net/some-page",
+ 8
+ );
+ await putTestRecord(
+ db,
+ "active-2-b",
+ "https://sub3.example.net/another-page",
+ 16
+ );
+
+ // A privileged subscription with a real URL that should not be affected
+ // because its quota is set to `Infinity`.
+ await putTestRecord(
+ db,
+ "privileged",
+ "https://sub.example.com/real-url",
+ Infinity
+ );
+
+ let handshakeDone;
+ let handshakePromise = new Promise(r => (handshakeDone = r));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(request.code, 200, "Expected manual unregister reason");
+ resolve();
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID: request.channelID,
+ })
+ );
+ },
+ });
+ },
+ });
+ // For cleared subscriptions, we only send unregister requests in the
+ // background and if we're connected.
+ await handshakePromise;
+});
+
+add_task(async function test_forgetAboutSubdomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ // Active subscriptions should be dropped.
+ promiseUnregister("active-sub"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 1;
+ }
+ ),
+ ]);
+ await ForgetAboutSite.removeDataFromDomain("sub.example.com");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ ["https://sub.example.com/sub-page"],
+ "Should fire modified notifications for active subscriptions"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ [
+ "active-1",
+ "active-1-b",
+ "active-2",
+ "active-2-b",
+ "active-sub-b",
+ "privileged",
+ ],
+ "Should only forget subscriptions for subdomain"
+ );
+});
+
+add_task(async function test_forgetAboutRootDomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ promiseUnregister("active-1"),
+ promiseUnregister("active-2"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 2;
+ }
+ ),
+ ]);
+
+ await ForgetAboutSite.removeDataFromDomain("example.com");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "https://sub2.example.com/some-page",
+ "https://sub3.example.com/another-page",
+ ],
+ "Should fire modified notifications for entire domain"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ ["active-1-b", "active-2-b", "active-sub-b", "privileged"],
+ "Should ignore privileged records with a real URL"
+ );
+});
+
+// Tests the legacy removeDataFromDomain method.
+add_task(async function test_forgetAboutBaseDomain() {
+ let modifiedScopes = [];
+ let promiseForgetSubs = Promise.all([
+ promiseUnregister("active-sub-b"),
+ promiseUnregister("active-1-b"),
+ promiseUnregister("active-2-b"),
+ promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ modifiedScopes.push(data);
+ return modifiedScopes.length == 3;
+ }
+ ),
+ ]);
+
+ await ForgetAboutSite.removeDataFromDomain("example.net");
+ await promiseForgetSubs;
+
+ deepEqual(
+ modifiedScopes.sort(compareAscending),
+ [
+ "https://sub.example.net/sub-page",
+ "https://sub2.example.net/some-page",
+ "https://sub3.example.net/another-page",
+ ],
+ "Should fire modified notifications for entire domain"
+ );
+
+ let remainingIDs = await getAllKeyIDs(db);
+ deepEqual(
+ remainingIDs,
+ ["privileged"],
+ "Should ignore privileged records with a real URL"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_clear_origin_data.js b/dom/push/test/xpcshell/test_clear_origin_data.js
new file mode 100644
index 0000000000..7c743148a6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_clear_origin_data.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+
+let clearForPattern = async function (testRecords, pattern) {
+ let patternString = JSON.stringify(pattern);
+ await PushService._clearOriginData(patternString);
+
+ for (let length = testRecords.length; length--; ) {
+ let test = testRecords[length];
+ let originSuffix = ChromeUtils.originAttributesToSuffix(
+ test.originAttributes
+ );
+
+ let registration = await PushService.registration({
+ scope: test.scope,
+ originAttributes: originSuffix,
+ });
+
+ let url = test.scope + originSuffix;
+
+ if (ObjectUtils.deepEqual(test.clearIf, pattern)) {
+ ok(
+ !registration,
+ "Should clear registration " + url + " for pattern " + patternString
+ );
+ testRecords.splice(length, 1);
+ } else {
+ ok(
+ registration,
+ "Should not clear registration " + url + " for pattern " + patternString
+ );
+ }
+ }
+};
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_webapps_cleardata() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let testRecords = [
+ {
+ scope: "https://example.org/1",
+ originAttributes: {},
+ clearIf: { inIsolatedMozBrowser: false },
+ },
+ {
+ scope: "https://example.org/1",
+ originAttributes: { inIsolatedMozBrowser: true },
+ clearIf: {},
+ },
+ ];
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(
+ resolve => (unregisterDone = after(testRecords.length, resolve))
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(data) {
+ equal(data.messageType, "register", "Register: wrong message type");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: data.channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/" + Math.random(),
+ })
+ );
+ },
+ onUnregister(data) {
+ equal(data.code, 200, "Expected manual unregister reason");
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await Promise.all(
+ testRecords.map(test =>
+ PushService.register({
+ scope: test.scope,
+ originAttributes: ChromeUtils.originAttributesToSuffix(
+ test.originAttributes
+ ),
+ })
+ )
+ );
+
+ // Removes all the records, Excluding where `inIsolatedMozBrowser` is true.
+ await clearForPattern(testRecords, { inIsolatedMozBrowser: false });
+
+ // Removes the all the remaining records where `inIsolatedMozBrowser` is true.
+ await clearForPattern(testRecords, {});
+
+ equal(testRecords.length, 0, "Should remove all test records");
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_crypto.js b/dom/push/test/xpcshell/test_crypto.js
new file mode 100644
index 0000000000..e6b50e1d96
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -0,0 +1,666 @@
+"use strict";
+
+const { getCryptoParamsFromHeaders, PushCrypto } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushCrypto.sys.mjs"
+);
+
+const REJECT_PADDING = { padding: "reject" };
+
+// A common key to decrypt some aesgcm and aesgcm128 messages. Other decryption
+// tests have their own keys.
+const LEGACY_PRIVATE_KEY = {
+ kty: "EC",
+ crv: "P-256",
+ d: "4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg",
+ x: "sd85ZCbEG6dEkGMCmDyGBIt454Qy-Yo-1xhbaT2Jlk4",
+ y: "vr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs",
+ ext: true,
+};
+
+const LEGACY_PUBLIC_KEY =
+ "BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs";
+
+async function assertDecrypts(test, headers) {
+ let privateKey = test.privateKey;
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let authSecret = null;
+ if (test.authSecret) {
+ authSecret = ChromeUtils.base64URLDecode(test.authSecret, REJECT_PADDING);
+ }
+ let payload = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let result = await PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ headers,
+ payload
+ );
+ let decoder = new TextDecoder("utf-8");
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+}
+
+async function assertNotDecrypts(test, headers) {
+ let authSecret = null;
+ if (test.authSecret) {
+ authSecret = ChromeUtils.base64URLDecode(test.authSecret, REJECT_PADDING);
+ }
+ let data = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let promise = PushCrypto.decrypt(
+ test.privateKey,
+ publicKey,
+ authSecret,
+ headers,
+ data
+ );
+ await Assert.rejects(promise, test.expected, test.desc);
+}
+
+add_task(async function test_crypto_getCryptoParamsFromHeaders() {
+ // These headers should parse correctly.
+ let shouldParse = [
+ {
+ desc: "aesgcm with multiple keys",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: "keyid=p256dh;dh=Iy1Je2Kv11A,p256ecdsa=o2M8QfiEKuI",
+ encryption: "keyid=p256dh;salt=upk1yFkp1xI",
+ },
+ params: {
+ senderKey: "Iy1Je2Kv11A",
+ salt: "upk1yFkp1xI",
+ rs: 4096,
+ },
+ },
+ {
+ desc: "aesgcm with quoted key param",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: 'dh="byfHbUffc-k"',
+ encryption: "salt=C11AvAsp6Gc",
+ },
+ params: {
+ senderKey: "byfHbUffc-k",
+ salt: "C11AvAsp6Gc",
+ rs: 4096,
+ },
+ },
+ {
+ desc: "aesgcm with Crypto-Key and rs = 24",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: 'dh="ybuT4VDz-Bg"',
+ encryption: "salt=H7U7wcIoIKs; rs=24",
+ },
+ params: {
+ senderKey: "ybuT4VDz-Bg",
+ salt: "H7U7wcIoIKs",
+ rs: 24,
+ },
+ },
+ {
+ desc: "aesgcm128 with Encryption-Key and rs = 2",
+ headers: {
+ encoding: "aesgcm128",
+ encryption_key: "keyid=legacy; dh=LqrDQuVl9lY",
+ encryption: "keyid=legacy; salt=YngI8B7YapM; rs=2",
+ },
+ params: {
+ senderKey: "LqrDQuVl9lY",
+ salt: "YngI8B7YapM",
+ rs: 2,
+ },
+ },
+ {
+ desc: "aesgcm128 with Encryption-Key",
+ headers: {
+ encoding: "aesgcm128",
+ encryption_key: "keyid=v2; dh=VA6wmY1IpiE",
+ encryption: "keyid=v2; salt=khtpyXhpDKM",
+ },
+ params: {
+ senderKey: "VA6wmY1IpiE",
+ salt: "khtpyXhpDKM",
+ rs: 4096,
+ },
+ },
+ ];
+ for (let test of shouldParse) {
+ let params = getCryptoParamsFromHeaders(test.headers);
+ let senderKey = ChromeUtils.base64URLDecode(
+ test.params.senderKey,
+ REJECT_PADDING
+ );
+ let salt = ChromeUtils.base64URLDecode(test.params.salt, REJECT_PADDING);
+ deepEqual(
+ new Uint8Array(params.senderKey),
+ new Uint8Array(senderKey),
+ "Sender key should match for " + test.desc
+ );
+ deepEqual(
+ new Uint8Array(params.salt),
+ new Uint8Array(salt),
+ "Salt should match for " + test.desc
+ );
+ equal(
+ params.rs,
+ test.params.rs,
+ "Record size should match for " + test.desc
+ );
+ }
+
+ // These headers should be rejected.
+ let shouldThrow = [
+ {
+ desc: "aesgcm128 with Crypto-Key",
+ headers: {
+ encoding: "aesgcm128",
+ crypto_key: "keyid=v2; dh=VA6wmY1IpiE",
+ encryption: "keyid=v2; salt=F0Im7RtGgNY",
+ },
+ exception: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Invalid encoding",
+ headers: {
+ encoding: "nonexistent",
+ },
+ exception: /Missing encryption header/,
+ },
+ {
+ desc: "Invalid record size",
+ headers: {
+ encoding: "aesgcm",
+ crypto_key: "dh=pbmv1QkcEDY",
+ encryption: "dh=Esao8aTBfIk;rs=bad",
+ },
+ exception: /Invalid salt parameter/,
+ },
+ {
+ desc: "aesgcm with Encryption-Key",
+ headers: {
+ encoding: "aesgcm",
+ encryption_key: "dh=FplK5KkvUF0",
+ encryption: "salt=p6YHhFF3BQY",
+ },
+ exception: /Missing Crypto-Key header/,
+ },
+ ];
+ for (let test of shouldThrow) {
+ throws(
+ () => getCryptoParamsFromHeaders(test.headers),
+ test.exception,
+ test.desc
+ );
+ }
+});
+
+add_task(async function test_aes128gcm_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "Example from draft-ietf-webpush-encryption-latest",
+ result: "When I grow up, I want to be a watermelon",
+ data: "DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN",
+ authSecret: "BTBZMqHH6r4Tts7J_aSIgg",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94",
+ x: "JXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzE",
+ y: "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ ext: true,
+ },
+ publicKey:
+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4",
+ },
+ {
+ desc: "rs = 24, pad = 0",
+ result:
+ "I am the very model of a modern Major-General; I've information vegetable, animal, and mineral",
+ data: "goagSH7PP0ZGwUsgShmdkwAAABhBBDJVyIuUJbOSVMeWHP8VNPnxY-dZSw86doqOkEzZZZY1ALBWVXTVf0rUDH3oi68I9Hrp-01zA-mr8XKWl5kcH8cX0KiV2PtCwdkEyaQ73YF5fsDxgoWDiaTA3wPqMvuLDqGsZWHnE9Psnfoy7UMEqKlh2a1nE7ZOXiXcOBHLNj260jYzSJnEPV2eXixSXfyWpaSJHAwfj4wVdAAocmViIg6ywk8wFB1hgJpnX2UVEU_qIOcaP6AOIOr1UUQPfosQqC2MEHe5u9gHXF5pi-E267LAlkoYefq01KV_xK_vjbxpw8GAYfSjQEm0L8FG-CN37c8pnQ2Yf61MkihaXac9OctfNeWq_22cN6hn4qsOq0F7QoWIiZqWhB1vS9cJ3KUlyPQvKI9cvevDxw0fJHWeTFzhuwT9BjdILjjb2Vkqc0-qTDOawqD4c8WXsvdGDQCec5Y1x3UhdQXdjR_mhXypxFM37OZTvKJBr1vPCpRXl-bI6iOd7KScgtMM1x5luKhGzZyz25HyuFyj1ec82A",
+ authSecret: "_tK2LDGoIt6be6agJ_nvGA",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "bGViEe3PvjjFJg8lcnLsqu71b2yqWGnZN9J2MTed-9s",
+ x: "auB0GHF0AZ2LAocFnvOXDS7EeCMopnzbg-tS21FMHrU",
+ y: "GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc",
+ ext: true,
+ },
+ publicKey:
+ "BGrgdBhxdAGdiwKHBZ7zlw0uxHgjKKZ824PrUttRTB61GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc",
+ },
+ {
+ desc: "rs = 49, pad = 84; ciphertext length falls on record boundary",
+ result: "Hello, world",
+ data: "-yiDzsHE_K3W0TcfbqSR4AAAADFBBC1EHuf5_2oDKaZJJ9BST9vnsixvtl4Qq0_cA4-UQgoMo_oo2tNshOyRoWLq4Hj6rSwc7XjegRPhlgKyDolPSXa5c-L89oL6DIzNmvPVv_Ht4W-tWjHOGdOLXh_h94pPrYQrvBAlTCxs3ZaitVKE2XLFPK2MO6yxD19X6w1KQzO2BBAroRfK4pEI-9n2Kai6aWDdAZRbOe03unBsQ0oQ_SvSCU_5JJvNrUUTX1_kX804Bx-LLTlBr9pDmBDXeqyvfOULVDJb9YyVAzN9BzeFoyPfo0M",
+ authSecret: "lfF1cOUI72orKtG09creMw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "ZwBKTqgg3u2OSdtelIDmPT6jzOGujhpgYJcT1SfQAe8",
+ x: "AU6PFLktoHzgg7k_ljZ-h7IXpH9-8u6TqdNDqgY-V1o",
+ y: "nzDVnGkMajmz_IFbFQyn3RSWAXQTN7U1B6UfQbFzpyE",
+ ext: true,
+ },
+ publicKey:
+ "BAFOjxS5LaB84IO5P5Y2foeyF6R_fvLuk6nTQ6oGPldanzDVnGkMajmz_IFbFQyn3RSWAXQTN7U1B6UfQbFzpyE",
+ },
+ {
+ desc: "rs = 18, pad = 0",
+ result: "1",
+ data: "fK69vCCTjuNAqUbxvU9o8QAAABJBBDfP21Ij2fleqgL27ZQP8i6vBbNiLpSdw86fM15u-bJq6qzKD3QICos2RZLyzMbV7d1DAEtwuRiH0UTZ-pPxbDvH6mj0_VR6lOyoSxbhOKYIAXc",
+ authSecret: "1loE35Xy215gSDn3F9zeeQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "J0M_q4lws8tShLYRg--0YoZWLNKnMw2MrpYJEaVXHQw",
+ x: "UV1DJjVWUjmdoksr6SQeYztc8U-vDPOm_WAxe5VMCi8",
+ y: "SEhUgASyewz3SAvIEMa-wDqPt5yOoA_IsF4A-INFY-8",
+ ext: true,
+ },
+ publicKey:
+ "BFFdQyY1VlI5naJLK-kkHmM7XPFPrwzzpv1gMXuVTAovSEhUgASyewz3SAvIEMa-wDqPt5yOoA_IsF4A-INFY-8",
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ let privateKey = test.privateKey;
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, {
+ padding: "reject",
+ });
+ let authSecret = ChromeUtils.base64URLDecode(test.authSecret, {
+ padding: "reject",
+ });
+ let payload = ChromeUtils.base64URLDecode(test.data, {
+ padding: "reject",
+ });
+ let result = await PushCrypto.decrypt(
+ privateKey,
+ publicKey,
+ authSecret,
+ {
+ encoding: "aes128gcm",
+ },
+ payload
+ );
+ let decoder = new TextDecoder("utf-8");
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+ }
+});
+
+add_task(async function test_aes128gcm_err() {
+ let expectedFailures = [
+ {
+ // Just the payload; no header at all.
+ desc: "Missing header block",
+ data: "RbdNK2m-mwdN47NaqH58FWEd",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "G-g_ODMu8JaB-vPzB7H_LhDKt4zHzatoOsDukqw_buE",
+ x: "26mRyiFTQ_Nr3T6FfK_ePRi_V_GDWygzutQU8IhBYgU",
+ y: "GslqCyRJADfQfPUo5OGOEAoaZOt0R0hUS_HiINq6zyw",
+ ext: true,
+ },
+ publicKey:
+ "BNupkcohU0Pza90-hXyv3j0Yv1fxg1soM7rUFPCIQWIFGslqCyRJADfQfPUo5OGOEAoaZOt0R0hUS_HiINq6zyw",
+ authSecret: "NHG7mEgeAlM785VCvPPbpA",
+ expected: /Truncated header/,
+ },
+ {
+ // The sender key should be 65 bytes; this header contains an invalid key
+ // that's only 1 byte.
+ desc: "Truncated sender key",
+ data: "3ltpa4fxoVy2revdedb5ngAAABIBALa8GCbDfJ9z3WtIWcK1BRgZUg",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "zojo4LMFekdS60yPqTHrYhwwLaWtA7ga9FnPZzVWDK4",
+ x: "oyXZkITEDeDOcioELESNlKMmkXIcp54890XnjGmIYZQ",
+ y: "sCzqGSJBdnlanU27sgc68szW-m8KTHxJaFVr5QKjuoE",
+ ext: true,
+ },
+ publicKey:
+ "BKMl2ZCExA3gznIqBCxEjZSjJpFyHKeePPdF54xpiGGUsCzqGSJBdnlanU27sgc68szW-m8KTHxJaFVr5QKjuoE",
+ authSecret: "XDHg2W2aE5iZrAlp01n3QA",
+ expected: /Invalid sender public key/,
+ },
+ {
+ // The message is encrypted with only the first 12 bytes of the 16-byte
+ // auth secret, so the derived decryption key and nonce won't match.
+ desc: "Encrypted with mismatched auth secret",
+ data: "gRX0mIuMOSp7rLQ8jxrFZQAAABJBBBmUSDxUHpvDmmrwP_cTqndFwoThOKQqJDW3l7IMS2mM9RGLT4VVMXwZDqvr-rdJwWTT9r3r4NRBcZExo1fYiQoTxNvUsW_z3VqD98ka1uBArEJzCn8LPNMkXp-Nb_McdR1BDP0",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "YMdjalF95wOaCsLQ4wZEAHlMeOfgSTmBKaInzuD5qAE",
+ x: "_dBBKKhcBYltf4H-EYvcuIe588H_QYOtxMgk0ShgcwA",
+ y: "6Yay37WmEOWvQ-QIoAcwWE-T49_d_ERzfV8I-y1viRY",
+ ext: true,
+ },
+ publicKey:
+ "BP3QQSioXAWJbX-B_hGL3LiHufPB_0GDrcTIJNEoYHMA6Yay37WmEOWvQ-QIoAcwWE-T49_d_ERzfV8I-y1viRY",
+ authSecret: "NVo4zW2b7xWZDi0zCNvWAA",
+ expected: /Bad encryption/,
+ },
+ {
+ // Multiple records; the first has padding delimiter = 2, but should be 1.
+ desc: "Early final record",
+ data: "2-IVUH0a09Lq6r6ubodNjwAAABJBBHvEND80qDSM3E5GL_x8QKpqjGGnOcTEHUUSVQX3Dp_F-e-oaFLdSI3Pjo6iyvt14Hq9XufJ1cA4uv7weVcbC9opRBHOmMdt0DHA5YBXekmAo3XkXtMEKb4OLunafm34aW0BuOw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "XdodkYvEB7o82hLLgBTUmqfgJpACggMERmvIADTKkkA",
+ x: "yVxlINrRHo9qG_gDGkDCpO4QRcGQO-BqHfp_gpzOst4",
+ y: "Akga5r0EdhIbEsVTLQsjF4gHfvoGg6W_4NYjObJRyzU",
+ ext: true,
+ },
+ publicKey:
+ "BMlcZSDa0R6Pahv4AxpAwqTuEEXBkDvgah36f4KczrLeAkga5r0EdhIbEsVTLQsjF4gHfvoGg6W_4NYjObJRyzU",
+ authSecret: "QMJB_eQmnuHm1yVZLZgnGA",
+ expected: /Padding is wrong!/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, { encoding: "aes128gcm" });
+ }
+});
+
+add_task(async function test_aesgcm_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "padSize = 2, rs = 24, pad = 0",
+ result: "Some message",
+ data: "Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU",
+ authSecret: "aTDc6JebzR6eScy2oLo4RQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHFVrflyxibGLlgztLwKelsRZp4gqX3tNfAKFaxAcBhpvYeN1yIUMrxaDKiLh4LNKPtj0BOXGdr-IQ-QP82Wjo",
+ encryption: "salt=zCU18Rw3A5aB_Xi-vfixmA; rs=24",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "padSize = 2, rs = 8, pad = 16",
+ result: "Yet another message",
+ data: "uEC5B_tR-fuQ3delQcrzrDCp40W6ipMZjGZ78USDJ5sMj-6bAOVG3AK6JqFl9E6AoWiBYYvMZfwThVxmDnw6RHtVeLKFM5DWgl1EwkOohwH2EhiDD0gM3io-d79WKzOPZE9rDWUSv64JstImSfX_ADQfABrvbZkeaWxh53EG59QMOElFJqHue4dMURpsMXg",
+ authSecret: "6plwZnSpVUbF7APDXus3UQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ",
+ encryption: "salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "padSize = 2, rs = 3, pad = 0",
+ result: "Small record size",
+ data: "oY4e5eDatDVt2fpQylxbPJM-3vrfhDasfPc8Q1PWt4tPfMVbz_sDNL_cvr0DXXkdFzS1lxsJsj550USx4MMl01ihjImXCjrw9R5xFgFrCAqJD3GwXA1vzS4T5yvGVbUp3SndMDdT1OCcEofTn7VC6xZ-zP8rzSQfDCBBxmPU7OISzr8Z4HyzFCGJeBfqiZ7yUfNlKF1x5UaZ4X6iU_TXx5KlQy_toV1dXZ2eEAMHJUcSdArvB6zRpFdEIxdcHcJyo1BIYgAYTDdAIy__IJVCPY_b2CE5W_6ohlYKB7xDyH8giNuWWXAgBozUfScLUVjPC38yJTpAUi6w6pXgXUWffende5FreQpnMFL1L4G-38wsI_-ISIOzdO8QIrXHxmtc1S5xzYu8bMqSgCinvCEwdeGFCmighRjj8t1zRWo0D14rHbQLPR_b1P5SvEeJTtS9Nm3iibM",
+ authSecret: "g2rWVHUCpUxgcL9Tz7vyeQ",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk",
+ encryption: "salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3",
+ encoding: "aesgcm",
+ },
+ },
+ {
+ desc: "Example from draft-ietf-httpbis-encryption-encoding-02",
+ result: "I am the walrus",
+ data: "6nqAQUME8hNqw5J3kl8cpVVJylXKYqZOeseZG8UueKpA",
+ authSecret: "R29vIGdvbyBnJyBqb29iIQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "9FWl15_QUQAWDaD3k3l50ZBZQJ4au27F1V4F0uLSD_M",
+ x: "ISQGPMvxncL6iLZDugTm3Y2n6nuiyMYuD3epQ_TC-pE",
+ y: "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU",
+ ext: true,
+ },
+ publicKey:
+ "BCEkBjzL8Z3C-oi2Q7oE5t2Np-p7osjGLg93qUP0wvqRT21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU",
+ headers: {
+ crypto_key:
+ 'keyid="dhkey"; dh="BNoRDbb84JGm8g5Z5CFxurSqsXWJ11ItfXEWYVLE85Y7CYkDjXsIEc4aqxYaQ1G8BqkXCJ6DPpDrWtdWj_mugHU"',
+ encryption: 'keyid="dhkey"; salt="lngarbyKfMoi9Z75xYXmkg"',
+ encoding: "aesgcm",
+ },
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ await assertDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm_err() {
+ let expectedFailures = [
+ {
+ desc: "aesgcm128 message decrypted as aesgcm",
+ data: "fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8",
+ authSecret: "BhbpNTWyO5wVJmVKTV6XaA",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0",
+ encryption: "salt=c6JQl9eJ0VvwrUVCQDxY7Q",
+ encoding: "aesgcm",
+ },
+ expected: /Bad encryption/,
+ },
+ {
+ // The plaintext is "O hai". The ciphertext is exactly `rs + 16` bytes,
+ // but we didn't include the empty trailing block that aesgcm requires for
+ // exact multiples.
+ desc: "rs = 7, no trailing block",
+ data: "YG4F-b06y590hRlnSsw_vuOw62V9Iz8",
+ authSecret: "QoDi0u6vcslIVJKiouXMXw",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "2bu4paOAZbL2ef1u-wTzONuTIcDPc00o0zUJgg46XTc",
+ x: "uEvLZUMVn1my0cwnLdcFT0mj1gSU5uzI3HeGwXC7jX8",
+ y: "SfNVLGL-FurydsuzciDfw8K1cUHyoDWnJJ_16UG6Dbo",
+ ext: true,
+ },
+ publicKey:
+ "BLhLy2VDFZ9ZstHMJy3XBU9Jo9YElObsyNx3hsFwu41_SfNVLGL-FurydsuzciDfw8K1cUHyoDWnJJ_16UG6Dbo",
+ headers: {
+ crypto_key:
+ "dh=BD_bsTUpxBMvSv8eksith3vijMLj44D4jhJjO51y7wK1ytbUlsyYBBYYyB5AAe5bnREA_WipTgemDVz00LiWcfM",
+ encryption: "salt=xKWvs_jWWeg4KOsot_uBhA; rs=7",
+ encoding: "aesgcm",
+ },
+ expected: /Encrypted data truncated/,
+ },
+ {
+ // The last block is only 1 byte, but valid blocks must be at least 2 bytes.
+ desc: "Pad size > last block length",
+ data: "JvX9HsJ4lL5gzP8_uCKc6s15iRIaNhD4pFCgq5-dfwbUqEcNUkqv",
+ authSecret: "QtGZeY8MQfCaq-XwKOVGBQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "CosERAVXgvTvoh7UkrRC2V-iXoNs0bXle9I68qzkles",
+ x: "_D0YqEwirvTJQJdjG6xXrjstMVpeAzf221cUqZz6hgY",
+ y: "9MnFbM7U14uiYMDI5e2I4jN29tYmsM9F66QodhKmA-c",
+ ext: true,
+ },
+ publicKey:
+ "BPw9GKhMIq70yUCXYxusV647LTFaXgM39ttXFKmc-oYG9MnFbM7U14uiYMDI5e2I4jN29tYmsM9F66QodhKmA-c",
+ headers: {
+ crypto_key:
+ "dh=BBNZNEi5Ew_ID5S4Y9jWBi1NeVDje6Mjs7SDLViUn6A8VAZj-6X3QAuYQ3j20BblqjwTgYst7PRnY6UGrKyLbmU",
+ encryption: "salt=ot8hzbwOo6CYe6ZhdlwKtg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Decoded array is too short/,
+ },
+ {
+ // The last block is 3 bytes (2 bytes for the pad length; 1 byte of data),
+ // but claims its pad length is 2.
+ desc: "Padding length > last block length",
+ data: "oWSOFA-UO5oWq-kI79RHaFfwAejLiQJ4C7eTmrSTBl4gArLXfx7lZ-Y",
+ authSecret: "gKG_P6-de5pyzS8hyH_NyQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "9l-ahcBM-I0ykwbWiDS9KRrPdhyvTZ0SxKiPpj2aeaI",
+ x: "qx0tU4EDaQv6ayFA3xvLLBdMmn4mLxjn7SK6mIeIxeg",
+ y: "ymbMcmUOEyh_-rLrBsi26NG4UFCis2MTDs5FG2VdDPI",
+ ext: true,
+ },
+ publicKey:
+ "BKsdLVOBA2kL-mshQN8byywXTJp-Ji8Y5-0iupiHiMXoymbMcmUOEyh_-rLrBsi26NG4UFCis2MTDs5FG2VdDPI",
+ headers: {
+ crypto_key:
+ "dh=BKe2IBO_cwmEzQyTVscSbQcj0Y3uBSzGZ_mHlANMciS8uGpb7U8_Bw7TNdlYfpwWDLd0cxM8YYWNDbNJ_p2Rp4o",
+ encryption: "salt=z7QJ6UR89SiFRkd4RsC4Vg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Padding is wrong/,
+ },
+ {
+ // The first block has no padding, but claims its pad length is 1.
+ desc: "Non-zero padding",
+ data: "Qdvjh0LkZXKu_1Hvv56D0rOSF6Mww3y0F8xkxUNlwVu2U1iakOUUGRs",
+ authSecret: "cMpWQW58BrpDbJ8KqbS9ig",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "IzuaxLqFJmjSu8GjLCo2oEaDZjDButW4m4T0qx02XsM",
+ x: "Xy7vt_TJTynxwWsQyY069BcKmrhkRjhKPFuTi-AphoY",
+ y: "0M10IVM1ourR7Q5AUX2b2fgdmGyTWcYsdHcdFK_b4Hk",
+ ext: true,
+ },
+ publicKey:
+ "BF8u77f0yU8p8cFrEMmNOvQXCpq4ZEY4Sjxbk4vgKYaG0M10IVM1ourR7Q5AUX2b2fgdmGyTWcYsdHcdFK_b4Hk",
+ headers: {
+ crypto_key:
+ "dh=BBicj01QI0ryiFzAaty9VpW_crgq9XbU1bOCtEZI9UNE6tuOgp4lyN_UN0N905ECnLWK5v_sCPUIxnQgOuCseSo",
+ encryption: "salt=SbkGHONbQBBsBcj9dLyIUw; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Padding is wrong/,
+ },
+ {
+ // The first record is 22 bytes: 2 bytes for the pad length, 4 bytes of
+ // data, and a 16-byte auth tag. The second "record" is missing the pad
+ // and data, and contains just the auth tag.
+ desc: "rs = 6, second record truncated to only auth tag",
+ data: "C7u3j5AL4Yzh2yYB_umN6tzrVHxrt7D5baTEW9DE1Bk3up9fY4w",
+ authSecret: "3rWhsRCU_KdaqfKPbd3zBQ",
+ privateKey: {
+ kty: "EC",
+ crv: "P-256",
+ d: "nhOT9171xuoQBJGkiZ3aqT5qw_ILJ94_PPiVNu1LFSY",
+ x: "lCj7ctQTmRfwzTMcODlNfHjFMAHmgdI44OhTQXX_xpE",
+ y: "WBdgz4GWGtGAisC63O9DtP5l--hnCzPZiV-YZ-a6Lcw",
+ ext: true,
+ },
+ publicKey:
+ "BJQo-3LUE5kX8M0zHDg5TXx4xTAB5oHSOODoU0F1_8aRWBdgz4GWGtGAisC63O9DtP5l--hnCzPZiV-YZ-a6Lcw",
+ headers: {
+ crypto_key:
+ "dh=BI38Qs_OhDmQIxbszc6Nako-MrX3FzAE_8HzxM1wgoEIG4ocxyF-YAAVhfkpJUvDpRyKW2LDHIaoylaZuxQfRhE",
+ encryption: "salt=QClh48OlvGpSjZ0Mg0e8rg; rs=6",
+ encoding: "aesgcm",
+ },
+ expected: /Decoded array is too short/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm128_ok() {
+ let expectedSuccesses = [
+ {
+ desc: "padSize = 1, rs = 4096, pad = 2",
+ result: "aesgcm128 encrypted message",
+ data: "ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ encryption_key:
+ "dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI",
+ encryption: "salt=btxxUtclbmgcc30b9rT3Bg; rs=4096",
+ encoding: "aesgcm128",
+ },
+ },
+ ];
+ for (let test of expectedSuccesses) {
+ await assertDecrypts(test, test.headers);
+ }
+});
+
+add_task(async function test_aesgcm128_err() {
+ let expectedFailures = [
+ {
+ // aesgcm128 doesn't use an auth secret, but we've mixed one in during
+ // encryption, so the decryption key and nonce won't match.
+ desc: "padSize = 1, rs = 4096, auth secret, pad = 8",
+ data: "h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r",
+ authSecret: "Sxb6u0gJIhGEogyLawjmCw",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM",
+ encryption: "salt=aGBpoKklLtrLcAUCcCr7JQ",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ // The first byte of each record must be the pad length.
+ desc: "Missing padding",
+ data: "anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4",
+ encryption: "salt=Czx2i18rar8XWOXAVDnUuw",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Truncated input",
+ data: "AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0",
+ encryption: "salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ {
+ desc: "Padding length > rs",
+ data: "Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu",
+ privateKey: LEGACY_PRIVATE_KEY,
+ publicKey: LEGACY_PUBLIC_KEY,
+ headers: {
+ crypto_key:
+ "dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls",
+ encryption: "salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1",
+ encoding: "aesgcm128",
+ },
+ expected: /Missing Encryption-Key header/,
+ },
+ ];
+ for (let test of expectedFailures) {
+ await assertNotDecrypts(test, test.headers);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_crypto_encrypt.js b/dom/push/test/xpcshell/test_crypto_encrypt.js
new file mode 100644
index 0000000000..d199ce2220
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto_encrypt.js
@@ -0,0 +1,199 @@
+// Test PushCrypto.encrypt()
+"use strict";
+
+const { PushCrypto } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushCrypto.sys.mjs"
+);
+
+let from64 = v => {
+ // allow whitespace in the strings.
+ let stripped = v.replace(/ |\t|\r|\n/g, "");
+ return new Uint8Array(
+ ChromeUtils.base64URLDecode(stripped, { padding: "reject" })
+ );
+};
+
+let to64 = v => ChromeUtils.base64URLEncode(v, { pad: false });
+
+// A helper function to take a public key (as a buffer containing a 65-byte
+// buffer of uncompressed EC points) and a private key (32byte buffer) and
+// return 2 crypto keys.
+async function importKeyPair(publicKeyBuffer, privateKeyBuffer) {
+ let jwk = {
+ kty: "EC",
+ crv: "P-256",
+ x: to64(publicKeyBuffer.slice(1, 33)),
+ y: to64(publicKeyBuffer.slice(33, 65)),
+ ext: true,
+ };
+ let publicKey = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ []
+ );
+ jwk.d = to64(privateKeyBuffer);
+ let privateKey = await crypto.subtle.importKey(
+ "jwk",
+ jwk,
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ["deriveBits"]
+ );
+ return { publicKey, privateKey };
+}
+
+// The example from draft-ietf-webpush-encryption-09.
+add_task(async function static_aes128gcm() {
+ let fixture = {
+ ciphertext:
+ from64(`DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
+ mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT
+ pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN`),
+ plaintext: new TextEncoder().encode(
+ "When I grow up, I want to be a watermelon"
+ ),
+ authSecret: from64("BTBZMqHH6r4Tts7J_aSIgg"),
+ receiver: {
+ private: from64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"),
+ public: from64(`BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx
+ aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4`),
+ },
+ sender: {
+ private: from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"),
+ public: from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIg
+ Dll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`),
+ },
+ salt: from64("DGv6ra1nlYgDCS1FRnbzlw"),
+ };
+
+ let options = {
+ senderKeyPair: await importKeyPair(
+ fixture.sender.public,
+ fixture.sender.private
+ ),
+ salt: fixture.salt,
+ };
+
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ fixture.plaintext,
+ fixture.receiver.public,
+ fixture.authSecret,
+ options
+ );
+
+ Assert.deepEqual(ciphertext, fixture.ciphertext);
+ Assert.equal(encoding, "aes128gcm");
+
+ // and for fun, decrypt it and check the plaintext.
+ let recvKeyPair = await importKeyPair(
+ fixture.receiver.public,
+ fixture.receiver.private
+ );
+ let jwk = await crypto.subtle.exportKey("jwk", recvKeyPair.privateKey);
+ let plaintext = await PushCrypto.decrypt(
+ jwk,
+ fixture.receiver.public,
+ fixture.authSecret,
+ { encoding: "aes128gcm" },
+ ciphertext
+ );
+ Assert.deepEqual(plaintext, fixture.plaintext);
+});
+
+// This is how we expect real code to interact with .encrypt.
+add_task(async function aes128gcm_simple() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ let message = new TextEncoder().encode("Fast for good.");
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+});
+
+// Variable record size tests
+add_task(async function aes128gcm_rs() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let rs of [-1, 0, 1, 17]) {
+ let payload = "x".repeat(1024);
+ info(`testing expected failure with rs=${rs}`);
+ let message = new TextEncoder().encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ await Assert.rejects(
+ PushCrypto.encrypt(message, recvPublicKey, authSecret, { rs }),
+ /recordsize is too small/
+ );
+ }
+ for (let rs of [18, 50, 1024, 4096, 16384]) {
+ info(`testing expected success with rs=${rs}`);
+ let payload = "x".repeat(rs * 3);
+ let message = new TextEncoder().encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret,
+ { rs }
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+ }
+});
+
+// And try and hit some edge-cases.
+add_task(async function aes128gcm_edgecases() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let size of [
+ 0,
+ 4096 - 16,
+ 4096 - 16 - 1,
+ 4096 - 16 + 1,
+ 4095,
+ 4096,
+ 4097,
+ 10240,
+ ]) {
+ info(`testing encryption of ${size} byte payload`);
+ let message = new TextEncoder().encode("x".repeat(size));
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let { ciphertext, encoding } = await PushCrypto.encrypt(
+ message,
+ recvPublicKey,
+ authSecret
+ );
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(
+ recvPrivateKey,
+ recvPublicKey,
+ authSecret,
+ { encoding },
+ ciphertext
+ );
+ deepEqual(message, plaintext);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_drop_expired.js b/dom/push/test/xpcshell/test_drop_expired.js
new file mode 100644
index 0000000000..823049c21f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_drop_expired.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "2c43af06-ab6e-476a-adc4-16cbda54fb89";
+
+var db;
+var quotaURI;
+var permURI;
+
+function visitURI(uri, timestamp) {
+ return PlacesTestUtils.addVisits({
+ uri,
+ title: uri.spec,
+ visitDate: timestamp * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+}
+
+var putRecord = async function ({ scope, perm, quota, lastPush, lastVisit }) {
+ let uri = Services.io.newURI(scope);
+
+ PermissionTestUtils.add(
+ uri,
+ "desktop-notification",
+ Ci.nsIPermissionManager[perm]
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ });
+
+ await visitURI(uri, lastVisit);
+
+ await db.put({
+ channelID: uri.pathQueryRef,
+ pushEndpoint: "https://example.org/push" + uri.pathQueryRef,
+ scope: uri.spec,
+ pushCount: 0,
+ lastPush,
+ version: null,
+ originAttributes: "",
+ quota,
+ });
+
+ return uri;
+};
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ run_next_test();
+}
+
+add_task(async function setUp() {
+ // An expired registration that should be evicted on startup. Permission is
+ // granted for this origin, and the last visit is more recent than the last
+ // push message.
+ await putRecord({
+ scope: "https://example.com/expired-quota-restored",
+ perm: "ALLOW_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now(),
+ });
+
+ // An expired registration that we should evict when the origin is visited
+ // again.
+ quotaURI = await putRecord({
+ scope: "https://example.xyz/expired-quota-exceeded",
+ perm: "ALLOW_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now() - 20,
+ });
+
+ // An expired registration that we should evict when permission is granted
+ // again.
+ permURI = await putRecord({
+ scope: "https://example.info/expired-perm-revoked",
+ perm: "DENY_ACTION",
+ quota: 0,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now(),
+ });
+
+ // An active registration that we should leave alone.
+ await putRecord({
+ scope: "https://example.ninja/active",
+ perm: "ALLOW_ACTION",
+ quota: 16,
+ lastPush: Date.now() - 10,
+ lastVisit: Date.now() - 20,
+ });
+
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.com/expired-quota-restored"
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ channelID: request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await subChangePromise;
+});
+
+add_task(async function test_site_visited() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.xyz/expired-quota-exceeded"
+ );
+
+ await visitURI(quotaURI, Date.now());
+ PushService.observe(null, "idle-daily", "");
+
+ await subChangePromise;
+});
+
+add_task(async function test_perm_restored() {
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.info/expired-perm-revoked"
+ );
+
+ PermissionTestUtils.add(
+ permURI,
+ "desktop-notification",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+
+ await subChangePromise;
+});
diff --git a/dom/push/test/xpcshell/test_handler_service.js b/dom/push/test/xpcshell/test_handler_service.js
new file mode 100644
index 0000000000..bee29c1c77
--- /dev/null
+++ b/dom/push/test/xpcshell/test_handler_service.js
@@ -0,0 +1,74 @@
+"use strict";
+
+// Here we test that if an xpcom component is registered with the category
+// manager for push notifications against a specific scope, that service is
+// instantiated before the message is delivered.
+
+const { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+);
+
+let pushService = Cc["@mozilla.org/push/Service;1"].getService(
+ Ci.nsIPushService
+);
+
+function PushServiceHandler() {
+ // Register a push observer.
+ this.observed = [];
+ Services.obs.addObserver(this, pushService.pushTopic);
+ Services.obs.addObserver(this, pushService.subscriptionChangeTopic);
+ Services.obs.addObserver(this, pushService.subscriptionModifiedTopic);
+}
+
+PushServiceHandler.prototype = {
+ classID: Components.ID("{bb7c5199-c0f7-4976-9f6d-1306e32c5591}"),
+ QueryInterface: ChromeUtils.generateQI([]),
+
+ observe(subject, topic, data) {
+ this.observed.push({ subject, topic, data });
+ },
+};
+
+let handlerService = new PushServiceHandler();
+
+add_test(function test_service_instantiation() {
+ const CONTRACT_ID = "@mozilla.org/dom/push/test/PushServiceHandler;1";
+ let scope = "chrome://test-scope";
+
+ MockRegistrar.register(CONTRACT_ID, handlerService);
+ Services.catMan.addCategoryEntry("push", scope, CONTRACT_ID, false, false);
+
+ let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+ );
+ let principal = Services.scriptSecurityManager.getSystemPrincipal();
+ pushNotifier.notifyPush(scope, principal, "");
+
+ equal(handlerService.observed.length, 1);
+ equal(handlerService.observed[0].topic, pushService.pushTopic);
+ let message = handlerService.observed[0].subject.QueryInterface(
+ Ci.nsIPushMessage
+ );
+ equal(message.principal, principal);
+ strictEqual(message.data, null);
+ equal(handlerService.observed[0].data, scope);
+
+ // and a subscription change.
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ equal(handlerService.observed.length, 2);
+ equal(handlerService.observed[1].topic, pushService.subscriptionChangeTopic);
+ equal(handlerService.observed[1].subject, principal);
+ equal(handlerService.observed[1].data, scope);
+
+ // and a subscription modified event.
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ equal(handlerService.observed.length, 3);
+ equal(
+ handlerService.observed[2].topic,
+ pushService.subscriptionModifiedTopic
+ );
+ equal(handlerService.observed[2].subject, principal);
+ equal(handlerService.observed[2].data, scope);
+
+ run_next_test();
+});
diff --git a/dom/push/test/xpcshell/test_notification_ack.js b/dom/push/test/xpcshell/test_notification_ack.js
new file mode 100644
index 0000000000..87c29f417a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_ack.js
@@ -0,0 +1,163 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var userAgentID = "5ab1d1df-7a3d-4024-a469-b9e1bb399fad";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+add_task(async function test_notification_ack() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ pushEndpoint: "https://example.com/update/1",
+ scope: "https://example.org/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ pushEndpoint: "https://example.com/update/2",
+ scope: "https://example.org/2",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ pushEndpoint: "https://example.com/update/3",
+ scope: "https://example.org/3",
+ originAttributes: "",
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let notifyCount = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ () => ++notifyCount == 3
+ );
+
+ let acks = 0;
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should send matching device IDs in handshake"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ version: 2,
+ },
+ ],
+ })
+ );
+ },
+ onACK(request) {
+ equal(request.messageType, "ack", "Should send acknowledgements");
+ let updates = request.updates;
+ switch (++acks) {
+ case 1:
+ deepEqual(
+ [
+ {
+ channelID: "21668e05-6da8-42c9-b8ab-9cc3f4d5630c",
+ version: 2,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 1"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ version: 4,
+ },
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ version: 6,
+ },
+ ],
+ })
+ );
+ break;
+
+ case 2:
+ deepEqual(
+ [
+ {
+ channelID: "9a5ff87f-47c9-4215-b2b8-0bdd38b4b305",
+ version: 4,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 2"
+ );
+ break;
+
+ case 3:
+ deepEqual(
+ [
+ {
+ channelID: "5477bfda-22db-45d4-9614-fee369630260",
+ version: 6,
+ code: 100,
+ },
+ ],
+ updates,
+ "Wrong updates for acknowledgement 3"
+ );
+ ackDone();
+ break;
+
+ default:
+ ok(false, "Unexpected acknowledgement " + acks);
+ }
+ },
+ });
+ },
+ });
+
+ await notifyPromise;
+ await ackPromise;
+});
diff --git a/dom/push/test/xpcshell/test_notification_data.js b/dom/push/test/xpcshell/test_notification_data.js
new file mode 100644
index 0000000000..9c9cc943a8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_data.js
@@ -0,0 +1,310 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let db;
+let userAgentID = "f5b47f8d-771f-4ea3-b999-91c135f8766d";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+function putRecord(channelID, scope, publicKey, privateKey, authSecret) {
+ return db.put({
+ channelID,
+ pushEndpoint: "https://example.org/push/" + channelID,
+ scope,
+ pushCount: 0,
+ lastPush: 0,
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ p256dhPublicKey: ChromeUtils.base64URLDecode(publicKey, {
+ padding: "reject",
+ }),
+ p256dhPrivateKey: privateKey,
+ authenticationSecret: ChromeUtils.base64URLDecode(authSecret, {
+ padding: "reject",
+ }),
+ });
+}
+
+let ackDone;
+let server;
+add_task(async function test_notification_ack_data_setup() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await putRecord(
+ "subscription1",
+ "https://example.com/page/1",
+ "BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ {
+ crv: "P-256",
+ d: "1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM",
+ y: "26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ },
+ "c_sGN6uCv9Hu7JOQT34jAQ"
+ );
+ await putRecord(
+ "subscription2",
+ "https://example.com/page/2",
+ "BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E",
+ {
+ crv: "P-256",
+ d: "lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE",
+ y: "5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E",
+ },
+ "t3P246Gj9vjKDHHRYaY6hw"
+ );
+ await putRecord(
+ "subscription3",
+ "https://example.com/page/3",
+ "BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI",
+ {
+ crv: "P-256",
+ d: "Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po",
+ y: "Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI",
+ },
+ "E0qiXGWvFSR0PS352ES1_Q"
+ );
+
+ let setupDone;
+ let setupDonePromise = new Promise(r => (setupDone = r));
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should send matching device IDs in handshake"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ use_webpush: true,
+ })
+ );
+ server = this;
+ setupDone();
+ },
+ onACK(request) {
+ if (ackDone) {
+ ackDone(request);
+ }
+ },
+ });
+ },
+ });
+ await setupDonePromise;
+});
+
+add_task(async function test_notification_ack_data() {
+ let allTestData = [
+ {
+ channelID: "subscription1",
+ version: "v1",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification1"; dh="BO_tgGm-yvYAGLeRe16AvhzaUcpYRiqgsGOlXpt0DRWDRGGdzVLGlEVJMygqAUECarLnxCiAOHTP_znkedrlWoU"',
+ encryption: 'keyid="notification1";salt="uAZaiXpOSfOLJxtOCZ09dA"',
+ encoding: "aesgcm128",
+ },
+ data: "NwrrOWPxLE8Sv5Rr0Kep7n0-r_j3rsYrUw_CXPo",
+ version: "v1",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/1",
+ data: "Some message",
+ },
+ },
+ {
+ channelID: "subscription2",
+ version: "v2",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification2"; dh="BKVdQcgfncpNyNWsGrbecX0zq3eHIlHu5XbCGmVcxPnRSbhjrA6GyBIeGdqsUL69j5Z2CvbZd-9z1UBH0akUnGQ"',
+ encryption: 'keyid="notification2";salt="vFn3t3M_k42zHBdpch3VRw"',
+ encoding: "aesgcm128",
+ },
+ data: "Zt9dEdqgHlyAL_l83385aEtb98ZBilz5tgnGgmwEsl5AOCNgesUUJ4p9qUU",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/2",
+ data: "Some message",
+ },
+ },
+ {
+ channelID: "subscription3",
+ version: "v3",
+ send: {
+ headers: {
+ encryption_key:
+ 'keyid="notification3";dh="BD3xV_ACT8r6hdIYES3BJj1qhz9wyv7MBrG9vM2UCnjPzwE_YFVpkD-SGqE-BR2--0M-Yf31wctwNsO1qjBUeMg"',
+ encryption:
+ 'keyid="notification3"; salt="DFq188piWU7osPBgqn4Nlg"; rs=24',
+ encoding: "aesgcm128",
+ },
+ data: "LKru3ZzxBZuAxYtsaCfaj_fehkrIvqbVd1iSwnwAUgnL-cTeDD-83blxHXTq7r0z9ydTdMtC3UjAcWi8LMnfY-BFzi0qJAjGYIikDA",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/3",
+ data: "Some message",
+ },
+ },
+ // A message encoded with `aesgcm` (2 bytes of padding, authenticated).
+ {
+ channelID: "subscription1",
+ version: "v5",
+ send: {
+ headers: {
+ crypto_key:
+ 'keyid=v4;dh="BMh_vsnqu79ZZkMTYkxl4gWDLdPSGE72Lr4w2hksSFW398xCMJszjzdblAWXyhSwakRNEU_GopAm4UGzyMVR83w"',
+ encryption: 'keyid="v4";salt="C14Wb7rQTlXzrgcPHtaUzw"',
+ encoding: "aesgcm",
+ },
+ data: "pus4kUaBWzraH34M-d_oN8e0LPpF_X6acx695AMXovDe",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/1",
+ data: "Another message",
+ },
+ },
+ // A message with 17 bytes of padding and rs of 24
+ {
+ channelID: "subscription2",
+ version: "v5",
+ send: {
+ headers: {
+ crypto_key:
+ 'keyid="v5"; dh="BOp-DpyR9eLY5Ci11_loIFqeHzWfc_0evJmq7N8NKzgp60UAMMM06XIi2VZp2_TSdw1omk7E19SyeCCwRp76E-U"',
+ encryption: 'keyid=v5;salt="TvjOou1TqJOQY_ZsOYV3Ww";rs=24',
+ encoding: "aesgcm",
+ },
+ data: "rG9WYQ2ZwUgfj_tMlZ0vcIaNpBN05FW-9RUBZAM-UUZf0_9eGpuENBpUDAw3mFmd2XJpmvPvAtLVs54l3rGwg1o",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/2",
+ data: "Some message",
+ },
+ },
+ // A message without key identifiers.
+ {
+ channelID: "subscription3",
+ version: "v6",
+ send: {
+ headers: {
+ crypto_key:
+ 'dh="BEEjwWbF5jZKCgW0kmUWgG-wNcRvaa9_3zZElHAF8przHwd4cp5_kQsc-IMNZcVA0iUix31jxuMOytU-5DwWtyQ"',
+ encryption: "salt=aAQcr2khAksgNspPiFEqiQ",
+ encoding: "aesgcm",
+ },
+ data: "pEYgefdI-7L46CYn5dR9TIy2AXGxe07zxclbhstY",
+ },
+ ackCode: 100,
+ receive: {
+ scope: "https://example.com/page/3",
+ data: "Some message",
+ },
+ },
+ // A malformed encrypted message.
+ {
+ channelID: "subscription3",
+ version: "v7",
+ send: {
+ headers: {
+ crypto_key: "dh=AAAAAAAA",
+ encryption: "salt=AAAAAAAA",
+ },
+ data: "AAAAAAAA",
+ },
+ ackCode: 101,
+ receive: null,
+ },
+ ];
+
+ let sendAndReceive = testData => {
+ let messageReceived = testData.receive
+ ? promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ let notification = subject.QueryInterface(Ci.nsIPushMessage).data;
+ equal(
+ notification.text(),
+ testData.receive.data,
+ "Check data for notification " + testData.version
+ );
+ equal(
+ data,
+ testData.receive.scope,
+ "Check scope for notification " + testData.version
+ );
+ return true;
+ }
+ )
+ : Promise.resolve();
+
+ let ackReceived = new Promise(resolve => (ackDone = resolve)).then(
+ ackData => {
+ deepEqual(
+ {
+ messageType: "ack",
+ updates: [
+ {
+ channelID: testData.channelID,
+ version: testData.version,
+ code: testData.ackCode,
+ },
+ ],
+ },
+ ackData,
+ "Check updates for acknowledgment " + testData.version
+ );
+ }
+ );
+
+ let msg = JSON.parse(JSON.stringify(testData.send));
+ msg.messageType = "notification";
+ msg.channelID = testData.channelID;
+ msg.version = testData.version;
+ server.serverSendMsg(JSON.stringify(msg));
+
+ return Promise.all([messageReceived, ackReceived]);
+ };
+
+ await allTestData.reduce((p, testData) => {
+ return p.then(_ => sendAndReceive(testData));
+ }, Promise.resolve());
+});
diff --git a/dom/push/test/xpcshell/test_notification_duplicate.js b/dom/push/test/xpcshell/test_notification_duplicate.js
new file mode 100644
index 0000000000..9812b63149
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_duplicate.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1500e7d9-8cbe-4ee6-98da-7fa5d6a39852";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ maxRecentMessageIDsPerSubscription: 4,
+ userAgentID,
+ });
+ run_next_test();
+}
+
+// Should acknowledge duplicate notifications, but not notify apps.
+add_task(async function test_notification_duplicate() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "has-recents",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/1",
+ originAttributes: "",
+ recentMessageIDs: ["dupe"],
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "no-recents",
+ pushEndpoint: "https://example.org/update/2",
+ scope: "https://example.com/2",
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "dropped-recents",
+ pushEndpoint: "https://example.org/update/3",
+ scope: "https://example.com/3",
+ originAttributes: "",
+ recentMessageIDs: ["newest", "newer", "older", "oldest"],
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let testData = [
+ {
+ channelID: "has-recents",
+ updates: 1,
+ acks: [
+ {
+ version: "dupe",
+ code: 102,
+ },
+ {
+ version: "not-dupe",
+ code: 100,
+ },
+ ],
+ recents: ["not-dupe", "dupe"],
+ },
+ {
+ channelID: "no-recents",
+ updates: 1,
+ acks: [
+ {
+ version: "not-dupe",
+ code: 100,
+ },
+ ],
+ recents: ["not-dupe"],
+ },
+ {
+ channelID: "dropped-recents",
+ acks: [
+ {
+ version: "overflow",
+ code: 100,
+ },
+ {
+ version: "oldest",
+ code: 100,
+ },
+ ],
+ updates: 2,
+ recents: ["oldest", "overflow", "newest", "newer"],
+ },
+ ];
+
+ let expectedUpdates = testData.reduce((sum, { updates }) => sum + updates, 0);
+ let notifiedScopes = [];
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == expectedUpdates;
+ }
+ );
+
+ let expectedAcks = testData.reduce((sum, { acks }) => sum + acks.length, 0);
+ let ackDone;
+ let ackPromise = new Promise(
+ resolve => (ackDone = after(expectedAcks, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ for (let { channelID, acks } of testData) {
+ for (let { version } of acks) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ channelID,
+ version,
+ })
+ );
+ }
+ }
+ },
+ onACK(request) {
+ let [ack] = request.updates;
+ let expectedData = testData.find(
+ test => test.channelID == ack.channelID
+ );
+ ok(expectedData, `Unexpected channel ${ack.channelID}`);
+ let expectedAck = expectedData.acks.find(
+ a => a.version == ack.version
+ );
+ ok(
+ expectedAck,
+ `Unexpected ack for message ${ack.version} on ${ack.channelID}`
+ );
+ equal(
+ expectedAck.code,
+ ack.code,
+ `Wrong ack status for message ${ack.version} on ${ack.channelID}`
+ );
+ ackDone();
+ },
+ });
+ },
+ });
+
+ await notifyPromise;
+ await ackPromise;
+
+ for (let { channelID, recents } of testData) {
+ let record = await db.getByKeyID(channelID);
+ deepEqual(
+ record.recentMessageIDs,
+ recents,
+ `Wrong recent message IDs for ${channelID}`
+ );
+ }
+});
diff --git a/dom/push/test/xpcshell/test_notification_error.js b/dom/push/test/xpcshell/test_notification_error.js
new file mode 100644
index 0000000000..21ab7ab94f
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_error.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "3c7462fc-270f-45be-a459-b9d631b0d093";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let originAttributes = "";
+ let records = [
+ {
+ channelID: "f04f1e46-9139-4826-b2d1-9411b0821283",
+ pushEndpoint: "https://example.org/update/success-1",
+ scope: "https://example.com/a",
+ originAttributes,
+ version: 1,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "3c3930ba-44de-40dc-a7ca-8a133ec1a866",
+ pushEndpoint: "https://example.org/update/error",
+ scope: "https://example.com/b",
+ originAttributes,
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ {
+ channelID: "b63f7bef-0a0d-4236-b41e-086a69dfd316",
+ pushEndpoint: "https://example.org/update/success-2",
+ scope: "https://example.com/c",
+ originAttributes,
+ version: 3,
+ quota: Infinity,
+ systemRecord: true,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let scopes = [];
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => scopes.push(data) == 2
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(
+ resolve => (ackDone = after(records.length, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ getByKeyID(prev, channelID) {
+ if (channelID == "3c3930ba-44de-40dc-a7ca-8a133ec1a866") {
+ return Promise.reject("splines not reticulated");
+ }
+ return prev.call(this, channelID);
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: records.map(({ channelID, version }) => ({
+ channelID,
+ version: ++version,
+ })),
+ })
+ );
+ },
+ // Should acknowledge all received updates, even if updating
+ // IndexedDB fails.
+ onACK: ackDone,
+ });
+ },
+ });
+
+ await notifyPromise;
+ ok(
+ scopes.includes("https://example.com/a"),
+ "Missing scope for notification A"
+ );
+ ok(
+ scopes.includes("https://example.com/c"),
+ "Missing scope for notification C"
+ );
+
+ await ackPromise;
+
+ let aRecord = await db.getByIdentifiers({
+ scope: "https://example.com/a",
+ originAttributes,
+ });
+ equal(
+ aRecord.channelID,
+ "f04f1e46-9139-4826-b2d1-9411b0821283",
+ "Wrong channel ID for record A"
+ );
+ strictEqual(aRecord.version, 2, "Should return the new version for record A");
+
+ let bRecord = await db.getByIdentifiers({
+ scope: "https://example.com/b",
+ originAttributes,
+ });
+ equal(
+ bRecord.channelID,
+ "3c3930ba-44de-40dc-a7ca-8a133ec1a866",
+ "Wrong channel ID for record B"
+ );
+ strictEqual(
+ bRecord.version,
+ 2,
+ "Should return the previous version for record B"
+ );
+
+ let cRecord = await db.getByIdentifiers({
+ scope: "https://example.com/c",
+ originAttributes,
+ });
+ equal(
+ cRecord.channelID,
+ "b63f7bef-0a0d-4236-b41e-086a69dfd316",
+ "Wrong channel ID for record C"
+ );
+ strictEqual(cRecord.version, 4, "Should return the new version for record C");
+});
diff --git a/dom/push/test/xpcshell/test_notification_incomplete.js b/dom/push/test/xpcshell/test_notification_incomplete.js
new file mode 100644
index 0000000000..48aba51132
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_incomplete.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1ca1cf66-eeb4-4df7-87c1-d5c92906ab90";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_incomplete() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "123",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/page/1",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "3ad1ed95-d37a-4d88-950f-22cbe2e240d7",
+ pushEndpoint: "https://example.org/update/2",
+ scope: "https://example.com/page/2",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "d239498b-1c85-4486-b99b-205866e82d1f",
+ pushEndpoint: "https://example.org/update/3",
+ scope: "https://example.com/page/3",
+ version: 3,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "a50de97d-b496-43ce-8b53-05522feb78db",
+ pushEndpoint: "https://example.org/update/4",
+ scope: "https://example.com/page/4",
+ version: 10,
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ function observeMessage(subject, topic, data) {
+ ok(false, "Should not deliver malformed updates");
+ }
+ registerCleanupFunction(() =>
+ Services.obs.removeObserver(observeMessage, PushServiceComponent.pushTopic)
+ );
+ Services.obs.addObserver(observeMessage, PushServiceComponent.pushTopic);
+
+ let notificationDone;
+ let notificationPromise = new Promise(
+ resolve => (notificationDone = after(2, resolve))
+ );
+ let prevHandler = PushServiceWebSocket._handleNotificationReply;
+ PushServiceWebSocket._handleNotificationReply =
+ function _handleNotificationReply() {
+ notificationDone();
+ return prevHandler.apply(this, arguments);
+ };
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ // Missing "updates" field; should ignore message.
+ messageType: "notification",
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ // Wrong channel ID field type.
+ channelID: 123,
+ version: 3,
+ },
+ {
+ // Missing version field.
+ channelID: "3ad1ed95-d37a-4d88-950f-22cbe2e240d7",
+ },
+ {
+ // Wrong version field type.
+ channelID: "d239498b-1c85-4486-b99b-205866e82d1f",
+ version: true,
+ },
+ {
+ // Negative versions should be ignored.
+ channelID: "a50de97d-b496-43ce-8b53-05522feb78db",
+ version: -5,
+ },
+ ],
+ })
+ );
+ },
+ onACK() {
+ ok(false, "Should not acknowledge malformed updates");
+ },
+ });
+ },
+ });
+
+ await notificationPromise;
+
+ let storeRecords = await db.getAllKeyIDs();
+ storeRecords.sort(({ pushEndpoint: a }, { pushEndpoint: b }) =>
+ compareAscending(a, b)
+ );
+ recordsAreEqual(records, storeRecords);
+});
+
+function recordIsEqual(a, b) {
+ strictEqual(a.channelID, b.channelID, "Wrong channel ID in record");
+ strictEqual(a.pushEndpoint, b.pushEndpoint, "Wrong push endpoint in record");
+ strictEqual(a.scope, b.scope, "Wrong scope in record");
+ strictEqual(a.version, b.version, "Wrong version in record");
+}
+
+function recordsAreEqual(a, b) {
+ equal(a.length, b.length, "Mismatched record count");
+ for (let i = 0; i < a.length; i++) {
+ recordIsEqual(a[i], b[i]);
+ }
+}
diff --git a/dom/push/test/xpcshell/test_notification_version_string.js b/dom/push/test/xpcshell/test_notification_version_string.js
new file mode 100644
index 0000000000..7aaaee5269
--- /dev/null
+++ b/dom/push/test/xpcshell/test_notification_version_string.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "ba31ac13-88d4-4984-8e6b-8731315a7cf8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_notification_version_string() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID: "6ff97d56-d0c0-43bc-8f5b-61b855e1d93b",
+ pushEndpoint: "https://example.org/updates/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ });
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "6ff97d56-d0c0-43bc-8f5b-61b855e1d93b",
+ version: "4",
+ },
+ ],
+ })
+ );
+ },
+ onACK: ackDone,
+ });
+ },
+ });
+
+ let { subject: message } = await notifyPromise;
+ equal(
+ message.QueryInterface(Ci.nsIPushMessage).data,
+ null,
+ "Unexpected data for Simple Push message"
+ );
+
+ await ackPromise;
+
+ let storeRecord = await db.getByKeyID("6ff97d56-d0c0-43bc-8f5b-61b855e1d93b");
+ strictEqual(storeRecord.version, 4, "Wrong record version");
+ equal(storeRecord.quota, Infinity, "Wrong quota");
+});
diff --git a/dom/push/test/xpcshell/test_observer_data.js b/dom/push/test/xpcshell/test_observer_data.js
new file mode 100644
index 0000000000..01c331237c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_data.js
@@ -0,0 +1,61 @@
+"use strict";
+
+var pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+);
+var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+add_task(async function test_notifyWithData() {
+ let textData = '{"hello":"world"}';
+ let payload = new TextEncoder().encode(textData);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ pushNotifier.notifyPushWithData(
+ "chrome://notify-test",
+ systemPrincipal,
+ "" /* messageId */,
+ payload
+ );
+
+ let data = (await notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage
+ ).data;
+ deepEqual(
+ data.json(),
+ {
+ hello: "world",
+ },
+ "Should extract JSON values"
+ );
+ deepEqual(
+ data.binary(),
+ Array.from(payload),
+ "Should extract raw binary data"
+ );
+ equal(data.text(), textData, "Should extract text data");
+});
+
+add_task(async function test_empty_notifyWithData() {
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ pushNotifier.notifyPushWithData(
+ "chrome://notify-test",
+ systemPrincipal,
+ "" /* messageId */,
+ []
+ );
+
+ let data = (await notifyPromise).subject.QueryInterface(
+ Ci.nsIPushMessage
+ ).data;
+ throws(
+ _ => data.json(),
+ /InvalidStateError/,
+ "Should throw an error when parsing an empty string as JSON"
+ );
+ strictEqual(data.text(), "", "Should return an empty string");
+ deepEqual(data.binary(), [], "Should return an empty array");
+});
diff --git a/dom/push/test/xpcshell/test_observer_remoting.js b/dom/push/test/xpcshell/test_observer_remoting.js
new file mode 100644
index 0000000000..086fdeab80
--- /dev/null
+++ b/dom/push/test/xpcshell/test_observer_remoting.js
@@ -0,0 +1,139 @@
+"use strict";
+
+const pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(
+ Ci.nsIPushNotifier
+);
+
+add_task(async function test_observer_remoting() {
+ do_get_profile();
+ if (isParent) {
+ await testInParent();
+ } else {
+ await testInChild();
+ }
+});
+
+const childTests = [
+ {
+ text: "Hello from child!",
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+ },
+];
+
+const parentTests = [
+ {
+ text: "Hello from parent!",
+ principal: Services.scriptSecurityManager.getSystemPrincipal(),
+ },
+];
+
+async function testInParent() {
+ setPrefs();
+ // Register observers for notifications from the child, then run the test in
+ // the child and wait for the notifications.
+ let promiseNotifications = childTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
+ Promise.resolve()
+ );
+ let promiseFinished = run_test_in_child("./test_observer_remoting.js");
+ await promiseNotifications;
+
+ // Wait until the child is listening for notifications from the parent.
+ await do_await_remote_message("push_test_observer_remoting_child_ready");
+
+ // Fire an observer notification in the parent that should be forwarded to
+ // the child.
+ await parentTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Wait for the child to exit.
+ await promiseFinished;
+}
+
+async function testInChild() {
+ // Fire an observer notification in the child that should be forwarded to
+ // the parent.
+ await childTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
+ Promise.resolve()
+ );
+
+ // Register observers for notifications from the parent, let the parent know
+ // we're ready, and wait for the notifications.
+ let promiseNotifierObservers = parentTests.reduce(
+ (p, test) =>
+ p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
+ Promise.resolve()
+ );
+ do_send_remote_message("push_test_observer_remoting_child_ready");
+ await promiseNotifierObservers;
+}
+
+var waitForNotifierObservers = async function (
+ { text, principal },
+ shouldNotify = false
+) {
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic
+ );
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ let scope = "chrome://test-scope";
+ let data = new TextEncoder().encode(text);
+
+ if (shouldNotify) {
+ pushNotifier.notifyPushWithData(scope, principal, "", data);
+ pushNotifier.notifySubscriptionChange(scope, principal);
+ pushNotifier.notifySubscriptionModified(scope, principal);
+ }
+
+ let { data: notifyScope, subject: notifySubject } = await notifyPromise;
+ equal(
+ notifyScope,
+ scope,
+ "Should fire push notifications with the correct scope"
+ );
+ let message = notifySubject.QueryInterface(Ci.nsIPushMessage);
+ equal(
+ message.principal,
+ principal,
+ "Should include the principal in the push message"
+ );
+ strictEqual(message.data.text(), text, "Should include data");
+
+ let { data: subChangeScope, subject: subChangePrincipal } =
+ await subChangePromise;
+ equal(
+ subChangeScope,
+ scope,
+ "Should fire subscription change notifications with the correct scope"
+ );
+ equal(
+ subChangePrincipal,
+ principal,
+ "Should pass the principal as the subject of a change notification"
+ );
+
+ let { data: subModifiedScope, subject: subModifiedPrincipal } =
+ await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ scope,
+ "Should fire subscription modified notifications with the correct scope"
+ );
+ equal(
+ subModifiedPrincipal,
+ principal,
+ "Should pass the principal as the subject of a modified notification"
+ );
+};
diff --git a/dom/push/test/xpcshell/test_permissions.js b/dom/push/test/xpcshell/test_permissions.js
new file mode 100644
index 0000000000..1b3e3282bb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_permissions.js
@@ -0,0 +1,332 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "2c43af06-ab6e-476a-adc4-16cbda54fb89";
+
+let db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ run_next_test();
+}
+
+let unregisterDefers = {};
+
+function promiseUnregister(keyID) {
+ return new Promise(r => (unregisterDefers[keyID] = r));
+}
+
+function makePushPermission(url, capability) {
+ return {
+ QueryInterface: ChromeUtils.generateQI(["nsIPermission"]),
+ capability: Ci.nsIPermissionManager[capability],
+ expireTime: 0,
+ expireType: Ci.nsIPermissionManager.EXPIRE_NEVER,
+ principal: Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(url),
+ {}
+ ),
+ type: "desktop-notification",
+ };
+}
+
+function promiseObserverNotifications(topic, count) {
+ let notifiedScopes = [];
+ let subChangePromise = promiseObserverNotification(topic, (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == count;
+ });
+ return subChangePromise.then(_ => notifiedScopes.sort());
+}
+
+function promiseSubscriptionChanges(count) {
+ return promiseObserverNotifications(
+ PushServiceComponent.subscriptionChangeTopic,
+ count
+ );
+}
+
+function promiseSubscriptionModifications(count) {
+ return promiseObserverNotifications(
+ PushServiceComponent.subscriptionModifiedTopic,
+ count
+ );
+}
+
+function allExpired(...keyIDs) {
+ return Promise.all(keyIDs.map(keyID => db.getByKeyID(keyID))).then(records =>
+ records.every(record => record.isExpired())
+ );
+}
+
+add_task(async function setUp() {
+ // Active registration; quota should be reset to 16. Since the quota isn't
+ // exposed to content, we shouldn't receive a subscription change event.
+ await putTestRecord(db, "active-allow", "https://example.info/page/1", 8);
+
+ // Expired registration; should be dropped.
+ await putTestRecord(db, "expired-allow", "https://example.info/page/2", 0);
+
+ // Active registration; should be expired when we change the permission
+ // to "deny".
+ await putTestRecord(
+ db,
+ "active-deny-changed",
+ "https://example.xyz/page/1",
+ 16
+ );
+
+ // Two active registrations for a visited site. These will expire when we
+ // add a "deny" permission.
+ await putTestRecord(db, "active-deny-added-1", "https://example.net/ham", 16);
+ await putTestRecord(
+ db,
+ "active-deny-added-2",
+ "https://example.net/green",
+ 8
+ );
+
+ // An already-expired registration for a visited site. We shouldn't send an
+ // `unregister` request for this one, but still receive an observer
+ // notification when we restore permissions.
+ await putTestRecord(db, "expired-deny-added", "https://example.net/eggs", 0);
+
+ // A registration that should not be affected by permission list changes
+ // because its quota is set to `Infinity`.
+ await putTestRecord(db, "never-expires", "app://chrome/only", Infinity);
+
+ // A registration that should be dropped when we clear the permission
+ // list.
+ await putTestRecord(db, "drop-on-clear", "https://example.edu/lonely", 16);
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+ onUnregister(request) {
+ let resolve = unregisterDefers[request.channelID];
+ equal(
+ typeof resolve,
+ "function",
+ "Dropped unexpected channel ID " + request.channelID
+ );
+ delete unregisterDefers[request.channelID];
+ equal(
+ request.code,
+ 202,
+ "Expected permission revoked unregister reason"
+ );
+ resolve();
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID: request.channelID,
+ })
+ );
+ },
+ onACK(request) {},
+ });
+ },
+ });
+ await handshakePromise;
+});
+
+add_task(async function test_permissions_allow_added() {
+ let subChangePromise = promiseSubscriptionChanges(1);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.info", "ALLOW_ACTION"),
+ "added"
+ );
+ let notifiedScopes = await subChangePromise;
+
+ deepEqual(
+ notifiedScopes,
+ ["https://example.info/page/2"],
+ "Wrong scopes after adding allow"
+ );
+
+ let record = await db.getByKeyID("active-allow");
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota for active records after adding allow"
+ );
+
+ record = await db.getByKeyID("expired-allow");
+ ok(!record, "Should drop expired records after adding allow");
+});
+
+add_task(async function test_permissions_allow_deleted() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister("active-allow");
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.info", "ALLOW_ACTION"),
+ "deleted"
+ );
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.info/page/1"],
+ "Wrong scopes modified after deleting allow"
+ );
+
+ let record = await db.getByKeyID("active-allow");
+ ok(record.isExpired(), "Should expire active record after deleting allow");
+});
+
+add_task(async function test_permissions_deny_added() {
+ let subModifiedPromise = promiseSubscriptionModifications(2);
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister("active-deny-added-1"),
+ promiseUnregister("active-deny-added-2"),
+ ]);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "DENY_ACTION"),
+ "added"
+ );
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.net/green", "https://example.net/ham"],
+ "Wrong scopes modified after adding deny"
+ );
+
+ let isExpired = await allExpired("active-deny-added-1", "expired-deny-added");
+ ok(isExpired, "Should expire all registrations after adding deny");
+});
+
+add_task(async function test_permissions_deny_deleted() {
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "DENY_ACTION"),
+ "deleted"
+ );
+
+ let isExpired = await allExpired("active-deny-added-1", "expired-deny-added");
+ ok(isExpired, "Should retain expired registrations after deleting deny");
+});
+
+add_task(async function test_permissions_allow_changed() {
+ let subChangePromise = promiseSubscriptionChanges(3);
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.net", "ALLOW_ACTION"),
+ "changed"
+ );
+
+ let notifiedScopes = await subChangePromise;
+
+ deepEqual(
+ notifiedScopes,
+ [
+ "https://example.net/eggs",
+ "https://example.net/green",
+ "https://example.net/ham",
+ ],
+ "Wrong scopes after changing to allow"
+ );
+
+ let droppedRecords = await Promise.all([
+ db.getByKeyID("active-deny-added-1"),
+ db.getByKeyID("active-deny-added-2"),
+ db.getByKeyID("expired-deny-added"),
+ ]);
+ ok(
+ !droppedRecords.some(Boolean),
+ "Should drop all expired registrations after changing to allow"
+ );
+});
+
+add_task(async function test_permissions_deny_changed() {
+ let subModifiedPromise = promiseSubscriptionModifications(1);
+
+ let unregisterPromise = promiseUnregister("active-deny-changed");
+
+ await PushService._onPermissionChange(
+ makePushPermission("https://example.xyz", "DENY_ACTION"),
+ "changed"
+ );
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ ["https://example.xyz/page/1"],
+ "Wrong scopes modified after changing to deny"
+ );
+
+ let record = await db.getByKeyID("active-deny-changed");
+ ok(record.isExpired(), "Should expire active record after changing to deny");
+});
+
+add_task(async function test_permissions_clear() {
+ let subModifiedPromise = promiseSubscriptionModifications(3);
+
+ deepEqual(
+ await getAllKeyIDs(db),
+ ["active-allow", "active-deny-changed", "drop-on-clear", "never-expires"],
+ "Wrong records in database before clearing"
+ );
+
+ let unregisterPromise = Promise.all([
+ promiseUnregister("active-allow"),
+ promiseUnregister("active-deny-changed"),
+ promiseUnregister("drop-on-clear"),
+ ]);
+
+ await PushService._onPermissionChange(null, "cleared");
+
+ await unregisterPromise;
+
+ let notifiedScopes = await subModifiedPromise;
+ deepEqual(
+ notifiedScopes,
+ [
+ "https://example.edu/lonely",
+ "https://example.info/page/1",
+ "https://example.xyz/page/1",
+ ],
+ "Wrong scopes modified after clearing registrations"
+ );
+
+ deepEqual(
+ await getAllKeyIDs(db),
+ ["never-expires"],
+ "Unrestricted registrations should not be dropped"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_quota_exceeded.js b/dom/push/test/xpcshell/test_quota_exceeded.js
new file mode 100644
index 0000000000..f8365aa888
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_exceeded.js
@@ -0,0 +1,156 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "7eb873f9-8d47-4218-804b-fff78dc04e88";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ "testing.ignorePermission": true,
+ });
+ run_next_test();
+}
+
+add_task(async function test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(_ => db.close()));
+
+ await db.put({
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/auctions",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+ await db.put({
+ channelID: "46cc6f6a-c106-4ffa-bb7c-55c60bd50c41",
+ pushEndpoint: "https://example.org/push/2",
+ scope: "https://example.com/deals",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // The notification threshold is per-origin, even with multiple service
+ // workers for different scopes.
+ await PlacesTestUtils.addVisits([
+ {
+ uri: "https://example.com/login",
+ title: "Sign in to see your auctions",
+ visitDate: (Date.now() - 7 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ },
+ // We'll always use your most recent visit to an origin.
+ {
+ uri: "https://example.com/auctions",
+ title: "Your auctions",
+ visitDate: (Date.now() - 2 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ },
+ // ...But we won't count downloads or embeds.
+ {
+ uri: "https://example.com/invoices/invoice.pdf",
+ title: "Invoice #123",
+ visitDate: (Date.now() - 1 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_EMBED,
+ },
+ {
+ uri: "https://example.com/invoices/invoice.pdf",
+ title: "Invoice #123",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD,
+ },
+ ]);
+
+ // We expect to receive 6 notifications: 5 on the `auctions` channel,
+ // and 1 on the `deals` channel. They're from the same origin, but
+ // different scopes, so each can send 5 notifications before we remove
+ // their subscription.
+ let updates = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ updates++;
+ return updates == 6;
+ }
+ );
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ // We last visited the site 2 days ago, so we can send 5
+ // notifications without throttling. Sending a 6th should
+ // drop the registration.
+ for (let version = 1; version <= 6; version++) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ version,
+ },
+ ],
+ })
+ );
+ }
+ // But the limits are per-channel, so we can send 5 more
+ // notifications on a different channel.
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "46cc6f6a-c106-4ffa-bb7c-55c60bd50c41",
+ version: 1,
+ },
+ ],
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(
+ request.channelID,
+ "eb33fc90-c883-4267-b5cb-613969e8e349",
+ "Unregistered wrong channel ID"
+ );
+ equal(request.code, 201, "Expected quota exceeded unregister reason");
+ unregisterDone();
+ },
+ // We expect to receive acks, but don't care about their
+ // contents.
+ onACK(request) {},
+ });
+ },
+ });
+
+ await unregisterPromise;
+
+ await notifyPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "eb33fc90-c883-4267-b5cb-613969e8e349"
+ );
+ strictEqual(expiredRecord.quota, 0, "Expired record not updated");
+});
diff --git a/dom/push/test/xpcshell/test_quota_observer.js b/dom/push/test/xpcshell/test_quota_observer.js
new file mode 100644
index 0000000000..447f509967
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_observer.js
@@ -0,0 +1,210 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "28cd09e2-7506-42d8-9e50-b02785adc7ef";
+
+var db;
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+let putRecord = async function (perm, record) {
+ let uri = Services.io.newURI(record.scope);
+
+ PermissionTestUtils.add(
+ uri,
+ "desktop-notification",
+ Ci.nsIPermissionManager[perm]
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ });
+
+ await db.put(record);
+};
+
+add_task(async function test_expiration_history_observer() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => db.drop().then(_ => db.close()));
+
+ // A registration that we'll expire...
+ await putRecord("ALLOW_ACTION", {
+ channelID: "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/deals",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // ...And a registration that we'll evict on startup.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "4cb6e454-37cf-41c4-a013-4e3a7fdd0bf1",
+ pushEndpoint: "https://example.org/push/3",
+ scope: "https://example.com/stuff",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/infrequent",
+ title: "Infrequently-visited page",
+ visitDate: (Date.now() - 14 * 24 * 60 * 60 * 1000) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ let subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => data == "https://example.com/stuff"
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ version: 2,
+ },
+ ],
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(
+ request.channelID,
+ "379c0668-8323-44d2-a315-4ee83f1a9ee9",
+ "Dropped wrong channel ID"
+ );
+ equal(request.code, 201, "Expected quota exceeded unregister reason");
+ unregisterDone();
+ },
+ onACK(request) {},
+ });
+ },
+ });
+
+ await subChangePromise;
+ await unregisterPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "379c0668-8323-44d2-a315-4ee83f1a9ee9"
+ );
+ strictEqual(expiredRecord.quota, 0, "Expired record not updated");
+
+ let notifiedScopes = [];
+ subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ notifiedScopes.push(data);
+ return notifiedScopes.length == 2;
+ }
+ );
+
+ // Add an expired registration that we'll revive later using the idle
+ // observer.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "eb33fc90-c883-4267-b5cb-613969e8e349",
+ pushEndpoint: "https://example.org/push/2",
+ scope: "https://example.com/auctions",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+ // ...And an expired registration that we'll revive on fetch.
+ await putRecord("ALLOW_ACTION", {
+ channelID: "6b2d13fe-d848-4c5f-bdda-e9fc89727dca",
+ pushEndpoint: "https://example.org/push/4",
+ scope: "https://example.net/sales",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 0,
+ });
+
+ // Now visit the site...
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/another-page",
+ title: "Infrequently-visited page",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ Services.obs.notifyObservers(null, "idle-daily");
+
+ // And we should receive notifications for both scopes.
+ await subChangePromise;
+ deepEqual(
+ notifiedScopes.sort(),
+ ["https://example.com/auctions", "https://example.com/deals"],
+ "Wrong scopes for subscription changes"
+ );
+
+ let aRecord = await db.getByKeyID("379c0668-8323-44d2-a315-4ee83f1a9ee9");
+ ok(!aRecord, "Should drop expired record");
+
+ let bRecord = await db.getByKeyID("eb33fc90-c883-4267-b5cb-613969e8e349");
+ ok(!bRecord, "Should drop evicted record");
+
+ // Simulate a visit to a site with an expired registration, then fetch the
+ // record. This should drop the expired record and fire an observer
+ // notification.
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.net/sales",
+ title: "Firefox plushies, 99% off",
+ visitDate: Date.now() * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ subChangePromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ (subject, data) => {
+ if (data == "https://example.net/sales") {
+ ok(
+ subject.isContentPrincipal,
+ "Should pass subscription principal as the subject"
+ );
+ return true;
+ }
+ return false;
+ }
+ );
+ let record = await PushService.registration({
+ scope: "https://example.net/sales",
+ originAttributes: "",
+ });
+ ok(!record, "Should not return evicted record");
+ ok(
+ !(await db.getByKeyID("6b2d13fe-d848-4c5f-bdda-e9fc89727dca")),
+ "Should drop evicted record on fetch"
+ );
+ await subChangePromise;
+});
diff --git a/dom/push/test/xpcshell/test_quota_with_notification.js b/dom/push/test/xpcshell/test_quota_with_notification.js
new file mode 100644
index 0000000000..d2e6d7cae8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_quota_with_notification.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "aaabf1f8-2f68-44f1-a920-b88e9e7d7559";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ "testing.ignorePermission": true,
+ });
+ run_next_test();
+}
+
+add_task(async function test_expiration_origin_threshold() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ PushService.notificationForOriginClosed("https://example.com");
+ return db.drop().then(_ => db.close());
+ });
+
+ // Simulate a notification being shown for the origin,
+ // this should relax the quota and allow as many push messages
+ // as we want.
+ PushService.notificationForOriginShown("https://example.com");
+
+ await db.put({
+ channelID: "f56645a9-1f32-4655-92ad-ddc37f6d54fb",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.com/quota",
+ pushCount: 0,
+ lastPush: 0,
+ version: null,
+ originAttributes: "",
+ quota: 16,
+ });
+
+ // A visit one day ago should provide a quota of 8 messages.
+ await PlacesTestUtils.addVisits({
+ uri: "https://example.com/login",
+ title: "Sign in to see your auctions",
+ visitDate: (Date.now() - MS_IN_ONE_DAY) * 1000,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+
+ let numMessages = 10;
+
+ let updates = 0;
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic,
+ (subject, data) => {
+ updates++;
+ return updates == numMessages;
+ }
+ );
+
+ let modifications = 0;
+ let modifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic,
+ (subject, data) => {
+ // Each subscription should be modified twice: once to update the message
+ // count and last push time, and the second time to update the quota.
+ modifications++;
+ return modifications == numMessages * 2;
+ }
+ );
+
+ let updateQuotaPromise = new Promise((resolve, reject) => {
+ let quotaUpdateCount = 0;
+ PushService._updateQuotaTestCallback = function () {
+ quotaUpdateCount++;
+ if (quotaUpdateCount == numMessages) {
+ resolve();
+ }
+ };
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+
+ // If the origin has visible notifications, the
+ // message should not affect quota.
+ for (let version = 1; version <= 10; version++) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: "f56645a9-1f32-4655-92ad-ddc37f6d54fb",
+ version,
+ },
+ ],
+ })
+ );
+ }
+ },
+ onUnregister(request) {
+ ok(false, "Channel should not be unregistered.");
+ },
+ // We expect to receive acks, but don't care about their
+ // contents.
+ onACK(request) {},
+ });
+ },
+ });
+
+ await notifyPromise;
+
+ await updateQuotaPromise;
+ await modifiedPromise;
+
+ let expiredRecord = await db.getByKeyID(
+ "f56645a9-1f32-4655-92ad-ddc37f6d54fb"
+ );
+ notStrictEqual(expiredRecord.quota, 0, "Expired record not updated");
+});
diff --git a/dom/push/test/xpcshell/test_reconnect_retry.js b/dom/push/test/xpcshell/test_reconnect_retry.js
new file mode 100644
index 0000000000..7ff3740ee8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_reconnect_retry.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 10000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_reconnect_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let registers = 0;
+ let channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "083e6c17-1063-4677-8638-ab705aebebc2",
+ })
+ );
+ },
+ onRegister(request) {
+ registers++;
+ if (registers == 1) {
+ channelID = request.channelID;
+ this.serverClose();
+ return;
+ }
+ if (registers == 2) {
+ equal(
+ request.channelID,
+ channelID,
+ "Should retry registers after reconnect"
+ );
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ channelID: request.channelID,
+ pushEndpoint: "https://example.org/push/" + request.channelID,
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let registration = await PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let retryEndpoint = "https://example.org/push/" + channelID;
+ equal(
+ registration.endpoint,
+ retryEndpoint,
+ "Wrong endpoint for retried request"
+ );
+
+ registration = await PushService.register({
+ scope: "https://example.com/page/2",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ notEqual(
+ registration.endpoint,
+ retryEndpoint,
+ "Wrong endpoint for new request"
+ );
+
+ equal(registers, 3, "Wrong registration count");
+});
diff --git a/dom/push/test/xpcshell/test_record.js b/dom/push/test/xpcshell/test_record.js
new file mode 100644
index 0000000000..144c0ee346
--- /dev/null
+++ b/dom/push/test/xpcshell/test_record.js
@@ -0,0 +1,132 @@
+"use strict";
+
+const { PushRecord } = ChromeUtils.importESModule(
+ "resource://gre/modules/PushRecord.sys.mjs"
+);
+
+add_task(async function test_updateQuota() {
+ let record = new PushRecord({
+ quota: 8,
+ lastPush: Date.now() - 1 * MS_IN_ONE_DAY,
+ });
+
+ record.updateQuota(Date.now() - 2 * MS_IN_ONE_DAY);
+ equal(
+ record.quota,
+ 8,
+ "Should not update quota if last visit is older than last push"
+ );
+
+ record.updateQuota(Date.now());
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota if last visit is newer than last push"
+ );
+
+ record.reduceQuota();
+ equal(record.quota, 15, "Should reduce quota");
+
+ // Make sure we calculate the quota correctly for visit dates in the
+ // future (bug 1206424).
+ record.updateQuota(Date.now() + 1 * MS_IN_ONE_DAY);
+ equal(
+ record.quota,
+ 16,
+ "Should reset quota to maximum if last visit is in the future"
+ );
+
+ record.updateQuota(-1);
+ strictEqual(record.quota, 0, "Should set quota to 0 if history was cleared");
+ ok(record.isExpired(), "Should expire records once the quota reaches 0");
+ record.reduceQuota();
+ strictEqual(record.quota, 0, "Quota should never be negative");
+});
+
+add_task(async function test_systemRecord_updateQuota() {
+ let systemRecord = new PushRecord({
+ quota: Infinity,
+ systemRecord: true,
+ });
+ systemRecord.updateQuota(Date.now() - 3 * MS_IN_ONE_DAY);
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore quota updates"
+ );
+ systemRecord.updateQuota(-1);
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore the last visit time"
+ );
+ systemRecord.reduceQuota();
+ equal(
+ systemRecord.quota,
+ Infinity,
+ "System subscriptions should ignore quota reductions"
+ );
+});
+
+function testPermissionCheck(props) {
+ let record = new PushRecord(props);
+ let originSuffix;
+ equal(
+ record.uri.spec,
+ props.scope,
+ `Record URI should match scope URL for ${JSON.stringify(props)}`
+ );
+ if (props.originAttributes) {
+ originSuffix = ChromeUtils.originAttributesToSuffix(
+ record.principal.originAttributes
+ );
+ equal(
+ originSuffix,
+ props.originAttributes,
+ `Origin suffixes should match for ${JSON.stringify(props)}`
+ );
+ }
+ ok(
+ !record.hasPermission(),
+ `Record ${JSON.stringify(props)} should not have permission yet`
+ );
+ // Adding permission from origin string
+ PermissionTestUtils.add(
+ props.scope + (originSuffix || ""),
+ "desktop-notification",
+ Ci.nsIPermissionManager.ALLOW_ACTION
+ );
+ try {
+ ok(
+ record.hasPermission(),
+ `Record ${JSON.stringify(props)} should have permission`
+ );
+ } finally {
+ PermissionTestUtils.remove(
+ props.scope + (originSuffix || ""),
+ "desktop-notification"
+ );
+ }
+}
+
+add_task(async function test_principal_permissions() {
+ let testProps = [
+ {
+ scope: "https://example.com/",
+ },
+ {
+ scope: "https://example.com/",
+ originAttributes: "^userContextId=1",
+ },
+ {
+ scope: "https://xn--90aexm.xn--80ag3aejvc.xn--p1ai/",
+ },
+ {
+ scope: "https://xn--90aexm.xn--80ag3aejvc.xn--p1ai/",
+ originAttributes: "^userContextId=1",
+ },
+ ];
+ for (let props of testProps) {
+ testPermissionCheck(props);
+ }
+});
diff --git a/dom/push/test/xpcshell/test_register_5xxCode_http2.js b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
new file mode 100644
index 0000000000..4d9e0891b3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_5xxCode_http2.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var retries = 0;
+
+function subscribe5xxCodeHandler(metadata, response) {
+ if (retries == 0) {
+ ok(true, "Subscribe 5xx code");
+ do_test_finished();
+ response.setHeader("Retry-After", "1");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+ } else {
+ ok(true, "Subscribed");
+ do_test_finished();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/subscription"
+ );
+ response.setHeader(
+ "Link",
+ '</pushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</receiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+ }
+ retries++;
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ Assert.equal(retries, 2, "Should try 2 times.");
+ do_test_finished();
+ response.setHeader("Retry-After", "10");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscribe5xxCode", subscribe5xxCodeHandler);
+httpServer.registerPathHandler("/subscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ do_test_pending();
+ do_test_pending();
+ do_test_pending();
+ do_test_pending();
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe5xxCode",
+ db,
+ });
+
+ let originAttributes = ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ });
+ let newRecord = await PushService.register({
+ scope: "https://example.com/retry5xxCode",
+ originAttributes,
+ });
+
+ var subscriptionUri = serverURL + "/subscription";
+ var pushEndpoint = serverURL + "/pushEndpoint";
+ var pushReceiptEndpoint = serverURL + "/receiptPushEndpoint";
+ equal(
+ newRecord.endpoint,
+ pushEndpoint,
+ "Wrong push endpoint in registration record"
+ );
+
+ equal(
+ newRecord.pushReceiptEndpoint,
+ pushReceiptEndpoint,
+ "Wrong push endpoint receipt in registration record"
+ );
+
+ let record = await db.getByKeyID(subscriptionUri);
+ equal(
+ record.subscriptionUri,
+ subscriptionUri,
+ "Wrong subscription ID in database record"
+ );
+ equal(
+ record.pushEndpoint,
+ pushEndpoint,
+ "Wrong push endpoint in database record"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ pushReceiptEndpoint,
+ "Wrong push endpoint receipt in database record"
+ );
+ equal(
+ record.scope,
+ "https://example.com/retry5xxCode",
+ "Wrong scope in database record"
+ );
+
+ httpServer.stop(do_test_finished);
+});
diff --git a/dom/push/test/xpcshell/test_register_case.js b/dom/push/test/xpcshell/test_register_case.js
new file mode 100644
index 0000000000..1ae6f127f3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_case.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "1760b1f5-c3ba-40e3-9344-adef7c18ab12";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_case() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "HELLO",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "ReGiStEr",
+ uaid: userAgentID,
+ channelID: request.channelID,
+ status: 200,
+ pushEndpoint: "https://example.com/update/case",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.net/case",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.com/update/case",
+ "Wrong push endpoint in registration record"
+ );
+
+ let record = await db.getByPushEndpoint("https://example.com/update/case");
+ equal(
+ record.scope,
+ "https://example.net/case",
+ "Wrong scope in database record"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_register_flush.js b/dom/push/test/xpcshell/test_register_flush.js
new file mode 100644
index 0000000000..2c12ecb9ba
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_flush.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "9ce1e6d3-7bdb-4fe9-90a5-def1d64716f1";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_flush() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let record = {
+ channelID: "9bcc7efb-86c7-4457-93ea-e24e6eb59b74",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ version: 2,
+ quota: Infinity,
+ systemRecord: true,
+ };
+ await db.put(record);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.pushTopic
+ );
+
+ let ackDone;
+ let ackPromise = new Promise(resolve => (ackDone = after(2, resolve)));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "notification",
+ updates: [
+ {
+ channelID: request.channelID,
+ version: 2,
+ },
+ {
+ channelID: "9bcc7efb-86c7-4457-93ea-e24e6eb59b74",
+ version: 3,
+ },
+ ],
+ })
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: request.channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.org/update/2",
+ })
+ );
+ },
+ onACK: ackDone,
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.com/page/2",
+ originAttributes: "",
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.org/update/2",
+ "Wrong push endpoint in record"
+ );
+
+ let { data: scope } = await notifyPromise;
+ equal(scope, "https://example.com/page/1", "Wrong notification scope");
+
+ await ackPromise;
+
+ let prevRecord = await db.getByKeyID("9bcc7efb-86c7-4457-93ea-e24e6eb59b74");
+ equal(
+ prevRecord.pushEndpoint,
+ "https://example.org/update/1",
+ "Wrong existing push endpoint"
+ );
+ strictEqual(
+ prevRecord.version,
+ 3,
+ "Should record version updates sent before register responses"
+ );
+
+ let registeredRecord = await db.getByPushEndpoint(
+ "https://example.org/update/2"
+ );
+ ok(!registeredRecord.version, "Should not record premature updates");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_channel.js b/dom/push/test/xpcshell/test_register_invalid_channel.js
new file mode 100644
index 0000000000..0f1b6cf1b1
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_channel.js
@@ -0,0 +1,63 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "52b2b04c-b6cc-42c6-abdf-bef9cbdbea00";
+const channelID = "cafed00d";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_channel() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ uaid: userAgentID,
+ status: 200,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 403,
+ channelID,
+ error: "Invalid channel ID",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/invalid-channel",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid channel ID"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records for error responses");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_endpoint.js b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
new file mode 100644
index 0000000000..64398b97f8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_endpoint.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "c9a12e81-ea5e-40f9-8bf4-acee34621671";
+const channelID = "c0660af8-b532-4931-81f0-9fd27a12d6ab";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_endpoint() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "!@#$%^&*",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/invalid-endpoint",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid endpoint"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records with invalid endpoints");
+});
diff --git a/dom/push/test/xpcshell/test_register_invalid_json.js b/dom/push/test/xpcshell/test_register_invalid_json.js
new file mode 100644
index 0000000000..d7a19e789c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_invalid_json.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "8271186b-8073-43a3-adf6-225bd44a8b0a";
+const channelID = "2d08571e-feab-48a0-9f05-8254c3c7e61f";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_invalid_json() {
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+ let registers = 0;
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(");alert(1);(");
+ registers++;
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/invalid-json",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for invalid JSON response"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_no_id.js b/dom/push/test/xpcshell/test_register_no_id.js
new file mode 100644
index 0000000000..763350b11e
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_no_id.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var userAgentID = "9a2f9efe-2ebb-4bcb-a5d9-9e2b73d30afe";
+var channelID = "264c2ba0-f6db-4e84-acdb-bd225b62d9e3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_no_id() {
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ registers++;
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/incomplete",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for incomplete register response"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_request_queue.js b/dom/push/test/xpcshell/test_register_request_queue.js
new file mode 100644
index 0000000000..6d7928c52a
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_request_queue.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_request_queue() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let onHello;
+ let helloPromise = new Promise(
+ resolve =>
+ (onHello = after(2, function onHelloReceived(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "54b08a9e-59c6-4ed7-bb54-f4fd60d6f606",
+ })
+ );
+ resolve();
+ }))
+ );
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello,
+ onRegister() {
+ ok(false, "Should cancel timed-out requests");
+ },
+ });
+ },
+ });
+
+ let firstRegister = PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let secondRegister = PushService.register({
+ scope: "https://example.com/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+
+ await Promise.all([
+ // eslint-disable-next-line mozilla/rejects-requires-await
+ Assert.rejects(
+ firstRegister,
+ /Registration error/,
+ "Should time out the first request"
+ ),
+ // eslint-disable-next-line mozilla/rejects-requires-await
+ Assert.rejects(
+ secondRegister,
+ /Registration error/,
+ "Should time out the second request"
+ ),
+ ]);
+
+ await helloPromise;
+});
diff --git a/dom/push/test/xpcshell/test_register_rollback.js b/dom/push/test/xpcshell/test_register_rollback.js
new file mode 100644
index 0000000000..9a0233aca8
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_rollback.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "b2546987-4f63-49b1-99f7-739cd3c40e44";
+const channelID = "35a820f7-d7dd-43b3-af21-d65352212ae3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_rollback() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let handshakes = 0;
+ let registers = 0;
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ put(prev, record) {
+ return Promise.reject("universe has imploded");
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ handshakes++;
+ if (registers > 0) {
+ equal(request.uaid, userAgentID, "Handshake: wrong device ID");
+ } else {
+ ok(
+ !request.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ }
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ equal(request.channelID, channelID, "Register: wrong channel ID");
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ uaid: userAgentID,
+ channelID,
+ pushEndpoint: "https://example.com/update/rollback",
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(request.channelID, channelID, "Unregister: wrong channel ID");
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ // Should return a rejected promise if storage fails.
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/storage-error",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /universe has imploded/,
+ "Expected error for unregister database failure"
+ );
+
+ // Should send an out-of-band unregister request.
+ await unregisterPromise;
+ equal(handshakes, 1, "Wrong handshake count");
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_success.js b/dom/push/test/xpcshell/test_register_success.js
new file mode 100644
index 0000000000..9b6692ca25
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_success.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "bd744428-f125-436a-b6d0-dd0c9845837f";
+const channelID = "0ef2ad4a-6c49-41ad-af6e-95d2425276bf";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(data) {
+ equal(data.messageType, "hello", "Handshake: wrong message type");
+ ok(
+ !data.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(data) {
+ equal(data.messageType, "register", "Register: wrong message type");
+ equal(data.channelID, channelID, "Register: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/1",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ let newRecord = await PushService.register({
+ scope: "https://example.org/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ newRecord.endpoint,
+ "https://example.com/update/1",
+ "Wrong push endpoint in registration record"
+ );
+
+ let { data: subModifiedScope } = await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ "https://example.org/1",
+ "Should fire a subscription modified event after subscribing"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ equal(record.channelID, channelID, "Wrong channel ID in database record");
+ equal(
+ record.pushEndpoint,
+ "https://example.com/update/1",
+ "Wrong push endpoint in database record"
+ );
+ equal(record.quota, 16, "Wrong quota in database record");
+});
diff --git a/dom/push/test/xpcshell/test_register_timeout.js b/dom/push/test/xpcshell/test_register_timeout.js
new file mode 100644
index 0000000000..27a5994e7c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_timeout.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "a4be0df9-b16d-4b5f-8f58-0f93b6f1e23d";
+const channelID = "e1944e0b-48df-45e7-bdc0-d1fbaa7986d3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_timeout() {
+ let handshakes = 0;
+ let timeoutDone;
+ let timeoutPromise = new Promise(resolve => (timeoutDone = resolve));
+ let registers = 0;
+
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushServiceWebSocket._generateID = () => channelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ if (registers > 0) {
+ equal(
+ request.uaid,
+ userAgentID,
+ "Should include device ID on reconnect with subscriptions"
+ );
+ } else {
+ ok(
+ !request.uaid,
+ "Should not send UAID in handshake without local subscriptions"
+ );
+ }
+ if (handshakes > 1) {
+ ok(false, "Unexpected reconnect attempt " + handshakes);
+ }
+ handshakes++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ },
+ onRegister(request) {
+ equal(
+ request.channelID,
+ channelID,
+ "Wrong channel ID in register request"
+ );
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => {
+ // Should ignore replies for timed-out requests.
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.com/update/timeout",
+ })
+ );
+ registers++;
+ timeoutDone();
+ }, 2000);
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.net/page/timeout",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for request timeout"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Should not store records for timed-out responses");
+
+ await timeoutPromise;
+ equal(registers, 1, "Should not handle timed-out register requests");
+});
diff --git a/dom/push/test/xpcshell/test_register_wrong_id.js b/dom/push/test/xpcshell/test_register_wrong_id.js
new file mode 100644
index 0000000000..674ce9f9ff
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_id.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "84afc774-6995-40d1-9c90-8c34ddcd0cb4";
+const clientChannelID = "4b42a681c99e4dfbbb166a7e01a09b8b";
+const serverChannelID = "3f5aeb89c6e8405a9569619522783436";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_wrong_id() {
+ // Should reconnect after the register request times out.
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushServiceWebSocket._generateID = () => clientChannelID;
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ equal(
+ request.channelID,
+ clientChannelID,
+ "Register: wrong channel ID"
+ );
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ // Reply with a different channel ID. Since the ID is used as a
+ // nonce, the registration request will time out.
+ channelID: serverChannelID,
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/mismatched",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for mismatched register reply"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_register_wrong_type.js b/dom/push/test/xpcshell/test_register_wrong_type.js
new file mode 100644
index 0000000000..b3b9aaa927
--- /dev/null
+++ b/dom/push/test/xpcshell/test_register_wrong_type.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "c293fdc5-a75e-4eb1-af88-a203991c0787";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ requestTimeout: 1000,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_register_wrong_type() {
+ let registers = 0;
+ let helloDone;
+ let helloPromise = new Promise(resolve => (helloDone = after(2, resolve)));
+
+ PushService._generateID = () => "1234";
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ helloDone();
+ },
+ onRegister(request) {
+ registers++;
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ channelID: 1234,
+ uaid: userAgentID,
+ pushEndpoint: "https://example.org/update/wrong-type",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.register({
+ scope: "https://example.com/mistyped",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Registration error/,
+ "Expected error for non-string channel ID"
+ );
+
+ await helloPromise;
+ equal(registers, 1, "Wrong register count");
+});
diff --git a/dom/push/test/xpcshell/test_registration_error.js b/dom/push/test/xpcshell/test_registration_error.js
new file mode 100644
index 0000000000..cba22ddc1c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID: "6faed1f0-1439-4aac-a978-db21c81cd5eb",
+ });
+ run_next_test();
+}
+
+add_task(async function test_registrations_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers(prev, scope) {
+ return Promise.reject("Database error");
+ },
+ }),
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+
+ await Assert.rejects(
+ PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ function (error) {
+ return error == "Database error";
+ },
+ "Wrong message"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_error_http2.js b/dom/push/test/xpcshell/test_registration_error_http2.js
new file mode 100644
index 0000000000..101cef8dc6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_error_http2.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
+
+add_task(async function test_registrations_error() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ PushService.init({
+ serverURI: "https://push.example.org/",
+ db: makeStub(db, {
+ getByIdentifiers() {
+ return Promise.reject("Database error");
+ },
+ }),
+ });
+
+ await Assert.rejects(
+ PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ function (error) {
+ return error == "Database error";
+ },
+ "Wrong message"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_missing_scope.js b/dom/push/test/xpcshell/test_registration_missing_scope.js
new file mode 100644
index 0000000000..dd0dab842c
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_missing_scope.js
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_registration_missing_scope() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+ await Assert.rejects(
+ PushService.registration({ scope: "", originAttributes: "" }),
+ /Invalid page record/,
+ "Record missing page and manifest URLs"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_registration_none.js b/dom/push/test/xpcshell/test_registration_none.js
new file mode 100644
index 0000000000..3e9235b0ad
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_none.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "a722e448-c481-4c48-aea0-fc411cb7c9ed";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+// Should not open a connection if the client has no registrations.
+add_task(async function test_registration_none() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri);
+ },
+ });
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(!registration, "Should not open a connection without registration");
+});
diff --git a/dom/push/test/xpcshell/test_registration_success.js b/dom/push/test/xpcshell/test_registration_success.js
new file mode 100644
index 0000000000..e3113e2007
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "997ee7ba-36b1-4526-ae9e-2d3f38d6efe8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({ userAgentID });
+ run_next_test();
+}
+
+add_task(async function test_registration_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "bf001fe0-2684-42f2-bc4d-a3e14b11dd5b",
+ pushEndpoint: "https://example.com/update/same-manifest/1",
+ scope: "https://example.net/a",
+ originAttributes: "",
+ version: 5,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ equal(request.uaid, userAgentID, "Wrong device ID in handshake");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ },
+ });
+ },
+ });
+
+ await handshakePromise;
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/a",
+ originAttributes: "",
+ });
+ equal(
+ registration.endpoint,
+ "https://example.com/update/same-manifest/1",
+ "Wrong push endpoint for scope"
+ );
+ equal(registration.version, 5, "Wrong version for scope");
+});
diff --git a/dom/push/test/xpcshell/test_registration_success_http2.js b/dom/push/test/xpcshell/test_registration_success_http2.js
new file mode 100644
index 0000000000..ebb4cb3f26
--- /dev/null
+++ b/dom/push/test/xpcshell/test_registration_success_http2.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+
+ run_next_test();
+}
+
+add_task(async function test_pushNotifications() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscriptionA",
+ pushEndpoint: serverURL + "/pushEndpointA",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointA",
+ scope: "https://example.net/a",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ {
+ subscriptionUri: serverURL + "/subscriptionB",
+ pushEndpoint: serverURL + "/pushEndpointB",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointB",
+ scope: "https://example.net/b",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ {
+ subscriptionUri: serverURL + "/subscriptionC",
+ pushEndpoint: serverURL + "/pushEndpointC",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpointC",
+ scope: "https://example.net/c",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL,
+ db,
+ });
+
+ let registration = await PushService.registration({
+ scope: "https://example.net/a",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ equal(
+ registration.endpoint,
+ serverURL + "/pushEndpointA",
+ "Wrong push endpoint for scope"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
new file mode 100644
index 0000000000..c922d13bc6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_4xxCode_http2.js
@@ -0,0 +1,113 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(3, r)));
+
+function listen4xxCodeHandler(metadata, response) {
+ ok(true, "Listener point error");
+ handlerDone();
+ response.setStatusLine(metadata.httpVersion, 410, "GONE");
+}
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscription4xxCode", listen4xxCodeHandler);
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "testing.notifyWorkers": false,
+ "testing.notifyAllObservers": true,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscription4xxCode",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
new file mode 100644
index 0000000000..5ef1a4f9e6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_5xxCode_http2.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.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");
+ Assert.equal(retries, 3, "Should retry 2 times.");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscription5xxCode", listen5xxCodeHandler);
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: serverURL + "/subscription5xxCode",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
new file mode 100644
index 0000000000..1373c3c91b
--- /dev/null
+++ b/dom/push/test/xpcshell/test_resubscribe_listening_for_msg_error_http2.js
@@ -0,0 +1,116 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+ChromeUtils.defineLazyGetter(this, "serverPort", function () {
+ return httpServer.identity.primaryPort;
+});
+
+var handlerDone;
+var handlerPromise = new Promise(r => (handlerDone = after(2, r)));
+
+function resubscribeHandler(metadata, response) {
+ ok(true, "Ask for new subscription");
+ handlerDone();
+ response.setHeader(
+ "Location",
+ "http://localhost:" + serverPort + "/newSubscription"
+ );
+ response.setHeader(
+ "Link",
+ '</newPushEndpoint>; rel="urn:ietf:params:push", ' +
+ '</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"'
+ );
+ response.setStatusLine(metadata.httpVersion, 201, "OK");
+}
+
+function listenSuccessHandler(metadata, response) {
+ Assert.ok(true, "New listener point");
+ httpServer.stop(handlerDone);
+ response.setStatusLine(metadata.httpVersion, 204, "Try again");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscribe", resubscribeHandler);
+httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let records = [
+ {
+ subscriptionUri: "http://localhost/subscriptionNotExist",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ p256dhPublicKey:
+ "BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ p256dhPrivateKey: {
+ crv: "P-256",
+ d: "1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM",
+ ext: true,
+ key_ops: ["deriveBits"],
+ kty: "EC",
+ x: "8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM",
+ y: "26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA",
+ },
+ originAttributes: "",
+ quota: Infinity,
+ },
+ ];
+
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await handlerPromise;
+
+ let record = await db.getByIdentifiers({
+ scope: "https://example.com/page",
+ originAttributes: "",
+ });
+ equal(
+ record.keyID,
+ serverURL + "/newSubscription",
+ "Should update subscription URL"
+ );
+ equal(
+ record.pushEndpoint,
+ serverURL + "/newPushEndpoint",
+ "Should update push endpoint"
+ );
+ equal(
+ record.pushReceiptEndpoint,
+ serverURL + "/newReceiptPushEndpoint",
+ "Should update push receipt endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_retry_ws.js b/dom/push/test/xpcshell/test_retry_ws.js
new file mode 100644
index 0000000000..19df72acd3
--- /dev/null
+++ b/dom/push/test/xpcshell/test_retry_ws.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "05f7b940-51b6-4b6f-8032-b83ebb577ded";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ pingInterval: 2000,
+ retryBaseInterval: 25,
+ });
+ run_next_test();
+}
+
+add_task(async function test_ws_retry() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ await db.put({
+ channelID: "61770ba9-2d57-4134-b949-d40404630d5b",
+ pushEndpoint: "https://example.org/push/1",
+ scope: "https://example.net/push/1",
+ version: 1,
+ originAttributes: "",
+ quota: Infinity,
+ });
+
+ // Use a mock timer to avoid waiting for the backoff interval.
+ let reconnects = 0;
+ PushServiceWebSocket._backoffTimer = {
+ init(observer, delay, type) {
+ reconnects++;
+ ok(
+ delay >= 5 && delay <= 2000,
+ `Backoff delay ${delay} out of range for attempt ${reconnects}`
+ );
+ observer.observe(this, "timer-callback", null);
+ },
+
+ cancel() {},
+ };
+
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ if (reconnects == 10) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ })
+ );
+ handshakeDone();
+ return;
+ }
+ this.serverInterrupt();
+ },
+ });
+ },
+ });
+
+ await handshakePromise;
+});
diff --git a/dom/push/test/xpcshell/test_service_child.js b/dom/push/test/xpcshell/test_service_child.js
new file mode 100644
index 0000000000..a22f1f4d73
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_child.js
@@ -0,0 +1,352 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var db;
+
+function done() {
+ do_test_finished();
+ run_next_test();
+}
+
+function generateKey() {
+ return crypto.subtle
+ .generateKey(
+ {
+ name: "ECDSA",
+ namedCurve: "P-256",
+ },
+ true,
+ ["sign", "verify"]
+ )
+ .then(cryptoKey => crypto.subtle.exportKey("raw", cryptoKey.publicKey))
+ .then(publicKey => new Uint8Array(publicKey));
+}
+
+function run_test() {
+ if (isParent) {
+ do_get_profile();
+ }
+ run_next_test();
+}
+
+if (isParent) {
+ add_test(function setUp() {
+ db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ setUpServiceInParent(PushService, db).then(run_next_test, run_next_test);
+ });
+}
+
+add_test(function test_subscribe_success() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "https://example.com/sub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(Components.isSuccessCode(result), "Error creating subscription");
+ ok(subscription.isSystemSubscription, "Expected system subscription");
+ ok(
+ subscription.endpoint.startsWith("https://example.org/push"),
+ "Wrong endpoint prefix"
+ );
+ equal(subscription.pushCount, 0, "Wrong push count");
+ equal(subscription.lastPush, 0, "Wrong last push time");
+ equal(subscription.quota, -1, "Wrong quota for system subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_error() {
+ do_test_pending();
+
+ let invalidKey = [0, 1];
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/invalid",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ invalidKey,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with invalid key"
+ );
+ equal(
+ result,
+ Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR,
+ "Wrong error code for invalid key"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_success() {
+ do_test_pending();
+
+ generateKey().then(
+ key => {
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ key,
+ (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error creating subscription with key"
+ );
+ notStrictEqual(subscription, null, "Expected subscription");
+ done();
+ }
+ );
+ },
+ error => {
+ ok(false, "Error generating app server key");
+ done();
+ }
+ );
+});
+
+add_test(function test_subscribeWithKey_conflict() {
+ do_test_pending();
+
+ generateKey().then(
+ differentKey => {
+ PushServiceComponent.subscribeWithKey(
+ "https://example.com/sub-key/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ differentKey,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with conflicting key"
+ );
+ equal(
+ result,
+ Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR,
+ "Wrong error code for mismatched key"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+ done();
+ }
+ );
+ },
+ error => {
+ ok(false, "Error generating different app server key");
+ done();
+ }
+ );
+});
+
+add_test(function test_subscribe_error() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "https://example.com/sub/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_exists() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(Components.isSuccessCode(result), "Error getting subscription");
+
+ equal(
+ subscription.endpoint,
+ "https://example.org/push/get",
+ "Wrong endpoint"
+ );
+ equal(subscription.pushCount, 10, "Wrong push count");
+ equal(subscription.lastPush, 1438360548322, "Wrong last push");
+ equal(subscription.quota, 16, "Wrong quota for subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_missing() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/missing",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error getting nonexistent subscription"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Nonexistent subscriptions should return null"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_getSubscription_error() {
+ do_test_pending();
+ PushServiceComponent.getSubscription(
+ "https://example.com/get/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error getting subscription"
+ );
+ strictEqual(subscription, null, "Unexpected subscription");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_success() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(Components.isSuccessCode(result), "Error unsubscribing");
+ strictEqual(success, true, "Expected successful unsubscribe");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_nonexistent() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/ok",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Error removing nonexistent subscription"
+ );
+ strictEqual(
+ success,
+ false,
+ "Nonexistent subscriptions should return false"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_unsubscribe_error() {
+ do_test_pending();
+ PushServiceComponent.unsubscribe(
+ "https://example.com/unsub/fail",
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ (result, success) => {
+ ok(!Components.isSuccessCode(result), "Expected error unsubscribing");
+ strictEqual(success, false, "Unexpected successful unsubscribe");
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribe_origin_principal() {
+ let scope = "https://example.net/origin-principal";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(scope);
+
+ do_test_pending();
+ PushServiceComponent.subscribe(scope, principal, (result, subscription) => {
+ ok(
+ Components.isSuccessCode(result),
+ "Expected error creating subscription with origin principal"
+ );
+ ok(
+ !subscription.isSystemSubscription,
+ "Unexpected system subscription for origin principal"
+ );
+ equal(subscription.quota, 16, "Wrong quota for origin subscription");
+
+ do_test_finished();
+ run_next_test();
+ });
+});
+
+add_test(function test_subscribe_null_principal() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "chrome://push/null-principal",
+ Services.scriptSecurityManager.createNullPrincipal({}),
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription with null principal"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Unexpected subscription with null principal"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+add_test(function test_subscribe_missing_principal() {
+ do_test_pending();
+ PushServiceComponent.subscribe(
+ "chrome://push/missing-principal",
+ null,
+ (result, subscription) => {
+ ok(
+ !Components.isSuccessCode(result),
+ "Expected error creating subscription without principal"
+ );
+ strictEqual(
+ subscription,
+ null,
+ "Unexpected subscription without principal"
+ );
+
+ do_test_finished();
+ run_next_test();
+ }
+ );
+});
+
+if (isParent) {
+ add_test(function tearDown() {
+ tearDownServiceInParent(db).then(run_next_test, run_next_test);
+ });
+}
diff --git a/dom/push/test/xpcshell/test_service_parent.js b/dom/push/test/xpcshell/test_service_parent.js
new file mode 100644
index 0000000000..7dda1db775
--- /dev/null
+++ b/dom/push/test/xpcshell/test_service_parent.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_service_parent() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+ await setUpServiceInParent(PushService, db);
+
+ // Accessing the lazy service getter will start the service in the main
+ // process.
+ equal(
+ PushServiceComponent.pushTopic,
+ "push-message",
+ "Wrong push message observer topic"
+ );
+ equal(
+ PushServiceComponent.subscriptionChangeTopic,
+ "push-subscription-change",
+ "Wrong subscription change observer topic"
+ );
+
+ await run_test_in_child("./test_service_child.js");
+
+ await tearDownServiceInParent(db);
+});
diff --git a/dom/push/test/xpcshell/test_unregister_empty_scope.js b/dom/push/test/xpcshell/test_unregister_empty_scope.js
new file mode 100644
index 0000000000..b5b6109ddb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_empty_scope.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_empty_scope() {
+ let handshakeDone;
+ let handshakePromise = new Promise(resolve => (handshakeDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "5619557c-86fe-4711-8078-d1fd6987aef7",
+ })
+ );
+ handshakeDone();
+ },
+ });
+ },
+ });
+ await handshakePromise;
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Invalid page record/,
+ "Expected error for empty endpoint"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_unregister_error.js b/dom/push/test/xpcshell/test_unregister_error.js
new file mode 100644
index 0000000000..bc56dc49b4
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_error.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const channelID = "00c7fa13-7b71-447d-bd27-a91abc09d1b2";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_error() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID,
+ pushEndpoint: "https://example.org/update/failure",
+ scope: "https://example.net/page/failure",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "083e6c17-1063-4677-8638-ab705aebebc2",
+ })
+ );
+ },
+ onUnregister(request) {
+ // The server is notified out-of-band. Since channels may be pruned,
+ // any failures are swallowed.
+ equal(request.channelID, channelID, "Unregister: wrong channel ID");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 500,
+ error: "omg, everything is exploding",
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await PushService.unregister({
+ scope: "https://example.net/page/failure",
+ originAttributes: "",
+ });
+
+ let result = await db.getByKeyID(channelID);
+ ok(!result, "Deleted push record exists");
+
+ // Make sure we send a request to the server.
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_invalid_json.js b/dom/push/test/xpcshell/test_unregister_invalid_json.js
new file mode 100644
index 0000000000..fa709c8eae
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_invalid_json.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "7f0af1bb-7e1f-4fb8-8e4a-e8de434abde3";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ requestTimeout: 150,
+ retryBaseInterval: 150,
+ });
+ run_next_test();
+}
+
+add_task(async function test_unregister_invalid_json() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ let records = [
+ {
+ channelID: "87902e90-c57e-4d18-8354-013f4a556559",
+ pushEndpoint: "https://example.org/update/1",
+ scope: "https://example.edu/page/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ },
+ {
+ channelID: "057caa8f-9b99-47ff-891c-adad18ce603e",
+ pushEndpoint: "https://example.com/update/2",
+ scope: "https://example.net/page/1",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(
+ resolve => (unregisterDone = after(2, resolve))
+ );
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ },
+ onUnregister(request) {
+ this.serverSendMsg(");alert(1);(");
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "https://example.edu/page/1",
+ originAttributes: "",
+ }),
+ /Request timed out/,
+ "Expected error for first invalid JSON response"
+ );
+
+ let record = await db.getByKeyID("87902e90-c57e-4d18-8354-013f4a556559");
+ ok(!record, "Failed to delete unregistered record");
+
+ await Assert.rejects(
+ PushService.unregister({
+ scope: "https://example.net/page/1",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ }),
+ /Request timed out/,
+ "Expected error for second invalid JSON response"
+ );
+
+ record = await db.getByKeyID("057caa8f-9b99-47ff-891c-adad18ce603e");
+ ok(
+ !record,
+ "Failed to delete unregistered record after receiving invalid JSON"
+ );
+
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_not_found.js b/dom/push/test/xpcshell/test_unregister_not_found.js
new file mode 100644
index 0000000000..a7693f3cf5
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_not_found.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function run_test() {
+ do_get_profile();
+ setPrefs();
+ run_next_test();
+}
+
+add_task(async function test_unregister_not_found() {
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: "f074ed80-d479-44fa-ba65-792104a79ea9",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let result = await PushService.unregister({
+ scope: "https://example.net/nonexistent",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ Assert.strictEqual(
+ result,
+ false,
+ "unregister should resolve with false for nonexistent scope"
+ );
+});
diff --git a/dom/push/test/xpcshell/test_unregister_success.js b/dom/push/test/xpcshell/test_unregister_success.js
new file mode 100644
index 0000000000..ccd0d31495
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "fbe865a6-aeb8-446f-873c-aeebdb8d493c";
+const channelID = "db0a7021-ec2d-4bd3-8802-7a6966f10ed8";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_unregister_success() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+ await db.put({
+ channelID,
+ pushEndpoint: "https://example.org/update/unregister-success",
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: "",
+ version: 1,
+ quota: Infinity,
+ });
+
+ let unregisterDone;
+ let unregisterPromise = new Promise(resolve => (unregisterDone = resolve));
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: userAgentID,
+ use_webpush: true,
+ })
+ );
+ },
+ onUnregister(request) {
+ equal(request.channelID, channelID, "Should include the channel ID");
+ equal(request.code, 200, "Expected manual unregister reason");
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "unregister",
+ status: 200,
+ channelID,
+ })
+ );
+ unregisterDone();
+ },
+ });
+ },
+ });
+
+ let subModifiedPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionModifiedTopic
+ );
+
+ await PushService.unregister({
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: "",
+ });
+
+ let { data: subModifiedScope } = await subModifiedPromise;
+ equal(
+ subModifiedScope,
+ "https://example.com/page/unregister-success",
+ "Should fire a subscription modified event after unsubscribing"
+ );
+
+ let record = await db.getByKeyID(channelID);
+ ok(!record, "Unregister did not remove record");
+
+ await unregisterPromise;
+});
diff --git a/dom/push/test/xpcshell/test_unregister_success_http2.js b/dom/push/test/xpcshell/test_unregister_success_http2.js
new file mode 100644
index 0000000000..8ae2ebafdb
--- /dev/null
+++ b/dom/push/test/xpcshell/test_unregister_success_http2.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var pushEnabled;
+var pushConnectionEnabled;
+
+var serverPort = -1;
+
+function run_test() {
+ serverPort = getTestServerPort();
+
+ do_get_profile();
+ pushEnabled = Services.prefs.getBoolPref("dom.push.enabled");
+ pushConnectionEnabled = Services.prefs.getBoolPref(
+ "dom.push.connection.enabled"
+ );
+
+ // Set to allow the cert presented by our H2 server
+ var oldPref = Services.prefs.getIntPref(
+ "network.http.speculative-parallel-limit"
+ );
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", 0);
+ Services.prefs.setBoolPref("dom.push.enabled", true);
+ Services.prefs.setBoolPref("dom.push.connection.enabled", true);
+
+ trustHttp2CA();
+
+ Services.prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
+
+ run_next_test();
+}
+
+add_task(async function test_pushUnsubscriptionSuccess() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ var serverURL = "https://localhost:" + serverPort;
+
+ await db.put({
+ subscriptionUri: serverURL + "/subscriptionUnsubscriptionSuccess",
+ pushEndpoint: serverURL + "/pushEndpointUnsubscriptionSuccess",
+ pushReceiptEndpoint:
+ serverURL + "/receiptPushEndpointUnsubscriptionSuccess",
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ quota: Infinity,
+ });
+
+ PushService.init({
+ serverURI: serverURL,
+ db,
+ });
+
+ await PushService.unregister({
+ scope: "https://example.com/page/unregister-success",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ let record = await db.getByKeyID(
+ serverURL + "/subscriptionUnsubscriptionSuccess"
+ );
+ ok(!record, "Unregister did not remove record");
+});
+
+add_task(async function test_complete() {
+ Services.prefs.setBoolPref("dom.push.enabled", pushEnabled);
+ Services.prefs.setBoolPref(
+ "dom.push.connection.enabled",
+ pushConnectionEnabled
+ );
+});
diff --git a/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
new file mode 100644
index 0000000000..0d58cb60b6
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_http2.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.importESModule(
+ "resource://testing-common/httpd.sys.mjs"
+);
+
+var httpServer = null;
+
+function listenHandler(metadata, response) {
+ Assert.ok(true, "Start listening");
+ httpServer.stop(do_test_finished);
+ response.setHeader("Retry-After", "10");
+ response.setStatusLine(metadata.httpVersion, 500, "Retry");
+}
+
+httpServer = new HttpServer();
+httpServer.registerPathHandler("/subscriptionNoKey", listenHandler);
+httpServer.start(-1);
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ "testing.allowInsecureServerURL": true,
+ "http2.retryInterval": 1000,
+ "http2.maxRetries": 2,
+ });
+
+ run_next_test();
+}
+
+add_task(async function test1() {
+ let db = PushServiceHttp2.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(() => db.close());
+ });
+
+ do_test_pending();
+
+ var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
+
+ let record = {
+ subscriptionUri: serverURL + "/subscriptionNoKey",
+ pushEndpoint: serverURL + "/pushEndpoint",
+ pushReceiptEndpoint: serverURL + "/pushReceiptEndpoint",
+ scope: "https://example.com/page",
+ originAttributes: "",
+ quota: Infinity,
+ systemRecord: true,
+ };
+
+ await db.put(record);
+
+ let notifyPromise = promiseObserverNotification(
+ PushServiceComponent.subscriptionChangeTopic,
+ _ => true
+ );
+
+ PushService.init({
+ serverURI: serverURL + "/subscribe",
+ db,
+ });
+
+ await notifyPromise;
+
+ let aRecord = await db.getByKeyID(serverURL + "/subscriptionNoKey");
+ ok(aRecord, "The record should still be there");
+ ok(aRecord.p256dhPublicKey, "There should be a public key");
+ ok(aRecord.p256dhPrivateKey, "There should be a private key");
+});
diff --git a/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
new file mode 100644
index 0000000000..b54735c042
--- /dev/null
+++ b/dom/push/test/xpcshell/test_updateRecordNoEncryptionKeys_ws.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const userAgentID = "4dffd396-6582-471d-8c0c-84f394e9f7db";
+
+function run_test() {
+ do_get_profile();
+ setPrefs({
+ userAgentID,
+ });
+ run_next_test();
+}
+
+add_task(async function test_with_data_enabled() {
+ let db = PushServiceWebSocket.newPushDB();
+ registerCleanupFunction(() => {
+ return db.drop().then(_ => db.close());
+ });
+
+ let [publicKey, privateKey] = await PushCrypto.generateKeys();
+ let records = [
+ {
+ channelID: "eb18f12a-cc42-4f14-accb-3bfc1227f1aa",
+ pushEndpoint: "https://example.org/push/no-key/1",
+ scope: "https://example.com/page/1",
+ originAttributes: "",
+ quota: Infinity,
+ },
+ {
+ channelID: "0d8886b9-8da1-4778-8f5d-1cf93a877ed6",
+ pushEndpoint: "https://example.org/push/key",
+ scope: "https://example.com/page/2",
+ originAttributes: "",
+ p256dhPublicKey: publicKey,
+ p256dhPrivateKey: privateKey,
+ quota: Infinity,
+ },
+ ];
+ for (let record of records) {
+ await db.put(record);
+ }
+
+ PushService.init({
+ serverURI: "wss://push.example.org/",
+ db,
+ makeWebSocket(uri) {
+ return new MockWebSocket(uri, {
+ onHello(request) {
+ ok(
+ request.use_webpush,
+ "Should use Web Push if data delivery is enabled"
+ );
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "hello",
+ status: 200,
+ uaid: request.uaid,
+ use_webpush: true,
+ })
+ );
+ },
+ onRegister(request) {
+ this.serverSendMsg(
+ JSON.stringify({
+ messageType: "register",
+ status: 200,
+ uaid: userAgentID,
+ channelID: request.channelID,
+ pushEndpoint: "https://example.org/push/new",
+ })
+ );
+ },
+ });
+ },
+ });
+
+ let newRecord = await PushService.register({
+ scope: "https://example.com/page/3",
+ originAttributes: ChromeUtils.originAttributesToSuffix({
+ inIsolatedMozBrowser: false,
+ }),
+ });
+ ok(newRecord.p256dhKey, "Should generate public keys for new records");
+
+ let record = await db.getByKeyID("eb18f12a-cc42-4f14-accb-3bfc1227f1aa");
+ ok(record.p256dhPublicKey, "Should add public key to partial record");
+ ok(record.p256dhPrivateKey, "Should add private key to partial record");
+
+ record = await db.getByKeyID("0d8886b9-8da1-4778-8f5d-1cf93a877ed6");
+ deepEqual(
+ record.p256dhPublicKey,
+ publicKey,
+ "Should leave existing public key"
+ );
+ deepEqual(
+ record.p256dhPrivateKey,
+ privateKey,
+ "Should leave existing private key"
+ );
+});
diff --git a/dom/push/test/xpcshell/xpcshell.toml b/dom/push/test/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..476677506c
--- /dev/null
+++ b/dom/push/test/xpcshell/xpcshell.toml
@@ -0,0 +1,121 @@
+[DEFAULT]
+head = "head.js head-http2.js"
+# Push notifications and alarms are currently disabled on Android.
+skip-if = ["os == 'android'"]
+support-files = ["broadcast_handler.sys.mjs"]
+
+["test_broadcast_success.js"]
+
+["test_clearAll_successful.js"]
+# This used to be hasNode, but that caused too many issues with tests being
+# silently disabled, so now we explicitly call out the platforms not known
+# to have node installed.
+skip-if = ["os == 'android'"]
+run-sequentially = "This will delete all existing push subscriptions."
+
+["test_clear_forgetAboutSite.js"]
+
+["test_clear_origin_data.js"]
+
+["test_crypto.js"]
+
+["test_crypto_encrypt.js"]
+
+["test_drop_expired.js"]
+
+["test_handler_service.js"]
+
+["test_notification_ack.js"]
+
+["test_notification_data.js"]
+
+["test_notification_duplicate.js"]
+
+["test_notification_error.js"]
+
+["test_notification_incomplete.js"]
+
+["test_notification_version_string.js"]
+
+["test_observer_data.js"]
+
+["test_observer_remoting.js"]
+skip-if = ["serviceworker_e10s"]
+
+["test_permissions.js"]
+run-sequentially = "This will delete all existing push subscriptions."
+
+["test_quota_exceeded.js"]
+
+["test_quota_observer.js"]
+
+["test_quota_with_notification.js"]
+
+["test_reconnect_retry.js"]
+
+["test_record.js"]
+
+["test_register_5xxCode_http2.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_resubscribe_4xxCode_http2.js"]
+
+["test_resubscribe_5xxCode_http2.js"]
+
+["test_resubscribe_listening_for_msg_error_http2.js"]
+
+["test_retry_ws.js"]
+
+["test_service_child.js"]
+
+#http2 test
+
+["test_service_parent.js"]
+
+["test_unregister_empty_scope.js"]
+
+["test_unregister_error.js"]
+
+["test_unregister_invalid_json.js"]
+skip-if = ["os == 'win'"] # Bug 1627379
+run-sequentially = "very high failure rate in parallel"
+
+["test_unregister_not_found.js"]
+
+["test_unregister_success.js"]
+
+["test_updateRecordNoEncryptionKeys_http2.js"]
+
+["test_updateRecordNoEncryptionKeys_ws.js"]
+skip-if = ["os == 'linux'"] # Bug 1265233