diff options
Diffstat (limited to 'dom/push')
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 |