import FIREFOX_52_6_0esr_RELEASE from mozilla-esr52 hg repo

This commit is contained in:
Roy Tam 2018-01-19 03:59:58 +08:00
commit dcd9973243
150858 changed files with 23884658 additions and 0 deletions

284
dom/push/Push.js Normal file
View file

@ -0,0 +1,284 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/DOMRequestHelper.jsm");
XPCOMUtils.defineLazyGetter(this, "console", () => {
let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
return new ConsoleAPI({
maxLogLevelPref: "dom.push.loglevel",
prefix: "Push",
});
});
XPCOMUtils.defineLazyServiceGetter(this, "PushService",
"@mozilla.org/push/Service;1", "nsIPushService");
const PUSH_CID = Components.ID("{cde1d019-fad8-4044-b141-65fb4fb7a245}");
/**
* The Push component runs in the child process and exposes the Push API
* to the web application. The PushService running in the parent process is the
* one actually performing all operations.
*/
function Push() {
console.debug("Push()");
}
Push.prototype = {
__proto__: DOMRequestIpcHelper.prototype,
contractID: "@mozilla.org/push/PushManager;1",
classID : PUSH_CID,
QueryInterface : XPCOMUtils.generateQI([Ci.nsIDOMGlobalPropertyInitializer,
Ci.nsISupportsWeakReference,
Ci.nsIObserver]),
init: function(win) {
console.debug("init()");
this._window = win;
this.initDOMRequestHelper(win);
this._principal = win.document.nodePrincipal;
},
__init: function(scope) {
this._scope = scope;
},
askPermission: function () {
console.debug("askPermission()");
return this.createPromise((resolve, reject) => {
let permissionDenied = () => {
reject(new this._window.DOMException(
"User denied permission to use the Push API.",
"NotAllowedError"
));
};
let permission = Ci.nsIPermissionManager.UNKNOWN_ACTION;
try {
permission = this._testPermission();
} catch (e) {
permissionDenied();
return;
}
if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
resolve();
} else if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
permissionDenied();
} else {
this._requestPermission(resolve, permissionDenied);
}
});
},
subscribe: function(options) {
console.debug("subscribe()", this._scope);
let histogram = Services.telemetry.getHistogramById("PUSH_API_USED");
histogram.add(true);
return this.askPermission().then(() =>
this.createPromise((resolve, reject) => {
let callback = new PushSubscriptionCallback(this, resolve, reject);
if (!options || !options.applicationServerKey) {
PushService.subscribe(this._scope, this._principal, callback);
return;
}
let appServerKey = options.applicationServerKey;
let keyView = new this._window.Uint8Array(ArrayBuffer.isView(appServerKey) ?
appServerKey.buffer : appServerKey);
if (keyView.byteLength === 0) {
callback._rejectWithError(Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
return;
}
PushService.subscribeWithKey(this._scope, this._principal,
appServerKey.length, appServerKey,
callback);
})
);
},
getSubscription: function() {
console.debug("getSubscription()", this._scope);
return this.createPromise((resolve, reject) => {
let callback = new PushSubscriptionCallback(this, resolve, reject);
PushService.getSubscription(this._scope, this._principal, callback);
});
},
permissionState: function() {
console.debug("permissionState()", this._scope);
return this.createPromise((resolve, reject) => {
let permission = Ci.nsIPermissionManager.UNKNOWN_ACTION;
try {
permission = this._testPermission();
} catch(e) {
reject();
return;
}
let pushPermissionStatus = "prompt";
if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
pushPermissionStatus = "granted";
} else if (permission == Ci.nsIPermissionManager.DENY_ACTION) {
pushPermissionStatus = "denied";
}
resolve(pushPermissionStatus);
});
},
_testPermission: function() {
let permission = Services.perms.testExactPermissionFromPrincipal(
this._principal, "desktop-notification");
if (permission == Ci.nsIPermissionManager.ALLOW_ACTION) {
return permission;
}
try {
if (Services.prefs.getBoolPref("dom.push.testing.ignorePermission")) {
permission = Ci.nsIPermissionManager.ALLOW_ACTION;
}
} catch (e) {}
return permission;
},
_requestPermission: function(allowCallback, cancelCallback) {
// Create an array with a single nsIContentPermissionType element.
let type = {
type: "desktop-notification",
access: null,
options: [],
QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPermissionType]),
};
let typeArray = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
typeArray.appendElement(type, false);
// create a nsIContentPermissionRequest
let request = {
types: typeArray,
principal: this._principal,
QueryInterface: XPCOMUtils.generateQI([Ci.nsIContentPermissionRequest]),
allow: function() {
let histogram = Services.telemetry.getHistogramById("PUSH_API_PERMISSION_GRANTED");
histogram.add();
allowCallback();
},
cancel: function() {
let histogram = Services.telemetry.getHistogramById("PUSH_API_PERMISSION_DENIED");
histogram.add();
cancelCallback();
},
window: this._window,
};
let histogram = Services.telemetry.getHistogramById("PUSH_API_PERMISSION_REQUESTED");
histogram.add(1);
// Using askPermission from nsIDOMWindowUtils that takes care of the
// remoting if needed.
let windowUtils = this._window.QueryInterface(Ci.nsIInterfaceRequestor)
.getInterface(Ci.nsIDOMWindowUtils);
windowUtils.askPermission(request);
},
};
function PushSubscriptionCallback(pushManager, resolve, reject) {
this.pushManager = pushManager;
this.resolve = resolve;
this.reject = reject;
}
PushSubscriptionCallback.prototype = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIPushSubscriptionCallback]),
onPushSubscription: function(ok, subscription) {
let {pushManager} = this;
if (!Components.isSuccessCode(ok)) {
this._rejectWithError(ok);
return;
}
if (!subscription) {
this.resolve(null);
return;
}
let p256dhKey = this._getKey(subscription, "p256dh");
let authSecret = this._getKey(subscription, "auth");
let options = {
endpoint: subscription.endpoint,
scope: pushManager._scope,
p256dhKey: p256dhKey,
authSecret: authSecret,
};
let appServerKey = this._getKey(subscription, "appServer");
if (appServerKey) {
// Avoid passing null keys to work around bug 1256449.
options.appServerKey = appServerKey;
}
let sub = new pushManager._window.PushSubscription(options);
this.resolve(sub);
},
_getKey: function(subscription, name) {
let outKeyLen = {};
let rawKey = Cu.cloneInto(subscription.getKey(name, outKeyLen),
this.pushManager._window);
if (!outKeyLen.value) {
return null;
}
let key = new this.pushManager._window.ArrayBuffer(outKeyLen.value);
let keyView = new this.pushManager._window.Uint8Array(key);
keyView.set(rawKey);
return key;
},
_rejectWithError: function(result) {
let error;
switch (result) {
case Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR:
error = new this.pushManager._window.DOMException(
"Invalid raw ECDSA P-256 public key.",
"InvalidAccessError"
);
break;
case Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR:
error = new this.pushManager._window.DOMException(
"A subscription with a different application server key already exists.",
"InvalidStateError"
);
break;
default:
error = new this.pushManager._window.DOMException(
"Error retrieving push subscription.",
"AbortError"
);
}
this.reject(error);
},
};
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Push]);

11
dom/push/Push.manifest Normal file
View file

@ -0,0 +1,11 @@
# DOM API
component {cde1d019-fad8-4044-b141-65fb4fb7a245} Push.js
contract @mozilla.org/push/PushManager;1 {cde1d019-fad8-4044-b141-65fb4fb7a245}
# XPCOM components.
component {daaa8d73-677e-4233-8acd-2c404bd01658} PushComponents.js
contract @mozilla.org/push/Service;1 {daaa8d73-677e-4233-8acd-2c404bd01658}
category app-startup PushServiceParent @mozilla.org/push/Service;1
# For immediate loading of PushService instead of delayed loading.
category android-push-service PushServiceParent @mozilla.org/push/Service;1

558
dom/push/PushComponents.js Normal file
View file

@ -0,0 +1,558 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* This file exports XPCOM components for C++ and chrome JavaScript callers to
* interact with the Push service.
*/
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
var isParent = Services.appinfo.processType === Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
// The default Push service implementation.
XPCOMUtils.defineLazyGetter(this, "PushService", function() {
const {PushService} = Cu.import("resource://gre/modules/PushService.jsm",
{});
PushService.init();
return PushService;
});
// Observer notification topics for push messages and subscription status
// changes. These are duplicated and used in `nsIPushNotifier`. They're exposed
// on `nsIPushService` so that JS callers only need to import this service.
const OBSERVER_TOPIC_PUSH = "push-message";
const OBSERVER_TOPIC_SUBSCRIPTION_CHANGE = "push-subscription-change";
const OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED = "push-subscription-modified";
/**
* `PushServiceBase`, `PushServiceParent`, and `PushServiceContent` collectively
* implement the `nsIPushService` interface. This interface provides calls
* similar to the Push DOM API, but does not require service workers.
*
* Push service methods may be called from the parent or content process. The
* parent process implementation loads `PushService.jsm` at app startup, and
* calls its methods directly. The content implementation forwards calls to
* the parent Push service via IPC.
*
* The implementations share a class and contract ID.
*/
function PushServiceBase() {
this.wrappedJSObject = this;
this._addListeners();
}
PushServiceBase.prototype = {
classID: Components.ID("{daaa8d73-677e-4233-8acd-2c404bd01658}"),
contractID: "@mozilla.org/push/Service;1",
QueryInterface: XPCOMUtils.generateQI([
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
Ci.nsIPushService,
Ci.nsIPushQuotaManager,
Ci.nsIPushErrorReporter,
]),
pushTopic: OBSERVER_TOPIC_PUSH,
subscriptionChangeTopic: OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
subscriptionModifiedTopic: OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
_handleReady() {},
_addListeners() {
for (let message of this._messages) {
this._mm.addMessageListener(message, this);
}
},
_isValidMessage(message) {
return this._messages.includes(message.name);
},
observe(subject, topic, data) {
if (topic === "app-startup") {
Services.obs.addObserver(this, "sessionstore-windows-restored", true);
return;
}
if (topic === "sessionstore-windows-restored") {
Services.obs.removeObserver(this, "sessionstore-windows-restored");
this._handleReady();
return;
}
if (topic === "android-push-service") {
// Load PushService immediately.
this._handleReady();
return;
}
},
_deliverSubscription(request, props) {
if (!props) {
request.onPushSubscription(Cr.NS_OK, null);
return;
}
request.onPushSubscription(Cr.NS_OK, new PushSubscription(props));
},
_deliverSubscriptionError(request, error) {
let result = typeof error.result == "number" ?
error.result : Cr.NS_ERROR_FAILURE;
request.onPushSubscription(result, null);
},
};
/**
* The parent process implementation of `nsIPushService`. This version loads
* `PushService.jsm` at startup and calls its methods directly. It also
* receives and responds to requests from the content process.
*/
function PushServiceParent() {
PushServiceBase.call(this);
}
PushServiceParent.prototype = Object.create(PushServiceBase.prototype);
XPCOMUtils.defineLazyServiceGetter(PushServiceParent.prototype, "_mm",
"@mozilla.org/parentprocessmessagemanager;1", "nsIMessageBroadcaster");
Object.assign(PushServiceParent.prototype, {
_xpcom_factory: XPCOMUtils.generateSingletonFactory(PushServiceParent),
_messages: [
"Push:Register",
"Push:Registration",
"Push:Unregister",
"Push:Clear",
"Push:NotificationForOriginShown",
"Push:NotificationForOriginClosed",
"Push:ReportError",
],
// nsIPushService methods
subscribe(scope, principal, callback) {
this.subscribeWithKey(scope, principal, 0, null, callback);
},
subscribeWithKey(scope, principal, keyLen, key, callback) {
this._handleRequest("Push:Register", principal, {
scope: scope,
appServerKey: key,
}).then(result => {
this._deliverSubscription(callback, result);
}, error => {
this._deliverSubscriptionError(callback, error);
}).catch(Cu.reportError);
},
unsubscribe(scope, principal, callback) {
this._handleRequest("Push:Unregister", principal, {
scope: scope,
}).then(result => {
callback.onUnsubscribe(Cr.NS_OK, result);
}, error => {
callback.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
}).catch(Cu.reportError);
},
getSubscription(scope, principal, callback) {
return this._handleRequest("Push:Registration", principal, {
scope: scope,
}).then(result => {
this._deliverSubscription(callback, result);
}, error => {
this._deliverSubscriptionError(callback, error);
}).catch(Cu.reportError);
},
clearForDomain(domain, callback) {
return this._handleRequest("Push:Clear", null, {
domain: domain,
}).then(result => {
callback.onClear(Cr.NS_OK);
}, error => {
callback.onClear(Cr.NS_ERROR_FAILURE);
}).catch(Cu.reportError);
},
// nsIPushQuotaManager methods
notificationForOriginShown(origin) {
this.service.notificationForOriginShown(origin);
},
notificationForOriginClosed(origin) {
this.service.notificationForOriginClosed(origin);
},
// nsIPushErrorReporter methods
reportDeliveryError(messageId, reason) {
this.service.reportDeliveryError(messageId, reason);
},
receiveMessage(message) {
if (!this._isValidMessage(message)) {
return;
}
let {name, principal, target, data} = message;
if (name === "Push:NotificationForOriginShown") {
this.notificationForOriginShown(data);
return;
}
if (name === "Push:NotificationForOriginClosed") {
this.notificationForOriginClosed(data);
return;
}
if (!target.assertPermission("push")) {
return;
}
if (name === "Push:ReportError") {
this.reportDeliveryError(data.messageId, data.reason);
return;
}
let sender = target.QueryInterface(Ci.nsIMessageSender);
return this._handleRequest(name, principal, data).then(result => {
sender.sendAsyncMessage(this._getResponseName(name, "OK"), {
requestID: data.requestID,
result: result
});
}, error => {
sender.sendAsyncMessage(this._getResponseName(name, "KO"), {
requestID: data.requestID,
result: error.result,
});
}).catch(Cu.reportError);
},
_handleReady() {
this.service.init();
},
_toPageRecord(principal, data) {
if (!data.scope) {
throw new Error("Invalid page record: missing scope");
}
if (!principal) {
throw new Error("Invalid page record: missing principal");
}
if (principal.isNullPrincipal || principal.isExpandedPrincipal) {
throw new Error("Invalid page record: unsupported principal");
}
// System subscriptions can only be created by chrome callers, and are
// exempt from the background message quota and permission checks. They
// also do not fire service worker events.
data.systemRecord = principal.isSystemPrincipal;
data.originAttributes =
ChromeUtils.originAttributesToSuffix(principal.originAttributes);
return data;
},
_handleRequest(name, principal, data) {
if (name == "Push:Clear") {
return this.service.clear(data);
}
let pageRecord;
try {
pageRecord = this._toPageRecord(principal, data);
} catch (e) {
return Promise.reject(e);
}
if (name === "Push:Register") {
return this.service.register(pageRecord);
}
if (name === "Push:Registration") {
return this.service.registration(pageRecord);
}
if (name === "Push:Unregister") {
return this.service.unregister(pageRecord);
}
return Promise.reject(new Error("Invalid request: unknown name"));
},
_getResponseName(requestName, suffix) {
let name = requestName.slice("Push:".length);
return "PushService:" + name + ":" + suffix;
},
// Methods used for mocking in tests.
replaceServiceBackend(options) {
return this.service.changeTestServer(options.serverURI, options);
},
restoreServiceBackend() {
var defaultServerURL = Services.prefs.getCharPref("dom.push.serverURL");
return this.service.changeTestServer(defaultServerURL);
},
});
// Used to replace the implementation with a mock.
Object.defineProperty(PushServiceParent.prototype, "service", {
get() {
return this._service || PushService;
},
set(impl) {
this._service = impl;
},
});
/**
* The content process implementation of `nsIPushService`. This version
* uses the child message manager to forward calls to the parent process.
* The parent Push service instance handles the request, and responds with a
* message containing the result.
*/
function PushServiceContent() {
PushServiceBase.apply(this, arguments);
this._requests = new Map();
this._requestId = 0;
}
PushServiceContent.prototype = Object.create(PushServiceBase.prototype);
XPCOMUtils.defineLazyServiceGetter(PushServiceContent.prototype,
"_mm", "@mozilla.org/childprocessmessagemanager;1",
"nsISyncMessageSender");
Object.assign(PushServiceContent.prototype, {
_xpcom_factory: XPCOMUtils.generateSingletonFactory(PushServiceContent),
_messages: [
"PushService:Register:OK",
"PushService:Register:KO",
"PushService:Registration:OK",
"PushService:Registration:KO",
"PushService:Unregister:OK",
"PushService:Unregister:KO",
"PushService:Clear:OK",
"PushService:Clear:KO",
],
// nsIPushService methods
subscribe(scope, principal, callback) {
this.subscribeWithKey(scope, principal, 0, null, callback);
},
subscribeWithKey(scope, principal, keyLen, key, callback) {
let requestId = this._addRequest(callback);
this._mm.sendAsyncMessage("Push:Register", {
scope: scope,
appServerKey: key,
requestID: requestId,
}, null, principal);
},
unsubscribe(scope, principal, callback) {
let requestId = this._addRequest(callback);
this._mm.sendAsyncMessage("Push:Unregister", {
scope: scope,
requestID: requestId,
}, null, principal);
},
getSubscription(scope, principal, callback) {
let requestId = this._addRequest(callback);
this._mm.sendAsyncMessage("Push:Registration", {
scope: scope,
requestID: requestId,
}, null, principal);
},
clearForDomain(domain, callback) {
let requestId = this._addRequest(callback);
this._mm.sendAsyncMessage("Push:Clear", {
domain: domain,
requestID: requestId,
});
},
// nsIPushQuotaManager methods
notificationForOriginShown(origin) {
this._mm.sendAsyncMessage("Push:NotificationForOriginShown", origin);
},
notificationForOriginClosed(origin) {
this._mm.sendAsyncMessage("Push:NotificationForOriginClosed", origin);
},
// nsIPushErrorReporter methods
reportDeliveryError(messageId, reason) {
this._mm.sendAsyncMessage("Push:ReportError", {
messageId: messageId,
reason: reason,
});
},
_addRequest(data) {
let id = ++this._requestId;
this._requests.set(id, data);
return id;
},
_takeRequest(requestId) {
let d = this._requests.get(requestId);
this._requests.delete(requestId);
return d;
},
receiveMessage(message) {
if (!this._isValidMessage(message)) {
return;
}
let {name, data} = message;
let request = this._takeRequest(data.requestID);
if (!request) {
return;
}
switch (name) {
case "PushService:Register:OK":
case "PushService:Registration:OK":
this._deliverSubscription(request, data.result);
break;
case "PushService:Register:KO":
case "PushService:Registration:KO":
this._deliverSubscriptionError(request, data);
break;
case "PushService:Unregister:OK":
if (typeof data.result === "boolean") {
request.onUnsubscribe(Cr.NS_OK, data.result);
} else {
request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
}
break;
case "PushService:Unregister:KO":
request.onUnsubscribe(Cr.NS_ERROR_FAILURE, false);
break;
case "PushService:Clear:OK":
request.onClear(Cr.NS_OK);
break;
case "PushService:Clear:KO":
request.onClear(Cr.NS_ERROR_FAILURE);
break;
default:
break;
}
},
});
/** `PushSubscription` instances are passed to all subscription callbacks. */
function PushSubscription(props) {
this._props = props;
}
PushSubscription.prototype = {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIPushSubscription]),
/** The URL for sending messages to this subscription. */
get endpoint() {
return this._props.endpoint;
},
/** The last time a message was sent to this subscription. */
get lastPush() {
return this._props.lastPush;
},
/** The total number of messages sent to this subscription. */
get pushCount() {
return this._props.pushCount;
},
/** The number of remaining background messages that can be sent to this
* subscription, or -1 of the subscription is exempt from the quota.
*/
get quota() {
return this._props.quota;
},
/**
* Indicates whether this subscription was created with the system principal.
* System subscriptions are exempt from the background message quota and
* permission checks.
*/
get isSystemSubscription() {
return !!this._props.systemRecord;
},
/** The private key used to decrypt incoming push messages, in JWK format */
get p256dhPrivateKey() {
return this._props.p256dhPrivateKey;
},
/**
* Indicates whether this subscription is subject to the background message
* quota.
*/
quotaApplies() {
return this.quota >= 0;
},
/**
* Indicates whether this subscription exceeded the background message quota,
* or the user revoked the notification permission. The caller must request a
* new subscription to continue receiving push messages.
*/
isExpired() {
return this.quota === 0;
},
/**
* Returns a key for encrypting messages sent to this subscription. JS
* callers receive the key buffer as a return value, while C++ callers
* receive the key size and buffer as out parameters.
*/
getKey(name, outKeyLen) {
switch (name) {
case "p256dh":
return this._getRawKey(this._props.p256dhKey, outKeyLen);
case "auth":
return this._getRawKey(this._props.authenticationSecret, outKeyLen);
case "appServer":
return this._getRawKey(this._props.appServerKey, outKeyLen);
}
return null;
},
_getRawKey(key, outKeyLen) {
if (!key) {
return null;
}
let rawKey = new Uint8Array(key);
if (outKeyLen) {
outKeyLen.value = rawKey.length;
}
return rawKey;
},
};
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([
// Export the correct implementation depending on whether we're running in
// the parent or content process.
isParent ? PushServiceParent : PushServiceContent,
]);

454
dom/push/PushCrypto.jsm Normal file
View file

@ -0,0 +1,454 @@
/* jshint moz: true, esnext: true */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
'use strict';
const Cu = Components.utils;
Cu.import('resource://gre/modules/Services.jsm');
Cu.import('resource://gre/modules/XPCOMUtils.jsm');
XPCOMUtils.defineLazyGetter(this, 'gDOMBundle', () =>
Services.strings.createBundle('chrome://global/locale/dom/dom.properties'));
Cu.importGlobalProperties(['crypto']);
this.EXPORTED_SYMBOLS = ['PushCrypto', 'concatArray'];
var UTF8 = new TextEncoder('utf-8');
// Legacy encryption scheme (draft-thomson-http-encryption-02).
var AESGCM128_ENCODING = 'aesgcm128';
var AESGCM128_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm128');
// New encryption scheme (draft-ietf-httpbis-encryption-encoding-01).
var AESGCM_ENCODING = 'aesgcm';
var AESGCM_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm');
var NONCE_INFO = UTF8.encode('Content-Encoding: nonce');
var AUTH_INFO = UTF8.encode('Content-Encoding: auth\0'); // note nul-terminus
var P256DH_INFO = UTF8.encode('P-256\0');
var ECDH_KEY = { name: 'ECDH', namedCurve: 'P-256' };
var ECDSA_KEY = { name: 'ECDSA', namedCurve: 'P-256' };
// A default keyid with a name that won't conflict with a real keyid.
var DEFAULT_KEYID = '';
/** Localized error property names. */
// `Encryption` header missing or malformed.
const BAD_ENCRYPTION_HEADER = 'PushMessageBadEncryptionHeader';
// `Crypto-Key` or legacy `Encryption-Key` header missing.
const BAD_CRYPTO_KEY_HEADER = 'PushMessageBadCryptoKeyHeader';
const BAD_ENCRYPTION_KEY_HEADER = 'PushMessageBadEncryptionKeyHeader';
// `Content-Encoding` header missing or contains unsupported encoding.
const BAD_ENCODING_HEADER = 'PushMessageBadEncodingHeader';
// `dh` parameter of `Crypto-Key` header missing or not base64url-encoded.
const BAD_DH_PARAM = 'PushMessageBadSenderKey';
// `salt` parameter of `Encryption` header missing or not base64url-encoded.
const BAD_SALT_PARAM = 'PushMessageBadSalt';
// `rs` parameter of `Encryption` header not a number or less than pad size.
const BAD_RS_PARAM = 'PushMessageBadRecordSize';
// Invalid or insufficient padding for encrypted chunk.
const BAD_PADDING = 'PushMessageBadPaddingError';
// Generic crypto error.
const BAD_CRYPTO = 'PushMessageBadCryptoError';
class CryptoError extends Error {
/**
* Creates an error object indicating an incoming push message could not be
* decrypted.
*
* @param {String} message A human-readable error message. This is only for
* internal module logging, and doesn't need to be localized.
* @param {String} property The localized property name from `dom.properties`.
* @param {String...} params Substitutions to insert into the localized
* string.
*/
constructor(message, property, ...params) {
super(message);
this.isCryptoError = true;
this.property = property;
this.params = params;
}
/**
* Formats a localized string for reporting decryption errors to the Web
* Console.
*
* @param {String} scope The scope of the service worker receiving the
* message, prepended to any other substitutions in the string.
* @returns {String} The localized string.
*/
format(scope) {
let params = [scope, ...this.params].map(String);
return gDOMBundle.formatStringFromName(this.property, params,
params.length);
}
}
function getEncryptionKeyParams(encryptKeyField) {
if (!encryptKeyField) {
return null;
}
var params = encryptKeyField.split(',');
return params.reduce((m, p) => {
var pmap = p.split(';').reduce(parseHeaderFieldParams, {});
if (pmap.keyid && pmap.dh) {
m[pmap.keyid] = pmap.dh;
}
if (!m[DEFAULT_KEYID] && pmap.dh) {
m[DEFAULT_KEYID] = pmap.dh;
}
return m;
}, {});
}
function getEncryptionParams(encryptField) {
if (!encryptField) {
throw new CryptoError('Missing encryption header',
BAD_ENCRYPTION_HEADER);
}
var p = encryptField.split(',', 1)[0];
if (!p) {
throw new CryptoError('Encryption header missing params',
BAD_ENCRYPTION_HEADER);
}
return p.split(';').reduce(parseHeaderFieldParams, {});
}
function getCryptoParams(headers) {
if (!headers) {
return null;
}
var keymap;
var padSize;
if (!headers.encoding) {
throw new CryptoError('Missing Content-Encoding header',
BAD_ENCODING_HEADER);
}
if (headers.encoding == AESGCM_ENCODING) {
// aesgcm uses the Crypto-Key header, 2 bytes for the pad length, and an
// authentication secret.
// https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-01
keymap = getEncryptionKeyParams(headers.crypto_key);
if (!keymap) {
throw new CryptoError('Missing Crypto-Key header',
BAD_CRYPTO_KEY_HEADER);
}
padSize = 2;
} else if (headers.encoding == AESGCM128_ENCODING) {
// aesgcm128 uses Encryption-Key, 1 byte for the pad length, and no secret.
// https://tools.ietf.org/html/draft-thomson-http-encryption-02
keymap = getEncryptionKeyParams(headers.encryption_key);
if (!keymap) {
throw new CryptoError('Missing Encryption-Key header',
BAD_ENCRYPTION_KEY_HEADER);
}
padSize = 1;
} else {
throw new CryptoError('Unsupported Content-Encoding: ' + headers.encoding,
BAD_ENCODING_HEADER);
}
var enc = getEncryptionParams(headers.encryption);
var dh = keymap[enc.keyid || DEFAULT_KEYID];
if (!dh) {
throw new CryptoError('Missing dh parameter', BAD_DH_PARAM);
}
var salt = enc.salt;
if (!salt) {
throw new CryptoError('Missing salt parameter', BAD_SALT_PARAM);
}
var rs = enc.rs ? parseInt(enc.rs, 10) : 4096;
if (isNaN(rs)) {
throw new CryptoError('rs parameter must be a number', BAD_RS_PARAM);
}
if (rs <= padSize) {
throw new CryptoError('rs parameter must be at least ' + padSize,
BAD_RS_PARAM, padSize);
}
return {dh, salt, rs, padSize};
}
// Decodes an unpadded, base64url-encoded string.
function base64URLDecode(string) {
try {
return ChromeUtils.base64URLDecode(string, {
// draft-ietf-httpbis-encryption-encoding-01 prohibits padding.
padding: 'reject',
});
} catch (ex) {}
return null;
}
var parseHeaderFieldParams = (m, v) => {
var i = v.indexOf('=');
if (i >= 0) {
// A quoted string with internal quotes is invalid for all the possible
// values of this header field.
m[v.substring(0, i).trim()] = v.substring(i + 1).trim()
.replace(/^"(.*)"$/, '$1');
}
return m;
};
function chunkArray(array, size) {
var start = array.byteOffset || 0;
array = array.buffer || array;
var index = 0;
var result = [];
while(index + size <= array.byteLength) {
result.push(new Uint8Array(array, start + index, size));
index += size;
}
if (index < array.byteLength) {
result.push(new Uint8Array(array, start + index));
}
return result;
}
this.concatArray = function(arrays) {
var size = arrays.reduce((total, a) => total + a.byteLength, 0);
var index = 0;
return arrays.reduce((result, a) => {
result.set(new Uint8Array(a), index);
index += a.byteLength;
return result;
}, new Uint8Array(size));
};
var HMAC_SHA256 = { name: 'HMAC', hash: 'SHA-256' };
function hmac(key) {
this.keyPromise = crypto.subtle.importKey('raw', key, HMAC_SHA256,
false, ['sign']);
}
hmac.prototype.hash = function(input) {
return this.keyPromise.then(k => crypto.subtle.sign('HMAC', k, input));
};
function hkdf(salt, ikm) {
this.prkhPromise = new hmac(salt).hash(ikm)
.then(prk => new hmac(prk));
}
hkdf.prototype.extract = function(info, len) {
var input = concatArray([info, new Uint8Array([1])]);
return this.prkhPromise
.then(prkh => prkh.hash(input))
.then(h => {
if (h.byteLength < len) {
throw new CryptoError('HKDF length is too long', BAD_CRYPTO);
}
return h.slice(0, len);
});
};
/* generate a 96-bit nonce for use in GCM, 48-bits of which are populated */
function generateNonce(base, index) {
if (index >= Math.pow(2, 48)) {
throw new CryptoError('Nonce index is too large', BAD_CRYPTO);
}
var nonce = base.slice(0, 12);
nonce = new Uint8Array(nonce);
for (var i = 0; i < 6; ++i) {
nonce[nonce.byteLength - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
}
return nonce;
}
this.PushCrypto = {
generateAuthenticationSecret() {
return crypto.getRandomValues(new Uint8Array(16));
},
validateAppServerKey(key) {
return crypto.subtle.importKey('raw', key, ECDSA_KEY,
true, ['verify'])
.then(_ => key);
},
generateKeys() {
return crypto.subtle.generateKey(ECDH_KEY, true, ['deriveBits'])
.then(cryptoKey =>
Promise.all([
crypto.subtle.exportKey('raw', cryptoKey.publicKey),
crypto.subtle.exportKey('jwk', cryptoKey.privateKey)
]));
},
/**
* Decrypts a push message.
*
* @param {JsonWebKey} privateKey The ECDH private key of the subscription
* receiving the message, in JWK form.
* @param {BufferSource} publicKey The ECDH public key of the subscription
* receiving the message, in raw form.
* @param {BufferSource} authenticationSecret The 16-byte shared
* authentication secret of the subscription receiving the message.
* @param {Object} headers The encryption headers passed to `getCryptoParams`.
* @param {BufferSource} ciphertext The encrypted message data.
* @returns {Promise} Resolves with a `Uint8Array` containing the decrypted
* message data. Rejects with a `CryptoError` if decryption fails.
*/
decrypt(privateKey, publicKey, authenticationSecret, headers, ciphertext) {
return Promise.resolve().then(_ => {
let cryptoParams = getCryptoParams(headers);
if (!cryptoParams) {
return null;
}
return this._decodeMsg(ciphertext, privateKey, publicKey,
cryptoParams.dh, cryptoParams.salt,
cryptoParams.rs, authenticationSecret,
cryptoParams.padSize);
}).catch(error => {
if (error.isCryptoError) {
throw error;
}
// Web Crypto returns an unhelpful "operation failed for an
// operation-specific reason" error if decryption fails. We don't have
// context about what went wrong, so we throw a generic error instead.
throw new CryptoError('Bad encryption', BAD_CRYPTO);
});
},
_decodeMsg(aData, aPrivateKey, aPublicKey, aSenderPublicKey, aSalt, aRs,
aAuthenticationSecret, aPadSize) {
if (aData.byteLength === 0) {
// Zero length messages will be passed as null.
return null;
}
// The last chunk of data must be less than aRs, if it is not return an
// error.
if (aData.byteLength % (aRs + 16) === 0) {
throw new CryptoError('Encrypted data truncated', BAD_CRYPTO);
}
let senderKey = base64URLDecode(aSenderPublicKey);
if (!senderKey) {
throw new CryptoError('dh parameter is not base64url-encoded',
BAD_DH_PARAM);
}
let salt = base64URLDecode(aSalt);
if (!salt) {
throw new CryptoError('salt parameter is not base64url-encoded',
BAD_SALT_PARAM);
}
return Promise.all([
crypto.subtle.importKey('raw', senderKey, ECDH_KEY,
false, ['deriveBits']),
crypto.subtle.importKey('jwk', aPrivateKey, ECDH_KEY,
false, ['deriveBits'])
])
.then(([appServerKey, subscriptionPrivateKey]) =>
crypto.subtle.deriveBits({ name: 'ECDH', public: appServerKey },
subscriptionPrivateKey, 256))
.then(ikm => this._deriveKeyAndNonce(aPadSize,
new Uint8Array(ikm),
salt,
aPublicKey,
senderKey,
aAuthenticationSecret))
.then(r =>
// AEAD_AES_128_GCM expands ciphertext to be 16 octets longer.
Promise.all(chunkArray(aData, aRs + 16).map((slice, index) =>
this._decodeChunk(aPadSize, slice, index, r[1], r[0]))))
.then(r => concatArray(r));
},
_deriveKeyAndNonce(padSize, ikm, salt, receiverKey, senderKey,
authenticationSecret) {
var kdfPromise;
var context;
var encryptInfo;
// The size of the padding determines which key derivation we use.
//
// 1. If the pad size is 1, we assume "aesgcm128". This scheme ignores the
// authenticationSecret, and uses "Content-Encoding: <blah>" for the
// context string. It should eventually be removed: bug 1230038.
//
// 2. If the pad size is 2, we assume "aesgcm", and mix the
// authenticationSecret with the ikm using HKDF. The context string is:
// "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
// receiver key and sender key.
if (padSize == 2) {
// Since we are using an authentication secret, we need to run an extra
// round of HKDF with the authentication secret as salt.
var authKdf = new hkdf(authenticationSecret, ikm);
kdfPromise = authKdf.extract(AUTH_INFO, 32)
.then(ikm2 => new hkdf(salt, ikm2));
// aesgcm requires extra context for the info parameter.
context = concatArray([
new Uint8Array([0]), P256DH_INFO,
this._encodeLength(receiverKey), receiverKey,
this._encodeLength(senderKey), senderKey
]);
encryptInfo = AESGCM_ENCRYPT_INFO;
} else {
kdfPromise = Promise.resolve(new hkdf(salt, ikm));
context = new Uint8Array(0);
encryptInfo = AESGCM128_ENCRYPT_INFO;
}
return kdfPromise.then(kdf => Promise.all([
kdf.extract(concatArray([encryptInfo, context]), 16)
.then(gcmBits => crypto.subtle.importKey('raw', gcmBits, 'AES-GCM', false,
['decrypt'])),
kdf.extract(concatArray([NONCE_INFO, context]), 12)
]));
},
_encodeLength(buffer) {
return new Uint8Array([0, buffer.byteLength]);
},
_decodeChunk(aPadSize, aSlice, aIndex, aNonce, aKey) {
let params = {
name: 'AES-GCM',
iv: generateNonce(aNonce, aIndex)
};
return crypto.subtle.decrypt(params, aKey, aSlice)
.then(decoded => this._unpadChunk(aPadSize, new Uint8Array(decoded)));
},
/**
* Removes padding from a decrypted chunk.
*
* @param {Number} padSize The size of the padding length prepended to each
* chunk. For aesgcm, the padding length is expressed as a 16-bit unsigned
* big endian integer. For aesgcm128, the padding is an 8-bit integer.
* @param {Uint8Array} decoded The decrypted, padded chunk.
* @returns {Uint8Array} The chunk with padding removed.
*/
_unpadChunk(padSize, decoded) {
if (padSize < 1 || padSize > 2) {
throw new CryptoError('Unsupported pad size', BAD_CRYPTO);
}
if (decoded.length < padSize) {
throw new CryptoError('Decoded array is too short!', BAD_PADDING);
}
var pad = decoded[0];
if (padSize == 2) {
pad = (pad << 8) | decoded[1];
}
if (pad > decoded.length) {
throw new CryptoError('Padding is wrong!', BAD_PADDING);
}
// All padded bytes must be zero except the first one.
for (var i = padSize; i <= pad; i++) {
if (decoded[i] !== 0) {
throw new CryptoError('Padding is wrong!', BAD_PADDING);
}
}
return decoded.slice(pad + padSize);
},
};

440
dom/push/PushDB.jsm Normal file
View file

@ -0,0 +1,440 @@
/* jshint moz: true, esnext: true */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const Cu = Components.utils;
Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.importGlobalProperties(["indexedDB"]);
this.EXPORTED_SYMBOLS = ["PushDB"];
XPCOMUtils.defineLazyGetter(this, "console", () => {
let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
return new ConsoleAPI({
maxLogLevelPref: "dom.push.loglevel",
prefix: "PushDB",
});
});
this.PushDB = function PushDB(dbName, dbVersion, dbStoreName, keyPath, model) {
console.debug("PushDB()");
this._dbStoreName = dbStoreName;
this._keyPath = keyPath;
this._model = model;
// set the indexeddb database
this.initDBHelper(dbName, dbVersion,
[dbStoreName]);
};
this.PushDB.prototype = {
__proto__: IndexedDBHelper.prototype,
toPushRecord: function(record) {
if (!record) {
return;
}
return new this._model(record);
},
isValidRecord: function(record) {
return record && typeof record.scope == "string" &&
typeof record.originAttributes == "string" &&
record.quota >= 0 &&
typeof record[this._keyPath] == "string";
},
upgradeSchema: function(aTransaction, aDb, aOldVersion, aNewVersion) {
if (aOldVersion <= 3) {
//XXXnsm We haven't shipped Push during this upgrade, so I'm just going to throw old
//registrations away without even informing the app.
if (aDb.objectStoreNames.contains(this._dbStoreName)) {
aDb.deleteObjectStore(this._dbStoreName);
}
let objectStore = aDb.createObjectStore(this._dbStoreName,
{ keyPath: this._keyPath });
// index to fetch records based on endpoints. used by unregister
objectStore.createIndex("pushEndpoint", "pushEndpoint", { unique: true });
// index to fetch records by identifiers.
// In the current security model, the originAttributes distinguish between
// different 'apps' on the same origin. Since ServiceWorkers are
// same-origin to the scope they are registered for, the attributes and
// scope are enough to reconstruct a valid principal.
objectStore.createIndex("identifiers", ["scope", "originAttributes"], { unique: true });
objectStore.createIndex("originAttributes", "originAttributes", { unique: false });
}
if (aOldVersion < 4) {
let objectStore = aTransaction.objectStore(this._dbStoreName);
// index to fetch active and expired registrations.
objectStore.createIndex("quota", "quota", { unique: false });
}
},
/*
* @param aRecord
* The record to be added.
*/
put: function(aRecord) {
console.debug("put()", aRecord);
if (!this.isValidRecord(aRecord)) {
return Promise.reject(new TypeError(
"Scope, originAttributes, and quota are required! " +
JSON.stringify(aRecord)
)
);
}
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.put(aRecord).onsuccess = aEvent => {
console.debug("put: Request successful. Updated record",
aEvent.target.result);
aTxn.result = this.toPushRecord(aRecord);
};
},
resolve,
reject
)
);
},
/*
* @param aKeyID
* The ID of record to be deleted.
*/
delete: function(aKeyID) {
console.debug("delete()");
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
(aTxn, aStore) => {
console.debug("delete: Removing record", aKeyID);
aStore.get(aKeyID).onsuccess = event => {
aTxn.result = this.toPushRecord(event.target.result);
aStore.delete(aKeyID);
};
},
resolve,
reject
)
);
},
// testFn(record) is called with a database record and should return true if
// that record should be deleted.
clearIf: function(testFn) {
console.debug("clearIf()");
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.openCursor().onsuccess = event => {
let cursor = event.target.result;
if (cursor) {
let record = this.toPushRecord(cursor.value);
if (testFn(record)) {
let deleteRequest = cursor.delete();
deleteRequest.onerror = e => {
console.error("clearIf: Error removing record",
record.keyID, e);
}
}
cursor.continue();
}
}
},
resolve,
reject
)
);
},
getByPushEndpoint: function(aPushEndpoint) {
console.debug("getByPushEndpoint()");
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index("pushEndpoint");
index.get(aPushEndpoint).onsuccess = aEvent => {
let record = this.toPushRecord(aEvent.target.result);
console.debug("getByPushEndpoint: Got record", record);
aTxn.result = record;
};
},
resolve,
reject
)
);
},
getByKeyID: function(aKeyID) {
console.debug("getByKeyID()");
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.get(aKeyID).onsuccess = aEvent => {
let record = this.toPushRecord(aEvent.target.result);
console.debug("getByKeyID: Got record", record);
aTxn.result = record;
};
},
resolve,
reject
)
);
},
/**
* Iterates over all records associated with an origin.
*
* @param {String} origin The origin, matched as a prefix against the scope.
* @param {String} originAttributes Additional origin attributes. Requires
* an exact match.
* @param {Function} callback A function with the signature `(record,
* cursor)`, called for each record. `record` is the registration, and
* `cursor` is an `IDBCursor`.
* @returns {Promise} Resolves once all records have been processed.
*/
forEachOrigin: function(origin, originAttributes, callback) {
console.debug("forEachOrigin()");
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index("identifiers");
let range = IDBKeyRange.bound(
[origin, originAttributes],
[origin + "\x7f", originAttributes]
);
index.openCursor(range).onsuccess = event => {
let cursor = event.target.result;
if (!cursor) {
return;
}
callback(this.toPushRecord(cursor.value), cursor);
cursor.continue();
};
},
resolve,
reject
)
);
},
// Perform a unique match against { scope, originAttributes }
getByIdentifiers: function(aPageRecord) {
console.debug("getByIdentifiers()", aPageRecord);
if (!aPageRecord.scope || aPageRecord.originAttributes == undefined) {
console.error("getByIdentifiers: Scope and originAttributes are required",
aPageRecord);
return Promise.reject(new TypeError("Invalid page record"));
}
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index("identifiers");
let request = index.get(IDBKeyRange.only([aPageRecord.scope, aPageRecord.originAttributes]));
request.onsuccess = aEvent => {
aTxn.result = this.toPushRecord(aEvent.target.result);
};
},
resolve,
reject
)
);
},
_getAllByKey: function(aKeyName, aKeyValue) {
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
let index = aStore.index(aKeyName);
// It seems ok to use getAll here, since unlike contacts or other
// high storage APIs, we don't expect more than a handful of
// registrations per domain, and usually only one.
let getAllReq = index.mozGetAll(aKeyValue);
getAllReq.onsuccess = aEvent => {
aTxn.result = aEvent.target.result.map(
record => this.toPushRecord(record));
};
},
resolve,
reject
)
);
},
// aOriginAttributes must be a string!
getAllByOriginAttributes: function(aOriginAttributes) {
if (typeof aOriginAttributes !== "string") {
return Promise.reject("Expected string!");
}
return this._getAllByKey("originAttributes", aOriginAttributes);
},
getAllKeyIDs: function() {
console.debug("getAllKeyIDs()");
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = undefined;
aStore.mozGetAll().onsuccess = event => {
aTxn.result = event.target.result.map(
record => this.toPushRecord(record));
};
},
resolve,
reject
)
);
},
_getAllByPushQuota: function(range) {
console.debug("getAllByPushQuota()");
return new Promise((resolve, reject) =>
this.newTxn(
"readonly",
this._dbStoreName,
(aTxn, aStore) => {
aTxn.result = [];
let index = aStore.index("quota");
index.openCursor(range).onsuccess = event => {
let cursor = event.target.result;
if (cursor) {
aTxn.result.push(this.toPushRecord(cursor.value));
cursor.continue();
}
};
},
resolve,
reject
)
);
},
getAllUnexpired: function() {
console.debug("getAllUnexpired()");
return this._getAllByPushQuota(IDBKeyRange.lowerBound(1));
},
getAllExpired: function() {
console.debug("getAllExpired()");
return this._getAllByPushQuota(IDBKeyRange.only(0));
},
/**
* Updates an existing push registration.
*
* @param {String} aKeyID The registration ID.
* @param {Function} aUpdateFunc A function that receives the existing
* registration record as its argument, and returns a new record.
* @returns {Promise} A promise resolved with either the updated record.
* Rejects if the record does not exist, or the function returns an invalid
* record.
*/
update: function(aKeyID, aUpdateFunc) {
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
(aTxn, aStore) => {
aStore.get(aKeyID).onsuccess = aEvent => {
aTxn.result = undefined;
let record = aEvent.target.result;
if (!record) {
throw new Error("Record " + aKeyID + " does not exist");
}
let newRecord = aUpdateFunc(this.toPushRecord(record));
if (!this.isValidRecord(newRecord)) {
console.error("update: Ignoring invalid update",
aKeyID, newRecord);
throw new Error("Invalid update for record " + aKeyID);
}
function putRecord() {
let req = aStore.put(newRecord);
req.onsuccess = aEvent => {
console.debug("update: Update successful", aKeyID, newRecord);
aTxn.result = newRecord;
};
}
if (aKeyID === newRecord.keyID) {
putRecord();
} else {
// If we changed the primary key, delete the old record to avoid
// unique constraint errors.
aStore.delete(aKeyID).onsuccess = putRecord;
}
};
},
resolve,
reject
)
);
},
drop: function() {
console.debug("drop()");
return new Promise((resolve, reject) =>
this.newTxn(
"readwrite",
this._dbStoreName,
function txnCb(aTxn, aStore) {
aStore.clear();
},
resolve,
reject
)
);
},
};

600
dom/push/PushManager.cpp Normal file
View file

@ -0,0 +1,600 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=8 sts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "mozilla/dom/PushManager.h"
#include "mozilla/Preferences.h"
#include "mozilla/Services.h"
#include "mozilla/Unused.h"
#include "mozilla/dom/PushManagerBinding.h"
#include "mozilla/dom/PushSubscription.h"
#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
#include "mozilla/dom/PushUtil.h"
#include "mozilla/dom/Promise.h"
#include "mozilla/dom/PromiseWorkerProxy.h"
#include "nsIGlobalObject.h"
#include "nsIPermissionManager.h"
#include "nsIPrincipal.h"
#include "nsIPushService.h"
#include "nsComponentManagerUtils.h"
#include "nsContentUtils.h"
#include "WorkerRunnable.h"
#include "WorkerPrivate.h"
#include "WorkerScope.h"
namespace mozilla {
namespace dom {
using namespace workers;
namespace {
nsresult
GetPermissionState(nsIPrincipal* aPrincipal,
PushPermissionState& aState)
{
nsCOMPtr<nsIPermissionManager> permManager =
mozilla::services::GetPermissionManager();
if (!permManager) {
return NS_ERROR_FAILURE;
}
uint32_t permission = nsIPermissionManager::UNKNOWN_ACTION;
nsresult rv = permManager->TestExactPermissionFromPrincipal(
aPrincipal,
"desktop-notification",
&permission);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
if (permission == nsIPermissionManager::ALLOW_ACTION ||
Preferences::GetBool("dom.push.testing.ignorePermission", false)) {
aState = PushPermissionState::Granted;
} else if (permission == nsIPermissionManager::DENY_ACTION) {
aState = PushPermissionState::Denied;
} else {
aState = PushPermissionState::Prompt;
}
return NS_OK;
}
// A helper class that frees an `nsIPushSubscription` key buffer when it
// goes out of scope.
class MOZ_RAII AutoFreeKeyBuffer final
{
uint8_t** mKeyBuffer;
public:
explicit AutoFreeKeyBuffer(uint8_t** aKeyBuffer)
: mKeyBuffer(aKeyBuffer)
{
MOZ_ASSERT(mKeyBuffer);
}
~AutoFreeKeyBuffer()
{
NS_Free(*mKeyBuffer);
}
};
// Copies a subscription key buffer into an array.
nsresult
CopySubscriptionKeyToArray(nsIPushSubscription* aSubscription,
const nsAString& aKeyName,
nsTArray<uint8_t>& aKey)
{
uint8_t* keyBuffer = nullptr;
AutoFreeKeyBuffer autoFree(&keyBuffer);
uint32_t keyLen;
nsresult rv = aSubscription->GetKey(aKeyName, &keyLen, &keyBuffer);
if (NS_FAILED(rv)) {
return rv;
}
if (!aKey.SetCapacity(keyLen, fallible) ||
!aKey.InsertElementsAt(0, keyBuffer, keyLen, fallible)) {
return NS_ERROR_OUT_OF_MEMORY;
}
return NS_OK;
}
nsresult
GetSubscriptionParams(nsIPushSubscription* aSubscription,
nsAString& aEndpoint,
nsTArray<uint8_t>& aRawP256dhKey,
nsTArray<uint8_t>& aAuthSecret,
nsTArray<uint8_t>& aAppServerKey)
{
if (!aSubscription) {
return NS_OK;
}
nsresult rv = aSubscription->GetEndpoint(aEndpoint);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
rv = CopySubscriptionKeyToArray(aSubscription, NS_LITERAL_STRING("p256dh"),
aRawP256dhKey);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
rv = CopySubscriptionKeyToArray(aSubscription, NS_LITERAL_STRING("auth"),
aAuthSecret);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
rv = CopySubscriptionKeyToArray(aSubscription, NS_LITERAL_STRING("appServer"),
aAppServerKey);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
return NS_OK;
}
class GetSubscriptionResultRunnable final : public WorkerRunnable
{
public:
GetSubscriptionResultRunnable(WorkerPrivate* aWorkerPrivate,
already_AddRefed<PromiseWorkerProxy>&& aProxy,
nsresult aStatus,
const nsAString& aEndpoint,
const nsAString& aScope,
nsTArray<uint8_t>&& aRawP256dhKey,
nsTArray<uint8_t>&& aAuthSecret,
nsTArray<uint8_t>&& aAppServerKey)
: WorkerRunnable(aWorkerPrivate)
, mProxy(Move(aProxy))
, mStatus(aStatus)
, mEndpoint(aEndpoint)
, mScope(aScope)
, mRawP256dhKey(Move(aRawP256dhKey))
, mAuthSecret(Move(aAuthSecret))
, mAppServerKey(Move(aAppServerKey))
{ }
bool
WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override
{
RefPtr<Promise> promise = mProxy->WorkerPromise();
if (NS_SUCCEEDED(mStatus)) {
if (mEndpoint.IsEmpty()) {
promise->MaybeResolve(JS::NullHandleValue);
} else {
RefPtr<PushSubscription> sub =
new PushSubscription(nullptr, mEndpoint, mScope,
Move(mRawP256dhKey), Move(mAuthSecret),
Move(mAppServerKey));
promise->MaybeResolve(sub);
}
} else if (NS_ERROR_GET_MODULE(mStatus) == NS_ERROR_MODULE_DOM_PUSH ) {
promise->MaybeReject(mStatus);
} else {
promise->MaybeReject(NS_ERROR_DOM_PUSH_ABORT_ERR);
}
mProxy->CleanUp();
return true;
}
private:
~GetSubscriptionResultRunnable()
{}
RefPtr<PromiseWorkerProxy> mProxy;
nsresult mStatus;
nsString mEndpoint;
nsString mScope;
nsTArray<uint8_t> mRawP256dhKey;
nsTArray<uint8_t> mAuthSecret;
nsTArray<uint8_t> mAppServerKey;
};
class GetSubscriptionCallback final : public nsIPushSubscriptionCallback
{
public:
NS_DECL_ISUPPORTS
explicit GetSubscriptionCallback(PromiseWorkerProxy* aProxy,
const nsAString& aScope)
: mProxy(aProxy)
, mScope(aScope)
{}
NS_IMETHOD
OnPushSubscription(nsresult aStatus,
nsIPushSubscription* aSubscription) override
{
AssertIsOnMainThread();
MOZ_ASSERT(mProxy, "OnPushSubscription() called twice?");
MutexAutoLock lock(mProxy->Lock());
if (mProxy->CleanedUp()) {
return NS_OK;
}
nsAutoString endpoint;
nsTArray<uint8_t> rawP256dhKey, authSecret, appServerKey;
if (NS_SUCCEEDED(aStatus)) {
aStatus = GetSubscriptionParams(aSubscription, endpoint, rawP256dhKey,
authSecret, appServerKey);
}
WorkerPrivate* worker = mProxy->GetWorkerPrivate();
RefPtr<GetSubscriptionResultRunnable> r =
new GetSubscriptionResultRunnable(worker,
mProxy.forget(),
aStatus,
endpoint,
mScope,
Move(rawP256dhKey),
Move(authSecret),
Move(appServerKey));
MOZ_ALWAYS_TRUE(r->Dispatch());
return NS_OK;
}
// Convenience method for use in this file.
void
OnPushSubscriptionError(nsresult aStatus)
{
Unused << NS_WARN_IF(NS_FAILED(
OnPushSubscription(aStatus, nullptr)));
}
protected:
~GetSubscriptionCallback()
{}
private:
RefPtr<PromiseWorkerProxy> mProxy;
nsString mScope;
};
NS_IMPL_ISUPPORTS(GetSubscriptionCallback, nsIPushSubscriptionCallback)
class GetSubscriptionRunnable final : public Runnable
{
public:
GetSubscriptionRunnable(PromiseWorkerProxy* aProxy,
const nsAString& aScope,
PushManager::SubscriptionAction aAction,
nsTArray<uint8_t>&& aAppServerKey)
: mProxy(aProxy)
, mScope(aScope)
, mAction(aAction)
, mAppServerKey(Move(aAppServerKey))
{}
NS_IMETHOD
Run() override
{
AssertIsOnMainThread();
nsCOMPtr<nsIPrincipal> principal;
{
// Bug 1228723: If permission is revoked or an error occurs, the
// subscription callback will be called synchronously. This causes
// `GetSubscriptionCallback::OnPushSubscription` to deadlock when
// it tries to acquire the lock.
MutexAutoLock lock(mProxy->Lock());
if (mProxy->CleanedUp()) {
return NS_OK;
}
principal = mProxy->GetWorkerPrivate()->GetPrincipal();
}
MOZ_ASSERT(principal);
RefPtr<GetSubscriptionCallback> callback = new GetSubscriptionCallback(mProxy, mScope);
PushPermissionState state;
nsresult rv = GetPermissionState(principal, state);
if (NS_FAILED(rv)) {
callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
return NS_OK;
}
if (state != PushPermissionState::Granted) {
if (mAction == PushManager::GetSubscriptionAction) {
callback->OnPushSubscriptionError(NS_OK);
return NS_OK;
}
callback->OnPushSubscriptionError(NS_ERROR_DOM_PUSH_DENIED_ERR);
return NS_OK;
}
nsCOMPtr<nsIPushService> service =
do_GetService("@mozilla.org/push/Service;1");
if (NS_WARN_IF(!service)) {
callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
return NS_OK;
}
if (mAction == PushManager::SubscribeAction) {
if (mAppServerKey.IsEmpty()) {
rv = service->Subscribe(mScope, principal, callback);
} else {
rv = service->SubscribeWithKey(mScope, principal,
mAppServerKey.Length(),
mAppServerKey.Elements(), callback);
}
} else {
MOZ_ASSERT(mAction == PushManager::GetSubscriptionAction);
rv = service->GetSubscription(mScope, principal, callback);
}
if (NS_WARN_IF(NS_FAILED(rv))) {
callback->OnPushSubscriptionError(NS_ERROR_FAILURE);
return NS_OK;
}
return NS_OK;
}
private:
~GetSubscriptionRunnable()
{}
RefPtr<PromiseWorkerProxy> mProxy;
nsString mScope;
PushManager::SubscriptionAction mAction;
nsTArray<uint8_t> mAppServerKey;
};
class PermissionResultRunnable final : public WorkerRunnable
{
public:
PermissionResultRunnable(PromiseWorkerProxy *aProxy,
nsresult aStatus,
PushPermissionState aState)
: WorkerRunnable(aProxy->GetWorkerPrivate())
, mProxy(aProxy)
, mStatus(aStatus)
, mState(aState)
{
AssertIsOnMainThread();
}
bool
WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override
{
MOZ_ASSERT(aWorkerPrivate);
aWorkerPrivate->AssertIsOnWorkerThread();
RefPtr<Promise> promise = mProxy->WorkerPromise();
if (NS_SUCCEEDED(mStatus)) {
promise->MaybeResolve(mState);
} else {
promise->MaybeReject(aCx, JS::UndefinedHandleValue);
}
mProxy->CleanUp();
return true;
}
private:
~PermissionResultRunnable()
{}
RefPtr<PromiseWorkerProxy> mProxy;
nsresult mStatus;
PushPermissionState mState;
};
class PermissionStateRunnable final : public Runnable
{
public:
explicit PermissionStateRunnable(PromiseWorkerProxy* aProxy)
: mProxy(aProxy)
{}
NS_IMETHOD
Run() override
{
AssertIsOnMainThread();
MutexAutoLock lock(mProxy->Lock());
if (mProxy->CleanedUp()) {
return NS_OK;
}
PushPermissionState state;
nsresult rv = GetPermissionState(
mProxy->GetWorkerPrivate()->GetPrincipal(),
state
);
RefPtr<PermissionResultRunnable> r =
new PermissionResultRunnable(mProxy, rv, state);
MOZ_ALWAYS_TRUE(r->Dispatch());
return NS_OK;
}
private:
~PermissionStateRunnable()
{}
RefPtr<PromiseWorkerProxy> mProxy;
};
} // anonymous namespace
PushManager::PushManager(nsIGlobalObject* aGlobal, PushManagerImpl* aImpl)
: mGlobal(aGlobal)
, mImpl(aImpl)
{
AssertIsOnMainThread();
MOZ_ASSERT(aImpl);
}
PushManager::PushManager(const nsAString& aScope)
: mScope(aScope)
{
#ifdef DEBUG
// There's only one global on a worker, so we don't need to pass a global
// object to the constructor.
WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(worker);
worker->AssertIsOnWorkerThread();
#endif
}
PushManager::~PushManager()
{}
NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(PushManager, mGlobal, mImpl)
NS_IMPL_CYCLE_COLLECTING_ADDREF(PushManager)
NS_IMPL_CYCLE_COLLECTING_RELEASE(PushManager)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushManager)
NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
NS_INTERFACE_MAP_ENTRY(nsISupports)
NS_INTERFACE_MAP_END
JSObject*
PushManager::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto)
{
return PushManagerBinding::Wrap(aCx, this, aGivenProto);
}
// static
already_AddRefed<PushManager>
PushManager::Constructor(GlobalObject& aGlobal,
const nsAString& aScope,
ErrorResult& aRv)
{
if (!NS_IsMainThread()) {
RefPtr<PushManager> ret = new PushManager(aScope);
return ret.forget();
}
RefPtr<PushManagerImpl> impl = PushManagerImpl::Constructor(aGlobal,
aGlobal.Context(),
aScope, aRv);
if (aRv.Failed()) {
return nullptr;
}
nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
RefPtr<PushManager> ret = new PushManager(global, impl);
return ret.forget();
}
already_AddRefed<Promise>
PushManager::Subscribe(const PushSubscriptionOptionsInit& aOptions,
ErrorResult& aRv)
{
if (mImpl) {
MOZ_ASSERT(NS_IsMainThread());
return mImpl->Subscribe(aOptions, aRv);
}
return PerformSubscriptionActionFromWorker(SubscribeAction, aOptions, aRv);
}
already_AddRefed<Promise>
PushManager::GetSubscription(ErrorResult& aRv)
{
if (mImpl) {
MOZ_ASSERT(NS_IsMainThread());
return mImpl->GetSubscription(aRv);
}
return PerformSubscriptionActionFromWorker(GetSubscriptionAction, aRv);
}
already_AddRefed<Promise>
PushManager::PermissionState(const PushSubscriptionOptionsInit& aOptions,
ErrorResult& aRv)
{
if (mImpl) {
MOZ_ASSERT(NS_IsMainThread());
return mImpl->PermissionState(aOptions, aRv);
}
WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(worker);
worker->AssertIsOnWorkerThread();
nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
RefPtr<Promise> p = Promise::Create(global, aRv);
if (NS_WARN_IF(aRv.Failed())) {
return nullptr;
}
RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
if (!proxy) {
p->MaybeReject(worker->GetJSContext(), JS::UndefinedHandleValue);
return p.forget();
}
RefPtr<PermissionStateRunnable> r =
new PermissionStateRunnable(proxy);
NS_DispatchToMainThread(r);
return p.forget();
}
already_AddRefed<Promise>
PushManager::PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
ErrorResult& aRv)
{
PushSubscriptionOptionsInit options;
return PerformSubscriptionActionFromWorker(aAction, options, aRv);
}
already_AddRefed<Promise>
PushManager::PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
const PushSubscriptionOptionsInit& aOptions,
ErrorResult& aRv)
{
WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(worker);
worker->AssertIsOnWorkerThread();
nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
RefPtr<Promise> p = Promise::Create(global, aRv);
if (NS_WARN_IF(aRv.Failed())) {
return nullptr;
}
RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
if (!proxy) {
p->MaybeReject(NS_ERROR_DOM_PUSH_ABORT_ERR);
return p.forget();
}
nsTArray<uint8_t> appServerKey;
if (!aOptions.mApplicationServerKey.IsNull()) {
const OwningArrayBufferViewOrArrayBuffer& bufferSource =
aOptions.mApplicationServerKey.Value();
if (!PushUtil::CopyBufferSourceToArray(bufferSource, appServerKey) ||
appServerKey.IsEmpty()) {
p->MaybeReject(NS_ERROR_DOM_PUSH_INVALID_KEY_ERR);
return p.forget();
}
}
RefPtr<GetSubscriptionRunnable> r =
new GetSubscriptionRunnable(proxy, mScope, aAction, Move(appServerKey));
MOZ_ALWAYS_SUCCEEDS(NS_DispatchToMainThread(r));
return p.forget();
}
} // namespace dom
} // namespace mozilla

117
dom/push/PushManager.h Normal file
View file

@ -0,0 +1,117 @@
/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim: set ts=8 sts=2 et sw=2 tw=80: */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* PushManager and PushSubscription are exposed on the main and worker threads.
* The main thread version is implemented in Push.js. The JS implementation
* makes it easier to use certain APIs like the permission prompt and Promises.
*
* Unfortunately, JS-implemented WebIDL is not supported off the main thread.
* To work around this, we use a chain of runnables to query the JS-implemented
* nsIPushService component for subscription information, and return the
* results to the worker. We don't have to deal with permission prompts, since
* we just reject calls if the principal does not have permission.
*
* On the main thread, PushManager wraps a JS-implemented PushManagerImpl
* instance. The C++ wrapper is necessary because our bindings code cannot
* accomodate "JS-implemented on the main thread, C++ on the worker" bindings.
*
* PushSubscription is in C++ on both threads since it isn't particularly
* verbose to implement in C++ compared to JS.
*/
#ifndef mozilla_dom_PushManager_h
#define mozilla_dom_PushManager_h
#include "nsWrapperCache.h"
#include "mozilla/AlreadyAddRefed.h"
#include "mozilla/ErrorResult.h"
#include "mozilla/dom/BindingDeclarations.h"
#include "mozilla/dom/TypedArray.h"
#include "nsCOMPtr.h"
#include "nsContentUtils.h" // Required for nsContentUtils::PushEnabled
#include "mozilla/RefPtr.h"
class nsIGlobalObject;
class nsIPrincipal;
namespace mozilla {
namespace dom {
namespace workers {
class WorkerPrivate;
}
class Promise;
class PushManagerImpl;
struct PushSubscriptionOptionsInit;
class PushManager final : public nsISupports
, public nsWrapperCache
{
public:
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushManager)
enum SubscriptionAction {
SubscribeAction,
GetSubscriptionAction,
};
// The main thread constructor.
PushManager(nsIGlobalObject* aGlobal, PushManagerImpl* aImpl);
// The worker thread constructor.
explicit PushManager(const nsAString& aScope);
nsIGlobalObject*
GetParentObject() const
{
return mGlobal;
}
JSObject*
WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
static already_AddRefed<PushManager>
Constructor(GlobalObject& aGlobal, const nsAString& aScope,
ErrorResult& aRv);
already_AddRefed<Promise>
PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
ErrorResult& aRv);
already_AddRefed<Promise>
PerformSubscriptionActionFromWorker(SubscriptionAction aAction,
const PushSubscriptionOptionsInit& aOptions,
ErrorResult& aRv);
already_AddRefed<Promise>
Subscribe(const PushSubscriptionOptionsInit& aOptions, ErrorResult& aRv);
already_AddRefed<Promise>
GetSubscription(ErrorResult& aRv);
already_AddRefed<Promise>
PermissionState(const PushSubscriptionOptionsInit& aOptions,
ErrorResult& aRv);
private:
~PushManager();
// The following are only set and accessed on the main thread.
nsCOMPtr<nsIGlobalObject> mGlobal;
RefPtr<PushManagerImpl> mImpl;
// Only used on the worker thread.
nsString mScope;
};
} // namespace dom
} // namespace mozilla
#endif // mozilla_dom_PushManager_h

550
dom/push/PushNotifier.cpp Normal file
View file

@ -0,0 +1,550 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "PushNotifier.h"
#include "nsContentUtils.h"
#include "nsCOMPtr.h"
#include "nsICategoryManager.h"
#include "nsIXULRuntime.h"
#include "nsNetUtil.h"
#include "nsXPCOM.h"
#include "ServiceWorkerManager.h"
#include "mozilla/Services.h"
#include "mozilla/Unused.h"
#include "mozilla/dom/BodyUtil.h"
#include "mozilla/dom/ContentChild.h"
#include "mozilla/dom/ContentParent.h"
namespace mozilla {
namespace dom {
using workers::AssertIsOnMainThread;
using workers::ServiceWorkerManager;
PushNotifier::PushNotifier()
{}
PushNotifier::~PushNotifier()
{}
NS_IMPL_CYCLE_COLLECTION_0(PushNotifier)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushNotifier)
NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushNotifier)
NS_INTERFACE_MAP_ENTRY(nsIPushNotifier)
NS_INTERFACE_MAP_END
NS_IMPL_CYCLE_COLLECTING_ADDREF(PushNotifier)
NS_IMPL_CYCLE_COLLECTING_RELEASE(PushNotifier)
NS_IMETHODIMP
PushNotifier::NotifyPushWithData(const nsACString& aScope,
nsIPrincipal* aPrincipal,
const nsAString& aMessageId,
uint32_t aDataLen, uint8_t* aData)
{
NS_ENSURE_ARG(aPrincipal);
nsTArray<uint8_t> data;
if (!data.SetCapacity(aDataLen, fallible)) {
return NS_ERROR_OUT_OF_MEMORY;
}
if (!data.InsertElementsAt(0, aData, aDataLen, fallible)) {
return NS_ERROR_OUT_OF_MEMORY;
}
PushMessageDispatcher dispatcher(aScope, aPrincipal, aMessageId, Some(data));
return Dispatch(dispatcher);
}
NS_IMETHODIMP
PushNotifier::NotifyPush(const nsACString& aScope, nsIPrincipal* aPrincipal,
const nsAString& aMessageId)
{
NS_ENSURE_ARG(aPrincipal);
PushMessageDispatcher dispatcher(aScope, aPrincipal, aMessageId, Nothing());
return Dispatch(dispatcher);
}
NS_IMETHODIMP
PushNotifier::NotifySubscriptionChange(const nsACString& aScope,
nsIPrincipal* aPrincipal)
{
NS_ENSURE_ARG(aPrincipal);
PushSubscriptionChangeDispatcher dispatcher(aScope, aPrincipal);
return Dispatch(dispatcher);
}
NS_IMETHODIMP
PushNotifier::NotifySubscriptionModified(const nsACString& aScope,
nsIPrincipal* aPrincipal)
{
NS_ENSURE_ARG(aPrincipal);
PushSubscriptionModifiedDispatcher dispatcher(aScope, aPrincipal);
return Dispatch(dispatcher);
}
NS_IMETHODIMP
PushNotifier::NotifyError(const nsACString& aScope, nsIPrincipal* aPrincipal,
const nsAString& aMessage, uint32_t aFlags)
{
NS_ENSURE_ARG(aPrincipal);
PushErrorDispatcher dispatcher(aScope, aPrincipal, aMessage, aFlags);
return Dispatch(dispatcher);
}
nsresult
PushNotifier::Dispatch(PushDispatcher& aDispatcher)
{
if (XRE_IsParentProcess()) {
// Always notify XPCOM observers in the parent process.
Unused << NS_WARN_IF(NS_FAILED(aDispatcher.NotifyObservers()));
nsTArray<ContentParent*> contentActors;
ContentParent::GetAll(contentActors);
if (!contentActors.IsEmpty()) {
// At least one content process is active, so e10s must be enabled.
// Broadcast a message to notify observers and service workers.
for (uint32_t i = 0; i < contentActors.Length(); ++i) {
Unused << NS_WARN_IF(!aDispatcher.SendToChild(contentActors[i]));
}
return NS_OK;
}
if (BrowserTabsRemoteAutostart()) {
// e10s is enabled, but no content processes are active.
return aDispatcher.HandleNoChildProcesses();
}
// e10s is disabled; notify workers in the parent.
return aDispatcher.NotifyWorkers();
}
// Otherwise, we're in the content process, so e10s must be enabled. Notify
// observers and workers, then send a message to notify observers in the
// parent.
MOZ_ASSERT(XRE_IsContentProcess());
nsresult rv = aDispatcher.NotifyObserversAndWorkers();
ContentChild* parentActor = ContentChild::GetSingleton();
if (!NS_WARN_IF(!parentActor)) {
Unused << NS_WARN_IF(!aDispatcher.SendToParent(parentActor));
}
return rv;
}
PushData::PushData(const nsTArray<uint8_t>& aData)
: mData(aData)
{}
PushData::~PushData()
{}
NS_IMPL_CYCLE_COLLECTION_0(PushData)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushData)
NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushData)
NS_INTERFACE_MAP_ENTRY(nsIPushData)
NS_INTERFACE_MAP_END
NS_IMPL_CYCLE_COLLECTING_ADDREF(PushData)
NS_IMPL_CYCLE_COLLECTING_RELEASE(PushData)
nsresult
PushData::EnsureDecodedText()
{
if (mData.IsEmpty() || !mDecodedText.IsEmpty()) {
return NS_OK;
}
nsresult rv = BodyUtil::ConsumeText(
mData.Length(),
reinterpret_cast<uint8_t*>(mData.Elements()),
mDecodedText
);
if (NS_WARN_IF(NS_FAILED(rv))) {
mDecodedText.Truncate();
return rv;
}
return NS_OK;
}
NS_IMETHODIMP
PushData::Text(nsAString& aText)
{
nsresult rv = EnsureDecodedText();
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
aText = mDecodedText;
return NS_OK;
}
NS_IMETHODIMP
PushData::Json(JSContext* aCx,
JS::MutableHandle<JS::Value> aResult)
{
nsresult rv = EnsureDecodedText();
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
ErrorResult error;
BodyUtil::ConsumeJson(aCx, aResult, mDecodedText, error);
return error.StealNSResult();
}
NS_IMETHODIMP
PushData::Binary(uint32_t* aDataLen, uint8_t** aData)
{
NS_ENSURE_ARG_POINTER(aDataLen);
NS_ENSURE_ARG_POINTER(aData);
*aData = nullptr;
if (mData.IsEmpty()) {
*aDataLen = 0;
return NS_OK;
}
uint32_t length = mData.Length();
uint8_t* data = static_cast<uint8_t*>(NS_Alloc(length * sizeof(uint8_t)));
if (!data) {
return NS_ERROR_OUT_OF_MEMORY;
}
memcpy(data, mData.Elements(), length * sizeof(uint8_t));
*aDataLen = length;
*aData = data;
return NS_OK;
}
PushMessage::PushMessage(nsIPrincipal* aPrincipal, nsIPushData* aData)
: mPrincipal(aPrincipal)
, mData(aData)
{}
PushMessage::~PushMessage()
{}
NS_IMPL_CYCLE_COLLECTION(PushMessage, mPrincipal, mData)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushMessage)
NS_INTERFACE_MAP_ENTRY_AMBIGUOUS(nsISupports, nsIPushMessage)
NS_INTERFACE_MAP_ENTRY(nsIPushMessage)
NS_INTERFACE_MAP_END
NS_IMPL_CYCLE_COLLECTING_ADDREF(PushMessage)
NS_IMPL_CYCLE_COLLECTING_RELEASE(PushMessage)
NS_IMETHODIMP
PushMessage::GetPrincipal(nsIPrincipal** aPrincipal)
{
NS_ENSURE_ARG_POINTER(aPrincipal);
nsCOMPtr<nsIPrincipal> principal = mPrincipal;
principal.forget(aPrincipal);
return NS_OK;
}
NS_IMETHODIMP
PushMessage::GetData(nsIPushData** aData)
{
NS_ENSURE_ARG_POINTER(aData);
nsCOMPtr<nsIPushData> data = mData;
data.forget(aData);
return NS_OK;
}
PushDispatcher::PushDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal)
: mScope(aScope)
, mPrincipal(aPrincipal)
{}
PushDispatcher::~PushDispatcher()
{}
nsresult
PushDispatcher::HandleNoChildProcesses()
{
return NS_OK;
}
nsresult
PushDispatcher::NotifyObserversAndWorkers()
{
Unused << NS_WARN_IF(NS_FAILED(NotifyObservers()));
return NotifyWorkers();
}
bool
PushDispatcher::ShouldNotifyWorkers()
{
if (NS_WARN_IF(!mPrincipal)) {
return false;
}
// System subscriptions use observer notifications instead of service worker
// events. The `testing.notifyWorkers` pref disables worker events for
// non-system subscriptions.
return !nsContentUtils::IsSystemPrincipal(mPrincipal) &&
Preferences::GetBool("dom.push.testing.notifyWorkers", true);
}
nsresult
PushDispatcher::DoNotifyObservers(nsISupports *aSubject, const char *aTopic,
const nsACString& aScope)
{
nsCOMPtr<nsIObserverService> obsService =
mozilla::services::GetObserverService();
if (!obsService) {
return NS_ERROR_FAILURE;
}
// If there's a service for this push category, make sure it is alive.
nsCOMPtr<nsICategoryManager> catMan =
do_GetService(NS_CATEGORYMANAGER_CONTRACTID);
if (catMan) {
nsXPIDLCString contractId;
nsresult rv = catMan->GetCategoryEntry("push",
mScope.BeginReading(),
getter_Copies(contractId));
if (NS_SUCCEEDED(rv)) {
// Ensure the service is created - we don't need to do anything with
// it though - we assume the service constructor attaches a listener.
nsCOMPtr<nsISupports> service = do_GetService(contractId);
}
}
return obsService->NotifyObservers(aSubject, aTopic,
NS_ConvertUTF8toUTF16(mScope).get());
}
PushMessageDispatcher::PushMessageDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal,
const nsAString& aMessageId,
const Maybe<nsTArray<uint8_t>>& aData)
: PushDispatcher(aScope, aPrincipal)
, mMessageId(aMessageId)
, mData(aData)
{}
PushMessageDispatcher::~PushMessageDispatcher()
{}
nsresult
PushMessageDispatcher::NotifyObservers()
{
nsCOMPtr<nsIPushData> data;
if (mData) {
data = new PushData(mData.ref());
}
nsCOMPtr<nsIPushMessage> message = new PushMessage(mPrincipal, data);
return DoNotifyObservers(message, OBSERVER_TOPIC_PUSH, mScope);
}
nsresult
PushMessageDispatcher::NotifyWorkers()
{
if (!ShouldNotifyWorkers()) {
return NS_OK;
}
RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
if (!swm) {
return NS_ERROR_FAILURE;
}
nsAutoCString originSuffix;
nsresult rv = mPrincipal->GetOriginSuffix(originSuffix);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
return swm->SendPushEvent(originSuffix, mScope, mMessageId, mData);
}
bool
PushMessageDispatcher::SendToParent(ContentChild* aParentActor)
{
if (mData) {
return aParentActor->SendNotifyPushObserversWithData(mScope,
IPC::Principal(mPrincipal),
mMessageId,
mData.ref());
}
return aParentActor->SendNotifyPushObservers(mScope,
IPC::Principal(mPrincipal),
mMessageId);
}
bool
PushMessageDispatcher::SendToChild(ContentParent* aContentActor)
{
if (mData) {
return aContentActor->SendPushWithData(mScope, IPC::Principal(mPrincipal),
mMessageId, mData.ref());
}
return aContentActor->SendPush(mScope, IPC::Principal(mPrincipal),
mMessageId);
}
PushSubscriptionChangeDispatcher::PushSubscriptionChangeDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal)
: PushDispatcher(aScope, aPrincipal)
{}
PushSubscriptionChangeDispatcher::~PushSubscriptionChangeDispatcher()
{}
nsresult
PushSubscriptionChangeDispatcher::NotifyObservers()
{
return DoNotifyObservers(mPrincipal, OBSERVER_TOPIC_SUBSCRIPTION_CHANGE,
mScope);
}
nsresult
PushSubscriptionChangeDispatcher::NotifyWorkers()
{
if (!ShouldNotifyWorkers()) {
return NS_OK;
}
RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
if (!swm) {
return NS_ERROR_FAILURE;
}
nsAutoCString originSuffix;
nsresult rv = mPrincipal->GetOriginSuffix(originSuffix);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
return swm->SendPushSubscriptionChangeEvent(originSuffix, mScope);
}
bool
PushSubscriptionChangeDispatcher::SendToParent(ContentChild* aParentActor)
{
return aParentActor->SendNotifyPushSubscriptionChangeObservers(mScope,
IPC::Principal(mPrincipal));
}
bool
PushSubscriptionChangeDispatcher::SendToChild(ContentParent* aContentActor)
{
return aContentActor->SendPushSubscriptionChange(mScope,
IPC::Principal(mPrincipal));
}
PushSubscriptionModifiedDispatcher::PushSubscriptionModifiedDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal)
: PushDispatcher(aScope, aPrincipal)
{}
PushSubscriptionModifiedDispatcher::~PushSubscriptionModifiedDispatcher()
{}
nsresult
PushSubscriptionModifiedDispatcher::NotifyObservers()
{
return DoNotifyObservers(mPrincipal, OBSERVER_TOPIC_SUBSCRIPTION_MODIFIED,
mScope);
}
nsresult
PushSubscriptionModifiedDispatcher::NotifyWorkers()
{
return NS_OK;
}
bool
PushSubscriptionModifiedDispatcher::SendToParent(ContentChild* aParentActor)
{
return aParentActor->SendNotifyPushSubscriptionModifiedObservers(mScope,
IPC::Principal(mPrincipal));
}
bool
PushSubscriptionModifiedDispatcher::SendToChild(ContentParent* aContentActor)
{
return aContentActor->SendNotifyPushSubscriptionModifiedObservers(mScope,
IPC::Principal(mPrincipal));
}
PushErrorDispatcher::PushErrorDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal,
const nsAString& aMessage,
uint32_t aFlags)
: PushDispatcher(aScope, aPrincipal)
, mMessage(aMessage)
, mFlags(aFlags)
{}
PushErrorDispatcher::~PushErrorDispatcher()
{}
nsresult
PushErrorDispatcher::NotifyObservers()
{
return NS_OK;
}
nsresult
PushErrorDispatcher::NotifyWorkers()
{
if (!ShouldNotifyWorkers()) {
// For system subscriptions, log the error directly to the browser console.
return nsContentUtils::ReportToConsoleNonLocalized(mMessage,
mFlags,
NS_LITERAL_CSTRING("Push"),
nullptr, /* aDocument */
nullptr, /* aURI */
EmptyString(), /* aLine */
0, /* aLineNumber */
0, /* aColumnNumber */
nsContentUtils::eOMIT_LOCATION);
}
// For service worker subscriptions, report the error to all clients.
RefPtr<ServiceWorkerManager> swm = ServiceWorkerManager::GetInstance();
if (swm) {
swm->ReportToAllClients(mScope,
mMessage,
NS_ConvertUTF8toUTF16(mScope), /* aFilename */
EmptyString(), /* aLine */
0, /* aLineNumber */
0, /* aColumnNumber */
mFlags);
}
return NS_OK;
}
bool
PushErrorDispatcher::SendToParent(ContentChild*)
{
return true;
}
bool
PushErrorDispatcher::SendToChild(ContentParent* aContentActor)
{
return aContentActor->SendPushError(mScope, IPC::Principal(mPrincipal),
mMessage, mFlags);
}
nsresult
PushErrorDispatcher::HandleNoChildProcesses()
{
// Report to the console if no content processes are active.
nsCOMPtr<nsIURI> scopeURI;
nsresult rv = NS_NewURI(getter_AddRefs(scopeURI), mScope);
if (NS_WARN_IF(NS_FAILED(rv))) {
return rv;
}
return nsContentUtils::ReportToConsoleNonLocalized(mMessage,
mFlags,
NS_LITERAL_CSTRING("Push"),
nullptr, /* aDocument */
scopeURI, /* aURI */
EmptyString(), /* aLine */
0, /* aLineNumber */
0, /* aColumnNumber */
nsContentUtils::eOMIT_LOCATION);
}
} // namespace dom
} // namespace mozilla

203
dom/push/PushNotifier.h Normal file
View file

@ -0,0 +1,203 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#ifndef mozilla_dom_PushNotifier_h
#define mozilla_dom_PushNotifier_h
#include "nsIPushNotifier.h"
#include "nsCycleCollectionParticipant.h"
#include "nsIPrincipal.h"
#include "nsString.h"
#include "mozilla/Maybe.h"
namespace mozilla {
namespace dom {
class ContentChild;
class ContentParent;
/**
* `PushDispatcher` is a base class used to forward observer notifications and
* service worker events to the correct process.
*/
class MOZ_STACK_CLASS PushDispatcher
{
public:
// Fires an XPCOM observer notification. This method may be called from both
// processes.
virtual nsresult NotifyObservers() = 0;
// Fires a service worker event. This method is called from the content
// process if e10s is enabled, or the parent otherwise.
virtual nsresult NotifyWorkers() = 0;
// A convenience method that calls `NotifyObservers` and `NotifyWorkers`.
nsresult NotifyObserversAndWorkers();
// Sends an IPDL message to fire an observer notification in the parent
// process. This method is only called from the content process, and only
// if e10s is enabled.
virtual bool SendToParent(ContentChild* aParentActor) = 0;
// Sends an IPDL message to fire an observer notification and a service worker
// event in the content process. This method is only called from the parent,
// and only if e10s is enabled.
virtual bool SendToChild(ContentParent* aContentActor) = 0;
// An optional method, called from the parent if e10s is enabled and there
// are no active content processes. The default behavior is a no-op.
virtual nsresult HandleNoChildProcesses();
protected:
PushDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal);
virtual ~PushDispatcher();
bool ShouldNotifyWorkers();
nsresult DoNotifyObservers(nsISupports *aSubject, const char *aTopic,
const nsACString& aScope);
const nsCString mScope;
nsCOMPtr<nsIPrincipal> mPrincipal;
};
/**
* `PushNotifier` implements the `nsIPushNotifier` interface. This service
* broadcasts XPCOM observer notifications for incoming push messages, then
* forwards incoming push messages to service workers.
*
* All scriptable methods on this interface may be called from the parent or
* content process. Observer notifications are broadcasted to both processes.
*/
class PushNotifier final : public nsIPushNotifier
{
public:
PushNotifier();
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushNotifier, nsIPushNotifier)
NS_DECL_NSIPUSHNOTIFIER
private:
~PushNotifier();
nsresult Dispatch(PushDispatcher& aDispatcher);
};
/**
* `PushData` provides methods for retrieving push message data in different
* formats. This class is similar to the `PushMessageData` WebIDL interface.
*/
class PushData final : public nsIPushData
{
public:
explicit PushData(const nsTArray<uint8_t>& aData);
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushData, nsIPushData)
NS_DECL_NSIPUSHDATA
private:
~PushData();
nsresult EnsureDecodedText();
nsTArray<uint8_t> mData;
nsString mDecodedText;
};
/**
* `PushMessage` exposes the subscription principal and data for a push
* message. Each `push-message` observer receives an instance of this class
* as the subject.
*/
class PushMessage final : public nsIPushMessage
{
public:
PushMessage(nsIPrincipal* aPrincipal, nsIPushData* aData);
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_CLASS_AMBIGUOUS(PushMessage, nsIPushMessage)
NS_DECL_NSIPUSHMESSAGE
private:
~PushMessage();
nsCOMPtr<nsIPrincipal> mPrincipal;
nsCOMPtr<nsIPushData> mData;
};
class PushMessageDispatcher final : public PushDispatcher
{
public:
PushMessageDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal,
const nsAString& aMessageId,
const Maybe<nsTArray<uint8_t>>& aData);
~PushMessageDispatcher();
nsresult NotifyObservers() override;
nsresult NotifyWorkers() override;
bool SendToParent(ContentChild* aParentActor) override;
bool SendToChild(ContentParent* aContentActor) override;
private:
const nsString mMessageId;
const Maybe<nsTArray<uint8_t>> mData;
};
class PushSubscriptionChangeDispatcher final : public PushDispatcher
{
public:
PushSubscriptionChangeDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal);
~PushSubscriptionChangeDispatcher();
nsresult NotifyObservers() override;
nsresult NotifyWorkers() override;
bool SendToParent(ContentChild* aParentActor) override;
bool SendToChild(ContentParent* aContentActor) override;
};
class PushSubscriptionModifiedDispatcher : public PushDispatcher
{
public:
PushSubscriptionModifiedDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal);
~PushSubscriptionModifiedDispatcher();
nsresult NotifyObservers() override;
nsresult NotifyWorkers() override;
bool SendToParent(ContentChild* aParentActor) override;
bool SendToChild(ContentParent* aContentActor) override;
};
class PushErrorDispatcher final : public PushDispatcher
{
public:
PushErrorDispatcher(const nsACString& aScope,
nsIPrincipal* aPrincipal,
const nsAString& aMessage,
uint32_t aFlags);
~PushErrorDispatcher();
nsresult NotifyObservers() override;
nsresult NotifyWorkers() override;
bool SendToParent(ContentChild* aParentActor) override;
bool SendToChild(ContentParent* aContentActor) override;
private:
nsresult HandleNoChildProcesses() override;
const nsString mMessage;
uint32_t mFlags;
};
} // namespace dom
} // namespace mozilla
#endif // mozilla_dom_PushNotifier_h

318
dom/push/PushRecord.jsm Normal file
View file

@ -0,0 +1,318 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/AppConstants.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Messaging",
"resource://gre/modules/Messaging.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils",
"resource://gre/modules/PlacesUtils.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "Task",
"resource://gre/modules/Task.jsm");
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
"resource://gre/modules/PrivateBrowsingUtils.jsm");
this.EXPORTED_SYMBOLS = ["PushRecord"];
const prefs = new Preferences("dom.push.");
/**
* The push subscription record, stored in IndexedDB.
*/
function PushRecord(props) {
this.pushEndpoint = props.pushEndpoint;
this.scope = props.scope;
this.originAttributes = props.originAttributes;
this.pushCount = props.pushCount || 0;
this.lastPush = props.lastPush || 0;
this.p256dhPublicKey = props.p256dhPublicKey;
this.p256dhPrivateKey = props.p256dhPrivateKey;
this.authenticationSecret = props.authenticationSecret;
this.systemRecord = !!props.systemRecord;
this.appServerKey = props.appServerKey;
this.recentMessageIDs = props.recentMessageIDs;
this.setQuota(props.quota);
this.ctime = (typeof props.ctime === "number") ? props.ctime : 0;
}
PushRecord.prototype = {
setQuota(suggestedQuota) {
if (this.quotaApplies()) {
let quota = +suggestedQuota;
this.quota = quota >= 0 ? quota : prefs.get("maxQuotaPerSubscription");
} else {
this.quota = Infinity;
}
},
resetQuota() {
this.quota = this.quotaApplies() ?
prefs.get("maxQuotaPerSubscription") : Infinity;
},
updateQuota(lastVisit) {
if (this.isExpired() || !this.quotaApplies()) {
// Ignore updates if the registration is already expired, or isn't
// subject to quota.
return;
}
if (lastVisit < 0) {
// If the user cleared their history, but retained the push permission,
// mark the registration as expired.
this.quota = 0;
return;
}
if (lastVisit > this.lastPush) {
// If the user visited the site since the last time we received a
// notification, reset the quota. `Math.max(0, ...)` ensures the
// last visit date isn't in the future.
let daysElapsed =
Math.max(0, (Date.now() - lastVisit) / 24 / 60 / 60 / 1000);
this.quota = Math.min(
Math.round(8 * Math.pow(daysElapsed, -0.8)),
prefs.get("maxQuotaPerSubscription")
);
Services.telemetry.getHistogramById("PUSH_API_QUOTA_RESET_TO").add(this.quota);
}
},
receivedPush(lastVisit) {
this.updateQuota(lastVisit);
this.pushCount++;
this.lastPush = Date.now();
},
/**
* Records a message ID sent to this push registration. We track the last few
* messages sent to each registration to avoid firing duplicate events for
* unacknowledged messages.
*/
noteRecentMessageID(id) {
if (this.recentMessageIDs) {
this.recentMessageIDs.unshift(id);
} else {
this.recentMessageIDs = [id];
}
// Drop older message IDs from the end of the list.
let maxRecentMessageIDs = Math.min(
this.recentMessageIDs.length,
Math.max(prefs.get("maxRecentMessageIDsPerSubscription"), 0)
);
this.recentMessageIDs.length = maxRecentMessageIDs || 0;
},
hasRecentMessageID(id) {
return this.recentMessageIDs && this.recentMessageIDs.includes(id);
},
reduceQuota() {
if (!this.quotaApplies()) {
return;
}
this.quota = Math.max(this.quota - 1, 0);
// We check for ctime > 0 to skip older records that did not have ctime.
if (this.isExpired() && this.ctime > 0) {
let duration = Date.now() - this.ctime;
Services.telemetry.getHistogramById("PUSH_API_QUOTA_EXPIRATION_TIME").add(duration / 1000);
}
},
/**
* Queries the Places database for the last time a user visited the site
* associated with a push registration.
*
* @returns {Promise} A promise resolved with either the last time the user
* visited the site, or `-Infinity` if the site is not in the user's history.
* The time is expressed in milliseconds since Epoch.
*/
getLastVisit: Task.async(function* () {
if (!this.quotaApplies() || this.isTabOpen()) {
// If the registration isn't subject to quota, or the user already
// has the site open, skip expensive database queries.
return Date.now();
}
if (AppConstants.MOZ_ANDROID_HISTORY) {
let result = yield Messaging.sendRequestForResult({
type: "History:GetPrePathLastVisitedTimeMilliseconds",
prePath: this.uri.prePath,
});
return result == 0 ? -Infinity : result;
}
// Places History transition types that can fire a
// `pushsubscriptionchange` event when the user visits a site with expired push
// registrations. Visits only count if the user sees the origin in the address
// bar. This excludes embedded resources, downloads, and framed links.
const QUOTA_REFRESH_TRANSITIONS_SQL = [
Ci.nsINavHistoryService.TRANSITION_LINK,
Ci.nsINavHistoryService.TRANSITION_TYPED,
Ci.nsINavHistoryService.TRANSITION_BOOKMARK,
Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT,
Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY
].join(",");
let db = yield PlacesUtils.promiseDBConnection();
// We're using a custom query instead of `nsINavHistoryQueryOptions`
// because the latter doesn't expose a way to filter by transition type:
// `setTransitions` performs a logical "and," but we want an "or." We
// also avoid an unneeded left join on `moz_favicons`, and an `ORDER BY`
// clause that emits a suboptimal index warning.
let rows = yield db.executeCached(
`SELECT MAX(visit_date) AS lastVisit
FROM moz_places p
JOIN moz_historyvisits ON p.id = place_id
WHERE rev_host = get_unreversed_host(:host || '.') || '.'
AND url BETWEEN :prePath AND :prePath || X'FFFF'
AND visit_type IN (${QUOTA_REFRESH_TRANSITIONS_SQL})
`,
{
// Restrict the query to all pages for this origin.
host: this.uri.host,
prePath: this.uri.prePath,
}
);
if (!rows.length) {
return -Infinity;
}
// Places records times in microseconds.
let lastVisit = rows[0].getResultByName("lastVisit");
return lastVisit / 1000;
}),
isTabOpen() {
let windows = Services.wm.getEnumerator("navigator:browser");
while (windows.hasMoreElements()) {
let window = windows.getNext();
if (window.closed || PrivateBrowsingUtils.isWindowPrivate(window)) {
continue;
}
// `gBrowser` on Desktop; `BrowserApp` on Fennec.
let tabs = window.gBrowser ? window.gBrowser.tabContainer.children :
window.BrowserApp.tabs;
for (let tab of tabs) {
// `linkedBrowser` on Desktop; `browser` on Fennec.
let tabURI = (tab.linkedBrowser || tab.browser).currentURI;
if (tabURI.prePath == this.uri.prePath) {
return true;
}
}
}
return false;
},
/**
* Indicates whether the registration can deliver push messages to its
* associated service worker. System subscriptions are exempt from the
* permission check.
*/
hasPermission() {
if (this.systemRecord || prefs.get("testing.ignorePermission")) {
return true;
}
let permission = Services.perms.testExactPermissionFromPrincipal(
this.principal, "desktop-notification");
return permission == Ci.nsIPermissionManager.ALLOW_ACTION;
},
quotaChanged() {
if (!this.hasPermission()) {
return Promise.resolve(false);
}
return this.getLastVisit()
.then(lastVisit => lastVisit > this.lastPush);
},
quotaApplies() {
return !this.systemRecord;
},
isExpired() {
return this.quota === 0;
},
matchesOriginAttributes(pattern) {
if (this.systemRecord) {
return false;
}
return ChromeUtils.originAttributesMatchPattern(
this.principal.originAttributes, pattern);
},
hasAuthenticationSecret() {
return !!this.authenticationSecret &&
this.authenticationSecret.byteLength == 16;
},
matchesAppServerKey(key) {
if (!this.appServerKey) {
return !key;
}
if (!key) {
return false;
}
return this.appServerKey.length === key.length &&
this.appServerKey.every((value, index) => value === key[index]);
},
toSubscription() {
return {
endpoint: this.pushEndpoint,
lastPush: this.lastPush,
pushCount: this.pushCount,
p256dhKey: this.p256dhPublicKey,
p256dhPrivateKey: this.p256dhPrivateKey,
authenticationSecret: this.authenticationSecret,
appServerKey: this.appServerKey,
quota: this.quotaApplies() ? this.quota : -1,
systemRecord: this.systemRecord,
};
},
};
// Define lazy getters for the principal and scope URI. IndexedDB can't store
// `nsIPrincipal` objects, so we keep them in a private weak map.
var principals = new WeakMap();
Object.defineProperties(PushRecord.prototype, {
principal: {
get() {
if (this.systemRecord) {
return Services.scriptSecurityManager.getSystemPrincipal();
}
let principal = principals.get(this);
if (!principal) {
let uri = Services.io.newURI(this.scope, null, null);
// Allow tests to omit origin attributes.
let originSuffix = this.originAttributes || "";
let originAttributes =
principal = Services.scriptSecurityManager.createCodebasePrincipal(uri,
ChromeUtils.createOriginAttributesFromOrigin(originSuffix));
principals.set(this, principal);
}
return principal;
},
configurable: true,
},
uri: {
get() {
return this.principal.URI;
},
configurable: true,
},
});

1365
dom/push/PushService.jsm Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,275 @@
/* jshint moz: true, esnext: true */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
const {PushCrypto} = Cu.import("resource://gre/modules/PushCrypto.jsm");
Cu.import("resource://gre/modules/Messaging.jsm"); /*global: Messaging */
Cu.import("resource://gre/modules/Services.jsm"); /*global: Services */
Cu.import("resource://gre/modules/Preferences.jsm"); /*global: Preferences */
Cu.import("resource://gre/modules/Promise.jsm"); /*global: Promise */
Cu.import("resource://gre/modules/XPCOMUtils.jsm"); /*global: XPCOMUtils */
const Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("Push");
this.EXPORTED_SYMBOLS = ["PushServiceAndroidGCM"];
XPCOMUtils.defineLazyGetter(this, "console", () => {
let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
return new ConsoleAPI({
dump: Log.i,
maxLogLevelPref: "dom.push.loglevel",
prefix: "PushServiceAndroidGCM",
});
});
const kPUSHANDROIDGCMDB_DB_NAME = "pushAndroidGCM";
const kPUSHANDROIDGCMDB_DB_VERSION = 5; // Change this if the IndexedDB format changes
const kPUSHANDROIDGCMDB_STORE_NAME = "pushAndroidGCM";
const FXA_PUSH_SCOPE = "chrome://fxa-push";
const prefs = new Preferences("dom.push.");
/**
* The implementation of WebPush push backed by Android's GCM
* delivery.
*/
this.PushServiceAndroidGCM = {
_mainPushService: null,
_serverURI: null,
newPushDB: function() {
return new PushDB(kPUSHANDROIDGCMDB_DB_NAME,
kPUSHANDROIDGCMDB_DB_VERSION,
kPUSHANDROIDGCMDB_STORE_NAME,
"channelID",
PushRecordAndroidGCM);
},
validServerURI: function(serverURI) {
if (!serverURI) {
return false;
}
if (serverURI.scheme == "https") {
return true;
}
if (serverURI.scheme == "http") {
// Allow insecure server URLs for development and testing.
return !!prefs.get("testing.allowInsecureServerURL");
}
console.info("Unsupported Android GCM dom.push.serverURL scheme", serverURI.scheme);
return false;
},
observe: function(subject, topic, data) {
switch (topic) {
case "nsPref:changed":
if (data == "dom.push.debug") {
// Reconfigure.
let debug = !!prefs.get("debug");
console.info("Debug parameter changed; updating configuration with new debug", debug);
this._configure(this._serverURI, debug);
}
break;
case "PushServiceAndroidGCM:ReceivedPushMessage":
this._onPushMessageReceived(data);
break;
default:
break;
}
},
_onPushMessageReceived(data) {
// TODO: Use Messaging.jsm for this.
if (this._mainPushService == null) {
// Shouldn't ever happen, but let's be careful.
console.error("No main PushService! Dropping message.");
return;
}
if (!data) {
console.error("No data from Java! Dropping message.");
return;
}
data = JSON.parse(data);
console.debug("ReceivedPushMessage with data", data);
let { headers, message } = this._messageAndHeaders(data);
console.debug("Delivering message to main PushService:", message, headers);
this._mainPushService.receivedPushMessage(
data.channelID, "", headers, message, (record) => {
// Always update the stored record.
return record;
});
},
_messageAndHeaders(data) {
// Default is no data (and no encryption).
let message = null;
let headers = null;
if (data.message && data.enc && (data.enckey || data.cryptokey)) {
headers = {
encryption_key: data.enckey,
crypto_key: data.cryptokey,
encryption: data.enc,
encoding: data.con,
};
// Ciphertext is (urlsafe) Base 64 encoded.
message = ChromeUtils.base64URLDecode(data.message, {
// The Push server may append padding.
padding: "ignore",
});
}
return { headers, message };
},
_configure: function(serverURL, debug) {
return Messaging.sendRequestForResult({
type: "PushServiceAndroidGCM:Configure",
endpoint: serverURL.spec,
debug: debug,
});
},
init: function(options, mainPushService, serverURL) {
console.debug("init()");
this._mainPushService = mainPushService;
this._serverURI = serverURL;
prefs.observe("debug", this);
Services.obs.addObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage", false);
return this._configure(serverURL, !!prefs.get("debug")).then(() => {
Messaging.sendRequestForResult({
type: "PushServiceAndroidGCM:Initialized"
});
});
},
uninit: function() {
console.debug("uninit()");
Messaging.sendRequestForResult({
type: "PushServiceAndroidGCM:Uninitialized"
});
this._mainPushService = null;
Services.obs.removeObserver(this, "PushServiceAndroidGCM:ReceivedPushMessage");
prefs.ignore("debug", this);
},
onAlarmFired: function() {
// No action required.
},
connect: function(records) {
console.debug("connect:", records);
// It's possible for the registration or subscriptions backing the
// PushService to not be registered with the underlying AndroidPushService.
// Expire those that are unrecognized.
return Messaging.sendRequestForResult({
type: "PushServiceAndroidGCM:DumpSubscriptions",
})
.then(subscriptions => {
console.debug("connect:", subscriptions);
// subscriptions maps chid => subscription data.
return Promise.all(records.map(record => {
if (subscriptions.hasOwnProperty(record.keyID)) {
console.debug("connect:", "hasOwnProperty", record.keyID);
return Promise.resolve();
}
console.debug("connect:", "!hasOwnProperty", record.keyID);
// Subscription is known to PushService.jsm but not to AndroidPushService. Drop it.
return this._mainPushService.dropRegistrationAndNotifyApp(record.keyID)
.catch(error => {
console.error("connect: Error dropping registration", record.keyID, error);
});
}));
});
},
isConnected: function() {
return this._mainPushService != null;
},
disconnect: function() {
console.debug("disconnect");
},
register: function(record) {
console.debug("register:", record);
let ctime = Date.now();
let appServerKey = record.appServerKey ?
ChromeUtils.base64URLEncode(record.appServerKey, {
// The Push server requires padding.
pad: true,
}) : null;
let message = {
type: "PushServiceAndroidGCM:SubscribeChannel",
appServerKey: appServerKey,
}
if (record.scope == FXA_PUSH_SCOPE) {
message.service = "fxa";
}
// Caller handles errors.
return Messaging.sendRequestForResult(message)
.then(data => {
console.debug("Got data:", data);
return PushCrypto.generateKeys()
.then(exportedKeys =>
new PushRecordAndroidGCM({
// Straight from autopush.
channelID: data.channelID,
pushEndpoint: data.endpoint,
// Common to all PushRecord implementations.
scope: record.scope,
originAttributes: record.originAttributes,
ctime: ctime,
systemRecord: record.systemRecord,
// Cryptography!
p256dhPublicKey: exportedKeys[0],
p256dhPrivateKey: exportedKeys[1],
authenticationSecret: PushCrypto.generateAuthenticationSecret(),
appServerKey: record.appServerKey,
})
);
});
},
unregister: function(record) {
console.debug("unregister: ", record);
return Messaging.sendRequestForResult({
type: "PushServiceAndroidGCM:UnsubscribeChannel",
channelID: record.keyID,
});
},
reportDeliveryError: function(messageID, reason) {
console.warn("reportDeliveryError: Ignoring message delivery error",
messageID, reason);
},
};
function PushRecordAndroidGCM(record) {
PushRecord.call(this, record);
this.channelID = record.channelID;
}
PushRecordAndroidGCM.prototype = Object.create(PushRecord.prototype, {
keyID: {
get() {
return this.channelID;
},
},
});

View file

@ -0,0 +1,820 @@
/* jshint moz: true, esnext: true */
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const Cc = Components.classes;
const Ci = Components.interfaces;
const Cu = Components.utils;
const Cr = Components.results;
const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
const {PushRecord} = Cu.import("resource://gre/modules/PushRecord.jsm");
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/NetUtil.jsm");
Cu.import("resource://gre/modules/IndexedDBHelper.jsm");
Cu.import("resource://gre/modules/Timer.jsm");
Cu.import("resource://gre/modules/Preferences.jsm");
Cu.import("resource://gre/modules/Promise.jsm");
const {
PushCrypto,
concatArray,
} = Cu.import("resource://gre/modules/PushCrypto.jsm");
this.EXPORTED_SYMBOLS = ["PushServiceHttp2"];
XPCOMUtils.defineLazyGetter(this, "console", () => {
let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
return new ConsoleAPI({
maxLogLevelPref: "dom.push.loglevel",
prefix: "PushServiceHttp2",
});
});
const prefs = new Preferences("dom.push.");
const kPUSHHTTP2DB_DB_NAME = "pushHttp2";
const kPUSHHTTP2DB_DB_VERSION = 5; // Change this if the IndexedDB format changes
const kPUSHHTTP2DB_STORE_NAME = "pushHttp2";
/**
* A proxy between the PushService and connections listening for incoming push
* messages. The PushService can silence messages from the connections by
* setting PushSubscriptionListener._pushService to null. This is required
* because it can happen that there is an outstanding push message that will
* be send on OnStopRequest but the PushService may not be interested in these.
* It's easier to stop listening than to have checks at specific points.
*/
var PushSubscriptionListener = function(pushService, uri) {
console.debug("PushSubscriptionListener()");
this._pushService = pushService;
this.uri = uri;
};
PushSubscriptionListener.prototype = {
QueryInterface: function (aIID) {
if (aIID.equals(Ci.nsIHttpPushListener) ||
aIID.equals(Ci.nsIStreamListener)) {
return this;
}
throw Components.results.NS_ERROR_NO_INTERFACE;
},
getInterface: function(aIID) {
return this.QueryInterface(aIID);
},
onStartRequest: function(aRequest, aContext) {
console.debug("PushSubscriptionListener: onStartRequest()");
// We do not do anything here.
},
onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
console.debug("PushSubscriptionListener: onDataAvailable()");
// Nobody should send data, but just to be sure, otherwise necko will
// complain.
if (aCount === 0) {
return;
}
let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
.createInstance(Ci.nsIScriptableInputStream);
inputStream.init(aStream);
var data = inputStream.read(aCount);
},
onStopRequest: function(aRequest, aContext, aStatusCode) {
console.debug("PushSubscriptionListener: onStopRequest()");
if (!this._pushService) {
return;
}
this._pushService.connOnStop(aRequest,
Components.isSuccessCode(aStatusCode),
this.uri);
},
onPush: function(associatedChannel, pushChannel) {
console.debug("PushSubscriptionListener: onPush()");
var pushChannelListener = new PushChannelListener(this);
pushChannel.asyncOpen2(pushChannelListener);
},
disconnect: function() {
this._pushService = null;
}
};
/**
* The listener for pushed messages. The message data is collected in
* OnDataAvailable and send to the app in OnStopRequest.
*/
var PushChannelListener = function(pushSubscriptionListener) {
console.debug("PushChannelListener()");
this._mainListener = pushSubscriptionListener;
this._message = [];
this._ackUri = null;
};
PushChannelListener.prototype = {
onStartRequest: function(aRequest, aContext) {
this._ackUri = aRequest.URI.spec;
},
onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
console.debug("PushChannelListener: onDataAvailable()");
if (aCount === 0) {
return;
}
let inputStream = Cc["@mozilla.org/binaryinputstream;1"]
.createInstance(Ci.nsIBinaryInputStream);
inputStream.setInputStream(aStream);
let chunk = new ArrayBuffer(aCount);
inputStream.readArrayBuffer(aCount, chunk);
this._message.push(chunk);
},
onStopRequest: function(aRequest, aContext, aStatusCode) {
console.debug("PushChannelListener: onStopRequest()", "status code",
aStatusCode);
if (Components.isSuccessCode(aStatusCode) &&
this._mainListener &&
this._mainListener._pushService) {
let headers = {
encryption_key: getHeaderField(aRequest, "Encryption-Key"),
crypto_key: getHeaderField(aRequest, "Crypto-Key"),
encryption: getHeaderField(aRequest, "Encryption"),
encoding: getHeaderField(aRequest, "Content-Encoding"),
};
let msg = concatArray(this._message);
this._mainListener._pushService._pushChannelOnStop(this._mainListener.uri,
this._ackUri,
headers,
msg);
}
}
};
function getHeaderField(aRequest, name) {
try {
return aRequest.getRequestHeader(name);
} catch(e) {
// getRequestHeader can throw.
return null;
}
}
var PushServiceDelete = function(resolve, reject) {
this._resolve = resolve;
this._reject = reject;
};
PushServiceDelete.prototype = {
onStartRequest: function(aRequest, aContext) {},
onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
// Nobody should send data, but just to be sure, otherwise necko will
// complain.
if (aCount === 0) {
return;
}
let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
.createInstance(Ci.nsIScriptableInputStream);
inputStream.init(aStream);
var data = inputStream.read(aCount);
},
onStopRequest: function(aRequest, aContext, aStatusCode) {
if (Components.isSuccessCode(aStatusCode)) {
this._resolve();
} else {
this._reject(new Error("Error removing subscription: " + aStatusCode));
}
}
};
var SubscriptionListener = function(aSubInfo, aResolve, aReject,
aServerURI, aPushServiceHttp2) {
console.debug("SubscriptionListener()");
this._subInfo = aSubInfo;
this._resolve = aResolve;
this._reject = aReject;
this._data = '';
this._serverURI = aServerURI;
this._service = aPushServiceHttp2;
this._ctime = Date.now();
this._retryTimeoutID = null;
};
SubscriptionListener.prototype = {
onStartRequest: function(aRequest, aContext) {},
onDataAvailable: function(aRequest, aContext, aStream, aOffset, aCount) {
console.debug("SubscriptionListener: onDataAvailable()");
// We do not expect any data, but necko will complain if we do not consume
// it.
if (aCount === 0) {
return;
}
let inputStream = Cc["@mozilla.org/scriptableinputstream;1"]
.createInstance(Ci.nsIScriptableInputStream);
inputStream.init(aStream);
this._data.concat(inputStream.read(aCount));
},
onStopRequest: function(aRequest, aContext, aStatus) {
console.debug("SubscriptionListener: onStopRequest()");
// Check if pushService is still active.
if (!this._service.hasmainPushService()) {
this._reject(new Error("Push service unavailable"));
return;
}
if (!Components.isSuccessCode(aStatus)) {
this._reject(new Error("Error listening for messages: " + aStatus));
return;
}
var statusCode = aRequest.QueryInterface(Ci.nsIHttpChannel).responseStatus;
if (Math.floor(statusCode / 100) == 5) {
if (this._subInfo.retries < prefs.get("http2.maxRetries")) {
this._subInfo.retries++;
var retryAfter = retryAfterParser(aRequest);
this._retryTimeoutID = setTimeout(_ =>
{
this._reject(
{
retry: true,
subInfo: this._subInfo
});
this._service.removeListenerPendingRetry(this);
this._retryTimeoutID = null;
}, retryAfter);
this._service.addListenerPendingRetry(this);
} else {
this._reject(new Error("Unexpected server response: " + statusCode));
}
return;
} else if (statusCode != 201) {
this._reject(new Error("Unexpected server response: " + statusCode));
return;
}
var subscriptionUri;
try {
subscriptionUri = aRequest.getResponseHeader("location");
} catch (err) {
this._reject(new Error("Missing Location header"));
return;
}
console.debug("onStopRequest: subscriptionUri", subscriptionUri);
var linkList;
try {
linkList = aRequest.getResponseHeader("link");
} catch (err) {
this._reject(new Error("Missing Link header"));
return;
}
var linkParserResult;
try {
linkParserResult = linkParser(linkList, this._serverURI);
} catch (e) {
this._reject(e);
return;
}
if (!subscriptionUri) {
this._reject(new Error("Invalid Location header"));
return;
}
try {
let uriTry = Services.io.newURI(subscriptionUri, null, null);
} catch (e) {
console.error("onStopRequest: Invalid subscription URI",
subscriptionUri);
this._reject(new Error("Invalid subscription endpoint: " +
subscriptionUri));
return;
}
let reply = new PushRecordHttp2({
subscriptionUri: subscriptionUri,
pushEndpoint: linkParserResult.pushEndpoint,
pushReceiptEndpoint: linkParserResult.pushReceiptEndpoint,
scope: this._subInfo.record.scope,
originAttributes: this._subInfo.record.originAttributes,
systemRecord: this._subInfo.record.systemRecord,
appServerKey: this._subInfo.record.appServerKey,
ctime: Date.now(),
});
Services.telemetry.getHistogramById("PUSH_API_SUBSCRIBE_HTTP2_TIME").add(Date.now() - this._ctime);
this._resolve(reply);
},
abortRetry: function() {
if (this._retryTimeoutID != null) {
clearTimeout(this._retryTimeoutID);
this._retryTimeoutID = null;
} else {
console.debug("SubscriptionListener.abortRetry: aborting non-existent retry?");
}
},
};
function retryAfterParser(aRequest) {
var retryAfter = 0;
try {
var retryField = aRequest.getResponseHeader("retry-after");
if (isNaN(retryField)) {
retryAfter = Date.parse(retryField) - (new Date().getTime());
} else {
retryAfter = parseInt(retryField, 10) * 1000;
}
retryAfter = (retryAfter > 0) ? retryAfter : 0;
} catch(e) {}
return retryAfter;
}
function linkParser(linkHeader, serverURI) {
var linkList = linkHeader.split(',');
if ((linkList.length < 1)) {
throw new Error("Invalid Link header");
}
var pushEndpoint;
var pushReceiptEndpoint;
linkList.forEach(link => {
var linkElems = link.split(';');
if (linkElems.length == 2) {
if (linkElems[1].trim() === 'rel="urn:ietf:params:push"') {
pushEndpoint = linkElems[0].substring(linkElems[0].indexOf('<') + 1,
linkElems[0].indexOf('>'));
} else if (linkElems[1].trim() === 'rel="urn:ietf:params:push:receipt"') {
pushReceiptEndpoint = linkElems[0].substring(linkElems[0].indexOf('<') + 1,
linkElems[0].indexOf('>'));
}
}
});
console.debug("linkParser: pushEndpoint", pushEndpoint);
console.debug("linkParser: pushReceiptEndpoint", pushReceiptEndpoint);
// Missing pushReceiptEndpoint is allowed.
if (!pushEndpoint) {
throw new Error("Missing push endpoint");
}
var pushURI = Services.io.newURI(pushEndpoint, null, serverURI);
var pushReceiptURI;
if (pushReceiptEndpoint) {
pushReceiptURI = Services.io.newURI(pushReceiptEndpoint, null,
serverURI);
}
return {
pushEndpoint: pushURI.spec,
pushReceiptEndpoint: (pushReceiptURI) ? pushReceiptURI.spec : "",
};
}
/**
* The implementation of the WebPush.
*/
this.PushServiceHttp2 = {
_mainPushService: null,
_serverURI: null,
// Keep information about all connections, e.g. the channel, listener...
_conns: {},
_started: false,
// Set of SubscriptionListeners that are pending a subscription retry attempt.
_listenersPendingRetry: new Set(),
newPushDB: function() {
return new PushDB(kPUSHHTTP2DB_DB_NAME,
kPUSHHTTP2DB_DB_VERSION,
kPUSHHTTP2DB_STORE_NAME,
"subscriptionUri",
PushRecordHttp2);
},
hasmainPushService: function() {
return this._mainPushService !== null;
},
validServerURI: function(serverURI) {
if (serverURI.scheme == "http") {
return !!prefs.get("testing.allowInsecureServerURL");
}
return serverURI.scheme == "https";
},
connect: function(subscriptions) {
this.startConnections(subscriptions);
},
isConnected: function() {
return this._mainPushService != null;
},
disconnect: function() {
this._shutdownConnections(false);
},
_makeChannel: function(aUri) {
var chan = NetUtil.newChannel({uri: aUri, loadUsingSystemPrincipal: true})
.QueryInterface(Ci.nsIHttpChannel);
var loadGroup = Cc["@mozilla.org/network/load-group;1"]
.createInstance(Ci.nsILoadGroup);
chan.loadGroup = loadGroup;
return chan;
},
/**
* Subscribe new resource.
*/
register: function(aRecord) {
console.debug("subscribeResource()");
return this._subscribeResourceInternal({
record: aRecord,
retries: 0
})
.then(result =>
PushCrypto.generateKeys()
.then(([publicKey, privateKey]) => {
result.p256dhPublicKey = publicKey;
result.p256dhPrivateKey = privateKey;
result.authenticationSecret = PushCrypto.generateAuthenticationSecret();
this._conns[result.subscriptionUri] = {
channel: null,
listener: null,
countUnableToConnect: 0,
lastStartListening: 0,
retryTimerID: 0,
};
this._listenForMsgs(result.subscriptionUri);
return result;
})
);
},
_subscribeResourceInternal: function(aSubInfo) {
console.debug("subscribeResourceInternal()");
return new Promise((resolve, reject) => {
var listener = new SubscriptionListener(aSubInfo,
resolve,
reject,
this._serverURI,
this);
var chan = this._makeChannel(this._serverURI.spec);
chan.requestMethod = "POST";
chan.asyncOpen2(listener);
})
.catch(err => {
if ("retry" in err) {
return this._subscribeResourceInternal(err.subInfo);
} else {
throw err;
}
})
},
_deleteResource: function(aUri) {
return new Promise((resolve,reject) => {
var chan = this._makeChannel(aUri);
chan.requestMethod = "DELETE";
chan.asyncOpen2(new PushServiceDelete(resolve, reject));
});
},
/**
* Unsubscribe the resource with a subscription uri aSubscriptionUri.
* We can't do anything about it if it fails, so we don't listen for response.
*/
_unsubscribeResource: function(aSubscriptionUri) {
console.debug("unsubscribeResource()");
return this._deleteResource(aSubscriptionUri);
},
/**
* Start listening for messages.
*/
_listenForMsgs: function(aSubscriptionUri) {
console.debug("listenForMsgs()", aSubscriptionUri);
if (!this._conns[aSubscriptionUri]) {
console.warn("listenForMsgs: We do not have this subscription",
aSubscriptionUri);
return;
}
var chan = this._makeChannel(aSubscriptionUri);
var conn = {};
conn.channel = chan;
var listener = new PushSubscriptionListener(this, aSubscriptionUri);
conn.listener = listener;
chan.notificationCallbacks = listener;
try {
chan.asyncOpen2(listener);
} catch (e) {
console.error("listenForMsgs: Error connecting to push server.",
"asyncOpen2 failed", e);
conn.listener.disconnect();
chan.cancel(Cr.NS_ERROR_ABORT);
this._retryAfterBackoff(aSubscriptionUri, -1);
return;
}
this._conns[aSubscriptionUri].lastStartListening = Date.now();
this._conns[aSubscriptionUri].channel = conn.channel;
this._conns[aSubscriptionUri].listener = conn.listener;
},
_ackMsgRecv: function(aAckUri) {
console.debug("ackMsgRecv()", aAckUri);
return this._deleteResource(aAckUri);
},
init: function(aOptions, aMainPushService, aServerURL) {
console.debug("init()");
this._mainPushService = aMainPushService;
this._serverURI = aServerURL;
return Promise.resolve();
},
_retryAfterBackoff: function(aSubscriptionUri, retryAfter) {
console.debug("retryAfterBackoff()");
var resetRetryCount = prefs.get("http2.reset_retry_count_after_ms");
// If it was running for some time, reset retry counter.
if ((Date.now() - this._conns[aSubscriptionUri].lastStartListening) >
resetRetryCount) {
this._conns[aSubscriptionUri].countUnableToConnect = 0;
}
let maxRetries = prefs.get("http2.maxRetries");
if (this._conns[aSubscriptionUri].countUnableToConnect >= maxRetries) {
this._shutdownSubscription(aSubscriptionUri);
this._resubscribe(aSubscriptionUri);
return;
}
if (retryAfter !== -1) {
// This is a 5xx response.
this._conns[aSubscriptionUri].countUnableToConnect++;
this._conns[aSubscriptionUri].retryTimerID =
setTimeout(_ => this._listenForMsgs(aSubscriptionUri), retryAfter);
return;
}
retryAfter = prefs.get("http2.retryInterval") *
Math.pow(2, this._conns[aSubscriptionUri].countUnableToConnect);
retryAfter = retryAfter * (0.8 + Math.random() * 0.4); // add +/-20%.
this._conns[aSubscriptionUri].countUnableToConnect++;
this._conns[aSubscriptionUri].retryTimerID =
setTimeout(_ => this._listenForMsgs(aSubscriptionUri), retryAfter);
console.debug("retryAfterBackoff: Retry in", retryAfter);
},
// Close connections.
_shutdownConnections: function(deleteInfo) {
console.debug("shutdownConnections()");
for (let subscriptionUri in this._conns) {
if (this._conns[subscriptionUri]) {
if (this._conns[subscriptionUri].listener) {
this._conns[subscriptionUri].listener._pushService = null;
}
if (this._conns[subscriptionUri].channel) {
try {
this._conns[subscriptionUri].channel.cancel(Cr.NS_ERROR_ABORT);
} catch (e) {}
}
this._conns[subscriptionUri].listener = null;
this._conns[subscriptionUri].channel = null;
if (this._conns[subscriptionUri].retryTimerID > 0) {
clearTimeout(this._conns[subscriptionUri].retryTimerID);
}
if (deleteInfo) {
delete this._conns[subscriptionUri];
}
}
}
},
// Start listening if subscriptions present.
startConnections: function(aSubscriptions) {
console.debug("startConnections()", aSubscriptions.length);
for (let i = 0; i < aSubscriptions.length; i++) {
let record = aSubscriptions[i];
this._mainPushService.ensureCrypto(record).then(record => {
this._startSingleConnection(record);
}, error => {
console.error("startConnections: Error updating record",
record.keyID, error);
});
}
},
_startSingleConnection: function(record) {
console.debug("_startSingleConnection()");
if (typeof this._conns[record.subscriptionUri] != "object") {
this._conns[record.subscriptionUri] = {channel: null,
listener: null,
countUnableToConnect: 0,
retryTimerID: 0};
}
if (!this._conns[record.subscriptionUri].conn) {
this._listenForMsgs(record.subscriptionUri);
}
},
// Close connection and notify apps that subscription are gone.
_shutdownSubscription: function(aSubscriptionUri) {
console.debug("shutdownSubscriptions()");
if (typeof this._conns[aSubscriptionUri] == "object") {
if (this._conns[aSubscriptionUri].listener) {
this._conns[aSubscriptionUri].listener._pushService = null;
}
if (this._conns[aSubscriptionUri].channel) {
try {
this._conns[aSubscriptionUri].channel.cancel(Cr.NS_ERROR_ABORT);
} catch (e) {}
}
delete this._conns[aSubscriptionUri];
}
},
uninit: function() {
console.debug("uninit()");
this._abortPendingSubscriptionRetries();
this._shutdownConnections(true);
this._mainPushService = null;
},
_abortPendingSubscriptionRetries: function() {
this._listenersPendingRetry.forEach((listener) => listener.abortRetry());
this._listenersPendingRetry.clear();
},
unregister: function(aRecord) {
this._shutdownSubscription(aRecord.subscriptionUri);
return this._unsubscribeResource(aRecord.subscriptionUri);
},
reportDeliveryError: function(messageID, reason) {
console.warn("reportDeliveryError: Ignoring message delivery error",
messageID, reason);
},
/** Push server has deleted subscription.
* Re-subscribe - if it succeeds send update db record and send
* pushsubscriptionchange,
* - on error delete record and send pushsubscriptionchange
* TODO: maybe pushsubscriptionerror will be included.
*/
_resubscribe: function(aSubscriptionUri) {
this._mainPushService.getByKeyID(aSubscriptionUri)
.then(record => this.register(record)
.then(recordNew => {
if (this._mainPushService) {
this._mainPushService
.updateRegistrationAndNotifyApp(aSubscriptionUri, recordNew)
.catch(Cu.reportError);
}
}, error => {
if (this._mainPushService) {
this._mainPushService
.dropRegistrationAndNotifyApp(aSubscriptionUri)
.catch(Cu.reportError);
}
})
);
},
connOnStop: function(aRequest, aSuccess,
aSubscriptionUri) {
console.debug("connOnStop() succeeded", aSuccess);
var conn = this._conns[aSubscriptionUri];
if (!conn) {
// there is no connection description that means that we closed
// connection, so do nothing. But we should have already deleted
// the listener.
return;
}
conn.channel = null;
conn.listener = null;
if (!aSuccess) {
this._retryAfterBackoff(aSubscriptionUri, -1);
} else if (Math.floor(aRequest.responseStatus / 100) == 5) {
var retryAfter = retryAfterParser(aRequest);
this._retryAfterBackoff(aSubscriptionUri, retryAfter);
} else if (Math.floor(aRequest.responseStatus / 100) == 4) {
this._shutdownSubscription(aSubscriptionUri);
this._resubscribe(aSubscriptionUri);
} else if (Math.floor(aRequest.responseStatus / 100) == 2) { // This should be 204
setTimeout(_ => this._listenForMsgs(aSubscriptionUri), 0);
} else {
this._retryAfterBackoff(aSubscriptionUri, -1);
}
},
addListenerPendingRetry: function(aListener) {
this._listenersPendingRetry.add(aListener);
},
removeListenerPendingRetry: function(aListener) {
if (!this._listenersPendingRetry.remove(aListener)) {
console.debug("removeListenerPendingRetry: listener not in list?");
}
},
_pushChannelOnStop: function(aUri, aAckUri, aHeaders, aMessage) {
console.debug("pushChannelOnStop()");
this._mainPushService.receivedPushMessage(
aUri, "", aHeaders, aMessage, record => {
// Always update the stored record.
return record;
}
)
.then(_ => this._ackMsgRecv(aAckUri))
.catch(err => {
console.error("pushChannelOnStop: Error receiving message",
err);
});
},
};
function PushRecordHttp2(record) {
PushRecord.call(this, record);
this.subscriptionUri = record.subscriptionUri;
this.pushReceiptEndpoint = record.pushReceiptEndpoint;
}
PushRecordHttp2.prototype = Object.create(PushRecord.prototype, {
keyID: {
get() {
return this.subscriptionUri;
},
},
});
PushRecordHttp2.prototype.toSubscription = function() {
let subscription = PushRecord.prototype.toSubscription.call(this);
subscription.pushReceiptEndpoint = this.pushReceiptEndpoint;
return subscription;
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,398 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "mozilla/dom/PushSubscription.h"
#include "nsIPushService.h"
#include "nsIScriptObjectPrincipal.h"
#include "mozilla/Base64.h"
#include "mozilla/Unused.h"
#include "mozilla/dom/Promise.h"
#include "mozilla/dom/PromiseWorkerProxy.h"
#include "mozilla/dom/PushSubscriptionOptions.h"
#include "mozilla/dom/PushUtil.h"
#include "mozilla/dom/WorkerPrivate.h"
#include "mozilla/dom/WorkerScope.h"
#include "mozilla/dom/workers/Workers.h"
namespace mozilla {
namespace dom {
using namespace workers;
namespace {
class UnsubscribeResultCallback final : public nsIUnsubscribeResultCallback
{
public:
NS_DECL_ISUPPORTS
explicit UnsubscribeResultCallback(Promise* aPromise)
: mPromise(aPromise)
{
AssertIsOnMainThread();
}
NS_IMETHOD
OnUnsubscribe(nsresult aStatus, bool aSuccess) override
{
if (NS_SUCCEEDED(aStatus)) {
mPromise->MaybeResolve(aSuccess);
} else {
mPromise->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
}
return NS_OK;
}
private:
~UnsubscribeResultCallback()
{}
RefPtr<Promise> mPromise;
};
NS_IMPL_ISUPPORTS(UnsubscribeResultCallback, nsIUnsubscribeResultCallback)
class UnsubscribeResultRunnable final : public WorkerRunnable
{
public:
UnsubscribeResultRunnable(WorkerPrivate* aWorkerPrivate,
already_AddRefed<PromiseWorkerProxy>&& aProxy,
nsresult aStatus,
bool aSuccess)
: WorkerRunnable(aWorkerPrivate)
, mProxy(Move(aProxy))
, mStatus(aStatus)
, mSuccess(aSuccess)
{
AssertIsOnMainThread();
}
bool
WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override
{
MOZ_ASSERT(aWorkerPrivate);
aWorkerPrivate->AssertIsOnWorkerThread();
RefPtr<Promise> promise = mProxy->WorkerPromise();
if (NS_SUCCEEDED(mStatus)) {
promise->MaybeResolve(mSuccess);
} else {
promise->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
}
mProxy->CleanUp();
return true;
}
private:
~UnsubscribeResultRunnable()
{}
RefPtr<PromiseWorkerProxy> mProxy;
nsresult mStatus;
bool mSuccess;
};
class WorkerUnsubscribeResultCallback final : public nsIUnsubscribeResultCallback
{
public:
NS_DECL_ISUPPORTS
explicit WorkerUnsubscribeResultCallback(PromiseWorkerProxy* aProxy)
: mProxy(aProxy)
{
AssertIsOnMainThread();
}
NS_IMETHOD
OnUnsubscribe(nsresult aStatus, bool aSuccess) override
{
AssertIsOnMainThread();
MOZ_ASSERT(mProxy, "OnUnsubscribe() called twice?");
MutexAutoLock lock(mProxy->Lock());
if (mProxy->CleanedUp()) {
return NS_OK;
}
WorkerPrivate* worker = mProxy->GetWorkerPrivate();
RefPtr<UnsubscribeResultRunnable> r =
new UnsubscribeResultRunnable(worker, mProxy.forget(), aStatus, aSuccess);
MOZ_ALWAYS_TRUE(r->Dispatch());
return NS_OK;
}
private:
~WorkerUnsubscribeResultCallback()
{
}
RefPtr<PromiseWorkerProxy> mProxy;
};
NS_IMPL_ISUPPORTS(WorkerUnsubscribeResultCallback, nsIUnsubscribeResultCallback)
class UnsubscribeRunnable final : public Runnable
{
public:
UnsubscribeRunnable(PromiseWorkerProxy* aProxy,
const nsAString& aScope)
: mProxy(aProxy)
, mScope(aScope)
{
MOZ_ASSERT(aProxy);
MOZ_ASSERT(!aScope.IsEmpty());
}
NS_IMETHOD
Run() override
{
AssertIsOnMainThread();
nsCOMPtr<nsIPrincipal> principal;
{
MutexAutoLock lock(mProxy->Lock());
if (mProxy->CleanedUp()) {
return NS_OK;
}
principal = mProxy->GetWorkerPrivate()->GetPrincipal();
}
MOZ_ASSERT(principal);
RefPtr<WorkerUnsubscribeResultCallback> callback =
new WorkerUnsubscribeResultCallback(mProxy);
nsCOMPtr<nsIPushService> service =
do_GetService("@mozilla.org/push/Service;1");
if (NS_WARN_IF(!service)) {
callback->OnUnsubscribe(NS_ERROR_FAILURE, false);
return NS_OK;
}
if (NS_WARN_IF(NS_FAILED(service->Unsubscribe(mScope, principal, callback)))) {
callback->OnUnsubscribe(NS_ERROR_FAILURE, false);
return NS_OK;
}
return NS_OK;
}
private:
~UnsubscribeRunnable()
{}
RefPtr<PromiseWorkerProxy> mProxy;
nsString mScope;
};
} // anonymous namespace
PushSubscription::PushSubscription(nsIGlobalObject* aGlobal,
const nsAString& aEndpoint,
const nsAString& aScope,
nsTArray<uint8_t>&& aRawP256dhKey,
nsTArray<uint8_t>&& aAuthSecret,
nsTArray<uint8_t>&& aAppServerKey)
: mEndpoint(aEndpoint)
, mScope(aScope)
, mRawP256dhKey(Move(aRawP256dhKey))
, mAuthSecret(Move(aAuthSecret))
{
if (NS_IsMainThread()) {
mGlobal = aGlobal;
} else {
#ifdef DEBUG
// There's only one global on a worker, so we don't need to pass a global
// object to the constructor.
WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(worker);
worker->AssertIsOnWorkerThread();
#endif
}
mOptions = new PushSubscriptionOptions(mGlobal, Move(aAppServerKey));
}
PushSubscription::~PushSubscription()
{}
NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(PushSubscription, mGlobal, mOptions)
NS_IMPL_CYCLE_COLLECTING_ADDREF(PushSubscription)
NS_IMPL_CYCLE_COLLECTING_RELEASE(PushSubscription)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushSubscription)
NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
NS_INTERFACE_MAP_ENTRY(nsISupports)
NS_INTERFACE_MAP_END
JSObject*
PushSubscription::WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto)
{
return PushSubscriptionBinding::Wrap(aCx, this, aGivenProto);
}
// static
already_AddRefed<PushSubscription>
PushSubscription::Constructor(GlobalObject& aGlobal,
const PushSubscriptionInit& aInitDict,
ErrorResult& aRv)
{
nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports());
nsTArray<uint8_t> rawKey;
if (aInitDict.mP256dhKey.WasPassed() &&
!aInitDict.mP256dhKey.Value().IsNull() &&
!PushUtil::CopyArrayBufferToArray(aInitDict.mP256dhKey.Value().Value(),
rawKey)) {
aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
return nullptr;
}
nsTArray<uint8_t> authSecret;
if (aInitDict.mAuthSecret.WasPassed() &&
!aInitDict.mAuthSecret.Value().IsNull() &&
!PushUtil::CopyArrayBufferToArray(aInitDict.mAuthSecret.Value().Value(),
authSecret)) {
aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
return nullptr;
}
nsTArray<uint8_t> appServerKey;
if (aInitDict.mAppServerKey.WasPassed() &&
!aInitDict.mAppServerKey.Value().IsNull()) {
const OwningArrayBufferViewOrArrayBuffer& bufferSource =
aInitDict.mAppServerKey.Value().Value();
if (!PushUtil::CopyBufferSourceToArray(bufferSource, appServerKey)) {
aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
return nullptr;
}
}
RefPtr<PushSubscription> sub = new PushSubscription(global,
aInitDict.mEndpoint,
aInitDict.mScope,
Move(rawKey),
Move(authSecret),
Move(appServerKey));
return sub.forget();
}
already_AddRefed<Promise>
PushSubscription::Unsubscribe(ErrorResult& aRv)
{
if (!NS_IsMainThread()) {
RefPtr<Promise> p = UnsubscribeFromWorker(aRv);
return p.forget();
}
MOZ_ASSERT(mGlobal);
nsCOMPtr<nsIPushService> service =
do_GetService("@mozilla.org/push/Service;1");
if (NS_WARN_IF(!service)) {
aRv.Throw(NS_ERROR_FAILURE);
return nullptr;
}
nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(mGlobal);
if (!sop) {
aRv.Throw(NS_ERROR_FAILURE);
return nullptr;
}
RefPtr<Promise> p = Promise::Create(mGlobal, aRv);
if (NS_WARN_IF(aRv.Failed())) {
return nullptr;
}
RefPtr<UnsubscribeResultCallback> callback =
new UnsubscribeResultCallback(p);
Unused << NS_WARN_IF(NS_FAILED(
service->Unsubscribe(mScope, sop->GetPrincipal(), callback)));
return p.forget();
}
void
PushSubscription::GetKey(JSContext* aCx,
PushEncryptionKeyName aType,
JS::MutableHandle<JSObject*> aKey,
ErrorResult& aRv)
{
if (aType == PushEncryptionKeyName::P256dh) {
PushUtil::CopyArrayToArrayBuffer(aCx, mRawP256dhKey, aKey, aRv);
} else if (aType == PushEncryptionKeyName::Auth) {
PushUtil::CopyArrayToArrayBuffer(aCx, mAuthSecret, aKey, aRv);
} else {
aKey.set(nullptr);
}
}
void
PushSubscription::ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv)
{
aJSON.mEndpoint.Construct();
aJSON.mEndpoint.Value() = mEndpoint;
aJSON.mKeys.mP256dh.Construct();
nsresult rv = Base64URLEncode(mRawP256dhKey.Length(),
mRawP256dhKey.Elements(),
Base64URLEncodePaddingPolicy::Omit,
aJSON.mKeys.mP256dh.Value());
if (NS_WARN_IF(NS_FAILED(rv))) {
aRv.Throw(rv);
return;
}
aJSON.mKeys.mAuth.Construct();
rv = Base64URLEncode(mAuthSecret.Length(), mAuthSecret.Elements(),
Base64URLEncodePaddingPolicy::Omit,
aJSON.mKeys.mAuth.Value());
if (NS_WARN_IF(NS_FAILED(rv))) {
aRv.Throw(rv);
return;
}
}
already_AddRefed<PushSubscriptionOptions>
PushSubscription::Options()
{
RefPtr<PushSubscriptionOptions> options = mOptions;
return options.forget();
}
already_AddRefed<Promise>
PushSubscription::UnsubscribeFromWorker(ErrorResult& aRv)
{
WorkerPrivate* worker = GetCurrentThreadWorkerPrivate();
MOZ_ASSERT(worker);
worker->AssertIsOnWorkerThread();
nsCOMPtr<nsIGlobalObject> global = worker->GlobalScope();
RefPtr<Promise> p = Promise::Create(global, aRv);
if (NS_WARN_IF(aRv.Failed())) {
return nullptr;
}
RefPtr<PromiseWorkerProxy> proxy = PromiseWorkerProxy::Create(worker, p);
if (!proxy) {
p->MaybeReject(NS_ERROR_DOM_PUSH_SERVICE_UNREACHABLE);
return p.forget();
}
RefPtr<UnsubscribeRunnable> r =
new UnsubscribeRunnable(proxy, mScope);
MOZ_ALWAYS_SUCCEEDS(NS_DispatchToMainThread(r));
return p.forget();
}
} // namespace dom
} // namespace mozilla

View file

@ -0,0 +1,99 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#ifndef mozilla_dom_PushSubscription_h
#define mozilla_dom_PushSubscription_h
#include "jsapi.h"
#include "nsCOMPtr.h"
#include "nsWrapperCache.h"
#include "nsContentUtils.h" // Required for nsContentUtils::PushEnabled
#include "mozilla/AlreadyAddRefed.h"
#include "mozilla/ErrorResult.h"
#include "mozilla/RefPtr.h"
#include "mozilla/dom/BindingDeclarations.h"
#include "mozilla/dom/PushSubscriptionBinding.h"
#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
#include "mozilla/dom/TypedArray.h"
class nsIGlobalObject;
namespace mozilla {
namespace dom {
namespace workers {
class WorkerPrivate;
}
class Promise;
class PushSubscription final : public nsISupports
, public nsWrapperCache
{
public:
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushSubscription)
PushSubscription(nsIGlobalObject* aGlobal,
const nsAString& aEndpoint,
const nsAString& aScope,
nsTArray<uint8_t>&& aP256dhKey,
nsTArray<uint8_t>&& aAuthSecret,
nsTArray<uint8_t>&& aAppServerKey);
JSObject*
WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
nsIGlobalObject*
GetParentObject() const
{
return mGlobal;
}
void
GetEndpoint(nsAString& aEndpoint) const
{
aEndpoint = mEndpoint;
}
void
GetKey(JSContext* cx,
PushEncryptionKeyName aType,
JS::MutableHandle<JSObject*> aKey,
ErrorResult& aRv);
static already_AddRefed<PushSubscription>
Constructor(GlobalObject& aGlobal,
const PushSubscriptionInit& aInitDict,
ErrorResult& aRv);
already_AddRefed<Promise>
Unsubscribe(ErrorResult& aRv);
void
ToJSON(PushSubscriptionJSON& aJSON, ErrorResult& aRv);
already_AddRefed<PushSubscriptionOptions>
Options();
private:
~PushSubscription();
already_AddRefed<Promise>
UnsubscribeFromWorker(ErrorResult& aRv);
nsString mEndpoint;
nsString mScope;
nsTArray<uint8_t> mRawP256dhKey;
nsTArray<uint8_t> mAuthSecret;
nsCOMPtr<nsIGlobalObject> mGlobal;
RefPtr<PushSubscriptionOptions> mOptions;
};
} // namespace dom
} // namespace mozilla
#endif // mozilla_dom_PushSubscription_h

View file

@ -0,0 +1,79 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "mozilla/dom/PushSubscriptionOptions.h"
#include "mozilla/dom/PushSubscriptionOptionsBinding.h"
#include "mozilla/HoldDropJSObjects.h"
namespace mozilla {
namespace dom {
PushSubscriptionOptions::PushSubscriptionOptions(nsIGlobalObject* aGlobal,
nsTArray<uint8_t>&& aRawAppServerKey)
: mGlobal(aGlobal)
, mRawAppServerKey(Move(aRawAppServerKey))
, mAppServerKey(nullptr)
{
// There's only one global on a worker, so we don't need to pass a global
// object to the constructor.
MOZ_ASSERT_IF(NS_IsMainThread(), mGlobal);
mozilla::HoldJSObjects(this);
}
PushSubscriptionOptions::~PushSubscriptionOptions()
{
mAppServerKey = nullptr;
mozilla::DropJSObjects(this);
}
NS_IMPL_CYCLE_COLLECTION_CLASS(PushSubscriptionOptions)
NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(PushSubscriptionOptions)
NS_IMPL_CYCLE_COLLECTION_UNLINK(mGlobal)
NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER
tmp->mAppServerKey = nullptr;
NS_IMPL_CYCLE_COLLECTION_UNLINK_END
NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(PushSubscriptionOptions)
NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mGlobal)
NS_IMPL_CYCLE_COLLECTION_TRAVERSE_SCRIPT_OBJECTS
NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END
NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN(PushSubscriptionOptions)
NS_IMPL_CYCLE_COLLECTION_TRACE_PRESERVED_WRAPPER
NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mAppServerKey)
NS_IMPL_CYCLE_COLLECTION_TRACE_END
NS_IMPL_CYCLE_COLLECTING_ADDREF(PushSubscriptionOptions)
NS_IMPL_CYCLE_COLLECTING_RELEASE(PushSubscriptionOptions)
NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(PushSubscriptionOptions)
NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY
NS_INTERFACE_MAP_ENTRY(nsISupports)
NS_INTERFACE_MAP_END
JSObject*
PushSubscriptionOptions::WrapObject(JSContext* aCx,
JS::Handle<JSObject*> aGivenProto)
{
return PushSubscriptionOptionsBinding::Wrap(aCx, this, aGivenProto);
}
void
PushSubscriptionOptions::GetApplicationServerKey(JSContext* aCx,
JS::MutableHandle<JSObject*> aKey,
ErrorResult& aRv)
{
if (!mRawAppServerKey.IsEmpty() && !mAppServerKey) {
JS::Rooted<JSObject*> appServerKey(aCx);
PushUtil::CopyArrayToArrayBuffer(aCx, mRawAppServerKey, &appServerKey, aRv);
if (aRv.Failed()) {
return;
}
MOZ_ASSERT(appServerKey);
mAppServerKey = appServerKey;
}
aKey.set(mAppServerKey);
}
} // namespace dom
} // namespace mozilla

View file

@ -0,0 +1,56 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#ifndef mozilla_dom_PushSubscriptionOptions_h
#define mozilla_dom_PushSubscriptionOptions_h
#include "nsCycleCollectionParticipant.h"
#include "nsContentUtils.h" // Required for nsContentUtils::PushEnabled
#include "nsTArray.h"
#include "nsWrapperCache.h"
class nsIGlobalObject;
namespace mozilla {
class ErrorResult;
namespace dom {
class PushSubscriptionOptions final : public nsISupports
, public nsWrapperCache
{
public:
NS_DECL_CYCLE_COLLECTING_ISUPPORTS
NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS(PushSubscriptionOptions)
PushSubscriptionOptions(nsIGlobalObject* aGlobal,
nsTArray<uint8_t>&& aRawAppServerKey);
nsIGlobalObject*
GetParentObject() const
{
return mGlobal;
}
JSObject*
WrapObject(JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override;
void
GetApplicationServerKey(JSContext* aCx,
JS::MutableHandle<JSObject*> aKey,
ErrorResult& aRv);
private:
~PushSubscriptionOptions();
nsCOMPtr<nsIGlobalObject> mGlobal;
nsTArray<uint8_t> mRawAppServerKey;
JS::Heap<JSObject*> mAppServerKey;
};
} // namespace dom
} // namespace mozilla
#endif // mozilla_dom_PushSubscriptionOptions_h

64
dom/push/PushUtil.cpp Normal file
View file

@ -0,0 +1,64 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#include "mozilla/dom/PushUtil.h"
namespace mozilla {
namespace dom {
/* static */ bool
PushUtil::CopyArrayBufferToArray(const ArrayBuffer& aBuffer,
nsTArray<uint8_t>& aArray)
{
MOZ_ASSERT(aArray.IsEmpty());
aBuffer.ComputeLengthAndData();
return aArray.SetCapacity(aBuffer.Length(), fallible) &&
aArray.InsertElementsAt(0, aBuffer.Data(), aBuffer.Length(), fallible);
}
/* static */ bool
PushUtil::CopyArrayBufferViewToArray(const ArrayBufferView& aView,
nsTArray<uint8_t>& aArray)
{
MOZ_ASSERT(aArray.IsEmpty());
aView.ComputeLengthAndData();
return aArray.SetCapacity(aView.Length(), fallible) &&
aArray.InsertElementsAt(0, aView.Data(), aView.Length(), fallible);
}
/* static */ bool
PushUtil::CopyBufferSourceToArray(
const OwningArrayBufferViewOrArrayBuffer& aSource, nsTArray<uint8_t>& aArray)
{
if (aSource.IsArrayBuffer()) {
return CopyArrayBufferToArray(aSource.GetAsArrayBuffer(), aArray);
}
if (aSource.IsArrayBufferView()) {
return CopyArrayBufferViewToArray(aSource.GetAsArrayBufferView(), aArray);
}
MOZ_CRASH("Uninitialized union: expected buffer or view");
}
/* static */ void
PushUtil::CopyArrayToArrayBuffer(JSContext* aCx,
const nsTArray<uint8_t>& aArray,
JS::MutableHandle<JSObject*> aValue,
ErrorResult& aRv)
{
if (aArray.IsEmpty()) {
aValue.set(nullptr);
return;
}
JS::Rooted<JSObject*> buffer(aCx, ArrayBuffer::Create(aCx,
aArray.Length(),
aArray.Elements()));
if (NS_WARN_IF(!buffer)) {
aRv.Throw(NS_ERROR_OUT_OF_MEMORY);
return;
}
aValue.set(buffer);
}
} // namespace dom
} // namespace mozilla

43
dom/push/PushUtil.h Normal file
View file

@ -0,0 +1,43 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#ifndef mozilla_dom_PushUtil_h
#define mozilla_dom_PushUtil_h
#include "nsTArray.h"
#include "mozilla/dom/TypedArray.h"
namespace mozilla {
namespace dom {
class OwningArrayBufferViewOrArrayBuffer;
class PushUtil final
{
private:
PushUtil() = delete;
public:
static bool
CopyArrayBufferToArray(const ArrayBuffer& aBuffer,
nsTArray<uint8_t>& aArray);
static bool
CopyArrayBufferViewToArray(const ArrayBufferView& aView,
nsTArray<uint8_t>& aArray);
static bool
CopyBufferSourceToArray(const OwningArrayBufferViewOrArrayBuffer& aSource,
nsTArray<uint8_t>& aArray);
static void
CopyArrayToArrayBuffer(JSContext* aCx, const nsTArray<uint8_t>& aArray,
JS::MutableHandle<JSObject*> aValue, ErrorResult& aRv);
};
} // namespace dom
} // namespace mozilla
#endif // mozilla_dom_PushUtil_h

65
dom/push/moz.build Normal file
View file

@ -0,0 +1,65 @@
# vim: set filetype=python:
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
EXTRA_COMPONENTS += [
'Push.js',
'Push.manifest',
'PushComponents.js',
]
EXTRA_JS_MODULES += [
'PushCrypto.jsm',
'PushDB.jsm',
'PushRecord.jsm',
'PushService.jsm',
]
if CONFIG['MOZ_BUILD_APP'] != 'mobile/android':
# Everything but Fennec.
EXTRA_JS_MODULES += [
'PushServiceHttp2.jsm',
'PushServiceWebSocket.jsm',
]
else:
# Fennec only.
EXTRA_JS_MODULES += [
'PushServiceAndroidGCM.jsm',
]
MOCHITEST_MANIFESTS += [
'test/mochitest.ini',
]
XPCSHELL_TESTS_MANIFESTS += [
'test/xpcshell/xpcshell.ini',
]
EXPORTS.mozilla.dom += [
'PushManager.h',
'PushNotifier.h',
'PushSubscription.h',
'PushSubscriptionOptions.h',
'PushUtil.h',
]
UNIFIED_SOURCES += [
'PushManager.cpp',
'PushNotifier.cpp',
'PushSubscription.cpp',
'PushSubscriptionOptions.cpp',
'PushUtil.cpp',
]
TEST_DIRS += ['test/xpcshell']
include('/ipc/chromium/chromium-config.mozbuild')
LOCAL_INCLUDES += [
'../base',
'../ipc',
'../workers',
]
FINAL_LIBRARY = 'xul'

View file

@ -0,0 +1,10 @@
this.onpush = function(event) {
var request = event.data.json();
if (request.type == "exception") {
throw new Error("Uncaught exception");
}
if (request.type == "rejection") {
event.waitUntil(Promise.reject(
new Error("Unhandled rejection")));
}
};

24
dom/push/test/frame.html Normal file
View file

@ -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>

View file

@ -0,0 +1,85 @@
var state = "from_scope";
var resolvePromiseCallback;
onfetch = function(event) {
if (event.request.url.indexOf("lifetime_frame.html") >= 0) {
event.respondWith(new Response("iframe_lifetime"));
return;
}
var currentState = state;
event.waitUntil(
clients.matchAll()
.then(clients => {
clients.forEach(client => {
client.postMessage({type: "fetch", state: currentState});
});
})
);
if (event.request.url.indexOf("update") >= 0) {
state = "update";
} else if (event.request.url.indexOf("wait") >= 0) {
event.respondWith(new Promise(function(res, rej) {
if (resolvePromiseCallback) {
dump("ERROR: service worker was already waiting on a promise.\n");
}
resolvePromiseCallback = function() {
res(new Response("resolve_respondWithPromise"));
};
}));
state = "wait";
} else if (event.request.url.indexOf("release") >= 0) {
state = "release";
resolvePromise();
}
}
function resolvePromise() {
if (resolvePromiseCallback === undefined || resolvePromiseCallback == null) {
dump("ERROR: wait promise was not set.\n");
return;
}
resolvePromiseCallback();
resolvePromiseCallback = null;
}
onmessage = function(event) {
var lastState = state;
state = event.data;
if (state === 'wait') {
event.waitUntil(new Promise(function(res, rej) {
if (resolvePromiseCallback) {
dump("ERROR: service worker was already waiting on a promise.\n");
}
resolvePromiseCallback = res;
}));
} else if (state === 'release') {
resolvePromise();
}
event.source.postMessage({type: "message", state: lastState});
}
onpush = function(event) {
var pushResolve;
event.waitUntil(new Promise(function(resolve) {
pushResolve = resolve;
}));
// FIXME(catalinb): push message carry no data. So we assume the only
// push message we get is "wait"
clients.matchAll().then(function(client) {
if (client.length == 0) {
dump("ERROR: no clients to send the response to.\n");
}
client[0].postMessage({type: "push", state: state});
state = "wait";
if (resolvePromiseCallback) {
dump("ERROR: service worker was already waiting on a promise.\n");
} else {
resolvePromiseCallback = pushResolve;
}
});
}

View file

@ -0,0 +1,24 @@
[DEFAULT]
skip-if = os == "android"
support-files =
worker.js
frame.html
webpush.js
lifetime_worker.js
test_utils.js
mockpushserviceparent.js
error_worker.js
[test_has_permissions.html]
[test_permissions.html]
[test_register.html]
[test_register_key.html]
[test_multiple_register.html]
[test_multiple_register_during_service_activation.html]
[test_unregister.html]
[test_multiple_register_different_scope.html]
[test_subscription_change.html]
[test_data.html]
[test_try_registering_offline_disabled.html]
[test_serviceworker_lifetime.html]
[test_error_reporting.html]

View file

@ -0,0 +1,168 @@
"use strict";
const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
/**
* Defers one or more callbacks until the next turn of the event loop. Multiple
* callbacks are executed in order.
*
* @param {Function[]} callbacks The callbacks to execute. One callback will be
* executed per tick.
*/
function waterfall(...callbacks) {
callbacks.reduce((promise, callback) => promise.then(() => {
callback();
}), Promise.resolve()).catch(Cu.reportError);
}
/**
* Minimal implementation of a mock WebSocket connect to be used with
* PushService. Forwards and receive messages from the implementation
* that lives in the content process.
*/
function MockWebSocketParent(originalURI) {
this._originalURI = originalURI;
}
MockWebSocketParent.prototype = {
_originalURI: null,
_listener: null,
_context: null,
QueryInterface: XPCOMUtils.generateQI([
Ci.nsISupports,
Ci.nsIWebSocketChannel
]),
get originalURI() {
return this._originalURI;
},
asyncOpen(uri, origin, windowId, listener, context) {
this._listener = listener;
this._context = context;
waterfall(() => this._listener.onStart(this._context));
},
sendMsg(msg) {
sendAsyncMessage("socket-client-msg", msg);
},
close() {
waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
},
serverSendMsg(msg) {
waterfall(() => this._listener.onMessageAvailable(this._context, msg),
() => this._listener.onAcknowledge(this._context, 0));
},
};
var pushService = Cc["@mozilla.org/push/Service;1"].
getService(Ci.nsIPushService).
wrappedJSObject;
var mockSocket;
var serverMsgs = [];
addMessageListener("socket-setup", function () {
pushService.replaceServiceBackend({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
mockSocket = new MockWebSocketParent(uri);
while (serverMsgs.length > 0) {
let msg = serverMsgs.shift();
mockSocket.serverSendMsg(msg);
}
return mockSocket;
}
});
});
addMessageListener("socket-teardown", function (msg) {
pushService.restoreServiceBackend().then(_ => {
serverMsgs.length = 0;
if (mockSocket) {
mockSocket.close();
mockSocket = null;
}
sendAsyncMessage("socket-server-teardown");
}).catch(error => {
Cu.reportError(`Error restoring service backend: ${error}`);
})
});
addMessageListener("socket-server-msg", function (msg) {
if (mockSocket) {
mockSocket.serverSendMsg(msg);
} else {
serverMsgs.push(msg);
}
});
var MockService = {
requestID: 1,
resolvers: new Map(),
sendRequest(name, params) {
return new Promise((resolve, reject) => {
let id = this.requestID++;
this.resolvers.set(id, { resolve, reject });
sendAsyncMessage("service-request", {
name: name,
id: id,
params: params,
});
});
},
handleResponse(response) {
if (!this.resolvers.has(response.id)) {
Cu.reportError(`Unexpected response for request ${response.id}`);
return;
}
let resolver = this.resolvers.get(response.id);
this.resolvers.delete(response.id);
if (response.error) {
resolver.reject(response.error);
} else {
resolver.resolve(response.result);
}
},
init() {},
register(pageRecord) {
return this.sendRequest("register", pageRecord);
},
registration(pageRecord) {
return this.sendRequest("registration", pageRecord);
},
unregister(pageRecord) {
return this.sendRequest("unregister", pageRecord);
},
reportDeliveryError(messageId, reason) {
sendAsyncMessage("service-delivery-error", {
messageId: messageId,
reason: reason,
});
},
};
addMessageListener("service-replace", function () {
pushService.service = MockService;
});
addMessageListener("service-restore", function () {
pushService.service = null;
});
addMessageListener("service-response", function (response) {
MockService.handleResponse(response);
});

View file

@ -0,0 +1,218 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1185544: Add data delivery to the WebSocket backend.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1185544</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/webpush.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1185544">Mozilla Bug 1185544</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
var userAgentID = "ac44402c-85fc-41e4-a0d0-483316d15351";
var channelID = null;
var mockSocket = new MockWebSocket();
mockSocket.onRegister = function(request) {
channelID = request.channelID;
this.serverSendMsg(JSON.stringify({
messageType: "register",
uaid: userAgentID,
channelID,
status: 200,
pushEndpoint: "https://example.com/endpoint/1"
}));
};
var registration;
add_task(function* start() {
yield setupPrefsAndMockSocket(mockSocket);
yield setPushPermission(true);
var url = "worker.js" + "?" + (Math.random());
registration = yield navigator.serviceWorker.register(url, {scope: "."});
yield waitForActive(registration);
});
var controlledFrame;
add_task(function* createControlledIFrame() {
controlledFrame = yield injectControlledFrame();
});
var pushSubscription;
add_task(function* subscribe() {
pushSubscription = yield registration.pushManager.subscribe();
});
function base64UrlDecode(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
// Replace padding if it was stripped by the sender.
// See http://tools.ietf.org/html/rfc4648#section-4
switch (s.length % 4) {
case 0:
break; // No pad chars in this case
case 2:
s += '==';
break; // Two pad chars
case 3:
s += '=';
break; // One pad char
default:
throw new Error('Illegal base64url string!');
}
// With correct padding restored, apply the standard base64 decoder
var decoded = atob(s);
var array = new Uint8Array(new ArrayBuffer(decoded.length));
for (var i = 0; i < decoded.length; i++) {
array[i] = decoded.charCodeAt(i);
}
return array;
}
add_task(function* compareJSONSubscription() {
var json = pushSubscription.toJSON();
is(json.endpoint, pushSubscription.endpoint, "Wrong endpoint");
["p256dh", "auth"].forEach(keyName => {
isDeeply(
base64UrlDecode(json.keys[keyName]),
new Uint8Array(pushSubscription.getKey(keyName)),
"Mismatched Base64-encoded key: " + keyName
);
});
});
add_task(function* comparePublicKey() {
var data = yield sendRequestToWorker({ type: "publicKey" });
var p256dhKey = new Uint8Array(pushSubscription.getKey("p256dh"));
is(p256dhKey.length, 65, "Key share should be 65 octets");
isDeeply(
p256dhKey,
new Uint8Array(data.p256dh),
"Mismatched key share"
);
var authSecret = new Uint8Array(pushSubscription.getKey("auth"));
ok(authSecret.length, 16, "Auth secret should be 16 octets");
isDeeply(
authSecret,
new Uint8Array(data.auth),
"Mismatched auth secret"
);
});
var version = 0;
function sendEncryptedMsg(pushSubscription, message) {
return webPushEncrypt(pushSubscription, message)
.then((encryptedData) => {
mockSocket.serverSendMsg(JSON.stringify({
messageType: 'notification',
version: version++,
channelID: channelID,
data: encryptedData.data,
headers: {
encryption: encryptedData.encryption,
encryption_key: encryptedData.encryption_key,
encoding: encryptedData.encoding
}
}));
});
}
function waitForMessage(pushSubscription, message) {
return Promise.all([
controlledFrame.waitOnWorkerMessage("finished"),
sendEncryptedMsg(pushSubscription, message),
]).then(([message]) => message);
}
add_task(function* sendPushMessageFromPage() {
var typedArray = new Uint8Array([226, 130, 40, 240, 40, 140, 188]);
var json = { hello: "world" };
var message = yield waitForMessage(pushSubscription, "Text message from page");
is(message.data.text, "Text message from page", "Wrong text message data");
message = yield waitForMessage(
pushSubscription,
typedArray
);
isDeeply(new Uint8Array(message.data.arrayBuffer), typedArray,
"Wrong array buffer message data");
message = yield waitForMessage(
pushSubscription,
JSON.stringify(json)
);
ok(message.data.json.ok, "Unexpected error parsing JSON");
isDeeply(message.data.json.value, json, "Wrong JSON message data");
message = yield waitForMessage(
pushSubscription,
""
);
ok(message, "Should include data for empty messages");
is(message.data.text, "", "Wrong text for empty message");
is(message.data.arrayBuffer.byteLength, 0, "Wrong buffer length for empty message");
ok(!message.data.json.ok, "Expected JSON parse error for empty message");
message = yield waitForMessage(
pushSubscription,
new Uint8Array([0x48, 0x69, 0x21, 0x20, 0xf0, 0x9f, 0x91, 0x80])
);
is(message.data.text, "Hi! \ud83d\udc40", "Wrong text for message with emoji");
var text = yield new Promise((resolve, reject) => {
var reader = new FileReader();
reader.onloadend = event => {
if (reader.error) {
reject(reader.error);
} else {
resolve(reader.result);
}
};
reader.readAsText(message.data.blob);
});
is(text, "Hi! \ud83d\udc40", "Wrong blob data for message with emoji");
var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
// Send a blank message.
mockSocket.serverSendMsg(JSON.stringify({
messageType: "notification",
version: "vDummy",
channelID: channelID
}));
var message = yield finishedPromise;
ok(!message.data, "Should exclude data for blank messages");
});
add_task(function* unsubscribe() {
controlledFrame.remove();
yield pushSubscription.unsubscribe();
});
add_task(function* unregister() {
yield registration.unregister();
});
</script>
</body>
</html>

View file

@ -0,0 +1,130 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1246341: Report message delivery failures to the Push server.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1246341</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1246341">Mozilla Bug 1246341</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
var pushNotifier = SpecialPowers.Cc["@mozilla.org/push/Notifier;1"]
.getService(SpecialPowers.Ci.nsIPushNotifier);
var reporters = new Map();
var registration;
add_task(function* start() {
yield setupPrefsAndReplaceService({
reportDeliveryError(messageId, reason) {
ok(reporters.has(messageId),
'Unexpected error reported for message ' + messageId);
var resolve = reporters.get(messageId);
reporters.delete(messageId);
resolve(reason);
},
});
yield setPushPermission(true);
var url = "error_worker.js" + "?" + (Math.random());
registration = yield navigator.serviceWorker.register(url, {scope: "."});
yield waitForActive(registration);
});
var controlledFrame;
add_task(function* createControlledIFrame() {
controlledFrame = yield injectControlledFrame();
});
var idCounter = 1;
function waitForDeliveryError(request) {
return new Promise(resolve => {
var data = new TextEncoder("utf-8").encode(JSON.stringify(request));
var principal = SpecialPowers.wrap(document).nodePrincipal;
let messageId = "message-" + (idCounter++);
reporters.set(messageId, resolve);
pushNotifier.notifyPushWithData(registration.scope, principal, messageId,
data.length, data);
});
}
add_task(function* reportDeliveryErrors() {
var reason = yield waitForDeliveryError({ type: "exception" });
is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNCAUGHT_EXCEPTION,
"Should report uncaught exceptions");
reason = yield waitForDeliveryError({ type: "rejection" });
is(reason, SpecialPowers.Ci.nsIPushErrorReporter.DELIVERY_UNHANDLED_REJECTION,
"Should report unhandled rejections");
});
add_task(function* reportDecryptionError() {
var message = yield new Promise(resolve => {
var consoleService = SpecialPowers.Cc["@mozilla.org/consoleservice;1"]
.getService(SpecialPowers.Ci.nsIConsoleService);
var listener = SpecialPowers.wrapCallbackObject({
QueryInterface(iid) {
if (!SpecialPowers.Ci.nsISupports.equals(iid) &&
!SpecialPowers.Ci.nsIConsoleListener.equals(iid)) {
throw SpecialPowers.Cr.NS_ERROR_NO_INTERFACE;
}
return this;
},
observe(message) {
let error = message;
try {
error.QueryInterface(SpecialPowers.Ci.nsIScriptError);
} catch (error) {
return;
}
if (message.innerWindowID == controlledFrame.innerWindowId()) {
consoleService.unregisterListener(listener);
resolve(error);
}
},
});
consoleService.registerListener(listener);
var principal = SpecialPowers.wrap(document).nodePrincipal;
pushNotifier.notifyError(registration.scope, principal, "Push error",
SpecialPowers.Ci.nsIScriptError.errorFlag);
});
is(message.sourceName, registration.scope,
"Should use the qualified scope URL as the source");
is(message.errorMessage, "Push error",
"Should report the given error string");
});
add_task(function* unsubscribe() {
controlledFrame.remove();
});
add_task(function* unregister() {
yield registration.unregister();
});
</script>
</body>
</html>

View file

@ -0,0 +1,84 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1038811: Push tests.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1038811</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function debug(str) {
// console.log(str + "\n");
}
function start() {
return navigator.serviceWorker.register("worker.js" + "?" + (Math.random()), {scope: "."})
.then((swr) => {
registration = swr;
return waitForActive(registration);
});
}
function unregister() {
return registration.unregister().then(function(result) {
ok(result, "Unregister should return true.");
}, function(e) {
dump("Unregistering the SW failed with " + e + "\n");
});
}
function hasPermission(swr) {
var p = new Promise(function(res, rej) {
swr.pushManager.permissionState().then(
function(state) {
debug("state: " + state);
ok(["granted", "denied", "prompt"].indexOf(state) >= 0, "permissionState() returned a valid state.");
res(swr);
}, function(error) {
ok(false, "permissionState() failed.");
res(swr);
}
);
});
return p;
}
function runTest() {
start()
.then(hasPermission)
.then(unregister)
.catch(function(e) {
ok(false, "Some test failed with error " + e);
}).then(SimpleTest.finish);
}
SpecialPowers.addPermission("desktop-notification", false, document);
SpecialPowers.pushPrefEnv({"set": [
["dom.push.enabled", true],
["dom.push.connection.enabled", true],
["dom.serviceWorkers.exemptFromPerDomainMax", true],
["dom.serviceWorkers.enabled", true],
["dom.serviceWorkers.testing.enabled", true]
]}, runTest);
SimpleTest.waitForExplicitFinish();
</script>
</body>
</html>

View file

@ -0,0 +1,130 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1038811: Push tests.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1038811</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function debug(str) {
// console.log(str + "\n");
}
function start() {
return navigator.serviceWorker.register("worker.js" + "?" + (Math.random()), {scope: "."})
.then((swr) => {
registration = swr
return waitForActive(registration);
});
}
function unregister() {
return registration.unregister().then(function(result) {
ok(result, "Unregister should return true.");
}, function(e) {
dump("Unregistering the SW failed with " + e + "\n");
});
}
function setupPushNotification(swr) {
var p = new Promise(function(res, rej) {
swr.pushManager.subscribe().then(
function(pushSubscription) {
ok(true, "successful registered for push notification");
res({swr: swr, pushSubscription: pushSubscription});
}, function(error) {
ok(false, "could not register for push notification");
res(null);
}
);
});
return p;
}
function setupSecondEndpoint(result) {
var p = new Promise(function(res, rej) {
result.swr.pushManager.subscribe().then(
function(pushSubscription) {
ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "setupSecondEndpoint - Got the same endpoint back.");
res(result);
}, function(error) {
ok(false, "could not register for push notification");
res(null);
}
);
});
return p;
}
function getEndpointExpectNull(swr) {
var p = new Promise(function(res, rej) {
swr.pushManager.getSubscription().then(
function(pushSubscription) {
ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
res(swr);
}, function(error) {
ok(false, "could not register for push notification");
res(null);
}
);
});
return p;
}
function getEndpoint(result) {
var p = new Promise(function(res, rej) {
result.swr.pushManager.getSubscription().then(
function(pushSubscription) {
ok(result.pushSubscription.endpoint == pushSubscription.endpoint, "getEndpoint - Got the same endpoint back.");
res(pushSubscription);
}, function(error) {
ok(false, "could not register for push notification");
res(null);
}
);
});
return p;
}
function unregisterPushNotification(pushSubscription) {
return pushSubscription.unsubscribe();
}
function runTest() {
start()
.then(getEndpointExpectNull)
.then(setupPushNotification)
.then(setupSecondEndpoint)
.then(getEndpoint)
.then(unregisterPushNotification)
.then(unregister)
.catch(function(e) {
ok(false, "Some test failed with error " + e);
}).then(SimpleTest.finish);
}
setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest());
SpecialPowers.addPermission("desktop-notification", true, document);
SimpleTest.waitForExplicitFinish();
</script>
</body>
</html>

View file

@ -0,0 +1,123 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1150812: Test registering for two different scopes.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1150812</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
var scopeA = "./a/";
var scopeB = "./b/";
function debug(str) {
// console.log(str + "\n");
}
function registerServiceWorker(scope) {
return navigator.serviceWorker.register("worker.js" + "?" + (Math.random()), {scope: scope})
.then(swr => waitForActive(swr));
}
function unregister(swr) {
return swr.unregister()
.then(result => {
ok(result, "Unregister should return true.");
}, err => {
ok(false,"Unregistering the SW failed with " + err + "\n");
throw err;
});
}
function subscribe(swr) {
return swr.pushManager.subscribe()
.then(sub => {
ok(true, "successful registered for push notification");
return sub;
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function setupMultipleSubscriptions(swr1, swr2) {
return Promise.all([
subscribe(swr1),
subscribe(swr2)
]).then(a => {
ok(a[0].endpoint != a[1].endpoint, "setupMultipleSubscriptions - Got different endpoints.");
return a;
});
}
function getEndpointExpectNull(swr) {
return swr.pushManager.getSubscription()
.then(pushSubscription => {
ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function getEndpoint(swr, results) {
return swr.pushManager.getSubscription()
.then(sub => {
ok((results[0].endpoint == sub.endpoint) ||
(results[1].endpoint == sub.endpoint), "getEndpoint - Got the same endpoint back.");
return results;
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function unsubscribe(result) {
return result[0].unsubscribe()
.then(_ => result[1].unsubscribe());
}
function runTest() {
registerServiceWorker(scopeA)
.then(swrA =>
registerServiceWorker(scopeB)
.then(swrB =>
getEndpointExpectNull(swrA)
.then(_ => getEndpointExpectNull(swrB))
.then(_ => setupMultipleSubscriptions(swrA, swrB))
.then(results => getEndpoint(swrA, results))
.then(results => getEndpoint(swrB, results))
.then(results => unsubscribe(results))
.then(_ => unregister(swrA))
.then(_ => unregister(swrB))
)
)
.catch(err => {
ok(false, "Some test failed with error " + err);
}).then(SimpleTest.finish);
}
setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest());
SpecialPowers.addPermission("desktop-notification", true, document);
SimpleTest.waitForExplicitFinish();
</script>
</body>
</html>

View file

@ -0,0 +1,111 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1150812: If service is in activating or no connection state it can not send
request immediately, but the requests are queued. This test test the case of
multiple subscription for the same scope during activation.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1150812</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function debug(str) {
// console.log(str + "\n");
}
function registerServiceWorker() {
return navigator.serviceWorker.register("worker.js" + "?" + (Math.random()), {scope: "."});
}
function unregister(swr) {
return swr.unregister()
.then(result => {
ok(result, "Unregister should return true.");
}, err => {
dump("Unregistering the SW failed with " + err + "\n");
throw err;
});
}
function subscribe(swr) {
return swr.pushManager.subscribe()
.then(sub => {
ok(true, "successful registered for push notification");
return sub;
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function setupMultipleSubscriptions(swr) {
// We need to do this to restart service so that a queue will be formed.
let promiseTeardown = teardownMockPushSocket();
setupMockPushSocket(new MockWebSocket());
var pushSubscription;
return Promise.all([
subscribe(swr),
subscribe(swr)
]).then(a => {
ok(a[0].endpoint == a[1].endpoint, "setupMultipleSubscriptions - Got the same endpoint back.");
pushSubscription = a[0];
return promiseTeardown;
}).then(_ => {
return pushSubscription;
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function getEndpointExpectNull(swr) {
return swr.pushManager.getSubscription()
.then(pushSubscription => {
ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function unsubscribe(sub) {
return sub.unsubscribe();
}
function runTest() {
registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => setupMultipleSubscriptions(swr))
.then(sub => unsubscribe(sub))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
}).then(SimpleTest.finish);
}
setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest());
SpecialPowers.addPermission("desktop-notification", true, document);
SimpleTest.waitForExplicitFinish();
</script>
</body>
</html>

View file

@ -0,0 +1,106 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1038811: Push tests.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1038811</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function debug(str) {
// console.log(str + "\n");
}
var registration;
add_task(function* start() {
yield setupPrefsAndMockSocket(new MockWebSocket());
yield setPushPermission(false);
var url = "worker.js" + "?" + Math.random();
registration = yield navigator.serviceWorker.register(url, {scope: "."});
yield waitForActive(registration);
});
add_task(function* denySubscribe() {
try {
yield registration.pushManager.subscribe();
ok(false, "subscribe() should fail because no permission for push");
} catch (error) {
ok(error instanceof DOMException, "Wrong exception type");
is(error.name, "NotAllowedError", "Wrong exception name");
}
});
add_task(function* denySubscribeInWorker() {
// If permission is revoked, `getSubscription()` should return `null`, and
// `subscribe()` should reject immediately. Calling these from the worker
// should not deadlock the main thread (see bug 1228723).
var errorInfo = yield sendRequestToWorker({
type: "denySubscribe",
});
ok(errorInfo.isDOMException, "Wrong exception type");
is(errorInfo.name, "NotAllowedError", "Wrong exception name");
});
add_task(function* getEndpoint() {
var pushSubscription = yield registration.pushManager.getSubscription();
is(pushSubscription, null, "getSubscription() should return null because no permission for push");
});
add_task(function* checkPermissionState() {
var permissionManager = SpecialPowers.Ci.nsIPermissionManager;
var tests = [{
action: permissionManager.ALLOW_ACTION,
state: "granted",
}, {
action: permissionManager.DENY_ACTION,
state: "denied",
}, {
action: permissionManager.PROMPT_ACTION,
state: "prompt",
}, {
action: permissionManager.UNKNOWN_ACTION,
state: "prompt",
}];
for (var test of tests) {
yield setPushPermission(test.action);
var state = yield registration.pushManager.permissionState();
is(state, test.state, JSON.stringify(test));
try {
yield SpecialPowers.pushPrefEnv({ set: [
["dom.push.testing.ignorePermission", true]] });
state = yield registration.pushManager.permissionState();
is(state, "granted", `Should ignore ${
test.action} if the override pref is set`);
} finally {
yield SpecialPowers.flushPrefEnv();
}
}
});
add_task(function* unregister() {
var result = yield registration.unregister();
ok(result, "Unregister should return true.");
});
</script>
</body>
</html>

View file

@ -0,0 +1,109 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1038811: Push tests.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1038811</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1038811">Mozilla Bug 1038811</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function debug(str) {
// console.log(str + "\n");
}
var mockSocket = new MockWebSocket();
var channelID = null;
mockSocket.onRegister = function(request) {
channelID = request.channelID;
this.serverSendMsg(JSON.stringify({
messageType: "register",
uaid: "c69e2014-9e15-438d-b253-d79cc2df60a8",
channelID,
status: 200,
pushEndpoint: "https://example.com/endpoint/1"
}));
};
var registration;
add_task(function* start() {
yield setupPrefsAndMockSocket(mockSocket);
yield setPushPermission(true);
var url = "worker.js" + "?" + (Math.random());
registration = yield navigator.serviceWorker.register(url, {scope: "."});
yield waitForActive(registration);
});
var controlledFrame;
add_task(function* createControlledIFrame() {
controlledFrame = yield injectControlledFrame();
});
add_task(function* checkPermissionState() {
var state = yield registration.pushManager.permissionState();
is(state, "granted", "permissionState() should resolve to granted.");
});
var pushSubscription;
add_task(function* subscribe() {
pushSubscription = yield registration.pushManager.subscribe();
is(pushSubscription.options.applicationServerKey, null,
"Subscription should not have an app server key");
});
add_task(function* resubscribe() {
var data = yield sendRequestToWorker({
type: "resubscribe",
endpoint: pushSubscription.endpoint,
});
pushSubscription = yield registration.pushManager.getSubscription();
is(data.endpoint, pushSubscription.endpoint,
"Subscription endpoints should match after resubscribing in worker");
});
add_task(function* waitForPushNotification() {
var finishedPromise = controlledFrame.waitOnWorkerMessage("finished");
// Send a blank message.
mockSocket.serverSendMsg(JSON.stringify({
messageType: "notification",
version: "vDummy",
channelID: channelID
}));
yield finishedPromise;
});
add_task(function* unsubscribe() {
controlledFrame.remove();
yield pushSubscription.unsubscribe();
});
add_task(function* unregister() {
var result = yield registration.unregister();
ok(result, "Unregister should return true.");
});
</script>
</body>
</html>

View file

@ -0,0 +1,210 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1247685: Implement `applicationServerKey` for subscription association.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1247685</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1247685">Mozilla Bug 1247685</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
var isTestingMismatchedKey = false;
var subscriptions = 0;
var testKey; // Generated in `start`.
function generateKey() {
return crypto.subtle.generateKey({
name: "ECDSA",
namedCurve: "P-256",
}, true, ["sign", "verify"]).then(cryptoKey =>
crypto.subtle.exportKey("raw", cryptoKey.publicKey)
).then(publicKey => new Uint8Array(publicKey));
}
var registration;
add_task(function* start() {
yield setupPrefsAndReplaceService({
register(pageRecord) {
ok(pageRecord.appServerKey.length > 0,
"App server key should not be empty");
if (pageRecord.appServerKey.length != 65) {
throw { result:
SpecialPowers.Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR };
}
if (isTestingMismatchedKey) {
throw { result:
SpecialPowers.Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR };
}
return {
endpoint: "https://example.com/push/" + (++subscriptions),
appServerKey: pageRecord.appServerKey,
};
},
registration(pageRecord) {
return {
endpoint: "https://example.com/push/subWithKey",
appServerKey: testKey,
};
},
});
yield setPushPermission(true);
testKey = yield generateKey();
var url = "worker.js" + "?" + (Math.random());
registration = yield navigator.serviceWorker.register(url, {scope: "."});
yield waitForActive(registration);
});
var controlledFrame;
add_task(function* createControlledIFrame() {
controlledFrame = yield injectControlledFrame();
});
add_task(function* emptyKey() {
try {
yield registration.pushManager.subscribe({
applicationServerKey: new ArrayBuffer(0),
});
ok(false, "Should reject for empty app server keys");
} catch (error) {
ok(error instanceof DOMException,
"Wrong exception type for empty key");
is(error.name, "InvalidAccessError",
"Wrong exception name for empty key");
}
});
add_task(function* invalidKey() {
try {
yield registration.pushManager.subscribe({
applicationServerKey: new Uint8Array([0]),
});
ok(false, "Should reject for invalid app server keys");
} catch (error) {
ok(error instanceof DOMException,
"Wrong exception type for invalid key");
is(error.name, "InvalidAccessError",
"Wrong exception name for invalid key");
}
});
add_task(function* validKey() {
var pushSubscription = yield registration.pushManager.subscribe({
applicationServerKey: yield generateKey(),
});
is(pushSubscription.endpoint, "https://example.com/push/1",
"Wrong endpoint for subscription with key");
is(pushSubscription.options.applicationServerKey,
pushSubscription.options.applicationServerKey,
"App server key getter should return the same object");
});
add_task(function* retrieveKey() {
var pushSubscription = yield registration.pushManager.getSubscription();
is(pushSubscription.endpoint, "https://example.com/push/subWithKey",
"Got wrong endpoint for subscription with key");
isDeeply(
new Uint8Array(pushSubscription.options.applicationServerKey),
testKey,
"Got wrong app server key"
);
});
add_task(function* mismatchedKey() {
isTestingMismatchedKey = true;
try {
yield registration.pushManager.subscribe({
applicationServerKey: yield generateKey(),
});
ok(false, "Should reject for mismatched app server keys");
} catch (error) {
ok(error instanceof DOMException,
"Wrong exception type for mismatched key");
is(error.name, "InvalidStateError",
"Wrong exception name for mismatched key");
} finally {
isTestingMismatchedKey = false;
}
});
add_task(function* emptyKeyInWorker() {
var errorInfo = yield sendRequestToWorker({
type: "subscribeWithKey",
key: new ArrayBuffer(0),
});
ok(errorInfo.isDOMException,
"Wrong exception type in worker for empty key");
is(errorInfo.name, "InvalidAccessError",
"Wrong exception name in worker for empty key");
});
add_task(function* invalidKeyInWorker() {
var errorInfo = yield sendRequestToWorker({
type: "subscribeWithKey",
key: new Uint8Array([1]),
});
ok(errorInfo.isDOMException,
"Wrong exception type in worker for invalid key");
is(errorInfo.name, "InvalidAccessError",
"Wrong exception name in worker for invalid key");
});
add_task(function* validKeyInWorker() {
var key = yield generateKey();
var data = yield sendRequestToWorker({
type: "subscribeWithKey",
key: key,
});
is(data.endpoint, "https://example.com/push/2",
"Wrong endpoint for subscription with key created in worker");
isDeeply(new Uint8Array(data.key), key,
"Wrong app server key for subscription created in worker");
});
add_task(function* mismatchedKeyInWorker() {
isTestingMismatchedKey = true;
try {
var errorInfo = yield sendRequestToWorker({
type: "subscribeWithKey",
key: yield generateKey(),
});
ok(errorInfo.isDOMException,
"Wrong exception type in worker for mismatched key");
is(errorInfo.name, "InvalidStateError",
"Wrong exception name in worker for mismatched key");
} finally {
isTestingMismatchedKey = false;
}
});
add_task(function* unsubscribe() {
is(subscriptions, 2, "Wrong subscription count");
controlledFrame.remove();
});
add_task(function* unregister() {
yield registration.unregister();
});
</script>
</body>
</html>

View file

@ -0,0 +1,362 @@
<!DOCTYPE HTML>
<html>
<!--
Test the lifetime management of service workers. We keep this test in
dom/push/tests to pass the external network check when connecting to
the mozilla push service.
How this test works:
- the service worker maintains a state variable and a promise used for
extending its lifetime. Note that the terminating the worker will reset
these variables to their default values.
- we send 3 types of requests to the service worker:
|update|, |wait| and |release|. All three requests will cause the sw to update
its state to the new value and reply with a message containing
its previous state. Furthermore, |wait| will set a waitUntil or a respondWith
promise that's not resolved until the next |release| message.
- Each subtest will use a combination of values for the timeouts and check
if the service worker is in the correct state as we send it different
events.
- We also wait and assert for service worker termination using an event dispatched
through nsIObserverService.
-->
<head>
<title>Test for Bug 1188545</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1188545">Mozilla Bug 118845</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function start() {
return navigator.serviceWorker.register("lifetime_worker.js", {scope: "./"})
.then((swr) => ({registration: swr}));
}
function waitForActiveServiceWorker(ctx) {
return waitForActive(ctx.registration).then(function(result) {
ok(ctx.registration.active, "Service Worker is active");
return ctx;
});
}
function unregister(ctx) {
return ctx.registration.unregister().then(function(result) {
ok(result, "Unregister should return true.");
}, function(e) {
dump("Unregistering the SW failed with " + e + "\n");
});
}
function registerPushNotification(ctx) {
var p = new Promise(function(res, rej) {
ctx.registration.pushManager.subscribe().then(
function(pushSubscription) {
ok(true, "successful registered for push notification");
ctx.subscription = pushSubscription;
res(ctx);
}, function(error) {
ok(false, "could not register for push notification");
res(ctx);
});
});
return p;
}
var mockSocket = new MockWebSocket();
var endpoint = "https://example.com/endpoint/1";
var channelID = null;
mockSocket.onRegister = function(request) {
channelID = request.channelID;
this.serverSendMsg(JSON.stringify({
messageType: "register",
uaid: "fa8f2e4b-5ddc-4408-b1e3-5f25a02abff0",
channelID,
status: 200,
pushEndpoint: endpoint
}));
};
function sendPushToPushServer(pushEndpoint) {
is(pushEndpoint, endpoint, "Got unexpected endpoint");
mockSocket.serverSendMsg(JSON.stringify({
messageType: "notification",
version: "vDummy",
channelID
}));
}
function unregisterPushNotification(ctx) {
return ctx.subscription.unsubscribe().then(function(result) {
ok(result, "unsubscribe should succeed.");
ctx.subscription = null;
return ctx;
});
}
function createIframe(ctx) {
var p = new Promise(function(res, rej) {
var iframe = document.createElement('iframe');
// This file doesn't exist, the service worker will give us an empty
// document.
iframe.src = "http://mochi.test:8888/tests/dom/push/test/lifetime_frame.html";
iframe.onload = function() {
ctx.iframe = iframe;
res(ctx);
}
document.body.appendChild(iframe);
});
return p;
}
function closeIframe(ctx) {
ctx.iframe.parentNode.removeChild(ctx.iframe);
return new Promise(function(res, rej) {
// XXXcatalinb: give the worker more time to "notice" it stopped
// controlling documents
ctx.iframe = null;
setTimeout(res, 0);
}).then(() => ctx);
}
function waitAndCheckMessage(contentWindow, expected) {
function checkMessage(expected, resolve, event) {
ok(event.data.type == expected.type, "Received correct message type: " + expected.type);
ok(event.data.state == expected.state, "Service worker is in the correct state: " + expected.state);
this.navigator.serviceWorker.onmessage = null;
resolve();
}
return new Promise(function(res, rej) {
contentWindow.navigator.serviceWorker.onmessage =
checkMessage.bind(contentWindow, expected, res);
});
}
function fetchEvent(ctx, expected_state, new_state) {
var expected = { type: "fetch", state: expected_state };
var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
ctx.iframe.contentWindow.fetch(new_state);
return p;
}
function pushEvent(ctx, expected_state, new_state) {
var expected = {type: "push", state: expected_state};
var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
sendPushToPushServer(ctx.subscription.endpoint);
return p;
}
function messageEventIframe(ctx, expected_state, new_state) {
var expected = {type: "message", state: expected_state};
var p = waitAndCheckMessage(ctx.iframe.contentWindow, expected);
ctx.iframe.contentWindow.navigator.serviceWorker.controller.postMessage(new_state);
return p;
}
function messageEvent(ctx, expected_state, new_state) {
var expected = {type: "message", state: expected_state};
var p = waitAndCheckMessage(window, expected);
ctx.registration.active.postMessage(new_state);
return p;
}
function checkStateAndUpdate(eventFunction, expected_state, new_state) {
return function(ctx) {
return eventFunction(ctx, expected_state, new_state)
.then(() => ctx);
}
}
function setShutdownObserver(expectingEvent) {
info("Setting shutdown observer: expectingEvent=" + expectingEvent);
return function(ctx) {
cancelShutdownObserver(ctx);
ctx.observer_promise = new Promise(function(res, rej) {
ctx.observer = {
observe: function(subject, topic, data) {
ok((topic == "service-worker-shutdown") && expectingEvent, "Service worker was terminated.");
this.remove(ctx);
},
remove: function(ctx) {
SpecialPowers.removeObserver(this, "service-worker-shutdown");
ctx.observer = null;
res(ctx);
}
}
SpecialPowers.addObserver(ctx.observer, "service-worker-shutdown", false);
});
return ctx;
}
}
function waitOnShutdownObserver(ctx) {
info("Waiting on worker to shutdown.");
return ctx.observer_promise;
}
function cancelShutdownObserver(ctx) {
if (ctx.observer) {
ctx.observer.remove(ctx);
}
return ctx.observer_promise;
}
function subTest(test) {
return function(ctx) {
return new Promise(function(res, rej) {
function run() {
test.steps(ctx).catch(function(e) {
ok(false, "Some test failed with error: " + e);
}).then((ctx) => res(ctx));
}
SpecialPowers.pushPrefEnv({"set" : test.prefs}, run);
});
}
}
var test1 = {
prefs: [
["dom.serviceWorkers.idle_timeout", 0],
["dom.serviceWorkers.idle_extended_timeout", 2999999]
],
// Test that service workers are terminated after the grace period expires
// when there are no pending waitUntil or respondWith promises.
steps: function(ctx) {
// Test with fetch events and respondWith promises
return createIframe(ctx)
.then(setShutdownObserver(true))
.then(checkStateAndUpdate(fetchEvent, "from_scope", "update"))
.then(waitOnShutdownObserver)
.then(setShutdownObserver(false))
.then(checkStateAndUpdate(fetchEvent, "from_scope", "wait"))
.then(checkStateAndUpdate(fetchEvent, "wait", "update"))
.then(checkStateAndUpdate(fetchEvent, "update", "update"))
.then(setShutdownObserver(true))
// The service worker should be terminated when the promise is resolved.
.then(checkStateAndUpdate(fetchEvent, "update", "release"))
.then(waitOnShutdownObserver)
.then(setShutdownObserver(false))
.then(closeIframe)
.then(cancelShutdownObserver)
// Test with push events and message events
.then(setShutdownObserver(true))
.then(createIframe)
// Make sure we are shutdown before entering our "no shutdown" sequence
// to avoid races.
.then(waitOnShutdownObserver)
.then(setShutdownObserver(false))
.then(checkStateAndUpdate(pushEvent, "from_scope", "wait"))
.then(checkStateAndUpdate(messageEventIframe, "wait", "update"))
.then(checkStateAndUpdate(messageEventIframe, "update", "update"))
.then(setShutdownObserver(true))
.then(checkStateAndUpdate(messageEventIframe, "update", "release"))
.then(waitOnShutdownObserver)
.then(closeIframe)
}
}
var test2 = {
prefs: [
["dom.serviceWorkers.idle_timeout", 0],
["dom.serviceWorkers.idle_extended_timeout", 2999999]
],
steps: function(ctx) {
// Older versions used to terminate workers when the last controlled
// window was closed. This should no longer happen, though. Verify
// the new behavior.
setShutdownObserver(true)(ctx);
return createIframe(ctx)
// Make sure we are shutdown before entering our "no shutdown" sequence
// to avoid races.
.then(waitOnShutdownObserver)
.then(setShutdownObserver(false))
.then(checkStateAndUpdate(fetchEvent, "from_scope", "wait"))
.then(closeIframe)
.then(setShutdownObserver(true))
.then(checkStateAndUpdate(messageEvent, "wait", "release"))
.then(waitOnShutdownObserver)
// Push workers were exempt from the old rule and should continue to
// survive past the closing of the last controlled window.
.then(setShutdownObserver(true))
.then(createIframe)
// Make sure we are shutdown before entering our "no shutdown" sequence
// to avoid races.
.then(waitOnShutdownObserver)
.then(setShutdownObserver(false))
.then(checkStateAndUpdate(pushEvent, "from_scope", "wait"))
.then(closeIframe)
.then(setShutdownObserver(true))
.then(checkStateAndUpdate(messageEvent, "wait", "release"))
.then(waitOnShutdownObserver)
}
};
var test3 = {
prefs: [
["dom.serviceWorkers.idle_timeout", 2999999],
["dom.serviceWorkers.idle_extended_timeout", 0]
],
steps: function(ctx) {
// set the grace period to 0 and dispatch a message which will reset
// the internal sw timer to the new value.
var test3_1 = {
prefs: [
["dom.serviceWorkers.idle_timeout", 0],
["dom.serviceWorkers.idle_extended_timeout", 0]
],
steps: function(ctx) {
return new Promise(function(res, rej) {
ctx.iframe.contentWindow.fetch("update");
res(ctx);
});
}
}
// Test that service worker is closed when the extended timeout expired
return createIframe(ctx)
.then(setShutdownObserver(false))
.then(checkStateAndUpdate(messageEvent, "from_scope", "update"))
.then(checkStateAndUpdate(messageEventIframe, "update", "update"))
.then(checkStateAndUpdate(fetchEvent, "update", "wait"))
.then(setShutdownObserver(true))
.then(subTest(test3_1)) // This should cause the internal timer to expire.
.then(waitOnShutdownObserver)
.then(closeIframe)
}
}
function runTest() {
start()
.then(waitForActiveServiceWorker)
.then(registerPushNotification)
.then(subTest(test1))
.then(subTest(test2))
.then(subTest(test3))
.then(unregisterPushNotification)
.then(unregister)
.catch(function(e) {
ok(false, "Some test failed with error " + e)
}).then(SimpleTest.finish);
}
setupPrefsAndMockSocket(mockSocket).then(_ => runTest());
SpecialPowers.addPermission('desktop-notification', true, document);
SimpleTest.waitForExplicitFinish();
</script>
</body>
</html>

View file

@ -0,0 +1,69 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1205109: Make `pushsubscriptionchange` extendable.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1205109</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1205109">Mozilla Bug 1205109</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
var registration;
add_task(function* start() {
yield setupPrefsAndMockSocket(new MockWebSocket());
yield setPushPermission(true);
var url = "worker.js" + "?" + (Math.random());
registration = yield navigator.serviceWorker.register(url, {scope: "."});
yield waitForActive(registration);
});
var controlledFrame;
add_task(function* createControlledIFrame() {
controlledFrame = yield injectControlledFrame();
});
add_task(function* togglePermission() {
var subscription = yield registration.pushManager.subscribe();
ok(subscription, "Should create a push subscription");
yield setPushPermission(false);
var permissionState = yield registration.pushManager.permissionState();
is(permissionState, "denied", "Should deny push permission");
var subscription = yield registration.pushManager.getSubscription();
is(subscription, null, "Should not return subscription when permission is revoked");
var changePromise = controlledFrame.waitOnWorkerMessage("changed");
yield setPushPermission(true);
yield changePromise;
subscription = yield registration.pushManager.getSubscription();
is(subscription, null, "Should drop subscription after reinstating permission");
});
add_task(function* unsubscribe() {
controlledFrame.remove();
yield registration.unregister();
});
</script>
</body>
</html>

View file

@ -0,0 +1,305 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1150812: Try to register when serviced if offline or connection is disabled.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1150812</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1150812">Mozilla Bug 1150812</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function debug(str) {
// console.log(str + "\n");
}
function registerServiceWorker() {
return navigator.serviceWorker.register("worker.js" + "?" + (Math.random()), {scope: "."})
.then(swr => waitForActive(swr));
}
function unregister(swr) {
return swr.unregister()
.then(result => {
ok(result, "Unregister should return true.");
}, err => {
dump("Unregistering the SW failed with " + err + "\n");
throw err;
});
}
function subscribe(swr) {
return swr.pushManager.subscribe()
.then(sub => {
ok(true, "successful registered for push notification");
return sub;
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function subscribeFail(swr) {
return new Promise((res, rej) => {
swr.pushManager.subscribe()
.then(sub => {
ok(false, "successful registered for push notification");
throw "Should fail";
}, err => {
ok(true, "could not register for push notification");
res(swr);
});
});
}
function getEndpointExpectNull(swr) {
return swr.pushManager.getSubscription()
.then(pushSubscription => {
ok(pushSubscription == null, "getEndpoint should return null when app not subscribed.");
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
function getEndpoint(swr, subOld) {
return swr.pushManager.getSubscription()
.then(sub => {
ok(subOld.endpoint == sub.endpoint, "getEndpoint - Got the same endpoint back.");
return sub;
}, err => {
ok(false, "could not register for push notification");
throw err;
});
}
// Load chrome script to change offline status in the
// parent process.
var chromeScript = SpecialPowers.loadChromeScript(_ => {
var { classes: Cc, interfaces: Ci } = Components;
var ioService = Cc["@mozilla.org/network/io-service;1"]
.getService(Ci.nsIIOService);
addMessageListener("change-status", function(offline) {
ioService.offline = offline;
});
});
function offlineObserver(res) {
this._res = res;
}
offlineObserver.prototype = {
_res: null,
observe: function(subject, topic, data) {
debug("observe: " + subject + " " + topic + " " + data);
if (topic === "network:offline-status-changed") {
var obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
.getService(SpecialPowers.Ci.nsIObserverService);
obsService.removeObserver(this, topic);
this._res(null);
}
}
}
function changeOfflineState(offline) {
return new Promise(function(res, rej) {
var obsService = SpecialPowers.Cc["@mozilla.org/observer-service;1"]
.getService(SpecialPowers.Ci.nsIObserverService);
obsService.addObserver(SpecialPowers.wrapCallbackObject(new offlineObserver(res)),
"network:offline-status-changed",
false);
chromeScript.sendAsyncMessage("change-status", offline);
});
}
function changePushServerConnectionEnabled(enable) {
debug("changePushServerConnectionEnabled");
SpecialPowers.setBoolPref("dom.push.connection.enabled", enable);
}
function unsubscribe(sub) {
return sub.unsubscribe()
.then(_ => {ok(true, "Unsubscribed!");});
}
// go offline then go online
function runTest1() {
return registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => changeOfflineState(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(false))
.then(_ => subscribe(swr))
.then(sub => getEndpoint(swr, sub)
.then(sub => unsubscribe(sub))
)
.then(_ => getEndpointExpectNull(swr))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
})
}
// disable - enable push connection.
function runTest2() {
return registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => changePushServerConnectionEnabled(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(true))
.then(_ => subscribe(swr))
.then(sub => getEndpoint(swr, sub)
.then(sub => unsubscribe(sub))
)
.then(_ => getEndpointExpectNull(swr))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
})
}
// go offline - disable - enable - go online
function runTest3() {
return registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => changeOfflineState(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(false))
.then(_ => subscribe(swr))
.then(sub => getEndpoint(swr, sub)
.then(sub => unsubscribe(sub))
)
.then(_ => getEndpointExpectNull(swr))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
})
}
// disable - offline - online - enable.
function runTest4() {
return registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => changePushServerConnectionEnabled(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(true))
.then(_ => subscribe(swr))
.then(sub => getEndpoint(swr, sub)
.then(sub => unsubscribe(sub))
)
.then(_ => getEndpointExpectNull(swr))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
})
}
// go offline - disable - go online - enable
function runTest5() {
return registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => changeOfflineState(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(true))
.then(_ => subscribe(swr))
.then(sub => getEndpoint(swr, sub)
.then(sub => unsubscribe(sub))
)
.then(_ => getEndpointExpectNull(swr))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
})
}
// disable - go offline - enable - go online.
function runTest6() {
return registerServiceWorker()
.then(swr =>
getEndpointExpectNull(swr)
.then(_ => changePushServerConnectionEnabled(false))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changePushServerConnectionEnabled(true))
.then(_ => subscribeFail(swr))
.then(_ => getEndpointExpectNull(swr))
.then(_ => changeOfflineState(false))
.then(_ => subscribe(swr))
.then(sub => getEndpoint(swr, sub)
.then(sub => unsubscribe(sub))
)
.then(_ => getEndpointExpectNull(swr))
.then(_ => unregister(swr))
)
.catch(err => {
ok(false, "Some test failed with error " + err);
})
}
function runTest() {
runTest1()
.then(_ => runTest2())
.then(_ => runTest3())
.then(_ => runTest4())
.then(_ => runTest5())
.then(_ => runTest6())
.then(SimpleTest.finish);
}
setupPrefsAndMockSocket(new MockWebSocket()).then(_ => runTest());
SpecialPowers.addPermission("desktop-notification", true, document);
SimpleTest.waitForExplicitFinish();
</script>
</body>
</html>

View file

@ -0,0 +1,81 @@
<!DOCTYPE HTML>
<html>
<!--
Bug 1170817: Push tests.
Any copyright is dedicated to the Public Domain.
http://creativecommons.org/licenses/publicdomain/
-->
<head>
<title>Test for Bug 1170817</title>
<script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="text/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
<script type="text/javascript" src="/tests/dom/push/test/test_utils.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" />
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
</head>
<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=1170817">Mozilla Bug 1170817</a>
<p id="display"></p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script class="testbody" type="text/javascript">
function generateURL() {
return "worker.js" + "?" + (Math.random());
}
var registration;
add_task(function* start() {
yield setupPrefsAndMockSocket(new MockWebSocket());
yield setPushPermission(true);
registration = yield navigator.serviceWorker.register(
generateURL(), {scope: "."});
yield waitForActive(registration);
});
var pushSubscription;
add_task(function* setupPushNotification() {
pushSubscription = yield registration.pushManager.subscribe();
ok(pushSubscription, "successful registered for push notification");
});
add_task(function* unregisterPushNotification() {
var result = yield pushSubscription.unsubscribe();
ok(result, "unsubscribe() on existing subscription should return true.");
});
add_task(function* unregisterAgain() {
var result = yield pushSubscription.unsubscribe();
ok(!result, "unsubscribe() on previously unsubscribed subscription should return false.");
});
add_task(function* subscribeAgain() {
pushSubscription = yield registration.pushManager.subscribe();
ok(pushSubscription, "Should create a new push subscription");
var result = yield registration.unregister();
ok(result, "Should unregister the service worker");
registration = yield navigator.serviceWorker.register(
generateURL(), {scope: "."});
yield waitForActive(registration);
var pushSubscription = yield registration.pushManager.getSubscription();
ok(!pushSubscription,
"Unregistering a service worker should drop its subscription");
});
add_task(function* unregister() {
var result = yield registration.unregister();
ok(result, "Unregister should return true.");
});
</script>
</body>
</html>

245
dom/push/test/test_utils.js Normal file
View file

@ -0,0 +1,245 @@
(function (g) {
"use strict";
let url = SimpleTest.getTestFileURL("mockpushserviceparent.js");
let chromeScript = SpecialPowers.loadChromeScript(url);
/**
* Replaces `PushService.jsm` with a mock implementation that handles requests
* from the DOM API. This allows tests to simulate local errors and error
* reporting, bypassing the `PushService.jsm` machinery.
*/
function replacePushService(mockService) {
chromeScript.sendSyncMessage("service-replace");
chromeScript.addMessageListener("service-delivery-error", function(msg) {
mockService.reportDeliveryError(msg.messageId, msg.reason);
});
chromeScript.addMessageListener("service-request", function(msg) {
let promise;
try {
let handler = mockService[msg.name];
promise = Promise.resolve(handler(msg.params));
} catch (error) {
promise = Promise.reject(error);
}
promise.then(result => {
chromeScript.sendAsyncMessage("service-response", {
id: msg.id,
result: result,
});
}, error => {
chromeScript.sendAsyncMessage("service-response", {
id: msg.id,
error: error,
});
});
});
}
function restorePushService() {
chromeScript.sendSyncMessage("service-restore");
}
let userAgentID = "8e1c93a9-139b-419c-b200-e715bb1e8ce8";
let currentMockSocket = null;
/**
* Sets up a mock connection for the WebSocket backend. This only replaces
* the transport layer; `PushService.jsm` still handles DOM API requests,
* observes permission changes, writes to IndexedDB, and notifies service
* workers of incoming push messages.
*/
function setupMockPushSocket(mockWebSocket) {
currentMockSocket = mockWebSocket;
currentMockSocket._isActive = true;
chromeScript.sendSyncMessage("socket-setup");
chromeScript.addMessageListener("socket-client-msg", function(msg) {
mockWebSocket.handleMessage(msg);
});
}
function teardownMockPushSocket() {
if (currentMockSocket) {
return new Promise(resolve => {
currentMockSocket._isActive = false;
chromeScript.addMessageListener("socket-server-teardown", resolve);
chromeScript.sendSyncMessage("socket-teardown");
});
}
return Promise.resolve();
}
/**
* Minimal implementation of web sockets for use in testing. Forwards
* messages to a mock web socket in the parent process that is used
* by the push service.
*/
function MockWebSocket() {}
let registerCount = 0;
// Default implementation to make the push server work minimally.
// Override methods to implement custom functionality.
MockWebSocket.prototype = {
// We only allow one active mock web socket to talk to the parent.
// This flag is used to keep track of which mock web socket is active.
_isActive: false,
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: "hello",
uaid: userAgentID,
status: 200,
use_webpush: true,
}));
},
onRegister(request) {
this.serverSendMsg(JSON.stringify({
messageType: "register",
uaid: userAgentID,
channelID: request.channelID,
status: 200,
pushEndpoint: "https://example.com/endpoint/" + registerCount++
}));
},
onUnregister(request) {
this.serverSendMsg(JSON.stringify({
messageType: "unregister",
channelID: request.channelID,
status: 200,
}));
},
onAck(request) {
// Do nothing.
},
handleMessage(msg) {
let request = JSON.parse(msg);
let messageType = request.messageType;
switch (messageType) {
case "hello":
this.onHello(request);
break;
case "register":
this.onRegister(request);
break;
case "unregister":
this.onUnregister(request);
break;
case "ack":
this.onAck(request);
break;
default:
throw new Error("Unexpected message: " + messageType);
}
},
serverSendMsg(msg) {
if (this._isActive) {
chromeScript.sendAsyncMessage("socket-server-msg", msg);
}
},
};
g.MockWebSocket = MockWebSocket;
g.setupMockPushSocket = setupMockPushSocket;
g.teardownMockPushSocket = teardownMockPushSocket;
g.replacePushService = replacePushService;
g.restorePushService = restorePushService;
}(this));
// Remove permissions and prefs when the test finishes.
SimpleTest.registerCleanupFunction(() => {
return new Promise(resolve =>
SpecialPowers.flushPermissions(resolve)
).then(_ => SpecialPowers.flushPrefEnv()).then(_ => {
restorePushService();
return teardownMockPushSocket();
});
});
function setPushPermission(allow) {
return new Promise(resolve => {
SpecialPowers.pushPermissions([
{ type: "desktop-notification", allow, context: document },
], resolve);
});
}
function setupPrefs() {
return SpecialPowers.pushPrefEnv({"set": [
["dom.push.enabled", true],
["dom.push.connection.enabled", true],
["dom.push.maxRecentMessageIDsPerSubscription", 0],
["dom.serviceWorkers.exemptFromPerDomainMax", true],
["dom.serviceWorkers.enabled", true],
["dom.serviceWorkers.testing.enabled", true]
]});
}
function setupPrefsAndReplaceService(mockService) {
replacePushService(mockService);
return setupPrefs();
}
function setupPrefsAndMockSocket(mockSocket) {
setupMockPushSocket(mockSocket);
return setupPrefs();
}
function injectControlledFrame(target = document.body) {
return new Promise(function(res, rej) {
var iframe = document.createElement("iframe");
iframe.src = "/tests/dom/push/test/frame.html";
var controlledFrame = {
remove() {
target.removeChild(iframe);
iframe = null;
},
waitOnWorkerMessage(type) {
return iframe ? iframe.contentWindow.waitOnWorkerMessage(type) :
Promise.reject(new Error("Frame removed from document"));
},
innerWindowId() {
var utils = SpecialPowers.getDOMWindowUtils(iframe.contentWindow);
return utils.currentInnerWindowID;
},
};
iframe.onload = () => res(controlledFrame);
target.appendChild(iframe);
});
}
function sendRequestToWorker(request) {
return navigator.serviceWorker.ready.then(registration => {
return new Promise((resolve, reject) => {
var channel = new MessageChannel();
channel.port1.onmessage = e => {
(e.data.error ? reject : resolve)(e.data);
};
registration.active.postMessage(request, [channel.port2]);
});
});
}
function waitForActive(swr) {
let sw = swr.installing || swr.waiting || swr.active;
return new Promise(resolve => {
if (sw.state === 'activated') {
resolve(swr);
return;
}
sw.addEventListener('statechange', function onStateChange(evt) {
if (sw.state === 'activated') {
sw.removeEventListener('statechange', onStateChange);
resolve(swr);
}
});
});
}

186
dom/push/test/webpush.js Normal file
View file

@ -0,0 +1,186 @@
/*
* Browser-based Web Push client for the application server piece.
*
* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/licenses/publicdomain/
*
* Uses the WebCrypto API.
* Uses the fetch API. Polyfill: https://github.com/github/fetch
*/
(function (g) {
'use strict';
var P256DH = {
name: 'ECDH',
namedCurve: 'P-256'
};
var webCrypto = g.crypto.subtle;
var ENCRYPT_INFO = new TextEncoder('utf-8').encode("Content-Encoding: aesgcm128");
var NONCE_INFO = new TextEncoder('utf-8').encode("Content-Encoding: nonce");
function chunkArray(array, size) {
var start = array.byteOffset || 0;
array = array.buffer || array;
var index = 0;
var result = [];
while(index + size <= array.byteLength) {
result.push(new Uint8Array(array, start + index, size));
index += size;
}
if (index < array.byteLength) {
result.push(new Uint8Array(array, start + index));
}
return result;
}
/* I can't believe that this is needed here, in this day and age ...
* Note: these are not efficient, merely expedient.
*/
var base64url = {
_strmap: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_',
encode: function(data) {
data = new Uint8Array(data);
var len = Math.ceil(data.length * 4 / 3);
return chunkArray(data, 3).map(chunk => [
chunk[0] >>> 2,
((chunk[0] & 0x3) << 4) | (chunk[1] >>> 4),
((chunk[1] & 0xf) << 2) | (chunk[2] >>> 6),
chunk[2] & 0x3f
].map(v => base64url._strmap[v]).join('')).join('').slice(0, len);
},
_lookup: function(s, i) {
return base64url._strmap.indexOf(s.charAt(i));
},
decode: function(str) {
var v = new Uint8Array(Math.floor(str.length * 3 / 4));
var vi = 0;
for (var si = 0; si < str.length;) {
var w = base64url._lookup(str, si++);
var x = base64url._lookup(str, si++);
var y = base64url._lookup(str, si++);
var z = base64url._lookup(str, si++);
v[vi++] = w << 2 | x >>> 4;
v[vi++] = x << 4 | y >>> 2;
v[vi++] = y << 6 | z;
}
return v;
}
};
g.base64url = base64url;
/* Coerces data into a Uint8Array */
function ensureView(data) {
if (typeof data === 'string') {
return new TextEncoder('utf-8').encode(data);
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer);
}
throw new Error('webpush() needs a string or BufferSource');
}
function bsConcat(arrays) {
var size = arrays.reduce((total, a) => total + a.byteLength, 0);
var index = 0;
return arrays.reduce((result, a) => {
result.set(new Uint8Array(a), index);
index += a.byteLength;
return result;
}, new Uint8Array(size));
}
function hmac(key) {
this.keyPromise = webCrypto.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' },
false, ['sign']);
}
hmac.prototype.hash = function(input) {
return this.keyPromise.then(k => webCrypto.sign('HMAC', k, input));
};
function hkdf(salt, ikm) {
this.prkhPromise = new hmac(salt).hash(ikm)
.then(prk => new hmac(prk));
}
hkdf.prototype.generate = function(info, len) {
var input = bsConcat([info, new Uint8Array([1])]);
return this.prkhPromise
.then(prkh => prkh.hash(input))
.then(h => {
if (h.byteLength < len) {
throw new Error('Length is too long');
}
return h.slice(0, len);
});
};
/* generate a 96-bit IV for use in GCM, 48-bits of which are populated */
function generateNonce(base, index) {
var nonce = base.slice(0, 12);
for (var i = 0; i < 6; ++i) {
nonce[nonce.length - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
}
return nonce;
}
function encrypt(localKey, remoteShare, salt, data) {
return webCrypto.importKey('raw', remoteShare, P256DH, false, ['deriveBits'])
.then(remoteKey =>
webCrypto.deriveBits({ name: P256DH.name, public: remoteKey },
localKey, 256))
.then(rawKey => {
var kdf = new hkdf(salt, rawKey);
return Promise.all([
kdf.generate(ENCRYPT_INFO, 16)
.then(gcmBits =>
webCrypto.importKey('raw', gcmBits, 'AES-GCM', false, ['encrypt'])),
kdf.generate(NONCE_INFO, 12)
]);
})
.then(([key, nonce]) => {
if (data.byteLength === 0) {
// Send an authentication tag for empty messages.
return webCrypto.encrypt({
name: 'AES-GCM',
iv: generateNonce(nonce, 0)
}, key, new Uint8Array([0])).then(value => [value]);
}
// 4096 is the default size, though we burn 1 for padding
return Promise.all(chunkArray(data, 4095).map((slice, index) => {
var padded = bsConcat([new Uint8Array([0]), slice]);
return webCrypto.encrypt({
name: 'AES-GCM',
iv: generateNonce(nonce, index)
}, key, padded);
}));
}).then(bsConcat);
}
function webPushEncrypt(subscription, data) {
data = ensureView(data);
var salt = g.crypto.getRandomValues(new Uint8Array(16));
return webCrypto.generateKey(P256DH, false, ['deriveBits'])
.then(localKey => {
return Promise.all([
encrypt(localKey.privateKey, subscription.getKey("p256dh"), salt, data),
// 1337 p-256 specific haxx to get the raw value out of the spki value
webCrypto.exportKey('raw', localKey.publicKey),
]);
}).then(([payload, pubkey]) => {
return {
data: base64url.encode(payload),
encryption: 'keyid=p256dh;salt=' + base64url.encode(salt),
encryption_key: 'keyid=p256dh;dh=' + base64url.encode(pubkey),
encoding: 'aesgcm128'
};
});
}
g.webPushEncrypt = webPushEncrypt;
}(this));

152
dom/push/test/worker.js Normal file
View file

@ -0,0 +1,152 @@
// Any copyright is dedicated to the Public Domain.
// http://creativecommons.org/licenses/publicdomain/
// This worker is used for two types of tests. `handlePush` sends messages to
// `frame.html`, which verifies that the worker can receive push messages.
// `handleMessage` receives messages from `test_push_manager_worker.html`
// and `test_data.html`, and verifies that `PushManager` can be used from
// the worker.
this.onpush = handlePush;
this.onmessage = handleMessage;
this.onpushsubscriptionchange = handlePushSubscriptionChange;
function getJSON(data) {
var result = {
ok: false,
};
try {
result.value = data.json();
result.ok = true;
} catch (e) {
// Ignore syntax errors for invalid JSON.
}
return result;
}
function assert(value, message) {
if (!value) {
throw new Error(message);
}
}
function broadcast(event, promise) {
event.waitUntil(Promise.resolve(promise).then(message => {
return self.clients.matchAll().then(clients => {
clients.forEach(client => client.postMessage(message));
});
}));
}
function reply(event, promise) {
event.waitUntil(Promise.resolve(promise).then(result => {
event.ports[0].postMessage(result);
}).catch(error => {
event.ports[0].postMessage({
error: String(error),
});
}));
}
function handlePush(event) {
if (event instanceof PushEvent) {
if (!('data' in event)) {
broadcast(event, {type: "finished", okay: "yes"});
return;
}
var message = {
type: "finished",
okay: "yes",
};
if (event.data) {
message.data = {
text: event.data.text(),
arrayBuffer: event.data.arrayBuffer(),
json: getJSON(event.data),
blob: event.data.blob(),
};
}
broadcast(event, message);
return;
}
broadcast(event, {type: "finished", okay: "no"});
}
var testHandlers = {
publicKey(data) {
return self.registration.pushManager.getSubscription().then(
subscription => ({
p256dh: subscription.getKey("p256dh"),
auth: subscription.getKey("auth"),
})
);
},
resubscribe(data) {
return self.registration.pushManager.getSubscription().then(
subscription => {
assert(subscription.endpoint == data.endpoint,
"Wrong push endpoint in worker");
return subscription.unsubscribe();
}
).then(result => {
assert(result, "Error unsubscribing in worker");
return self.registration.pushManager.getSubscription();
}).then(subscription => {
assert(!subscription, "Subscription not removed in worker");
return self.registration.pushManager.subscribe();
}).then(subscription => {
return {
endpoint: subscription.endpoint,
};
});
},
denySubscribe(data) {
return self.registration.pushManager.getSubscription().then(
subscription => {
assert(!subscription,
"Should not return worker subscription with revoked permission");
return self.registration.pushManager.subscribe().then(_ => {
assert(false, "Expected error subscribing with revoked permission");
}, error => {
return {
isDOMException: error instanceof DOMException,
name: error.name,
};
});
}
);
},
subscribeWithKey(data) {
return self.registration.pushManager.subscribe({
applicationServerKey: data.key,
}).then(subscription => {
return {
endpoint: subscription.endpoint,
key: subscription.options.applicationServerKey,
};
}, error => {
return {
isDOMException: error instanceof DOMException,
name: error.name,
};
});
},
};
function handleMessage(event) {
var handler = testHandlers[event.data.type];
if (handler) {
reply(event, handler(event.data));
} else {
reply(event, Promise.reject(
"Invalid message type: " + event.data.type));
}
}
function handlePushSubscriptionChange(event) {
broadcast(event, {type: "changed", okay: "yes"});
}

View file

@ -0,0 +1,31 @@
// An XPCOM service that's registered with the category manager in the parent
// process for handling push notifications with scope "chrome://test-scope"
"use strict";
const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
Cu.import("resource://gre/modules/Services.jsm");
let pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
function PushServiceHandler() {
// So JS code can reach into us.
this.wrappedJSObject = this;
// Register a push observer.
this.observed = [];
Services.obs.addObserver(this, pushService.pushTopic, false);
Services.obs.addObserver(this, pushService.subscriptionChangeTopic, false);
Services.obs.addObserver(this, pushService.subscriptionModifiedTopic, false);
}
PushServiceHandler.prototype = {
classID: Components.ID("{bb7c5199-c0f7-4976-9f6d-1306e32c5591}"),
QueryInterface: XPCOMUtils.generateQI([]),
observe(subject, topic, data) {
this.observed.push({ subject, topic, data });
},
}
this.NSGetFactory = XPCOMUtils.generateNSGetFactory([PushServiceHandler]);

View file

@ -0,0 +1,4 @@
component {bb7c5199-c0f7-4976-9f6d-1306e32c5591} PushServiceHandler.js
contract @mozilla.org/dom/push/test/PushServiceHandler;1 {bb7c5199-c0f7-4976-9f6d-1306e32c5591}
category push chrome://test-scope @mozilla.org/dom/push/test/PushServiceHandler;1

View file

@ -0,0 +1,62 @@
// Returns the test H/2 server port, throwing if it's missing or invalid.
function getTestServerPort() {
let portEnv = Cc["@mozilla.org/process/environment;1"]
.getService(Ci.nsIEnvironment).get("MOZHTTP2_PORT");
let port = parseInt(portEnv, 10);
if (!Number.isFinite(port) || port < 1 || port > 65535) {
throw new Error(`Invalid port in MOZHTTP2_PORT env var: ${portEnv}`);
}
do_print(`Using HTTP/2 server on port ${port}`);
return port;
}
// Support for making sure we can talk to the invalid cert the server presents
var CertOverrideListener = function(host, port, bits) {
this.host = host;
this.port = port || 443;
this.bits = bits;
};
CertOverrideListener.prototype = {
host: null,
bits: null,
getInterface: function(aIID) {
return this.QueryInterface(aIID);
},
QueryInterface: function(aIID) {
if (aIID.equals(Ci.nsIBadCertListener2) ||
aIID.equals(Ci.nsIInterfaceRequestor) ||
aIID.equals(Ci.nsISupports))
return this;
throw Components.results.NS_ERROR_NO_INTERFACE;
},
notifyCertProblem: function(socketInfo, sslStatus, targetHost) {
var cert = sslStatus.QueryInterface(Ci.nsISSLStatus).serverCert;
var cos = Cc["@mozilla.org/security/certoverride;1"].
getService(Ci.nsICertOverrideService);
cos.rememberValidityOverride(this.host, this.port, cert, this.bits, false);
dump("Certificate Override in place\n");
return true;
},
};
function addCertOverride(host, port, bits) {
var req = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
.createInstance(Ci.nsIXMLHttpRequest);
try {
var url;
if (port && (port > 0) && (port !== 443)) {
url = "https://" + host + ":" + port + "/";
} else {
url = "https://" + host + "/";
}
req.open("GET", url, false);
req.channel.notificationCallbacks = new CertOverrideListener(host, port, bits);
req.send(null);
} catch (e) {
// This will fail since the server is not trusted yet
}
}

View file

@ -0,0 +1,463 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
Cu.import('resource://gre/modules/XPCOMUtils.jsm');
Cu.import('resource://gre/modules/Services.jsm');
Cu.import('resource://gre/modules/Task.jsm');
Cu.import('resource://gre/modules/Timer.jsm');
Cu.import('resource://gre/modules/Promise.jsm');
Cu.import('resource://gre/modules/Preferences.jsm');
Cu.import('resource://gre/modules/PlacesUtils.jsm');
Cu.import('resource://gre/modules/ObjectUtils.jsm');
XPCOMUtils.defineLazyModuleGetter(this, 'PlacesTestUtils',
'resource://testing-common/PlacesTestUtils.jsm');
XPCOMUtils.defineLazyServiceGetter(this, 'PushServiceComponent',
'@mozilla.org/push/Service;1', 'nsIPushService');
const serviceExports = Cu.import('resource://gre/modules/PushService.jsm', {});
const servicePrefs = new Preferences('dom.push.');
const WEBSOCKET_CLOSE_GOING_AWAY = 1001;
const MS_IN_ONE_DAY = 24 * 60 * 60 * 1000;
var isParent = Cc['@mozilla.org/xre/runtime;1']
.getService(Ci.nsIXULRuntime).processType ==
Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT;
// Stop and clean up after the PushService.
Services.obs.addObserver(function observe(subject, topic, data) {
Services.obs.removeObserver(observe, topic, false);
serviceExports.PushService.uninit();
// Occasionally, `profile-change-teardown` and `xpcom-shutdown` will fire
// before the PushService and AlarmService finish writing to IndexedDB. This
// causes spurious errors and crashes, so we spin the event loop to let the
// writes finish.
let done = false;
setTimeout(() => done = true, 1000);
let thread = Services.tm.mainThread;
while (!done) {
try {
thread.processNextEvent(true);
} catch (e) {
Cu.reportError(e);
}
}
}, 'profile-change-net-teardown', false);
/**
* Gates a function so that it is called only after the wrapper is called a
* given number of times.
*
* @param {Number} times The number of wrapper calls before |func| is called.
* @param {Function} func The function to gate.
* @returns {Function} The gated function wrapper.
*/
function after(times, func) {
return function afterFunc() {
if (--times <= 0) {
return func.apply(this, arguments);
}
};
}
/**
* Defers one or more callbacks until the next turn of the event loop. Multiple
* callbacks are executed in order.
*
* @param {Function[]} callbacks The callbacks to execute. One callback will be
* executed per tick.
*/
function waterfall(...callbacks) {
callbacks.reduce((promise, callback) => promise.then(() => {
callback();
}), Promise.resolve()).catch(Cu.reportError);
}
/**
* Waits for an observer notification to fire.
*
* @param {String} topic The notification topic.
* @returns {Promise} A promise that fulfills when the notification is fired.
*/
function promiseObserverNotification(topic, matchFunc) {
return new Promise((resolve, reject) => {
Services.obs.addObserver(function observe(subject, topic, data) {
let matches = typeof matchFunc != 'function' || matchFunc(subject, data);
if (!matches) {
return;
}
Services.obs.removeObserver(observe, topic, false);
resolve({subject, data});
}, topic, false);
});
}
/**
* Wraps an object in a proxy that traps property gets and returns stubs. If
* the stub is a function, the original value will be passed as the first
* argument. If the original value is a function, the proxy returns a wrapper
* that calls the stub; otherwise, the stub is called as a getter.
*
* @param {Object} target The object to wrap.
* @param {Object} stubs An object containing stubbed values and functions.
* @returns {Proxy} A proxy that returns stubs for property gets.
*/
function makeStub(target, stubs) {
return new Proxy(target, {
get(target, property) {
if (!stubs || typeof stubs != 'object' || !(property in stubs)) {
return target[property];
}
let stub = stubs[property];
if (typeof stub != 'function') {
return stub;
}
let original = target[property];
if (typeof original != 'function') {
return stub.call(this, original);
}
return function callStub(...params) {
return stub.call(this, original, ...params);
};
}
});
}
/**
* Sets default PushService preferences. All pref names are prefixed with
* `dom.push.`; any additional preferences will override the defaults.
*
* @param {Object} [prefs] Additional preferences to set.
*/
function setPrefs(prefs = {}) {
let defaultPrefs = Object.assign({
loglevel: 'all',
serverURL: 'wss://push.example.org',
'connection.enabled': true,
userAgentID: '',
enabled: true,
// Defaults taken from /modules/libpref/init/all.js.
requestTimeout: 10000,
retryBaseInterval: 5000,
pingInterval: 30 * 60 * 1000,
// Misc. defaults.
'http2.maxRetries': 2,
'http2.retryInterval': 500,
'http2.reset_retry_count_after_ms': 60000,
maxQuotaPerSubscription: 16,
quotaUpdateDelay: 3000,
'testing.notifyWorkers': false,
}, prefs);
for (let pref in defaultPrefs) {
servicePrefs.set(pref, defaultPrefs[pref]);
}
}
function compareAscending(a, b) {
return a > b ? 1 : a < b ? -1 : 0;
}
/**
* Creates a mock WebSocket object that implements a subset of the
* nsIWebSocketChannel interface used by the PushService.
*
* The given protocol handlers are invoked for each Simple Push command sent
* by the PushService. The ping handler is optional; all others will throw if
* the PushService sends a command for which no handler is registered.
*
* All nsIWebSocketListener methods will be called asynchronously.
* serverSendMsg() and serverClose() can be used to respond to client messages
* and close the "server" end of the connection, respectively.
*
* @param {nsIURI} originalURI The original WebSocket URL.
* @param {Function} options.onHello The "hello" handshake command handler.
* @param {Function} options.onRegister The "register" command handler.
* @param {Function} options.onUnregister The "unregister" command handler.
* @param {Function} options.onACK The "ack" command handler.
* @param {Function} [options.onPing] An optional ping handler.
*/
function MockWebSocket(originalURI, handlers = {}) {
this._originalURI = originalURI;
this._onHello = handlers.onHello;
this._onRegister = handlers.onRegister;
this._onUnregister = handlers.onUnregister;
this._onACK = handlers.onACK;
this._onPing = handlers.onPing;
}
MockWebSocket.prototype = {
_originalURI: null,
_onHello: null,
_onRegister: null,
_onUnregister: null,
_onACK: null,
_onPing: null,
_listener: null,
_context: null,
QueryInterface: XPCOMUtils.generateQI([
Ci.nsISupports,
Ci.nsIWebSocketChannel
]),
get originalURI() {
return this._originalURI;
},
asyncOpen(uri, origin, windowId, listener, context) {
this._listener = listener;
this._context = context;
waterfall(() => this._listener.onStart(this._context));
},
_handleMessage(msg) {
let messageType, request;
if (msg == '{}') {
request = {};
messageType = 'ping';
} else {
request = JSON.parse(msg);
messageType = request.messageType;
}
switch (messageType) {
case 'hello':
if (typeof this._onHello != 'function') {
throw new Error('Unexpected handshake request');
}
this._onHello(request);
break;
case 'register':
if (typeof this._onRegister != 'function') {
throw new Error('Unexpected register request');
}
this._onRegister(request);
break;
case 'unregister':
if (typeof this._onUnregister != 'function') {
throw new Error('Unexpected unregister request');
}
this._onUnregister(request);
break;
case 'ack':
if (typeof this._onACK != 'function') {
throw new Error('Unexpected acknowledgement');
}
this._onACK(request);
break;
case 'ping':
if (typeof this._onPing == 'function') {
this._onPing(request);
} else {
// Echo ping packets.
this.serverSendMsg('{}');
}
break;
default:
throw new Error('Unexpected message: ' + messageType);
}
},
sendMsg(msg) {
this._handleMessage(msg);
},
close(code, reason) {
waterfall(() => this._listener.onStop(this._context, Cr.NS_OK));
},
/**
* Responds with the given message, calling onMessageAvailable() and
* onAcknowledge() synchronously. Throws if the message is not a string.
* Used by the tests to respond to client commands.
*
* @param {String} msg The message to send to the client.
*/
serverSendMsg(msg) {
if (typeof msg != 'string') {
throw new Error('Invalid response message');
}
waterfall(
() => this._listener.onMessageAvailable(this._context, msg),
() => this._listener.onAcknowledge(this._context, 0)
);
},
/**
* Closes the server end of the connection, calling onServerClose()
* followed by onStop(). Used to test abrupt connection termination.
*
* @param {Number} [statusCode] The WebSocket connection close code.
* @param {String} [reason] The connection close reason.
*/
serverClose(statusCode, reason = '') {
if (!isFinite(statusCode)) {
statusCode = WEBSOCKET_CLOSE_GOING_AWAY;
}
waterfall(
() => this._listener.onServerClose(this._context, statusCode, reason),
() => this._listener.onStop(this._context, Cr.NS_BASE_STREAM_CLOSED)
);
},
serverInterrupt(result = Cr.NS_ERROR_NET_RESET) {
waterfall(() => this._listener.onStop(this._context, result));
},
};
var setUpServiceInParent = Task.async(function* (service, db) {
if (!isParent) {
return;
}
let userAgentID = 'ce704e41-cb77-4206-b07b-5bf47114791b';
setPrefs({
userAgentID: userAgentID,
});
yield db.put({
channelID: '6e2814e1-5f84-489e-b542-855cc1311f09',
pushEndpoint: 'https://example.org/push/get',
scope: 'https://example.com/get/ok',
originAttributes: '',
version: 1,
pushCount: 10,
lastPush: 1438360548322,
quota: 16,
});
yield db.put({
channelID: '3a414737-2fd0-44c0-af05-7efc172475fc',
pushEndpoint: 'https://example.org/push/unsub',
scope: 'https://example.com/unsub/ok',
originAttributes: '',
version: 2,
pushCount: 10,
lastPush: 1438360848322,
quota: 4,
});
yield db.put({
channelID: 'ca3054e8-b59b-4ea0-9c23-4a3c518f3161',
pushEndpoint: 'https://example.org/push/stale',
scope: 'https://example.com/unsub/fail',
originAttributes: '',
version: 3,
pushCount: 10,
lastPush: 1438362348322,
quota: 1,
});
service.init({
serverURI: 'wss://push.example.org/',
db: makeStub(db, {
put(prev, record) {
if (record.scope == 'https://example.com/sub/fail') {
return Promise.reject('synergies not aligned');
}
return prev.call(this, record);
},
delete: function(prev, channelID) {
if (channelID == 'ca3054e8-b59b-4ea0-9c23-4a3c518f3161') {
return Promise.reject('splines not reticulated');
}
return prev.call(this, channelID);
},
getByIdentifiers(prev, identifiers) {
if (identifiers.scope == 'https://example.com/get/fail') {
return Promise.reject('qualia unsynchronized');
}
return prev.call(this, identifiers);
},
}),
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
uaid: userAgentID,
status: 200,
}));
},
onRegister(request) {
if (request.key) {
let appServerKey = new Uint8Array(
ChromeUtils.base64URLDecode(request.key, {
padding: "require",
})
);
equal(appServerKey.length, 65, 'Wrong app server key length');
equal(appServerKey[0], 4, 'Wrong app server key format');
}
this.serverSendMsg(JSON.stringify({
messageType: 'register',
uaid: userAgentID,
channelID: request.channelID,
status: 200,
pushEndpoint: 'https://example.org/push/' + request.channelID,
}));
},
onUnregister(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
channelID: request.channelID,
status: 200,
}));
},
});
},
});
});
var tearDownServiceInParent = Task.async(function* (db) {
if (!isParent) {
return;
}
let record = yield db.getByIdentifiers({
scope: 'https://example.com/sub/ok',
originAttributes: '',
});
ok(record.pushEndpoint.startsWith('https://example.org/push'),
'Wrong push endpoint in subscription record');
record = yield db.getByIdentifiers({
scope: 'https://example.net/scope/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: 1, inIsolatedMozBrowser: true }),
});
ok(record.pushEndpoint.startsWith('https://example.org/push'),
'Wrong push endpoint in app record');
record = yield db.getByKeyID('3a414737-2fd0-44c0-af05-7efc172475fc');
ok(!record, 'Unsubscribed record should not exist');
});
function putTestRecord(db, keyID, scope, quota) {
return db.put({
channelID: keyID,
pushEndpoint: 'https://example.org/push/' + keyID,
scope: scope,
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: quota,
systemRecord: quota == Infinity,
});
}
function getAllKeyIDs(db) {
return db.getAllKeyIDs().then(records =>
records.map(record => record.keyID).sort(compareAscending)
);
}

View file

@ -0,0 +1,4 @@
EXTRA_COMPONENTS += [
'PushServiceHandler.js',
'PushServiceHandler.manifest',
]

View file

@ -0,0 +1,115 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
var db;
var unregisterDefers = {};
var userAgentID = '4ce480ef-55b2-4f83-924c-dcd35ab978b4';
function promiseUnregister(keyID, code) {
return new Promise(r => unregisterDefers[keyID] = r);
}
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
run_next_test();
}
add_task(function* setup() {
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(_ => db.drop().then(_ => db.close()));
// Active subscriptions; should be expired then dropped.
yield putTestRecord(db, 'active-1', 'https://example.info/some-page', 8);
yield putTestRecord(db, 'active-2', 'https://example.com/another-page', 16);
// Expired subscription; should be dropped.
yield putTestRecord(db, 'expired', 'https://example.net/yet-another-page', 0);
// A privileged subscription that should not be affected by sanitizing data
// because its quota is set to `Infinity`.
yield putTestRecord(db, 'privileged', 'app://chrome/only', Infinity);
let handshakeDone;
let handshakePromise = new Promise(r => handshakeDone = r);
PushService.init({
serverURI: 'wss://push.example.org/',
db: db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
uaid: userAgentID,
status: 200,
use_webpush: true,
}));
handshakeDone();
},
onUnregister(request) {
let resolve = unregisterDefers[request.channelID];
equal(typeof resolve, 'function',
'Dropped unexpected channel ID ' + request.channelID);
delete unregisterDefers[request.channelID];
equal(request.code, 200,
'Expected manual unregister reason');
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
channelID: request.channelID,
status: 200,
}));
resolve();
},
});
},
});
yield handshakePromise;
});
add_task(function* test_sanitize() {
let modifiedScopes = [];
let changeScopes = [];
let promiseCleared = Promise.all([
// Active subscriptions should be unregistered.
promiseUnregister('active-1'),
promiseUnregister('active-2'),
promiseObserverNotification(
PushServiceComponent.subscriptionModifiedTopic, (subject, data) => {
modifiedScopes.push(data);
return modifiedScopes.length == 3;
}),
// Privileged should be recreated.
promiseUnregister('privileged'),
promiseObserverNotification(
PushServiceComponent.subscriptionChangeTopic, (subject, data) => {
changeScopes.push(data);
return changeScopes.length == 1;
}),
]);
yield PushService.clear({
domain: '*',
});
yield promiseCleared;
deepEqual(modifiedScopes.sort(compareAscending), [
'app://chrome/only',
'https://example.com/another-page',
'https://example.info/some-page',
], 'Should modify active subscription scopes');
deepEqual(changeScopes, ['app://chrome/only'],
'Should fire change notification for privileged scope');
let remainingIDs = yield getAllKeyIDs(db);
deepEqual(remainingIDs, [], 'Should drop all subscriptions');
});

View file

@ -0,0 +1,128 @@
'use strict';
const {PushService, PushServiceWebSocket} = serviceExports;
const {ForgetAboutSite} = Cu.import(
'resource://gre/modules/ForgetAboutSite.jsm', {});
var db;
var unregisterDefers = {};
var userAgentID = '4fe01c2d-72ac-4c13-93d2-bb072caf461d';
function promiseUnregister(keyID) {
return new Promise(r => unregisterDefers[keyID] = r);
}
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
run_next_test();
}
add_task(function* setup() {
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(_ => db.drop().then(_ => db.close()));
// Active and expired subscriptions for a subdomain. The active subscription
// should be expired, then removed; the expired subscription should be
// removed immediately.
yield putTestRecord(db, 'active-sub', 'https://sub.example.com/sub-page', 4);
yield putTestRecord(db, 'expired-sub', 'https://sub.example.com/yet-another-page', 0);
// Active subscriptions for another subdomain. Should be unsubscribed and
// dropped.
yield putTestRecord(db, 'active-1', 'https://sub2.example.com/some-page', 8);
yield putTestRecord(db, 'active-2', 'https://sub3.example.com/another-page', 16);
// A privileged subscription with a real URL that should not be affected
// because its quota is set to `Infinity`.
yield putTestRecord(db, 'privileged', 'https://sub.example.com/real-url', Infinity);
let handshakeDone;
let handshakePromise = new Promise(r => handshakeDone = r);
PushService.init({
serverURI: 'wss://push.example.org/',
db: db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
uaid: userAgentID,
status: 200,
use_webpush: true,
}));
handshakeDone();
},
onUnregister(request) {
let resolve = unregisterDefers[request.channelID];
equal(typeof resolve, 'function',
'Dropped unexpected channel ID ' + request.channelID);
delete unregisterDefers[request.channelID];
equal(request.code, 200,
'Expected manual unregister reason');
resolve();
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
status: 200,
channelID: request.channelID,
}));
},
});
},
});
// For cleared subscriptions, we only send unregister requests in the
// background and if we're connected.
yield handshakePromise;
});
add_task(function* test_forgetAboutSubdomain() {
let modifiedScopes = [];
let promiseForgetSubs = Promise.all([
// Active subscriptions should be dropped.
promiseUnregister('active-sub'),
promiseObserverNotification(
PushServiceComponent.subscriptionModifiedTopic, (subject, data) => {
modifiedScopes.push(data);
return modifiedScopes.length == 1;
}
),
]);
yield ForgetAboutSite.removeDataFromDomain('sub.example.com');
yield promiseForgetSubs;
deepEqual(modifiedScopes.sort(compareAscending), [
'https://sub.example.com/sub-page',
], 'Should fire modified notifications for active subscriptions');
let remainingIDs = yield getAllKeyIDs(db);
deepEqual(remainingIDs, ['active-1', 'active-2', 'privileged'],
'Should only forget subscriptions for subdomain');
});
add_task(function* test_forgetAboutRootDomain() {
let modifiedScopes = [];
let promiseForgetSubs = Promise.all([
promiseUnregister('active-1'),
promiseUnregister('active-2'),
promiseObserverNotification(
PushServiceComponent.subscriptionModifiedTopic, (subject, data) => {
modifiedScopes.push(data);
return modifiedScopes.length == 2;
}
),
]);
yield ForgetAboutSite.removeDataFromDomain('example.com');
yield promiseForgetSubs;
deepEqual(modifiedScopes.sort(compareAscending), [
'https://sub2.example.com/some-page',
'https://sub3.example.com/another-page',
], 'Should fire modified notifications for entire domain');
let remainingIDs = yield getAllKeyIDs(db);
deepEqual(remainingIDs, ['privileged'],
'Should ignore privileged records with a real URL');
});

View file

@ -0,0 +1,141 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = 'bd744428-f125-436a-b6d0-dd0c9845837f';
let clearForPattern = Task.async(function* (testRecords, pattern) {
let patternString = JSON.stringify(pattern);
yield PushService._clearOriginData(patternString);
for (let length = testRecords.length; length--;) {
let test = testRecords[length];
let originSuffix = ChromeUtils.originAttributesToSuffix(
test.originAttributes);
let registration = yield PushService.registration({
scope: test.scope,
originAttributes: originSuffix,
});
let url = test.scope + originSuffix;
if (ObjectUtils.deepEqual(test.clearIf, pattern)) {
ok(!registration, 'Should clear registration ' + url +
' for pattern ' + patternString);
testRecords.splice(length, 1);
} else {
ok(registration, 'Should not clear registration ' + url +
' for pattern ' + patternString);
}
}
});
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_webapps_cleardata() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let testRecords = [{
scope: 'https://example.org/1',
originAttributes: { appId: 1 },
clearIf: { appId: 1, inIsolatedMozBrowser: false },
}, {
scope: 'https://example.org/1',
originAttributes: { appId: 1, inIsolatedMozBrowser: true },
clearIf: { appId: 1 },
}, {
scope: 'https://example.org/1',
originAttributes: { appId: 2, inIsolatedMozBrowser: true },
clearIf: { appId: 2, inIsolatedMozBrowser: true },
}, {
scope: 'https://example.org/2',
originAttributes: { appId: 1 },
clearIf: { appId: 1, inIsolatedMozBrowser: false },
}, {
scope: 'https://example.org/2',
originAttributes: { appId: 2, inIsolatedMozBrowser: true },
clearIf: { appId: 2, inIsolatedMozBrowser: true },
}, {
scope: 'https://example.org/3',
originAttributes: { appId: 3, inIsolatedMozBrowser: true },
clearIf: { inIsolatedMozBrowser: true },
}, {
scope: 'https://example.org/3',
originAttributes: { appId: 4, inIsolatedMozBrowser: true },
clearIf: { inIsolatedMozBrowser: true },
}];
let unregisterDone;
let unregisterPromise = new Promise(resolve =>
unregisterDone = after(testRecords.length, resolve));
PushService.init({
serverURI: "wss://push.example.org",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(data) {
equal(data.messageType, 'hello', 'Handshake: wrong message type');
equal(data.uaid, userAgentID, 'Handshake: wrong device ID');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
},
onRegister(data) {
equal(data.messageType, 'register', 'Register: wrong message type');
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
channelID: data.channelID,
uaid: userAgentID,
pushEndpoint: 'https://example.com/update/' + Math.random(),
}));
},
onUnregister(data) {
equal(data.code, 200, 'Expected manual unregister reason');
unregisterDone();
},
});
}
});
yield Promise.all(testRecords.map(test =>
PushService.register({
scope: test.scope,
originAttributes: ChromeUtils.originAttributesToSuffix(
test.originAttributes),
})
));
// Removes records for all scopes with the same app ID. Excludes records
// where `inIsolatedMozBrowser` is true.
yield clearForPattern(testRecords, { appId: 1, inIsolatedMozBrowser: false });
// Removes the remaining record for app ID 1, where `inIsolatedMozBrowser` is true.
yield clearForPattern(testRecords, { appId: 1 });
// Removes all records for all scopes with the same app ID, where
// `inIsolatedMozBrowser` is true.
yield clearForPattern(testRecords, { appId: 2, inIsolatedMozBrowser: true });
// Removes all records where `inIsolatedMozBrowser` is true.
yield clearForPattern(testRecords, { inIsolatedMozBrowser: true });
equal(testRecords.length, 0, 'Should remove all test records');
yield unregisterPromise;
});

View file

@ -0,0 +1,249 @@
'use strict';
const {
getCryptoParams,
PushCrypto,
} = Cu.import('resource://gre/modules/PushCrypto.jsm', {});
function run_test() {
run_next_test();
}
add_task(function* test_crypto_getCryptoParams() {
// These headers should parse correctly.
let shouldParse = [{
desc: 'aesgcm with multiple keys',
headers: {
encoding: 'aesgcm',
crypto_key: 'keyid=p256dh;dh=Iy1Je2Kv11A,p256ecdsa=o2M8QfiEKuI',
encryption: 'keyid=p256dh;salt=upk1yFkp1xI',
},
params: {
dh: 'Iy1Je2Kv11A',
salt: 'upk1yFkp1xI',
rs: 4096,
padSize: 2,
},
}, {
desc: 'aesgcm with quoted key param',
headers: {
encoding: 'aesgcm',
crypto_key: 'dh="byfHbUffc-k"',
encryption: 'salt=C11AvAsp6Gc',
},
params: {
dh: 'byfHbUffc-k',
salt: 'C11AvAsp6Gc',
rs: 4096,
padSize: 2,
},
}, {
desc: 'aesgcm with Crypto-Key and rs = 24',
headers: {
encoding: 'aesgcm',
crypto_key: 'dh="ybuT4VDz-Bg"',
encryption: 'salt=H7U7wcIoIKs; rs=24',
},
params: {
dh: 'ybuT4VDz-Bg',
salt: 'H7U7wcIoIKs',
rs: 24,
padSize: 2,
},
}, {
desc: 'aesgcm128 with Encryption-Key and rs = 2',
headers: {
encoding: 'aesgcm128',
encryption_key: 'keyid=legacy; dh=LqrDQuVl9lY',
encryption: 'keyid=legacy; salt=YngI8B7YapM; rs=2',
},
params: {
dh: 'LqrDQuVl9lY',
salt: 'YngI8B7YapM',
rs: 2,
padSize: 1,
},
}, {
desc: 'aesgcm128 with Encryption-Key',
headers: {
encoding: 'aesgcm128',
encryption_key: 'keyid=v2; dh=VA6wmY1IpiE',
encryption: 'keyid=v2; salt=khtpyXhpDKM',
},
params: {
dh: 'VA6wmY1IpiE',
salt: 'khtpyXhpDKM',
rs: 4096,
padSize: 1,
}
}];
for (let test of shouldParse) {
let params = getCryptoParams(test.headers);
deepEqual(params, test.params, test.desc);
}
// These headers should be rejected.
let shouldThrow = [{
desc: 'aesgcm128 with Crypto-Key',
headers: {
encoding: 'aesgcm128',
crypto_key: 'keyid=v2; dh=VA6wmY1IpiE',
encryption: 'keyid=v2; salt=F0Im7RtGgNY',
},
}, {
desc: 'Invalid encoding',
headers: {
encoding: 'nonexistent',
},
}, {
desc: 'Invalid record size',
headers: {
encoding: 'aesgcm',
crypto_key: 'dh=pbmv1QkcEDY',
encryption: 'dh=Esao8aTBfIk;rs=bad',
},
}, {
desc: 'Insufficiently large record size',
headers: {
encoding: 'aesgcm',
crypto_key: 'dh=fK0EXaw5IU8',
encryption: 'salt=orbLLmlbJfM;rs=1',
},
}, {
desc: 'aesgcm with Encryption-Key',
headers: {
encoding: 'aesgcm',
encryption_key: 'dh=FplK5KkvUF0',
encryption: 'salt=p6YHhFF3BQY',
},
}];
for (let test of shouldThrow) {
throws(() => getCryptoParams(test.headers), test.desc);
}
});
add_task(function* test_crypto_decodeMsg() {
let privateKey = {
crv: 'P-256',
d: '4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg',
ext: true,
key_ops: ['deriveBits'],
kty: 'EC',
x: 'sd85ZCbEG6dEkGMCmDyGBIt454Qy-Yo-1xhbaT2Jlk4',
y: 'vr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs',
};
let publicKey = ChromeUtils.base64URLDecode('BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs', {
padding: "reject",
});
let expectedSuccesses = [{
desc: 'padSize = 2, rs = 24, pad = 0',
result: 'Some message',
data: 'Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU',
authSecret: 'aTDc6JebzR6eScy2oLo4RQ',
headers: {
crypto_key: 'dh=BCHFVrflyxibGLlgztLwKelsRZp4gqX3tNfAKFaxAcBhpvYeN1yIUMrxaDKiLh4LNKPtj0BOXGdr-IQ-QP82Wjo',
encryption: 'salt=zCU18Rw3A5aB_Xi-vfixmA; rs=24',
encoding: 'aesgcm',
},
}, {
desc: 'padSize = 2, rs = 8, pad = 16',
result: 'Yet another message',
data: 'uEC5B_tR-fuQ3delQcrzrDCp40W6ipMZjGZ78USDJ5sMj-6bAOVG3AK6JqFl9E6AoWiBYYvMZfwThVxmDnw6RHtVeLKFM5DWgl1EwkOohwH2EhiDD0gM3io-d79WKzOPZE9rDWUSv64JstImSfX_ADQfABrvbZkeaWxh53EG59QMOElFJqHue4dMURpsMXg',
authSecret: '6plwZnSpVUbF7APDXus3UQ',
headers: {
crypto_key: 'dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ',
encryption: 'salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8',
encoding: 'aesgcm',
},
}, {
desc: 'padSize = 1, rs = 4096, pad = 2',
result: 'aesgcm128 encrypted message',
data: 'ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE',
headers: {
encryption_key: 'dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI',
encryption: 'salt=btxxUtclbmgcc30b9rT3Bg; rs=4096',
encoding: 'aesgcm128',
},
}, {
desc: 'padSize = 2, rs = 3, pad = 0',
result: 'Small record size',
data: 'oY4e5eDatDVt2fpQylxbPJM-3vrfhDasfPc8Q1PWt4tPfMVbz_sDNL_cvr0DXXkdFzS1lxsJsj550USx4MMl01ihjImXCjrw9R5xFgFrCAqJD3GwXA1vzS4T5yvGVbUp3SndMDdT1OCcEofTn7VC6xZ-zP8rzSQfDCBBxmPU7OISzr8Z4HyzFCGJeBfqiZ7yUfNlKF1x5UaZ4X6iU_TXx5KlQy_toV1dXZ2eEAMHJUcSdArvB6zRpFdEIxdcHcJyo1BIYgAYTDdAIy__IJVCPY_b2CE5W_6ohlYKB7xDyH8giNuWWXAgBozUfScLUVjPC38yJTpAUi6w6pXgXUWffende5FreQpnMFL1L4G-38wsI_-ISIOzdO8QIrXHxmtc1S5xzYu8bMqSgCinvCEwdeGFCmighRjj8t1zRWo0D14rHbQLPR_b1P5SvEeJTtS9Nm3iibM',
authSecret: 'g2rWVHUCpUxgcL9Tz7vyeQ',
headers: {
crypto_key: 'dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk',
encryption: 'salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3',
encoding: 'aesgcm',
},
}];
for (let test of expectedSuccesses) {
let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, {
padding: "reject",
}) : null;
let data = ChromeUtils.base64URLDecode(test.data, {
padding: "reject",
});
let result = yield PushCrypto.decrypt(privateKey, publicKey, authSecret,
test.headers, data);
let decoder = new TextDecoder('utf-8');
equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
}
let expectedFailures = [{
desc: 'padSize = 1, rs = 4096, auth secret, pad = 8',
data: 'h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r',
senderPublicKey: '',
authSecret: 'Sxb6u0gJIhGEogyLawjmCw',
headers: {
crypto_key: 'dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM',
encryption: 'salt=aGBpoKklLtrLcAUCcCr7JQ',
encoding: 'aesgcm128',
},
}, {
desc: 'Missing padding',
data: 'anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg',
headers: {
crypto_key: 'dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4',
encryption: 'salt=Czx2i18rar8XWOXAVDnUuw',
encoding: 'aesgcm128',
},
}, {
desc: 'padSize > rs',
data: 'Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu',
headers: {
crypto_key: 'dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls',
encryption: 'salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1',
encoding: 'aesgcm',
},
}, {
desc: 'Encrypted with padSize = 1, decrypted with padSize = 2 and auth secret',
data: 'fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8',
authSecret: 'BhbpNTWyO5wVJmVKTV6XaA',
headers: {
crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q',
encoding: 'aesgcm',
},
}, {
desc: 'Truncated input',
data: 'AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0',
headers: {
crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25',
encoding: 'aesgcm',
},
}];
for (let test of expectedFailures) {
let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, {
padding: "reject",
}) : null;
let data = ChromeUtils.base64URLDecode(test.data, {
padding: "reject",
});
yield rejects(
PushCrypto.decrypt(privateKey, publicKey, authSecret,
test.headers, data),
test.desc
);
}
});

View file

@ -0,0 +1,154 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '2c43af06-ab6e-476a-adc4-16cbda54fb89';
var db;
var quotaURI;
var permURI;
function visitURI(uri, timestamp) {
return PlacesTestUtils.addVisits({
uri: uri,
title: uri.spec,
visitDate: timestamp * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
});
}
var putRecord = Task.async(function* ({scope, perm, quota, lastPush, lastVisit}) {
let uri = Services.io.newURI(scope, null, null);
Services.perms.add(uri, 'desktop-notification',
Ci.nsIPermissionManager[perm]);
do_register_cleanup(() => {
Services.perms.remove(uri, 'desktop-notification');
});
yield visitURI(uri, lastVisit);
yield db.put({
channelID: uri.path,
pushEndpoint: 'https://example.org/push' + uri.path,
scope: uri.spec,
pushCount: 0,
lastPush: lastPush,
version: null,
originAttributes: '',
quota: quota,
});
return uri;
});
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
run_next_test();
}
add_task(function* setUp() {
// An expired registration that should be evicted on startup. Permission is
// granted for this origin, and the last visit is more recent than the last
// push message.
yield putRecord({
scope: 'https://example.com/expired-quota-restored',
perm: 'ALLOW_ACTION',
quota: 0,
lastPush: Date.now() - 10,
lastVisit: Date.now(),
});
// An expired registration that we should evict when the origin is visited
// again.
quotaURI = yield putRecord({
scope: 'https://example.xyz/expired-quota-exceeded',
perm: 'ALLOW_ACTION',
quota: 0,
lastPush: Date.now() - 10,
lastVisit: Date.now() - 20,
});
// An expired registration that we should evict when permission is granted
// again.
permURI = yield putRecord({
scope: 'https://example.info/expired-perm-revoked',
perm: 'DENY_ACTION',
quota: 0,
lastPush: Date.now() - 10,
lastVisit: Date.now(),
});
// An active registration that we should leave alone.
yield putRecord({
scope: 'https://example.ninja/active',
perm: 'ALLOW_ACTION',
quota: 16,
lastPush: Date.now() - 10,
lastVisit: Date.now() - 20,
});
let subChangePromise = promiseObserverNotification(
PushServiceComponent.subscriptionChangeTopic,
(subject, data) => data == 'https://example.com/expired-quota-restored'
);
PushService.init({
serverURI: 'wss://push.example.org/',
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
},
onUnregister(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
channelID: request.channelID,
status: 200,
}));
},
});
},
});
yield subChangePromise;
});
add_task(function* test_site_visited() {
let subChangePromise = promiseObserverNotification(
PushServiceComponent.subscriptionChangeTopic,
(subject, data) => data == 'https://example.xyz/expired-quota-exceeded'
);
yield visitURI(quotaURI, Date.now());
PushService.observe(null, 'idle-daily', '');
yield subChangePromise;
});
add_task(function* test_perm_restored() {
let subChangePromise = promiseObserverNotification(
PushServiceComponent.subscriptionChangeTopic,
(subject, data) => data == 'https://example.info/expired-perm-revoked'
);
Services.perms.add(permURI, 'desktop-notification',
Ci.nsIPermissionManager.ALLOW_ACTION);
yield subChangePromise;
});

View file

@ -0,0 +1,47 @@
"use strict";
// Here we test that if an xpcom component is registered with the category
// manager for push notifications against a specific scope, that service is
// instantiated before the message is delivered.
// This component is registered for "chrome://test-scope"
const kServiceContractID = "@mozilla.org/dom/push/test/PushServiceHandler;1";
let pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
add_test(function test_service_instantiation() {
do_load_manifest("PushServiceHandler.manifest");
let scope = "chrome://test-scope";
let pushNotifier = Cc["@mozilla.org/push/Notifier;1"].getService(Ci.nsIPushNotifier);
let principal = Services.scriptSecurityManager.getSystemPrincipal();
pushNotifier.notifyPush(scope, principal, "");
// Now get a handle to our service and check it received the notification.
let handlerService = Cc[kServiceContractID]
.getService(Ci.nsISupports)
.wrappedJSObject;
equal(handlerService.observed.length, 1);
equal(handlerService.observed[0].topic, pushService.pushTopic);
let message = handlerService.observed[0].subject.QueryInterface(Ci.nsIPushMessage);
equal(message.principal, principal);
strictEqual(message.data, null);
equal(handlerService.observed[0].data, scope);
// and a subscription change.
pushNotifier.notifySubscriptionChange(scope, principal);
equal(handlerService.observed.length, 2);
equal(handlerService.observed[1].topic, pushService.subscriptionChangeTopic);
equal(handlerService.observed[1].subject, principal);
equal(handlerService.observed[1].data, scope);
// and a subscription modified event.
pushNotifier.notifySubscriptionModified(scope, principal);
equal(handlerService.observed.length, 3);
equal(handlerService.observed[2].topic, pushService.subscriptionModifiedTopic);
equal(handlerService.observed[2].subject, principal);
equal(handlerService.observed[2].data, scope);
run_next_test();
});

View file

@ -0,0 +1,125 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
var userAgentID = '5ab1d1df-7a3d-4024-a469-b9e1bb399fad';
function run_test() {
do_get_profile();
setPrefs({userAgentID});
run_next_test();
}
add_task(function* test_notification_ack() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let records = [{
channelID: '21668e05-6da8-42c9-b8ab-9cc3f4d5630c',
pushEndpoint: 'https://example.com/update/1',
scope: 'https://example.org/1',
originAttributes: '',
version: 1,
quota: Infinity,
systemRecord: true,
}, {
channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
pushEndpoint: 'https://example.com/update/2',
scope: 'https://example.org/2',
originAttributes: '',
version: 2,
quota: Infinity,
systemRecord: true,
}, {
channelID: '5477bfda-22db-45d4-9614-fee369630260',
pushEndpoint: 'https://example.com/update/3',
scope: 'https://example.org/3',
originAttributes: '',
version: 3,
quota: Infinity,
systemRecord: true,
}];
for (let record of records) {
yield db.put(record);
}
let notifyCount = 0;
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic, () =>
++notifyCount == 3);
let acks = 0;
let ackDone;
let ackPromise = new Promise(resolve => ackDone = resolve);
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
equal(request.uaid, userAgentID,
'Should send matching device IDs in handshake');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
uaid: userAgentID,
status: 200
}));
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '21668e05-6da8-42c9-b8ab-9cc3f4d5630c',
version: 2
}]
}));
},
onACK(request) {
equal(request.messageType, 'ack', 'Should send acknowledgements');
let updates = request.updates;
switch (++acks) {
case 1:
deepEqual([{
channelID: '21668e05-6da8-42c9-b8ab-9cc3f4d5630c',
version: 2,
code: 100,
}], updates, 'Wrong updates for acknowledgement 1');
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
version: 4
}, {
channelID: '5477bfda-22db-45d4-9614-fee369630260',
version: 6
}]
}));
break;
case 2:
deepEqual([{
channelID: '9a5ff87f-47c9-4215-b2b8-0bdd38b4b305',
version: 4,
code: 100,
}], updates, 'Wrong updates for acknowledgement 2');
break;
case 3:
deepEqual([{
channelID: '5477bfda-22db-45d4-9614-fee369630260',
version: 6,
code: 100,
}], updates, 'Wrong updates for acknowledgement 3');
ackDone();
break;
default:
ok(false, 'Unexpected acknowledgement ' + acks);
}
}
});
}
});
yield notifyPromise;
yield ackPromise;
});

View file

@ -0,0 +1,280 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
let db;
let userAgentID = 'f5b47f8d-771f-4ea3-b999-91c135f8766d';
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
run_next_test();
}
function putRecord(channelID, scope, publicKey, privateKey, authSecret) {
return db.put({
channelID: channelID,
pushEndpoint: 'https://example.org/push/' + channelID,
scope: scope,
pushCount: 0,
lastPush: 0,
originAttributes: '',
quota: Infinity,
systemRecord: true,
p256dhPublicKey: ChromeUtils.base64URLDecode(publicKey, {
padding: "reject",
}),
p256dhPrivateKey: privateKey,
authenticationSecret: ChromeUtils.base64URLDecode(authSecret, {
padding: "reject",
}),
});
}
let ackDone;
let server;
add_task(function* test_notification_ack_data_setup() {
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
yield putRecord(
'subscription1',
'https://example.com/page/1',
'BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA',
{
crv: 'P-256',
d: '1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM',
ext: true,
key_ops: ["deriveBits"],
kty: "EC",
x: '8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM',
y: '26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA'
},
'c_sGN6uCv9Hu7JOQT34jAQ'
);
yield putRecord(
'subscription2',
'https://example.com/page/2',
'BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E',
{
crv: 'P-256',
d: 'lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4',
ext: true,
key_ops: ["deriveBits"],
kty: 'EC',
x: '-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE',
y: '5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E'
},
't3P246Gj9vjKDHHRYaY6hw'
);
yield putRecord(
'subscription3',
'https://example.com/page/3',
'BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI',
{
crv: 'P-256',
d: 'Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8',
ext: true,
key_ops: ["deriveBits"],
kty: 'EC',
x: 'OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po',
y: 'Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI'
},
'E0qiXGWvFSR0PS352ES1_Q'
);
let setupDone;
let setupDonePromise = new Promise(r => setupDone = r);
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
equal(request.uaid, userAgentID,
'Should send matching device IDs in handshake');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
uaid: userAgentID,
status: 200,
use_webpush: true,
}));
server = this;
setupDone();
},
onACK(request) {
if (ackDone) {
ackDone(request);
}
}
});
}
});
yield setupDonePromise;
});
add_task(function* test_notification_ack_data() {
let allTestData = [
{
channelID: 'subscription1',
version: 'v1',
send: {
headers: {
encryption_key: 'keyid="notification1"; dh="BO_tgGm-yvYAGLeRe16AvhzaUcpYRiqgsGOlXpt0DRWDRGGdzVLGlEVJMygqAUECarLnxCiAOHTP_znkedrlWoU"',
encryption: 'keyid="notification1";salt="uAZaiXpOSfOLJxtOCZ09dA"',
encoding: 'aesgcm128',
},
data: 'NwrrOWPxLE8Sv5Rr0Kep7n0-r_j3rsYrUw_CXPo',
version: 'v1',
},
ackCode: 100,
receive: {
scope: 'https://example.com/page/1',
data: 'Some message'
}
},
{
channelID: 'subscription2',
version: 'v2',
send: {
headers: {
encryption_key: 'keyid="notification2"; dh="BKVdQcgfncpNyNWsGrbecX0zq3eHIlHu5XbCGmVcxPnRSbhjrA6GyBIeGdqsUL69j5Z2CvbZd-9z1UBH0akUnGQ"',
encryption: 'keyid="notification2";salt="vFn3t3M_k42zHBdpch3VRw"',
encoding: 'aesgcm128',
},
data: 'Zt9dEdqgHlyAL_l83385aEtb98ZBilz5tgnGgmwEsl5AOCNgesUUJ4p9qUU',
},
ackCode: 100,
receive: {
scope: 'https://example.com/page/2',
data: 'Some message'
}
},
{
channelID: 'subscription3',
version: 'v3',
send: {
headers: {
encryption_key: 'keyid="notification3";dh="BD3xV_ACT8r6hdIYES3BJj1qhz9wyv7MBrG9vM2UCnjPzwE_YFVpkD-SGqE-BR2--0M-Yf31wctwNsO1qjBUeMg"',
encryption: 'keyid="notification3"; salt="DFq188piWU7osPBgqn4Nlg"; rs=24',
encoding: 'aesgcm128',
},
data: 'LKru3ZzxBZuAxYtsaCfaj_fehkrIvqbVd1iSwnwAUgnL-cTeDD-83blxHXTq7r0z9ydTdMtC3UjAcWi8LMnfY-BFzi0qJAjGYIikDA',
},
ackCode: 100,
receive: {
scope: 'https://example.com/page/3',
data: 'Some message'
}
},
// A message encoded with `aesgcm` (2 bytes of padding, authenticated).
{
channelID: 'subscription1',
version: 'v5',
send: {
headers: {
crypto_key: 'keyid=v4;dh="BMh_vsnqu79ZZkMTYkxl4gWDLdPSGE72Lr4w2hksSFW398xCMJszjzdblAWXyhSwakRNEU_GopAm4UGzyMVR83w"',
encryption: 'keyid="v4";salt="C14Wb7rQTlXzrgcPHtaUzw"',
encoding: 'aesgcm',
},
data: 'pus4kUaBWzraH34M-d_oN8e0LPpF_X6acx695AMXovDe',
},
ackCode: 100,
receive: {
scope: 'https://example.com/page/1',
data: 'Another message'
}
},
// A message with 17 bytes of padding and rs of 24
{
channelID: 'subscription2',
version: 'v5',
send: {
headers: {
crypto_key: 'keyid="v5"; dh="BOp-DpyR9eLY5Ci11_loIFqeHzWfc_0evJmq7N8NKzgp60UAMMM06XIi2VZp2_TSdw1omk7E19SyeCCwRp76E-U"',
encryption: 'keyid=v5;salt="TvjOou1TqJOQY_ZsOYV3Ww";rs=24',
encoding: 'aesgcm',
},
data: 'rG9WYQ2ZwUgfj_tMlZ0vcIaNpBN05FW-9RUBZAM-UUZf0_9eGpuENBpUDAw3mFmd2XJpmvPvAtLVs54l3rGwg1o',
},
ackCode: 100,
receive: {
scope: 'https://example.com/page/2',
data: 'Some message'
}
},
// A message without key identifiers.
{
channelID: 'subscription3',
version: 'v6',
send: {
headers: {
crypto_key: 'dh="BEEjwWbF5jZKCgW0kmUWgG-wNcRvaa9_3zZElHAF8przHwd4cp5_kQsc-IMNZcVA0iUix31jxuMOytU-5DwWtyQ"',
encryption: 'salt=aAQcr2khAksgNspPiFEqiQ',
encoding: 'aesgcm',
},
data: 'pEYgefdI-7L46CYn5dR9TIy2AXGxe07zxclbhstY',
},
ackCode: 100,
receive: {
scope: 'https://example.com/page/3',
data: 'Some message'
}
},
// A malformed encrypted message.
{
channelID: 'subscription3',
version: 'v7',
send: {
headers: {
crypto_key: 'dh=AAAAAAAA',
encryption: 'salt=AAAAAAAA',
},
data: 'AAAAAAAA',
},
ackCode: 101,
receive: null,
},
];
let sendAndReceive = testData => {
let messageReceived = testData.receive ? promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) => {
let notification = subject.QueryInterface(Ci.nsIPushMessage).data;
equal(notification.text(), testData.receive.data,
'Check data for notification ' + testData.version);
equal(data, testData.receive.scope,
'Check scope for notification ' + testData.version);
return true;
}) : Promise.resolve();
let ackReceived = new Promise(resolve => ackDone = resolve)
.then(ackData => {
deepEqual({
messageType: 'ack',
updates: [{
channelID: testData.channelID,
version: testData.version,
code: testData.ackCode,
}],
}, ackData, 'Check updates for acknowledgment ' + testData.version);
});
let msg = JSON.parse(JSON.stringify(testData.send));
msg.messageType = 'notification';
msg.channelID = testData.channelID;
msg.version = testData.version;
server.serverSendMsg(JSON.stringify(msg));
return Promise.all([messageReceived, ackReceived]);
};
yield allTestData.reduce((p, testData) => {
return p.then(_ => sendAndReceive(testData));
}, Promise.resolve());
});

View file

@ -0,0 +1,140 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '1500e7d9-8cbe-4ee6-98da-7fa5d6a39852';
function run_test() {
do_get_profile();
setPrefs({
maxRecentMessageIDsPerSubscription: 4,
userAgentID: userAgentID,
});
run_next_test();
}
// Should acknowledge duplicate notifications, but not notify apps.
add_task(function* test_notification_duplicate() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let records = [{
channelID: 'has-recents',
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.com/1',
originAttributes: "",
recentMessageIDs: ['dupe'],
quota: Infinity,
systemRecord: true,
}, {
channelID: 'no-recents',
pushEndpoint: 'https://example.org/update/2',
scope: 'https://example.com/2',
originAttributes: "",
quota: Infinity,
systemRecord: true,
}, {
channelID: 'dropped-recents',
pushEndpoint: 'https://example.org/update/3',
scope: 'https://example.com/3',
originAttributes: '',
recentMessageIDs: ['newest', 'newer', 'older', 'oldest'],
quota: Infinity,
systemRecord: true,
}];
for (let record of records) {
yield db.put(record);
}
let testData = [{
channelID: 'has-recents',
updates: 1,
acks: [{
version: 'dupe',
code: 102,
}, {
version: 'not-dupe',
code: 100,
}],
recents: ['not-dupe', 'dupe'],
}, {
channelID: 'no-recents',
updates: 1,
acks: [{
version: 'not-dupe',
code: 100,
}],
recents: ['not-dupe'],
}, {
channelID: 'dropped-recents',
acks: [{
version: 'overflow',
code: 100,
}, {
version: 'oldest',
code: 100,
}],
updates: 2,
recents: ['oldest', 'overflow', 'newest', 'newer'],
}];
let expectedUpdates = testData.reduce((sum, {updates}) => sum + updates, 0);
let notifiedScopes = [];
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) => {
notifiedScopes.push(data);
return notifiedScopes.length == expectedUpdates;
});
let expectedAcks = testData.reduce((sum, {acks}) => sum + acks.length, 0);
let ackDone;
let ackPromise = new Promise(resolve => ackDone = after(expectedAcks, resolve));
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
use_webpush: true,
}));
for (let {channelID, acks} of testData) {
for (let {version} of acks) {
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
channelID: channelID,
version: version,
}))
}
}
},
onACK(request) {
let [ack] = request.updates;
let expectedData = testData.find(test =>
test.channelID == ack.channelID);
ok(expectedData, `Unexpected channel ${ack.channelID}`);
let expectedAck = expectedData.acks.find(expectedAck =>
expectedAck.version == ack.version);
ok(expectedAck, `Unexpected ack for message ${
ack.version} on ${ack.channelID}`);
equal(expectedAck.code, ack.code, `Wrong ack status for message ${
ack.version} on ${ack.channelID}`);
ackDone();
},
});
}
});
yield notifyPromise;
yield ackPromise;
for (let {channelID, recents} of testData) {
let record = yield db.getByKeyID(channelID);
deepEqual(record.recentMessageIDs, recents,
`Wrong recent message IDs for ${channelID}`);
}
});

View file

@ -0,0 +1,117 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '3c7462fc-270f-45be-a459-b9d631b0d093';
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
run_next_test();
}
add_task(function* test_notification_error() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let originAttributes = '';
let records = [{
channelID: 'f04f1e46-9139-4826-b2d1-9411b0821283',
pushEndpoint: 'https://example.org/update/success-1',
scope: 'https://example.com/a',
originAttributes: originAttributes,
version: 1,
quota: Infinity,
systemRecord: true,
}, {
channelID: '3c3930ba-44de-40dc-a7ca-8a133ec1a866',
pushEndpoint: 'https://example.org/update/error',
scope: 'https://example.com/b',
originAttributes: originAttributes,
version: 2,
quota: Infinity,
systemRecord: true,
}, {
channelID: 'b63f7bef-0a0d-4236-b41e-086a69dfd316',
pushEndpoint: 'https://example.org/update/success-2',
scope: 'https://example.com/c',
originAttributes: originAttributes,
version: 3,
quota: Infinity,
systemRecord: true,
}];
for (let record of records) {
yield db.put(record);
}
let scopes = [];
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) =>
scopes.push(data) == 2);
let ackDone;
let ackPromise = new Promise(resolve => ackDone = after(records.length, resolve));
PushService.init({
serverURI: "wss://push.example.org/",
db: makeStub(db, {
getByKeyID(prev, channelID) {
if (channelID == '3c3930ba-44de-40dc-a7ca-8a133ec1a866') {
return Promise.reject('splines not reticulated');
}
return prev.call(this, channelID);
}
}),
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: records.map(({channelID, version}) =>
({channelID, version: ++version}))
}));
},
// Should acknowledge all received updates, even if updating
// IndexedDB fails.
onACK: ackDone
});
}
});
yield notifyPromise;
ok(scopes.includes('https://example.com/a'),
'Missing scope for notification A');
ok(scopes.includes('https://example.com/c'),
'Missing scope for notification C');
yield ackPromise;
let aRecord = yield db.getByIdentifiers({scope: 'https://example.com/a',
originAttributes: originAttributes });
equal(aRecord.channelID, 'f04f1e46-9139-4826-b2d1-9411b0821283',
'Wrong channel ID for record A');
strictEqual(aRecord.version, 2,
'Should return the new version for record A');
let bRecord = yield db.getByIdentifiers({scope: 'https://example.com/b',
originAttributes: originAttributes });
equal(bRecord.channelID, '3c3930ba-44de-40dc-a7ca-8a133ec1a866',
'Wrong channel ID for record B');
strictEqual(bRecord.version, 2,
'Should return the previous version for record B');
let cRecord = yield db.getByIdentifiers({scope: 'https://example.com/c',
originAttributes: originAttributes });
equal(cRecord.channelID, 'b63f7bef-0a0d-4236-b41e-086a69dfd316',
'Wrong channel ID for record C');
strictEqual(cRecord.version, 4,
'Should return the new version for record C');
});

View file

@ -0,0 +1,189 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var prefs;
var tlsProfile;
var serverPort = -1;
function run_test() {
serverPort = getTestServerPort();
do_get_profile();
setPrefs({
'testing.allowInsecureServerURL': true,
});
prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
// Set to allow the cert presented by our H2 server
var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
prefs.setIntPref("network.http.speculative-parallel-limit", 0);
prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
prefs.setBoolPref("dom.push.enabled", true);
prefs.setBoolPref("dom.push.connection.enabled", true);
addCertOverride("localhost", serverPort,
Ci.nsICertOverrideService.ERROR_UNTRUSTED |
Ci.nsICertOverrideService.ERROR_MISMATCH |
Ci.nsICertOverrideService.ERROR_TIME);
prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
run_next_test();
}
add_task(function* test_pushNotifications() {
// /pushNotifications/subscription1 will send a message with no rs and padding
// length 1.
// /pushNotifications/subscription2 will send a message with no rs and padding
// length 16.
// /pushNotifications/subscription3 will send a message with rs equal 24 and
// padding length 16.
// /pushNotifications/subscription4 will send a message with no rs and padding
// length 256.
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
var serverURL = "https://localhost:" + serverPort;
let records = [{
subscriptionUri: serverURL + '/pushNotifications/subscription1',
pushEndpoint: serverURL + '/pushEndpoint1',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint1',
scope: 'https://example.com/page/1',
p256dhPublicKey: 'BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA',
p256dhPrivateKey: {
crv: 'P-256',
d: '1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM',
ext: true,
key_ops: ["deriveBits"],
kty: "EC",
x: '8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM',
y: '26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA'
},
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
systemRecord: true,
}, {
subscriptionUri: serverURL + '/pushNotifications/subscription2',
pushEndpoint: serverURL + '/pushEndpoint2',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint2',
scope: 'https://example.com/page/2',
p256dhPublicKey: 'BPnWyUo7yMnuMlyKtERuLfWE8a09dtdjHSW2lpC9_BqR5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E',
p256dhPrivateKey: {
crv: 'P-256',
d: 'lFm4nPsUKYgNGBJb5nXXKxl8bspCSp0bAhCYxbveqT4',
ext: true,
key_ops: ["deriveBits"],
kty: 'EC',
x: '-dbJSjvIye4yXIq0RG4t9YTxrT1212MdJbaWkL38GpE',
y: '5TZ1rK8Ldih6ljyxVwnBA-nygQHGRpEmu1jV5K8437E'
},
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
systemRecord: true,
}, {
subscriptionUri: serverURL + '/pushNotifications/subscription3',
pushEndpoint: serverURL + '/pushEndpoint3',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint3',
scope: 'https://example.com/page/3',
p256dhPublicKey: 'BDhUHITSeVrWYybFnb7ylVTCDDLPdQWMpf8gXhcWwvaaJa6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI',
p256dhPrivateKey: {
crv: 'P-256',
d: 'Q1_SE1NySTYzjbqgWwPgrYh7XRg3adqZLkQPsy319G8',
ext: true,
key_ops: ["deriveBits"],
kty: 'EC',
x: 'OFQchNJ5WtZjJsWdvvKVVMIMMs91BYyl_yBeFxbC9po',
y: 'Ja6n3YH8TOcH8narDF6t8mKVvg2ioLW-8MH5O4dzGcI'
},
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
systemRecord: true,
}, {
subscriptionUri: serverURL + '/pushNotifications/subscription4',
pushEndpoint: serverURL + '/pushEndpoint4',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint4',
scope: 'https://example.com/page/4',
p256dhPublicKey: ChromeUtils.base64URLDecode('BEcvDzkWCrUtjU_wygL98sbQCQrW1lY9irtgGnlCc4B0JJXLCHB9MTM73qD6GZYfL0YOvKo8XLOflh-J4dMGklU', {
padding: "reject",
}),
p256dhPrivateKey: {
crv: 'P-256',
d: 'fWi7tZaX0Pk6WnLrjQ3kiRq_g5XStL5pdH4pllNCqXw',
ext: true,
key_ops: ["deriveBits"],
kty: 'EC',
x: 'Ry8PORYKtS2NT_DKAv3yxtAJCtbWVj2Ku2AaeUJzgHQ',
y: 'JJXLCHB9MTM73qD6GZYfL0YOvKo8XLOflh-J4dMGklU'
},
authenticationSecret: ChromeUtils.base64URLDecode('cwDVC1iwAn8E37mkR3tMSg', {
padding: "reject",
}),
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
systemRecord: true,
}];
for (let record of records) {
yield db.put(record);
}
let notifyPromise = Promise.all([
promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
var message = subject.QueryInterface(Ci.nsIPushMessage).data;
if (message && (data == "https://example.com/page/1")){
equal(message.text(), "Some message", "decoded message is incorrect");
return true;
}
}),
promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
var message = subject.QueryInterface(Ci.nsIPushMessage).data;
if (message && (data == "https://example.com/page/2")){
equal(message.text(), "Some message", "decoded message is incorrect");
return true;
}
}),
promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
var message = subject.QueryInterface(Ci.nsIPushMessage).data;
if (message && (data == "https://example.com/page/3")){
equal(message.text(), "Some message", "decoded message is incorrect");
return true;
}
}),
promiseObserverNotification(PushServiceComponent.pushTopic, function(subject, data) {
var message = subject.QueryInterface(Ci.nsIPushMessage).data;
if (message && (data == "https://example.com/page/4")){
equal(message.text(), "Yet another message", "decoded message is incorrect");
return true;
}
}),
]);
PushService.init({
serverURI: serverURL,
db
});
yield notifyPromise;
});
add_task(function* test_complete() {
prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
});

View file

@ -0,0 +1,130 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '1ca1cf66-eeb4-4df7-87c1-d5c92906ab90';
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
run_next_test();
}
add_task(function* test_notification_incomplete() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let records = [{
channelID: '123',
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.com/page/1',
version: 1,
originAttributes: '',
quota: Infinity,
}, {
channelID: '3ad1ed95-d37a-4d88-950f-22cbe2e240d7',
pushEndpoint: 'https://example.org/update/2',
scope: 'https://example.com/page/2',
version: 1,
originAttributes: '',
quota: Infinity,
}, {
channelID: 'd239498b-1c85-4486-b99b-205866e82d1f',
pushEndpoint: 'https://example.org/update/3',
scope: 'https://example.com/page/3',
version: 3,
originAttributes: '',
quota: Infinity,
}, {
channelID: 'a50de97d-b496-43ce-8b53-05522feb78db',
pushEndpoint: 'https://example.org/update/4',
scope: 'https://example.com/page/4',
version: 10,
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
function observeMessage(subject, topic, data) {
ok(false, 'Should not deliver malformed updates');
}
do_register_cleanup(() =>
Services.obs.removeObserver(observeMessage, PushServiceComponent.pushTopic));
Services.obs.addObserver(observeMessage, PushServiceComponent.pushTopic, false);
let notificationDone;
let notificationPromise = new Promise(resolve => notificationDone = after(2, resolve));
let prevHandler = PushServiceWebSocket._handleNotificationReply;
PushServiceWebSocket._handleNotificationReply = function _handleNotificationReply() {
notificationDone();
return prevHandler.apply(this, arguments);
};
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
this.serverSendMsg(JSON.stringify({
// Missing "updates" field; should ignore message.
messageType: 'notification'
}));
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
// Wrong channel ID field type.
channelID: 123,
version: 3
}, {
// Missing version field.
channelID: '3ad1ed95-d37a-4d88-950f-22cbe2e240d7'
}, {
// Wrong version field type.
channelID: 'd239498b-1c85-4486-b99b-205866e82d1f',
version: true
}, {
// Negative versions should be ignored.
channelID: 'a50de97d-b496-43ce-8b53-05522feb78db',
version: -5
}]
}));
},
onACK() {
ok(false, 'Should not acknowledge malformed updates');
}
});
}
});
yield notificationPromise;
let storeRecords = yield db.getAllKeyIDs();
storeRecords.sort(({pushEndpoint: a}, {pushEndpoint: b}) =>
compareAscending(a, b));
recordsAreEqual(records, storeRecords);
});
function recordIsEqual(a, b) {
strictEqual(a.channelID, b.channelID, 'Wrong channel ID in record');
strictEqual(a.pushEndpoint, b.pushEndpoint, 'Wrong push endpoint in record');
strictEqual(a.scope, b.scope, 'Wrong scope in record');
strictEqual(a.version, b.version, 'Wrong version in record');
}
function recordsAreEqual(a, b) {
equal(a.length, b.length, 'Mismatched record count');
for (let i = 0; i < a.length; i++) {
recordIsEqual(a[i], b[i]);
}
}

View file

@ -0,0 +1,69 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = 'ba31ac13-88d4-4984-8e6b-8731315a7cf8';
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
});
run_next_test();
}
add_task(function* test_notification_version_string() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
yield db.put({
channelID: '6ff97d56-d0c0-43bc-8f5b-61b855e1d93b',
pushEndpoint: 'https://example.org/updates/1',
scope: 'https://example.com/page/1',
originAttributes: '',
version: 2,
quota: Infinity,
systemRecord: true,
});
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic);
let ackDone;
let ackPromise = new Promise(resolve => ackDone = resolve);
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '6ff97d56-d0c0-43bc-8f5b-61b855e1d93b',
version: '4'
}]
}));
},
onACK: ackDone
});
}
});
let {subject: message, data: scope} = yield notifyPromise;
equal(message.QueryInterface(Ci.nsIPushMessage).data, null,
'Unexpected data for Simple Push message');
yield ackPromise;
let storeRecord = yield db.getByKeyID(
'6ff97d56-d0c0-43bc-8f5b-61b855e1d93b');
strictEqual(storeRecord.version, 4, 'Wrong record version');
equal(storeRecord.quota, Infinity, 'Wrong quota');
});

View file

@ -0,0 +1,42 @@
'use strict';
var pushNotifier = Cc['@mozilla.org/push/Notifier;1']
.getService(Ci.nsIPushNotifier);
var systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
function run_test() {
run_next_test();
}
add_task(function* test_notifyWithData() {
let textData = '{"hello":"world"}';
let payload = new TextEncoder('utf-8').encode(textData);
let notifyPromise =
promiseObserverNotification(PushServiceComponent.pushTopic);
pushNotifier.notifyPushWithData('chrome://notify-test', systemPrincipal,
'' /* messageId */, payload.length, payload);
let data = (yield notifyPromise).subject.QueryInterface(
Ci.nsIPushMessage).data;
deepEqual(data.json(), {
hello: 'world',
}, 'Should extract JSON values');
deepEqual(data.binary(), Array.from(payload),
'Should extract raw binary data');
equal(data.text(), textData, 'Should extract text data');
});
add_task(function* test_empty_notifyWithData() {
let notifyPromise =
promiseObserverNotification(PushServiceComponent.pushTopic);
pushNotifier.notifyPushWithData('chrome://notify-test', systemPrincipal,
'' /* messageId */, 0, null);
let data = (yield notifyPromise).subject.QueryInterface(
Ci.nsIPushMessage).data;
throws(_ => data.json(),
'Should throw an error when parsing an empty string as JSON');
strictEqual(data.text(), '', 'Should return an empty string');
deepEqual(data.binary(), [], 'Should return an empty array');
});

View file

@ -0,0 +1,111 @@
'use strict';
const pushNotifier = Cc['@mozilla.org/push/Notifier;1']
.getService(Ci.nsIPushNotifier);
add_task(function* test_observer_remoting() {
if (isParent) {
yield testInParent();
} else {
yield testInChild();
}
});
const childTests = [{
text: 'Hello from child!',
principal: Services.scriptSecurityManager.getSystemPrincipal(),
}];
const parentTests = [{
text: 'Hello from parent!',
principal: Services.scriptSecurityManager.getSystemPrincipal(),
}];
function* testInParent() {
// Register observers for notifications from the child, then run the test in
// the child and wait for the notifications.
let promiseNotifications = childTests.reduce(
(p, test) => p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
Promise.resolve()
);
let promiseFinished = run_test_in_child('./test_observer_remoting.js');
yield promiseNotifications;
// Wait until the child is listening for notifications from the parent.
yield do_await_remote_message('push_test_observer_remoting_child_ready');
// Fire an observer notification in the parent that should be forwarded to
// the child.
yield parentTests.reduce(
(p, test) => p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
Promise.resolve()
);
// Wait for the child to exit.
yield promiseFinished;
}
function* testInChild() {
// Fire an observer notification in the child that should be forwarded to
// the parent.
yield childTests.reduce(
(p, test) => p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ true)),
Promise.resolve()
);
// Register observers for notifications from the parent, let the parent know
// we're ready, and wait for the notifications.
let promiseNotifierObservers = parentTests.reduce(
(p, test) => p.then(_ => waitForNotifierObservers(test, /* shouldNotify = */ false)),
Promise.resolve()
);
do_send_remote_message('push_test_observer_remoting_child_ready');
yield promiseNotifierObservers;
}
var waitForNotifierObservers = Task.async(function* ({ text, principal }, shouldNotify = false) {
let notifyPromise = promiseObserverNotification(
PushServiceComponent.pushTopic);
let subChangePromise = promiseObserverNotification(
PushServiceComponent.subscriptionChangeTopic);
let subModifiedPromise = promiseObserverNotification(
PushServiceComponent.subscriptionModifiedTopic);
let scope = 'chrome://test-scope';
let data = new TextEncoder('utf-8').encode(text);
if (shouldNotify) {
pushNotifier.notifyPushWithData(scope, principal, '', data.length, data);
pushNotifier.notifySubscriptionChange(scope, principal);
pushNotifier.notifySubscriptionModified(scope, principal);
}
let {
data: notifyScope,
subject: notifySubject,
} = yield notifyPromise;
equal(notifyScope, scope,
'Should fire push notifications with the correct scope');
let message = notifySubject.QueryInterface(Ci.nsIPushMessage);
equal(message.principal, principal,
'Should include the principal in the push message');
strictEqual(message.data.text(), text, 'Should include data');
let {
data: subChangeScope,
subject: subChangePrincipal,
} = yield subChangePromise;
equal(subChangeScope, scope,
'Should fire subscription change notifications with the correct scope');
equal(subChangePrincipal, principal,
'Should pass the principal as the subject of a change notification');
let {
data: subModifiedScope,
subject: subModifiedPrincipal,
} = yield subModifiedPromise;
equal(subModifiedScope, scope,
'Should fire subscription modified notifications with the correct scope');
equal(subModifiedPrincipal, principal,
'Should pass the principal as the subject of a modified notification');
});

View file

@ -0,0 +1,296 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '2c43af06-ab6e-476a-adc4-16cbda54fb89';
let db;
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
});
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
run_next_test();
}
let unregisterDefers = {};
function promiseUnregister(keyID) {
return new Promise(r => unregisterDefers[keyID] = r);
}
function makePushPermission(url, capability) {
return {
QueryInterface: XPCOMUtils.generateQI([Ci.nsIPermission]),
capability: Ci.nsIPermissionManager[capability],
expireTime: 0,
expireType: Ci.nsIPermissionManager.EXPIRE_NEVER,
principal: Services.scriptSecurityManager.getCodebasePrincipal(
Services.io.newURI(url, null, null)
),
type: 'desktop-notification',
};
}
function promiseObserverNotifications(topic, count) {
let notifiedScopes = [];
let subChangePromise = promiseObserverNotification(topic, (subject, data) => {
notifiedScopes.push(data);
return notifiedScopes.length == count;
});
return subChangePromise.then(_ => notifiedScopes.sort());
}
function promiseSubscriptionChanges(count) {
return promiseObserverNotifications(
PushServiceComponent.subscriptionChangeTopic, count);
}
function promiseSubscriptionModifications(count) {
return promiseObserverNotifications(
PushServiceComponent.subscriptionModifiedTopic, count);
}
function allExpired(...keyIDs) {
return Promise.all(keyIDs.map(
keyID => db.getByKeyID(keyID)
)).then(records =>
records.every(record => record.isExpired())
);
}
add_task(function* setUp() {
// Active registration; quota should be reset to 16. Since the quota isn't
// exposed to content, we shouldn't receive a subscription change event.
yield putTestRecord(db, 'active-allow', 'https://example.info/page/1', 8);
// Expired registration; should be dropped.
yield putTestRecord(db, 'expired-allow', 'https://example.info/page/2', 0);
// Active registration; should be expired when we change the permission
// to "deny".
yield putTestRecord(db, 'active-deny-changed', 'https://example.xyz/page/1', 16);
// Two active registrations for a visited site. These will expire when we
// add a "deny" permission.
yield putTestRecord(db, 'active-deny-added-1', 'https://example.net/ham', 16);
yield putTestRecord(db, 'active-deny-added-2', 'https://example.net/green', 8);
// An already-expired registration for a visited site. We shouldn't send an
// `unregister` request for this one, but still receive an observer
// notification when we restore permissions.
yield putTestRecord(db, 'expired-deny-added', 'https://example.net/eggs', 0);
// A registration that should not be affected by permission list changes
// because its quota is set to `Infinity`.
yield putTestRecord(db, 'never-expires', 'app://chrome/only', Infinity);
// A registration that should be dropped when we clear the permission
// list.
yield putTestRecord(db, 'drop-on-clear', 'https://example.edu/lonely', 16);
let handshakeDone;
let handshakePromise = new Promise(resolve => handshakeDone = resolve);
PushService.init({
serverURI: 'wss://push.example.org/',
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
handshakeDone();
},
onUnregister(request) {
let resolve = unregisterDefers[request.channelID];
equal(typeof resolve, 'function',
'Dropped unexpected channel ID ' + request.channelID);
delete unregisterDefers[request.channelID];
equal(request.code, 202,
'Expected permission revoked unregister reason');
resolve();
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
status: 200,
channelID: request.channelID,
}));
},
onACK(request) {},
});
}
});
yield handshakePromise;
});
add_task(function* test_permissions_allow_added() {
let subChangePromise = promiseSubscriptionChanges(1);
yield PushService._onPermissionChange(
makePushPermission('https://example.info', 'ALLOW_ACTION'),
'added'
);
let notifiedScopes = yield subChangePromise;
deepEqual(notifiedScopes, [
'https://example.info/page/2',
], 'Wrong scopes after adding allow');
let record = yield db.getByKeyID('active-allow');
equal(record.quota, 16,
'Should reset quota for active records after adding allow');
record = yield db.getByKeyID('expired-allow');
ok(!record, 'Should drop expired records after adding allow');
});
add_task(function* test_permissions_allow_deleted() {
let subModifiedPromise = promiseSubscriptionModifications(1);
let unregisterPromise = promiseUnregister('active-allow');
yield PushService._onPermissionChange(
makePushPermission('https://example.info', 'ALLOW_ACTION'),
'deleted'
);
yield unregisterPromise;
let notifiedScopes = yield subModifiedPromise;
deepEqual(notifiedScopes, [
'https://example.info/page/1',
], 'Wrong scopes modified after deleting allow');
let record = yield db.getByKeyID('active-allow');
ok(record.isExpired(),
'Should expire active record after deleting allow');
});
add_task(function* test_permissions_deny_added() {
let subModifiedPromise = promiseSubscriptionModifications(2);
let unregisterPromise = Promise.all([
promiseUnregister('active-deny-added-1'),
promiseUnregister('active-deny-added-2'),
]);
yield PushService._onPermissionChange(
makePushPermission('https://example.net', 'DENY_ACTION'),
'added'
);
yield unregisterPromise;
let notifiedScopes = yield subModifiedPromise;
deepEqual(notifiedScopes, [
'https://example.net/green',
'https://example.net/ham',
], 'Wrong scopes modified after adding deny');
let isExpired = yield allExpired(
'active-deny-added-1',
'expired-deny-added'
);
ok(isExpired, 'Should expire all registrations after adding deny');
});
add_task(function* test_permissions_deny_deleted() {
yield PushService._onPermissionChange(
makePushPermission('https://example.net', 'DENY_ACTION'),
'deleted'
);
let isExpired = yield allExpired(
'active-deny-added-1',
'expired-deny-added'
);
ok(isExpired, 'Should retain expired registrations after deleting deny');
});
add_task(function* test_permissions_allow_changed() {
let subChangePromise = promiseSubscriptionChanges(3);
yield PushService._onPermissionChange(
makePushPermission('https://example.net', 'ALLOW_ACTION'),
'changed'
);
let notifiedScopes = yield subChangePromise;
deepEqual(notifiedScopes, [
'https://example.net/eggs',
'https://example.net/green',
'https://example.net/ham'
], 'Wrong scopes after changing to allow');
let droppedRecords = yield Promise.all([
db.getByKeyID('active-deny-added-1'),
db.getByKeyID('active-deny-added-2'),
db.getByKeyID('expired-deny-added'),
]);
ok(!droppedRecords.some(Boolean),
'Should drop all expired registrations after changing to allow');
});
add_task(function* test_permissions_deny_changed() {
let subModifiedPromise = promiseSubscriptionModifications(1);
let unregisterPromise = promiseUnregister('active-deny-changed');
yield PushService._onPermissionChange(
makePushPermission('https://example.xyz', 'DENY_ACTION'),
'changed'
);
yield unregisterPromise;
let notifiedScopes = yield subModifiedPromise;
deepEqual(notifiedScopes, [
'https://example.xyz/page/1',
], 'Wrong scopes modified after changing to deny');
let record = yield db.getByKeyID('active-deny-changed');
ok(record.isExpired(),
'Should expire active record after changing to deny');
});
add_task(function* test_permissions_clear() {
let subModifiedPromise = promiseSubscriptionModifications(3);
deepEqual(yield getAllKeyIDs(db), [
'active-allow',
'active-deny-changed',
'drop-on-clear',
'never-expires',
], 'Wrong records in database before clearing');
let unregisterPromise = Promise.all([
promiseUnregister('active-allow'),
promiseUnregister('active-deny-changed'),
promiseUnregister('drop-on-clear'),
]);
yield PushService._onPermissionChange(null, 'cleared');
yield unregisterPromise;
let notifiedScopes = yield subModifiedPromise;
deepEqual(notifiedScopes, [
'https://example.edu/lonely',
'https://example.info/page/1',
'https://example.xyz/page/1',
], 'Wrong scopes modified after clearing registrations');
deepEqual(yield getAllKeyIDs(db), [
'never-expires',
], 'Unrestricted registrations should not be dropped');
});

View file

@ -0,0 +1,141 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
Cu.import("resource://gre/modules/Task.jsm");
const userAgentID = '7eb873f9-8d47-4218-804b-fff78dc04e88';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
'testing.ignorePermission': true,
});
run_next_test();
}
add_task(function* test_expiration_origin_threshold() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => db.drop().then(_ => db.close()));
yield db.put({
channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
pushEndpoint: 'https://example.org/push/1',
scope: 'https://example.com/auctions',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
yield db.put({
channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
pushEndpoint: 'https://example.org/push/2',
scope: 'https://example.com/deals',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
// The notification threshold is per-origin, even with multiple service
// workers for different scopes.
yield PlacesTestUtils.addVisits([
{
uri: 'https://example.com/login',
title: 'Sign in to see your auctions',
visitDate: (Date.now() - 7 * 24 * 60 * 60 * 1000) * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
},
// We'll always use your most recent visit to an origin.
{
uri: 'https://example.com/auctions',
title: 'Your auctions',
visitDate: (Date.now() - 2 * 24 * 60 * 60 * 1000) * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
},
// ...But we won't count downloads or embeds.
{
uri: 'https://example.com/invoices/invoice.pdf',
title: 'Invoice #123',
visitDate: (Date.now() - 1 * 24 * 60 * 60 * 1000) * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_EMBED
},
{
uri: 'https://example.com/invoices/invoice.pdf',
title: 'Invoice #123',
visitDate: Date.now() * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_DOWNLOAD
}
]);
// We expect to receive 6 notifications: 5 on the `auctions` channel,
// and 1 on the `deals` channel. They're from the same origin, but
// different scopes, so each can send 5 notifications before we remove
// their subscription.
let updates = 0;
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) => {
updates++;
return updates == 6;
});
let unregisterDone;
let unregisterPromise = new Promise(resolve => unregisterDone = resolve);
PushService.init({
serverURI: 'wss://push.example.org/',
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
// We last visited the site 2 days ago, so we can send 5
// notifications without throttling. Sending a 6th should
// drop the registration.
for (let version = 1; version <= 6; version++) {
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
version,
}],
}));
}
// But the limits are per-channel, so we can send 5 more
// notifications on a different channel.
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '46cc6f6a-c106-4ffa-bb7c-55c60bd50c41',
version: 1,
}],
}));
},
onUnregister(request) {
equal(request.channelID, 'eb33fc90-c883-4267-b5cb-613969e8e349', 'Unregistered wrong channel ID');
equal(request.code, 201, 'Expected quota exceeded unregister reason');
unregisterDone();
},
// We expect to receive acks, but don't care about their
// contents.
onACK(request) {},
});
},
});
yield unregisterPromise;
yield notifyPromise;
let expiredRecord = yield db.getByKeyID('eb33fc90-c883-4267-b5cb-613969e8e349');
strictEqual(expiredRecord.quota, 0, 'Expired record not updated');
});

View file

@ -0,0 +1,183 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '28cd09e2-7506-42d8-9e50-b02785adc7ef';
var db;
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
});
run_next_test();
}
let putRecord = Task.async(function* (perm, record) {
let uri = Services.io.newURI(record.scope, null, null);
Services.perms.add(uri, 'desktop-notification',
Ci.nsIPermissionManager[perm]);
do_register_cleanup(() => {
Services.perms.remove(uri, 'desktop-notification');
});
yield db.put(record);
});
add_task(function* test_expiration_history_observer() {
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => db.drop().then(_ => db.close()));
// A registration that we'll expire...
yield putRecord('ALLOW_ACTION', {
channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
pushEndpoint: 'https://example.org/push/1',
scope: 'https://example.com/deals',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
// ...And a registration that we'll evict on startup.
yield putRecord('ALLOW_ACTION', {
channelID: '4cb6e454-37cf-41c4-a013-4e3a7fdd0bf1',
pushEndpoint: 'https://example.org/push/3',
scope: 'https://example.com/stuff',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 0,
});
yield PlacesTestUtils.addVisits({
uri: 'https://example.com/infrequent',
title: 'Infrequently-visited page',
visitDate: (Date.now() - 14 * 24 * 60 * 60 * 1000) * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
});
let unregisterDone;
let unregisterPromise = new Promise(resolve => unregisterDone = resolve);
let subChangePromise = promiseObserverNotification(PushServiceComponent.subscriptionChangeTopic, (subject, data) =>
data == 'https://example.com/stuff');
PushService.init({
serverURI: 'wss://push.example.org/',
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: '379c0668-8323-44d2-a315-4ee83f1a9ee9',
version: 2,
}],
}));
},
onUnregister(request) {
equal(request.channelID, '379c0668-8323-44d2-a315-4ee83f1a9ee9', 'Dropped wrong channel ID');
equal(request.code, 201, 'Expected quota exceeded unregister reason');
unregisterDone();
},
onACK(request) {},
});
}
});
yield subChangePromise;
yield unregisterPromise;
let expiredRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
strictEqual(expiredRecord.quota, 0, 'Expired record not updated');
let notifiedScopes = [];
subChangePromise = promiseObserverNotification(PushServiceComponent.subscriptionChangeTopic, (subject, data) => {
notifiedScopes.push(data);
return notifiedScopes.length == 2;
});
// Add an expired registration that we'll revive later using the idle
// observer.
yield putRecord('ALLOW_ACTION', {
channelID: 'eb33fc90-c883-4267-b5cb-613969e8e349',
pushEndpoint: 'https://example.org/push/2',
scope: 'https://example.com/auctions',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 0,
});
// ...And an expired registration that we'll revive on fetch.
yield putRecord('ALLOW_ACTION', {
channelID: '6b2d13fe-d848-4c5f-bdda-e9fc89727dca',
pushEndpoint: 'https://example.org/push/4',
scope: 'https://example.net/sales',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 0,
});
// Now visit the site...
yield PlacesTestUtils.addVisits({
uri: 'https://example.com/another-page',
title: 'Infrequently-visited page',
visitDate: Date.now() * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
});
Services.obs.notifyObservers(null, 'idle-daily', '');
// And we should receive notifications for both scopes.
yield subChangePromise;
deepEqual(notifiedScopes.sort(), [
'https://example.com/auctions',
'https://example.com/deals'
], 'Wrong scopes for subscription changes');
let aRecord = yield db.getByKeyID('379c0668-8323-44d2-a315-4ee83f1a9ee9');
ok(!aRecord, 'Should drop expired record');
let bRecord = yield db.getByKeyID('eb33fc90-c883-4267-b5cb-613969e8e349');
ok(!bRecord, 'Should drop evicted record');
// Simulate a visit to a site with an expired registration, then fetch the
// record. This should drop the expired record and fire an observer
// notification.
yield PlacesTestUtils.addVisits({
uri: 'https://example.net/sales',
title: 'Firefox plushies, 99% off',
visitDate: Date.now() * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
});
subChangePromise = promiseObserverNotification(PushServiceComponent.subscriptionChangeTopic, (subject, data) => {
if (data == 'https://example.net/sales') {
ok(subject.isCodebasePrincipal,
'Should pass subscription principal as the subject');
return true;
}
});
let record = yield PushService.registration({
scope: 'https://example.net/sales',
originAttributes: '',
});
ok(!record, 'Should not return evicted record');
ok(!(yield db.getByKeyID('6b2d13fe-d848-4c5f-bdda-e9fc89727dca')),
'Should drop evicted record on fetch');
yield subChangePromise;
});

View file

@ -0,0 +1,120 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
Cu.import("resource://gre/modules/Task.jsm");
const userAgentID = 'aaabf1f8-2f68-44f1-a920-b88e9e7d7559';
const nsIPushQuotaManager = Components.interfaces.nsIPushQuotaManager;
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
'testing.ignorePermission': true,
});
run_next_test();
}
add_task(function* test_expiration_origin_threshold() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {
PushService.notificationForOriginClosed("https://example.com");
return db.drop().then(_ => db.close());
});
// Simulate a notification being shown for the origin,
// this should relax the quota and allow as many push messages
// as we want.
PushService.notificationForOriginShown("https://example.com");
yield db.put({
channelID: 'f56645a9-1f32-4655-92ad-ddc37f6d54fb',
pushEndpoint: 'https://example.org/push/1',
scope: 'https://example.com/quota',
pushCount: 0,
lastPush: 0,
version: null,
originAttributes: '',
quota: 16,
});
// A visit one day ago should provide a quota of 8 messages.
yield PlacesTestUtils.addVisits({
uri: 'https://example.com/login',
title: 'Sign in to see your auctions',
visitDate: (Date.now() - MS_IN_ONE_DAY) * 1000,
transition: Ci.nsINavHistoryService.TRANSITION_LINK
});
let numMessages = 10;
let updates = 0;
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic, (subject, data) => {
updates++;
return updates == numMessages;
});
let modifications = 0;
let modifiedPromise = promiseObserverNotification(PushServiceComponent.subscriptionModifiedTopic, (subject, data) => {
// Each subscription should be modified twice: once to update the message
// count and last push time, and the second time to update the quota.
modifications++;
return modifications == numMessages * 2;
});
let updateQuotaPromise = new Promise((resolve, reject) => {
let quotaUpdateCount = 0;
PushService._updateQuotaTestCallback = function() {
quotaUpdateCount++;
if (quotaUpdateCount == numMessages) {
resolve();
}
};
});
PushService.init({
serverURI: 'wss://push.example.org/',
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
// If the origin has visible notifications, the
// message should not affect quota.
for (let version = 1; version <= 10; version++) {
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: 'f56645a9-1f32-4655-92ad-ddc37f6d54fb',
version,
}],
}));
}
},
onUnregister(request) {
ok(false, "Channel should not be unregistered.");
},
// We expect to receive acks, but don't care about their
// contents.
onACK(request) {},
});
},
});
yield notifyPromise;
yield updateQuotaPromise;
yield modifiedPromise;
let expiredRecord = yield db.getByKeyID('f56645a9-1f32-4655-92ad-ddc37f6d54fb');
notStrictEqual(expiredRecord.quota, 0, 'Expired record not updated');
});

View file

@ -0,0 +1,73 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
function run_test() {
do_get_profile();
setPrefs({
requestTimeout: 10000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_reconnect_retry() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let registers = 0;
let channelID;
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: '083e6c17-1063-4677-8638-ab705aebebc2'
}));
},
onRegister(request) {
registers++;
if (registers == 1) {
channelID = request.channelID;
this.serverClose();
return;
}
if (registers == 2) {
equal(request.channelID, channelID,
'Should retry registers after reconnect');
}
this.serverSendMsg(JSON.stringify({
messageType: 'register',
channelID: request.channelID,
pushEndpoint: 'https://example.org/push/' + request.channelID,
status: 200,
}));
}
});
}
});
let registration = yield PushService.register({
scope: 'https://example.com/page/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
let retryEndpoint = 'https://example.org/push/' + channelID;
equal(registration.endpoint, retryEndpoint, 'Wrong endpoint for retried request');
registration = yield PushService.register({
scope: 'https://example.com/page/2',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
notEqual(registration.endpoint, retryEndpoint, 'Wrong endpoint for new request');
equal(registers, 3, 'Wrong registration count');
});

View file

@ -0,0 +1,93 @@
'use strict';
const {PushRecord} = Cu.import('resource://gre/modules/PushRecord.jsm', {});
function run_test() {
run_next_test();
}
add_task(function* test_updateQuota() {
let record = new PushRecord({
quota: 8,
lastPush: Date.now() - 1 * MS_IN_ONE_DAY,
});
record.updateQuota(Date.now() - 2 * MS_IN_ONE_DAY);
equal(record.quota, 8,
'Should not update quota if last visit is older than last push');
record.updateQuota(Date.now());
equal(record.quota, 16,
'Should reset quota if last visit is newer than last push');
record.reduceQuota();
equal(record.quota, 15, 'Should reduce quota');
// Make sure we calculate the quota correctly for visit dates in the
// future (bug 1206424).
record.updateQuota(Date.now() + 1 * MS_IN_ONE_DAY);
equal(record.quota, 16,
'Should reset quota to maximum if last visit is in the future');
record.updateQuota(-1);
strictEqual(record.quota, 0, 'Should set quota to 0 if history was cleared');
ok(record.isExpired(), 'Should expire records once the quota reaches 0');
record.reduceQuota();
strictEqual(record.quota, 0, 'Quota should never be negative');
});
add_task(function* test_systemRecord_updateQuota() {
let systemRecord = new PushRecord({
quota: Infinity,
systemRecord: true,
});
systemRecord.updateQuota(Date.now() - 3 * MS_IN_ONE_DAY);
equal(systemRecord.quota, Infinity,
'System subscriptions should ignore quota updates');
systemRecord.updateQuota(-1);
equal(systemRecord.quota, Infinity,
'System subscriptions should ignore the last visit time');
systemRecord.reduceQuota();
equal(systemRecord.quota, Infinity,
'System subscriptions should ignore quota reductions');
});
function testPermissionCheck(props) {
let record = new PushRecord(props);
equal(record.uri.spec, props.scope,
`Record URI should match scope URL for ${JSON.stringify(props)}`);
if (props.originAttributes) {
let originSuffix = ChromeUtils.originAttributesToSuffix(
record.principal.originAttributes);
equal(originSuffix, props.originAttributes,
`Origin suffixes should match for ${JSON.stringify(props)}`);
}
ok(!record.hasPermission(), `Record ${
JSON.stringify(props)} should not have permission yet`);
let permURI = Services.io.newURI(props.scope, null, null);
Services.perms.add(permURI, 'desktop-notification',
Ci.nsIPermissionManager.ALLOW_ACTION);
try {
ok(record.hasPermission(), `Record ${
JSON.stringify(props)} should have permission`);
} finally {
Services.perms.remove(permURI, 'desktop-notification');
}
}
add_task(function* test_principal_permissions() {
let testProps = [{
scope: 'https://example.com/',
}, {
scope: 'https://example.com/',
originAttributes: '^userContextId=1',
}, {
scope: 'https://блог.фанфрог.рф/',
}, {
scope: 'https://блог.фанфрог.рф/',
originAttributes: '^userContextId=1',
}];
for (let props of testProps) {
testPermissionCheck(props);
}
});

View file

@ -0,0 +1,112 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://testing-common/httpd.js");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var httpServer = null;
XPCOMUtils.defineLazyGetter(this, "serverPort", function() {
return httpServer.identity.primaryPort;
});
var retries = 0
function subscribe5xxCodeHandler(metadata, response) {
if (retries == 0) {
ok(true, "Subscribe 5xx code");
do_test_finished();
response.setHeader("Retry-After", '1');
response.setStatusLine(metadata.httpVersion, 500, "Retry");
} else {
ok(true, "Subscribed");
do_test_finished();
response.setHeader("Location",
'http://localhost:' + serverPort + '/subscription')
response.setHeader("Link",
'</pushEndpoint>; rel="urn:ietf:params:push", ' +
'</receiptPushEndpoint>; rel="urn:ietf:params:push:receipt"');
response.setStatusLine(metadata.httpVersion, 201, "OK");
}
retries++;
}
function listenSuccessHandler(metadata, response) {
do_check_true(true, "New listener point");
ok(retries == 2, "Should try 2 times.");
do_test_finished();
response.setHeader("Retry-After", '10');
response.setStatusLine(metadata.httpVersion, 500, "Retry");
}
httpServer = new HttpServer();
httpServer.registerPathHandler("/subscribe5xxCode", subscribe5xxCodeHandler);
httpServer.registerPathHandler("/subscription", listenSuccessHandler);
httpServer.start(-1);
function run_test() {
do_get_profile();
setPrefs({
'testing.allowInsecureServerURL': true,
'http2.retryInterval': 1000,
'http2.maxRetries': 2
});
run_next_test();
}
add_task(function* test1() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
do_test_pending();
do_test_pending();
do_test_pending();
do_test_pending();
var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
PushService.init({
serverURI: serverURL + "/subscribe5xxCode",
db
});
let originAttributes = ChromeUtils.originAttributesToSuffix({
appId: Ci.nsIScriptSecurityManager.NO_APP_ID,
inIsolatedMozBrowser: false,
});
let newRecord = yield PushService.register({
scope: 'https://example.com/retry5xxCode',
originAttributes: originAttributes,
});
var subscriptionUri = serverURL + '/subscription';
var pushEndpoint = serverURL + '/pushEndpoint';
var pushReceiptEndpoint = serverURL + '/receiptPushEndpoint';
equal(newRecord.endpoint, pushEndpoint,
'Wrong push endpoint in registration record');
equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint,
'Wrong push endpoint receipt in registration record');
let record = yield db.getByKeyID(subscriptionUri);
equal(record.subscriptionUri, subscriptionUri,
'Wrong subscription ID in database record');
equal(record.pushEndpoint, pushEndpoint,
'Wrong push endpoint in database record');
equal(record.pushReceiptEndpoint, pushReceiptEndpoint,
'Wrong push endpoint receipt in database record');
equal(record.scope, 'https://example.com/retry5xxCode',
'Wrong scope in database record');
httpServer.stop(do_test_finished);
});

View file

@ -0,0 +1,56 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '1760b1f5-c3ba-40e3-9344-adef7c18ab12';
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_register_case() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'HELLO',
uaid: userAgentID,
status: 200
}));
},
onRegister(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'ReGiStEr',
uaid: userAgentID,
channelID: request.channelID,
status: 200,
pushEndpoint: 'https://example.com/update/case'
}));
}
});
}
});
let newRecord = yield PushService.register({
scope: 'https://example.net/case',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
equal(newRecord.endpoint, 'https://example.com/update/case',
'Wrong push endpoint in registration record');
let record = yield db.getByPushEndpoint('https://example.com/update/case');
equal(record.scope, 'https://example.net/case',
'Wrong scope in database record');
});

View file

@ -0,0 +1,201 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var prefs;
var tlsProfile;
var serverURL;
var serverPort = -1;
function run_test() {
serverPort = getTestServerPort();
do_get_profile();
prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
serverURL = "https://localhost:" + serverPort;
run_next_test();
}
// Connection will fail because of the certificates.
add_task(function* test_pushSubscriptionNoConnection() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
PushService.init({
serverURI: serverURL + "/pushSubscriptionNoConnection/subscribe",
db
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-response',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for not being able to establish connecion.'
);
let record = yield db.getAllKeyIDs();
ok(record.length === 0, "Should not store records when connection couldn't be established.");
PushService.uninit();
});
add_task(function* test_TLS() {
// Set to allow the cert presented by our H2 server
var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
prefs.setIntPref("network.http.speculative-parallel-limit", 0);
prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
addCertOverride("localhost", serverPort,
Ci.nsICertOverrideService.ERROR_UNTRUSTED |
Ci.nsICertOverrideService.ERROR_MISMATCH |
Ci.nsICertOverrideService.ERROR_TIME);
prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
});
add_task(function* test_pushSubscriptionMissingLocation() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
PushService.init({
serverURI: serverURL + "/pushSubscriptionMissingLocation/subscribe",
db
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-response',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for the missing location header.'
);
let record = yield db.getAllKeyIDs();
ok(record.length === 0, 'Should not store records when the location header is missing.');
PushService.uninit();
});
add_task(function* test_pushSubscriptionMissingLink() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
PushService.init({
serverURI: serverURL + "/pushSubscriptionMissingLink/subscribe",
db
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-response',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for the missing link header.'
);
let record = yield db.getAllKeyIDs();
ok(record.length === 0, 'Should not store records when a link header is missing.');
PushService.uninit();
});
add_task(function* test_pushSubscriptionMissingLink1() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
PushService.init({
serverURI: serverURL + "/pushSubscriptionMissingLink1/subscribe",
db
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-response',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for the missing push endpoint.'
);
let record = yield db.getAllKeyIDs();
ok(record.length === 0, 'Should not store records when the push endpoint is missing.');
PushService.uninit();
});
add_task(function* test_pushSubscriptionLocationBogus() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
PushService.init({
serverURI: serverURL + "/pushSubscriptionLocationBogus/subscribe",
db
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-response',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for the bogus location'
);
let record = yield db.getAllKeyIDs();
ok(record.length === 0, 'Should not store records when location header is bogus.');
PushService.uninit();
});
add_task(function* test_pushSubscriptionNot2xxCode() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
PushService.init({
serverURI: serverURL + "/pushSubscriptionNot201Code/subscribe",
db
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-response',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for not 201 responce code.'
);
let record = yield db.getAllKeyIDs();
ok(record.length === 0, 'Should not store records when respons code is not 201.');
});
add_task(function* test_complete() {
prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
});

View file

@ -0,0 +1,96 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '9ce1e6d3-7bdb-4fe9-90a5-def1d64716f1';
const channelID = 'c26892c5-6e08-4c16-9f0c-0044697b4d85';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_flush() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let record = {
channelID: '9bcc7efb-86c7-4457-93ea-e24e6eb59b74',
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.com/page/1',
originAttributes: '',
version: 2,
quota: Infinity,
systemRecord: true,
};
yield db.put(record);
let notifyPromise = promiseObserverNotification(PushServiceComponent.pushTopic);
let ackDone;
let ackPromise = new Promise(resolve => ackDone = after(2, resolve));
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
},
onRegister(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'notification',
updates: [{
channelID: request.channelID,
version: 2
}, {
channelID: '9bcc7efb-86c7-4457-93ea-e24e6eb59b74',
version: 3
}]
}));
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
channelID: request.channelID,
uaid: userAgentID,
pushEndpoint: 'https://example.org/update/2'
}));
},
onACK: ackDone
});
}
});
let newRecord = yield PushService.register({
scope: 'https://example.com/page/2',
originAttributes: '',
});
equal(newRecord.endpoint, 'https://example.org/update/2',
'Wrong push endpoint in record');
let {data: scope} = yield notifyPromise;
equal(scope, 'https://example.com/page/1', 'Wrong notification scope');
yield ackPromise;
let prevRecord = yield db.getByKeyID(
'9bcc7efb-86c7-4457-93ea-e24e6eb59b74');
equal(prevRecord.pushEndpoint, 'https://example.org/update/1',
'Wrong existing push endpoint');
strictEqual(prevRecord.version, 3,
'Should record version updates sent before register responses');
let registeredRecord = yield db.getByPushEndpoint('https://example.org/update/2');
ok(!registeredRecord.version, 'Should not record premature updates');
});

View file

@ -0,0 +1,57 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '52b2b04c-b6cc-42c6-abdf-bef9cbdbea00';
const channelID = 'cafed00d';
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_register_invalid_channel() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
uaid: userAgentID,
status: 200
}));
},
onRegister(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 403,
channelID,
error: 'Invalid channel ID'
}));
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.com/invalid-channel',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for invalid channel ID'
);
let record = yield db.getByKeyID(channelID);
ok(!record, 'Should not store records for error responses');
});

View file

@ -0,0 +1,58 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = 'c9a12e81-ea5e-40f9-8bf4-acee34621671';
const channelID = 'c0660af8-b532-4931-81f0-9fd27a12d6ab';
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_register_invalid_endpoint() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
},
onRegister(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
channelID,
uaid: userAgentID,
pushEndpoint: '!@#$%^&*'
}));
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-endpoint',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for invalid endpoint'
);
let record = yield db.getByKeyID(channelID);
ok(!record, 'Should not store records with invalid endpoints');
});

View file

@ -0,0 +1,58 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '8271186b-8073-43a3-adf6-225bd44a8b0a';
const channelID = '2d08571e-feab-48a0-9f05-8254c3c7e61f';
function run_test() {
do_get_profile();
setPrefs({
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_invalid_json() {
let helloDone;
let helloPromise = new Promise(resolve => helloDone = after(2, resolve));
let registers = 0;
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
helloDone();
},
onRegister(request) {
equal(request.channelID, channelID, 'Register: wrong channel ID');
this.serverSendMsg(');alert(1);(');
registers++;
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/invalid-json',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for invalid JSON response'
);
yield helloPromise;
equal(registers, 1, 'Wrong register count');
});

View file

@ -0,0 +1,62 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
var userAgentID = '9a2f9efe-2ebb-4bcb-a5d9-9e2b73d30afe';
var channelID = '264c2ba0-f6db-4e84-acdb-bd225b62d9e3';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_no_id() {
let registers = 0;
let helloDone;
let helloPromise = new Promise(resolve => helloDone = after(2, resolve));
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
helloDone();
},
onRegister(request) {
registers++;
equal(request.channelID, channelID, 'Register: wrong channel ID');
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200
}));
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.com/incomplete',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for incomplete register response'
);
yield helloPromise;
equal(registers, 1, 'Wrong register count');
});

View file

@ -0,0 +1,61 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
function run_test() {
do_get_profile();
setPrefs({
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_request_queue() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let onHello;
let helloPromise = new Promise(resolve => onHello = after(2, function onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: '54b08a9e-59c6-4ed7-bb54-f4fd60d6f606'
}));
resolve();
}));
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello,
onRegister() {
ok(false, 'Should cancel timed-out requests');
}
});
}
});
let firstRegister = PushService.register({
scope: 'https://example.com/page/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
let secondRegister = PushService.register({
scope: 'https://example.com/page/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
yield Promise.all([
rejects(firstRegister, 'Should time out the first request'),
rejects(secondRegister, 'Should time out the second request')
]);
yield helloPromise;
});

View file

@ -0,0 +1,87 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = 'b2546987-4f63-49b1-99f7-739cd3c40e44';
const channelID = '35a820f7-d7dd-43b3-af21-d65352212ae3';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_rollback() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let handshakes = 0;
let registers = 0;
let unregisterDone;
let unregisterPromise = new Promise(resolve => unregisterDone = resolve);
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
db: makeStub(db, {
put(prev, record) {
return Promise.reject('universe has imploded');
}
}),
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
handshakes++;
equal(request.uaid, userAgentID, 'Handshake: wrong device ID');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
},
onRegister(request) {
equal(request.channelID, channelID, 'Register: wrong channel ID');
registers++;
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
uaid: userAgentID,
channelID,
pushEndpoint: 'https://example.com/update/rollback'
}));
},
onUnregister(request) {
equal(request.channelID, channelID, 'Unregister: wrong channel ID');
equal(request.code, 200, 'Expected manual unregister reason');
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
status: 200,
channelID
}));
unregisterDone();
}
});
}
});
// Should return a rejected promise if storage fails.
yield rejects(
PushService.register({
scope: 'https://example.com/storage-error',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for unregister database failure'
);
// Should send an out-of-band unregister request.
yield unregisterPromise;
equal(handshakes, 1, 'Wrong handshake count');
equal(registers, 1, 'Wrong register count');
});

View file

@ -0,0 +1,77 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = 'bd744428-f125-436a-b6d0-dd0c9845837f';
const channelID = '0ef2ad4a-6c49-41ad-af6e-95d2425276bf';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_success() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(data) {
equal(data.messageType, 'hello', 'Handshake: wrong message type');
equal(data.uaid, userAgentID, 'Handshake: wrong device ID');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
},
onRegister(data) {
equal(data.messageType, 'register', 'Register: wrong message type');
equal(data.channelID, channelID, 'Register: wrong channel ID');
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
channelID: channelID,
uaid: userAgentID,
pushEndpoint: 'https://example.com/update/1',
}));
}
});
}
});
let subModifiedPromise = promiseObserverNotification(
PushServiceComponent.subscriptionModifiedTopic);
let newRecord = yield PushService.register({
scope: 'https://example.org/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
equal(newRecord.endpoint, 'https://example.com/update/1',
'Wrong push endpoint in registration record');
let {data: subModifiedScope} = yield subModifiedPromise;
equal(subModifiedScope, 'https://example.org/1',
'Should fire a subscription modified event after subscribing');
let record = yield db.getByKeyID(channelID);
equal(record.channelID, channelID,
'Wrong channel ID in database record');
equal(record.pushEndpoint, 'https://example.com/update/1',
'Wrong push endpoint in database record');
equal(record.quota, 16,
'Wrong quota in database record');
});

View file

@ -0,0 +1,128 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var prefs;
var tlsProfile;
var serverURL;
var serverPort = -1;
var pushEnabled;
var pushConnectionEnabled;
var db;
function run_test() {
serverPort = getTestServerPort();
do_get_profile();
prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
tlsProfile = prefs.getBoolPref("network.http.spdy.enforce-tls-profile");
pushEnabled = prefs.getBoolPref("dom.push.enabled");
pushConnectionEnabled = prefs.getBoolPref("dom.push.connection.enabled");
// Set to allow the cert presented by our H2 server
var oldPref = prefs.getIntPref("network.http.speculative-parallel-limit");
prefs.setIntPref("network.http.speculative-parallel-limit", 0);
prefs.setBoolPref("network.http.spdy.enforce-tls-profile", false);
prefs.setBoolPref("dom.push.enabled", true);
prefs.setBoolPref("dom.push.connection.enabled", true);
addCertOverride("localhost", serverPort,
Ci.nsICertOverrideService.ERROR_UNTRUSTED |
Ci.nsICertOverrideService.ERROR_MISMATCH |
Ci.nsICertOverrideService.ERROR_TIME);
prefs.setIntPref("network.http.speculative-parallel-limit", oldPref);
serverURL = "https://localhost:" + serverPort;
run_next_test();
}
add_task(function* test_setup() {
db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
});
add_task(function* test_pushSubscriptionSuccess() {
PushService.init({
serverURI: serverURL + "/pushSubscriptionSuccess/subscribe",
db
});
let newRecord = yield PushService.register({
scope: 'https://example.org/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
var subscriptionUri = serverURL + '/pushSubscriptionSuccesss';
var pushEndpoint = serverURL + '/pushEndpointSuccess';
var pushReceiptEndpoint = serverURL + '/receiptPushEndpointSuccess';
equal(newRecord.endpoint, pushEndpoint,
'Wrong push endpoint in registration record');
equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint,
'Wrong push endpoint receipt in registration record');
let record = yield db.getByKeyID(subscriptionUri);
equal(record.subscriptionUri, subscriptionUri,
'Wrong subscription ID in database record');
equal(record.pushEndpoint, pushEndpoint,
'Wrong push endpoint in database record');
equal(record.pushReceiptEndpoint, pushReceiptEndpoint,
'Wrong push endpoint receipt in database record');
equal(record.scope, 'https://example.org/1',
'Wrong scope in database record');
PushService.uninit()
});
add_task(function* test_pushSubscriptionMissingLink2() {
PushService.init({
serverURI: serverURL + "/pushSubscriptionMissingLink2/subscribe",
db
});
let newRecord = yield PushService.register({
scope: 'https://example.org/no_receiptEndpoint',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
var subscriptionUri = serverURL + '/subscriptionMissingLink2';
var pushEndpoint = serverURL + '/pushEndpointMissingLink2';
var pushReceiptEndpoint = '';
equal(newRecord.endpoint, pushEndpoint,
'Wrong push endpoint in registration record');
equal(newRecord.pushReceiptEndpoint, pushReceiptEndpoint,
'Wrong push endpoint receipt in registration record');
let record = yield db.getByKeyID(subscriptionUri);
equal(record.subscriptionUri, subscriptionUri,
'Wrong subscription ID in database record');
equal(record.pushEndpoint, pushEndpoint,
'Wrong push endpoint in database record');
equal(record.pushReceiptEndpoint, pushReceiptEndpoint,
'Wrong push endpoint receipt in database record');
equal(record.scope, 'https://example.org/no_receiptEndpoint',
'Wrong scope in database record');
});
add_task(function* test_complete() {
prefs.setBoolPref("network.http.spdy.enforce-tls-profile", tlsProfile);
prefs.setBoolPref("dom.push.enabled", pushEnabled);
prefs.setBoolPref("dom.push.connection.enabled", pushConnectionEnabled);
});

View file

@ -0,0 +1,87 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = 'a4be0df9-b16d-4b5f-8f58-0f93b6f1e23d';
const channelID = 'e1944e0b-48df-45e7-bdc0-d1fbaa7986d3';
function run_test() {
do_get_profile();
setPrefs({
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_timeout() {
let handshakes = 0;
let timeoutDone;
let timeoutPromise = new Promise(resolve => timeoutDone = resolve);
let registers = 0;
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushServiceWebSocket._generateID = () => channelID;
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
if (handshakes === 0) {
equal(request.uaid, null, 'Should not include device ID');
} else if (handshakes === 1) {
// Should use the previously-issued device ID when reconnecting,
// but should not include the timed-out channel ID.
equal(request.uaid, userAgentID,
'Should include device ID on reconnect');
} else {
ok(false, 'Unexpected reconnect attempt ' + handshakes);
}
handshakes++;
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
},
onRegister(request) {
equal(request.channelID, channelID,
'Wrong channel ID in register request');
setTimeout(() => {
// Should ignore replies for timed-out requests.
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
channelID: channelID,
uaid: userAgentID,
pushEndpoint: 'https://example.com/update/timeout',
}));
timeoutDone();
}, 2000);
registers++;
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.net/page/timeout',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for request timeout'
);
let record = yield db.getByKeyID(channelID);
ok(!record, 'Should not store records for timed-out responses');
yield timeoutPromise;
equal(registers, 1, 'Should not handle timed-out register requests');
});

View file

@ -0,0 +1,68 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '84afc774-6995-40d1-9c90-8c34ddcd0cb4';
const clientChannelID = '4b42a681c99e4dfbbb166a7e01a09b8b';
const serverChannelID = '3f5aeb89c6e8405a9569619522783436';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_wrong_id() {
// Should reconnect after the register request times out.
let registers = 0;
let helloDone;
let helloPromise = new Promise(resolve => helloDone = after(2, resolve));
PushServiceWebSocket._generateID = () => clientChannelID;
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
helloDone();
},
onRegister(request) {
equal(request.channelID, clientChannelID,
'Register: wrong channel ID');
registers++;
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
// Reply with a different channel ID. Since the ID is used as a
// nonce, the registration request will time out.
channelID: serverChannelID
}));
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.com/mismatched',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for mismatched register reply'
);
yield helloPromise;
equal(registers, 1, 'Wrong register count');
});

View file

@ -0,0 +1,62 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService} = serviceExports;
const userAgentID = 'c293fdc5-a75e-4eb1-af88-a203991c0787';
function run_test() {
do_get_profile();
setPrefs({
requestTimeout: 1000,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_register_wrong_type() {
let registers = 0;
let helloDone;
let helloPromise = new Promise(resolve => helloDone = after(2, resolve));
PushService._generateID = () => '1234';
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
helloDone();
},
onRegister(request) {
registers++;
this.serverSendMsg(JSON.stringify({
messageType: 'register',
status: 200,
channelID: 1234,
uaid: userAgentID,
pushEndpoint: 'https://example.org/update/wrong-type'
}));
}
});
}
});
yield rejects(
PushService.register({
scope: 'https://example.com/mistyped',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for non-string channel ID'
);
yield helloPromise;
equal(registers, 1, 'Wrong register count');
});

View file

@ -0,0 +1,43 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
function run_test() {
do_get_profile();
setPrefs({
userAgentID: '6faed1f0-1439-4aac-a978-db21c81cd5eb'
});
run_next_test();
}
add_task(function* test_registrations_error() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushService.init({
serverURI: "wss://push.example.org/",
db: makeStub(db, {
getByIdentifiers(prev, scope) {
return Promise.reject('Database error');
}
}),
makeWebSocket(uri) {
return new MockWebSocket(uri);
}
});
yield rejects(
PushService.registration({
scope: 'https://example.net/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
function(error) {
return error == 'Database error';
},
'Wrong message'
);
});

View file

@ -0,0 +1,37 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
function run_test() {
do_get_profile();
run_next_test();
}
add_task(function* test_registrations_error() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushService.init({
serverURI: "https://push.example.org/",
db: makeStub(db, {
getByIdentifiers() {
return Promise.reject('Database error');
}
}),
});
yield rejects(
PushService.registration({
scope: 'https://example.net/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
function(error) {
return error == 'Database error';
},
'Wrong message'
);
});

View file

@ -0,0 +1,25 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService} = serviceExports;
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_registration_missing_scope() {
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri);
}
});
yield rejects(
PushService.registration({ scope: '', originAttributes: '' }),
'Record missing page and manifest URLs'
);
});

View file

@ -0,0 +1,31 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService} = serviceExports;
const userAgentID = 'a722e448-c481-4c48-aea0-fc411cb7c9ed';
function run_test() {
do_get_profile();
setPrefs({userAgentID});
run_next_test();
}
// Should not open a connection if the client has no registrations.
add_task(function* test_registration_none() {
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri);
}
});
let registration = yield PushService.registration({
scope: 'https://example.net/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
ok(!registration, 'Should not open a connection without registration');
});

View file

@ -0,0 +1,78 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '997ee7ba-36b1-4526-ae9e-2d3f38d6efe8';
function run_test() {
do_get_profile();
setPrefs({userAgentID});
run_next_test();
}
add_task(function* test_registration_success() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let records = [{
channelID: 'bf001fe0-2684-42f2-bc4d-a3e14b11dd5b',
pushEndpoint: 'https://example.com/update/same-manifest/1',
scope: 'https://example.net/a',
originAttributes: '',
version: 5,
quota: Infinity,
}, {
channelID: 'f6edfbcd-79d6-49b8-9766-48b9dcfeff0f',
pushEndpoint: 'https://example.com/update/same-manifest/2',
scope: 'https://example.net/b',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: 42 }),
version: 10,
quota: Infinity,
}, {
channelID: 'b1cf38c9-6836-4d29-8a30-a3e98d59b728',
pushEndpoint: 'https://example.org/update/different-manifest',
scope: 'https://example.org/c',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: 42, inIsolatedMozBrowser: true }),
version: 15,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
let handshakeDone;
let handshakePromise = new Promise(resolve => handshakeDone = resolve);
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
equal(request.uaid, userAgentID, 'Wrong device ID in handshake');
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID
}));
handshakeDone();
}
});
}
});
yield handshakePromise;
let registration = yield PushService.registration({
scope: 'https://example.net/a',
originAttributes: '',
});
equal(
registration.endpoint,
'https://example.com/update/same-manifest/1',
'Wrong push endpoint for scope'
);
equal(registration.version, 5, 'Wrong version for scope');
});

View file

@ -0,0 +1,77 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var prefs;
var serverPort = -1;
function run_test() {
serverPort = getTestServerPort();
do_get_profile();
prefs = Cc["@mozilla.org/preferences-service;1"].getService(Ci.nsIPrefBranch);
run_next_test();
}
add_task(function* test_pushNotifications() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
var serverURL = "https://localhost:" + serverPort;
let records = [{
subscriptionUri: serverURL + '/subscriptionA',
pushEndpoint: serverURL + '/pushEndpointA',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpointA',
scope: 'https://example.net/a',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
}, {
subscriptionUri: serverURL + '/subscriptionB',
pushEndpoint: serverURL + '/pushEndpointB',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpointB',
scope: 'https://example.net/b',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
}, {
subscriptionUri: serverURL + '/subscriptionC',
pushEndpoint: serverURL + '/pushEndpointC',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpointC',
scope: 'https://example.net/c',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
PushService.init({
serverURI: serverURL,
db
});
let registration = yield PushService.registration({
scope: 'https://example.net/a',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
equal(
registration.endpoint,
serverURL + '/pushEndpointA',
'Wrong push endpoint for scope'
);
});

View file

@ -0,0 +1,103 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://testing-common/httpd.js");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var httpServer = null;
XPCOMUtils.defineLazyGetter(this, "serverPort", function() {
return httpServer.identity.primaryPort;
});
var handlerDone;
var handlerPromise = new Promise(r => handlerDone = after(3, r));
function listen4xxCodeHandler(metadata, response) {
ok(true, "Listener point error")
handlerDone();
response.setStatusLine(metadata.httpVersion, 410, "GONE");
}
function resubscribeHandler(metadata, response) {
ok(true, "Ask for new subscription");
handlerDone();
response.setHeader("Location",
'http://localhost:' + serverPort + '/newSubscription')
response.setHeader("Link",
'</newPushEndpoint>; rel="urn:ietf:params:push", ' +
'</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"');
response.setStatusLine(metadata.httpVersion, 201, "OK");
}
function listenSuccessHandler(metadata, response) {
do_check_true(true, "New listener point");
httpServer.stop(handlerDone);
response.setStatusLine(metadata.httpVersion, 204, "Try again");
}
httpServer = new HttpServer();
httpServer.registerPathHandler("/subscription4xxCode", listen4xxCodeHandler);
httpServer.registerPathHandler("/subscribe", resubscribeHandler);
httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
httpServer.start(-1);
function run_test() {
do_get_profile();
setPrefs({
'testing.allowInsecureServerURL': true,
'testing.notifyWorkers': false,
'testing.notifyAllObservers': true,
});
run_next_test();
}
add_task(function* test1() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
let records = [{
subscriptionUri: serverURL + '/subscription4xxCode',
pushEndpoint: serverURL + '/pushEndpoint',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
scope: 'https://example.com/page',
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
PushService.init({
serverURI: serverURL + "/subscribe",
db
});
yield handlerPromise;
let record = yield db.getByIdentifiers({
scope: 'https://example.com/page',
originAttributes: '',
});
equal(record.keyID, serverURL + '/newSubscription',
'Should update subscription URL');
equal(record.pushEndpoint, serverURL + '/newPushEndpoint',
'Should update push endpoint');
equal(record.pushReceiptEndpoint, serverURL + '/newReceiptPushEndpoint',
'Should update push receipt endpoint');
});

View file

@ -0,0 +1,106 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://testing-common/httpd.js");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var httpServer = null;
XPCOMUtils.defineLazyGetter(this, "serverPort", function() {
return httpServer.identity.primaryPort;
});
var retries = 0;
var handlerDone;
var handlerPromise = new Promise(r => handlerDone = after(5, r));
function listen5xxCodeHandler(metadata, response) {
ok(true, "Listener 5xx code");
handlerDone();
retries++;
response.setHeader("Retry-After", '1');
response.setStatusLine(metadata.httpVersion, 500, "Retry");
}
function resubscribeHandler(metadata, response) {
ok(true, "Ask for new subscription");
ok(retries == 3, "Should retry 2 times.");
handlerDone();
response.setHeader("Location",
'http://localhost:' + serverPort + '/newSubscription')
response.setHeader("Link",
'</newPushEndpoint>; rel="urn:ietf:params:push", ' +
'</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"');
response.setStatusLine(metadata.httpVersion, 201, "OK");
}
function listenSuccessHandler(metadata, response) {
do_check_true(true, "New listener point");
httpServer.stop(handlerDone);
response.setStatusLine(metadata.httpVersion, 204, "Try again");
}
httpServer = new HttpServer();
httpServer.registerPathHandler("/subscription5xxCode", listen5xxCodeHandler);
httpServer.registerPathHandler("/subscribe", resubscribeHandler);
httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
httpServer.start(-1);
function run_test() {
do_get_profile();
setPrefs({
'testing.allowInsecureServerURL': true,
'http2.retryInterval': 1000,
'http2.maxRetries': 2
});
run_next_test();
}
add_task(function* test1() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
let records = [{
subscriptionUri: serverURL + '/subscription5xxCode',
pushEndpoint: serverURL + '/pushEndpoint',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
scope: 'https://example.com/page',
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
PushService.init({
serverURI: serverURL + "/subscribe",
db
});
yield handlerPromise;
let record = yield db.getByIdentifiers({
scope: 'https://example.com/page',
originAttributes: '',
});
equal(record.keyID, serverURL + '/newSubscription',
'Should update subscription URL');
equal(record.pushEndpoint, serverURL + '/newPushEndpoint',
'Should update push endpoint');
equal(record.pushReceiptEndpoint, serverURL + '/newReceiptPushEndpoint',
'Should update push receipt endpoint');
});

View file

@ -0,0 +1,105 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://testing-common/httpd.js");
const {PushDB, PushService, PushServiceHttp2} = serviceExports;
var httpServer = null;
XPCOMUtils.defineLazyGetter(this, "serverPort", function() {
return httpServer.identity.primaryPort;
});
var handlerDone;
var handlerPromise = new Promise(r => handlerDone = after(2, r));
function resubscribeHandler(metadata, response) {
ok(true, "Ask for new subscription");
handlerDone();
response.setHeader("Location",
'http://localhost:' + serverPort + '/newSubscription')
response.setHeader("Link",
'</newPushEndpoint>; rel="urn:ietf:params:push", ' +
'</newReceiptPushEndpoint>; rel="urn:ietf:params:push:receipt"');
response.setStatusLine(metadata.httpVersion, 201, "OK");
}
function listenSuccessHandler(metadata, response) {
do_check_true(true, "New listener point");
httpServer.stop(handlerDone);
response.setStatusLine(metadata.httpVersion, 204, "Try again");
}
httpServer = new HttpServer();
httpServer.registerPathHandler("/subscribe", resubscribeHandler);
httpServer.registerPathHandler("/newSubscription", listenSuccessHandler);
httpServer.start(-1);
function run_test() {
do_get_profile();
setPrefs({
'testing.allowInsecureServerURL': true,
'http2.retryInterval': 1000,
'http2.maxRetries': 2
});
run_next_test();
}
add_task(function* test1() {
let db = PushServiceHttp2.newPushDB();
do_register_cleanup(() => {
return db.drop().then(_ => db.close());
});
var serverURL = "http://localhost:" + httpServer.identity.primaryPort;
let records = [{
subscriptionUri: 'http://localhost/subscriptionNotExist',
pushEndpoint: serverURL + '/pushEndpoint',
pushReceiptEndpoint: serverURL + '/pushReceiptEndpoint',
scope: 'https://example.com/page',
p256dhPublicKey: 'BPCd4gNQkjwRah61LpdALdzZKLLnU5UAwDztQ5_h0QsT26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA',
p256dhPrivateKey: {
crv: 'P-256',
d: '1jUPhzVsRkzV0vIzwL4ZEsOlKdNOWm7TmaTfzitJkgM',
ext: true,
key_ops: ["deriveBits"],
kty: "EC",
x: '8J3iA1CSPBFqHrUul0At3NkosudTlQDAPO1Dn-HRCxM',
y: '26jk0IFbqcK6-JxhHAm-rsHEwy0CyVJjtnfOcqc1tgA'
},
originAttributes: '',
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
PushService.init({
serverURI: serverURL + "/subscribe",
db
});
yield handlerPromise;
let record = yield db.getByIdentifiers({
scope: 'https://example.com/page',
originAttributes: '',
});
equal(record.keyID, serverURL + '/newSubscription',
'Should update subscription URL');
equal(record.pushEndpoint, serverURL + '/newPushEndpoint',
'Should update push endpoint');
equal(record.pushReceiptEndpoint, serverURL + '/newReceiptPushEndpoint',
'Should update push receipt endpoint');
});

View file

@ -0,0 +1,69 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '05f7b940-51b6-4b6f-8032-b83ebb577ded';
function run_test() {
do_get_profile();
setPrefs({
userAgentID: userAgentID,
pingInterval: 2000,
retryBaseInterval: 25,
});
run_next_test();
}
add_task(function* test_ws_retry() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
yield db.put({
channelID: '61770ba9-2d57-4134-b949-d40404630d5b',
pushEndpoint: 'https://example.org/push/1',
scope: 'https://example.net/push/1',
version: 1,
originAttributes: '',
quota: Infinity,
});
// Use a mock timer to avoid waiting for the backoff interval.
let reconnects = 0;
PushServiceWebSocket._backoffTimer = {
init(observer, delay, type) {
reconnects++;
ok(delay >= 5 && delay <= 2000, `Backoff delay ${
delay} out of range for attempt ${reconnects}`);
observer.observe(this, "timer-callback", null);
},
cancel() {},
};
let handshakeDone;
let handshakePromise = new Promise(resolve => handshakeDone = resolve);
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
if (reconnects == 10) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
}));
handshakeDone();
return;
}
this.serverInterrupt();
},
});
},
});
yield handshakePromise;
});

View file

@ -0,0 +1,307 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
Cu.importGlobalProperties(["crypto"]);
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
var db;
function done() {
do_test_finished();
run_next_test();
}
function generateKey() {
return crypto.subtle.generateKey({
name: "ECDSA",
namedCurve: "P-256",
}, true, ["sign", "verify"]).then(cryptoKey =>
crypto.subtle.exportKey("raw", cryptoKey.publicKey)
).then(publicKey => new Uint8Array(publicKey));
}
function run_test() {
if (isParent) {
do_get_profile();
}
run_next_test();
}
if (isParent) {
add_test(function setUp() {
db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
setUpServiceInParent(PushService, db).then(run_next_test, run_next_test);
});
}
add_test(function test_subscribe_success() {
do_test_pending();
PushServiceComponent.subscribe(
'https://example.com/sub/ok',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, subscription) => {
ok(Components.isSuccessCode(result), 'Error creating subscription');
ok(subscription.isSystemSubscription, 'Expected system subscription');
ok(subscription.endpoint.startsWith('https://example.org/push'), 'Wrong endpoint prefix');
equal(subscription.pushCount, 0, 'Wrong push count');
equal(subscription.lastPush, 0, 'Wrong last push time');
equal(subscription.quota, -1, 'Wrong quota for system subscription');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_subscribeWithKey_error() {
do_test_pending();
let invalidKey = [0, 1];
PushServiceComponent.subscribeWithKey(
'https://example.com/sub-key/invalid',
Services.scriptSecurityManager.getSystemPrincipal(),
invalidKey.length,
invalidKey,
(result, subscription) => {
ok(!Components.isSuccessCode(result), 'Expected error creating subscription with invalid key');
equal(result, Cr.NS_ERROR_DOM_PUSH_INVALID_KEY_ERR, 'Wrong error code for invalid key');
strictEqual(subscription, null, 'Unexpected subscription');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_subscribeWithKey_success() {
do_test_pending();
generateKey().then(key => {
PushServiceComponent.subscribeWithKey(
'https://example.com/sub-key/ok',
Services.scriptSecurityManager.getSystemPrincipal(),
key.length,
key,
(result, subscription) => {
ok(Components.isSuccessCode(result), 'Error creating subscription with key');
notStrictEqual(subscription, null, 'Expected subscription');
done();
}
);
}, error => {
ok(false, "Error generating app server key");
done();
});
});
add_test(function test_subscribeWithKey_conflict() {
do_test_pending();
generateKey().then(differentKey => {
PushServiceComponent.subscribeWithKey(
'https://example.com/sub-key/ok',
Services.scriptSecurityManager.getSystemPrincipal(),
differentKey.length,
differentKey,
(result, subscription) => {
ok(!Components.isSuccessCode(result), 'Expected error creating subscription with conflicting key');
equal(result, Cr.NS_ERROR_DOM_PUSH_MISMATCHED_KEY_ERR, 'Wrong error code for mismatched key');
strictEqual(subscription, null, 'Unexpected subscription');
done();
}
);
}, error => {
ok(false, "Error generating different app server key");
done();
});
});
add_test(function test_subscribe_error() {
do_test_pending();
PushServiceComponent.subscribe(
'https://example.com/sub/fail',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, subscription) => {
ok(!Components.isSuccessCode(result), 'Expected error creating subscription');
strictEqual(subscription, null, 'Unexpected subscription');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_getSubscription_exists() {
do_test_pending();
PushServiceComponent.getSubscription(
'https://example.com/get/ok',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, subscription) => {
ok(Components.isSuccessCode(result), 'Error getting subscription');
equal(subscription.endpoint, 'https://example.org/push/get', 'Wrong endpoint');
equal(subscription.pushCount, 10, 'Wrong push count');
equal(subscription.lastPush, 1438360548322, 'Wrong last push');
equal(subscription.quota, 16, 'Wrong quota for subscription');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_getSubscription_missing() {
do_test_pending();
PushServiceComponent.getSubscription(
'https://example.com/get/missing',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, subscription) => {
ok(Components.isSuccessCode(result), 'Error getting nonexistent subscription');
strictEqual(subscription, null, 'Nonexistent subscriptions should return null');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_getSubscription_error() {
do_test_pending();
PushServiceComponent.getSubscription(
'https://example.com/get/fail',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, subscription) => {
ok(!Components.isSuccessCode(result), 'Expected error getting subscription');
strictEqual(subscription, null, 'Unexpected subscription');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_unsubscribe_success() {
do_test_pending();
PushServiceComponent.unsubscribe(
'https://example.com/unsub/ok',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, success) => {
ok(Components.isSuccessCode(result), 'Error unsubscribing');
strictEqual(success, true, 'Expected successful unsubscribe');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_unsubscribe_nonexistent() {
do_test_pending();
PushServiceComponent.unsubscribe(
'https://example.com/unsub/ok',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, success) => {
ok(Components.isSuccessCode(result), 'Error removing nonexistent subscription');
strictEqual(success, false, 'Nonexistent subscriptions should return false');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_unsubscribe_error() {
do_test_pending();
PushServiceComponent.unsubscribe(
'https://example.com/unsub/fail',
Services.scriptSecurityManager.getSystemPrincipal(),
(result, success) => {
ok(!Components.isSuccessCode(result), 'Expected error unsubscribing');
strictEqual(success, false, 'Unexpected successful unsubscribe');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_subscribe_app_principal() {
let principal = Services.scriptSecurityManager.getAppCodebasePrincipal(
Services.io.newURI('https://example.net/app/1', null, null),
1, /* appId */
true /* browserOnly */
);
do_test_pending();
PushServiceComponent.subscribe('https://example.net/scope/1', principal, (result, subscription) => {
ok(Components.isSuccessCode(result), 'Error creating subscription');
ok(subscription.endpoint.startsWith('https://example.org/push'),
'Wrong push endpoint in app subscription');
ok(!subscription.isSystemSubscription,
'Unexpected system subscription for app principal');
equal(subscription.quota, 16, 'Wrong quota for app subscription');
do_test_finished();
run_next_test();
});
});
add_test(function test_subscribe_origin_principal() {
let scope = 'https://example.net/origin-principal';
let principal =
Services.scriptSecurityManager.createCodebasePrincipalFromOrigin(scope);
do_test_pending();
PushServiceComponent.subscribe(scope, principal, (result, subscription) => {
ok(Components.isSuccessCode(result),
'Expected error creating subscription with origin principal');
ok(!subscription.isSystemSubscription,
'Unexpected system subscription for origin principal');
equal(subscription.quota, 16, 'Wrong quota for origin subscription');
do_test_finished();
run_next_test();
});
});
add_test(function test_subscribe_null_principal() {
do_test_pending();
PushServiceComponent.subscribe(
'chrome://push/null-principal',
Services.scriptSecurityManager.createNullPrincipal({}),
(result, subscription) => {
ok(!Components.isSuccessCode(result),
'Expected error creating subscription with null principal');
strictEqual(subscription, null,
'Unexpected subscription with null principal');
do_test_finished();
run_next_test();
}
);
});
add_test(function test_subscribe_missing_principal() {
do_test_pending();
PushServiceComponent.subscribe('chrome://push/missing-principal', null,
(result, subscription) => {
ok(!Components.isSuccessCode(result),
'Expected error creating subscription without principal');
strictEqual(subscription, null,
'Unexpected subscription without principal');
do_test_finished();
run_next_test();
}
);
});
if (isParent) {
add_test(function tearDown() {
tearDownServiceInParent(db).then(run_next_test, run_next_test);
});
}

View file

@ -0,0 +1,28 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
function run_test() {
do_get_profile();
run_next_test();
}
add_task(function* test_service_parent() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
yield setUpServiceInParent(PushService, db);
// Accessing the lazy service getter will start the service in the main
// process.
equal(PushServiceComponent.pushTopic, "push-message",
"Wrong push message observer topic");
equal(PushServiceComponent.subscriptionChangeTopic,
"push-subscription-change", "Wrong subscription change observer topic");
yield run_test_in_child('./test_service_child.js');
yield tearDownServiceInParent(db);
});

View file

@ -0,0 +1,71 @@
'use strict';
const {PushService, PushServiceWebSocket} = serviceExports;
function run_test() {
setPrefs();
do_get_profile();
run_next_test();
}
add_task(function* test_startup_error() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
PushService.init({
serverURI: 'wss://push.example.org/',
db: makeStub(db, {
getAllExpired(prev) {
return Promise.reject('database corruption on startup');
},
}),
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
ok(false, 'Unexpected handshake');
},
onRegister(request) {
ok(false, 'Unexpected register request');
},
});
},
});
yield rejects(
PushService.register({
scope: `https://example.net/1`,
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Should not register if startup failed'
);
PushService.uninit();
PushService.init({
serverURI: 'wss://push.example.org/',
db: makeStub(db, {
getAllUnexpired(prev) {
return Promise.reject('database corruption on connect');
},
}),
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
ok(false, 'Unexpected handshake');
},
onRegister(request) {
ok(false, 'Unexpected register request');
},
});
},
});
yield rejects(
PushService.registration({
scope: `https://example.net/1`,
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Should not return registration if connection failed'
);
});

View file

@ -0,0 +1,38 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService} = serviceExports;
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_unregister_empty_scope() {
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: '5619557c-86fe-4711-8078-d1fd6987aef7'
}));
}
});
}
});
yield rejects(
PushService.unregister({
scope: '',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for empty endpoint'
);
});

View file

@ -0,0 +1,68 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const channelID = '00c7fa13-7b71-447d-bd27-a91abc09d1b2';
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_unregister_error() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
yield db.put({
channelID: channelID,
pushEndpoint: 'https://example.org/update/failure',
scope: 'https://example.net/page/failure',
originAttributes: '',
version: 1,
quota: Infinity,
});
let unregisterDone;
let unregisterPromise = new Promise(resolve => unregisterDone = resolve);
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: '083e6c17-1063-4677-8638-ab705aebebc2'
}));
},
onUnregister(request) {
// The server is notified out-of-band. Since channels may be pruned,
// any failures are swallowed.
equal(request.channelID, channelID, 'Unregister: wrong channel ID');
this.serverSendMsg(JSON.stringify({
messageType: 'unregister',
status: 500,
error: 'omg, everything is exploding',
channelID
}));
unregisterDone();
}
});
}
});
yield PushService.unregister({
scope: 'https://example.net/page/failure',
originAttributes: '',
});
let result = yield db.getByKeyID(channelID);
ok(!result, 'Deleted push record exists');
// Make sure we send a request to the server.
yield unregisterPromise;
});

View file

@ -0,0 +1,92 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService, PushServiceWebSocket} = serviceExports;
const userAgentID = '7f0af1bb-7e1f-4fb8-8e4a-e8de434abde3';
function run_test() {
do_get_profile();
setPrefs({
userAgentID,
requestTimeout: 150,
retryBaseInterval: 150
});
run_next_test();
}
add_task(function* test_unregister_invalid_json() {
let db = PushServiceWebSocket.newPushDB();
do_register_cleanup(() => {return db.drop().then(_ => db.close());});
let records = [{
channelID: '87902e90-c57e-4d18-8354-013f4a556559',
pushEndpoint: 'https://example.org/update/1',
scope: 'https://example.edu/page/1',
originAttributes: '',
version: 1,
quota: Infinity,
}, {
channelID: '057caa8f-9b99-47ff-891c-adad18ce603e',
pushEndpoint: 'https://example.com/update/2',
scope: 'https://example.net/page/1',
originAttributes: '',
version: 1,
quota: Infinity,
}];
for (let record of records) {
yield db.put(record);
}
let unregisterDone;
let unregisterPromise = new Promise(resolve => unregisterDone = after(2, resolve));
PushService.init({
serverURI: "wss://push.example.org/",
db,
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: userAgentID,
use_webpush: true,
}));
},
onUnregister(request) {
this.serverSendMsg(');alert(1);(');
unregisterDone();
}
});
}
});
yield rejects(
PushService.unregister({
scope: 'https://example.edu/page/1',
originAttributes: '',
}),
'Expected error for first invalid JSON response'
);
let record = yield db.getByKeyID(
'87902e90-c57e-4d18-8354-013f4a556559');
ok(!record, 'Failed to delete unregistered record');
yield rejects(
PushService.unregister({
scope: 'https://example.net/page/1',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
}),
'Expected error for second invalid JSON response'
);
record = yield db.getByKeyID(
'057caa8f-9b99-47ff-891c-adad18ce603e');
ok(!record,
'Failed to delete unregistered record after receiving invalid JSON');
yield unregisterPromise;
});

View file

@ -0,0 +1,36 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
'use strict';
const {PushDB, PushService} = serviceExports;
function run_test() {
do_get_profile();
setPrefs();
run_next_test();
}
add_task(function* test_unregister_not_found() {
PushService.init({
serverURI: "wss://push.example.org/",
makeWebSocket(uri) {
return new MockWebSocket(uri, {
onHello(request) {
this.serverSendMsg(JSON.stringify({
messageType: 'hello',
status: 200,
uaid: 'f074ed80-d479-44fa-ba65-792104a79ea9'
}));
}
});
}
});
let result = yield PushService.unregister({
scope: 'https://example.net/nonexistent',
originAttributes: ChromeUtils.originAttributesToSuffix(
{ appId: Ci.nsIScriptSecurityManager.NO_APP_ID, inIsolatedMozBrowser: false }),
});
ok(result === false, "unregister should resolve with false for nonexistent scope");
});

Some files were not shown because too many files have changed in this diff Show more