Bug 1247786 - Add push subscription to device registration r=markh draft
authorvladikoff <vlad@vladikoff.com>
Mon, 07 Mar 2016 00:00:34 -0500
changeset 338048 355e9975b4f2a7ccc1a346d9ebca9a3c9c6b5e32
parent 338046 46296f1ef6a702846fb043f84c11d77cdc12532b
child 515706 8ed5c2638643fe1f112ef8f0e95a099ef4be2731
push id12408
push uservlad@vladikoff.com
push dateTue, 08 Mar 2016 04:33:37 +0000
reviewersmarkh
bugs1247786
milestone47.0a1
Bug 1247786 - Add push subscription to device registration r=markh MozReview-Commit-ID: 40Tt8sJPYrr
dom/push/test/xpcshell/PushServiceHandler.js
dom/push/test/xpcshell/PushServiceHandler.manifest
dom/push/test/xpcshell/test_handler_service.js
services/fxaccounts/FxAccounts.jsm
services/fxaccounts/FxAccountsClient.jsm
services/fxaccounts/FxAccountsCommon.js
services/fxaccounts/FxAccountsComponents.manifest
services/fxaccounts/FxAccountsPush.js
services/fxaccounts/moz.build
services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
services/fxaccounts/tests/xpcshell/test_push_service.js
services/fxaccounts/tests/xpcshell/xpcshell.ini
--- a/dom/push/test/xpcshell/PushServiceHandler.js
+++ b/dom/push/test/xpcshell/PushServiceHandler.js
@@ -1,10 +1,10 @@
 // An XPCOM service that's registered with the category manager for handling
-// push notifications with scope "test-scope"
+// 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);
--- a/dom/push/test/xpcshell/PushServiceHandler.manifest
+++ b/dom/push/test/xpcshell/PushServiceHandler.manifest
@@ -1,4 +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 test-scope @mozilla.org/dom/push/test/PushServiceHandler;1
+category push chrome://test-scope @mozilla.org/dom/push/test/PushServiceHandler;1
--- a/dom/push/test/xpcshell/test_handler_service.js
+++ b/dom/push/test/xpcshell/test_handler_service.js
@@ -1,23 +1,23 @@
 "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 "test-scope"
+// 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 = "test-scope";
+  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;
--- a/services/fxaccounts/FxAccounts.jsm
+++ b/services/fxaccounts/FxAccounts.jsm
@@ -31,16 +31,17 @@ XPCOMUtils.defineLazyModuleGetter(this, 
   "resource://gre/modules/FxAccountsProfile.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Utils",
   "resource://services-sync/util.js");
 
 // All properties exposed by the public FxAccounts API.
 var publicProperties = [
   "accountStatus",
+  "checkVerificationStatus",
   "getAccountsClient",
   "getAccountsSignInURI",
   "getAccountsSignUpURI",
   "getAssertion",
   "getDeviceId",
   "getKeys",
   "getSignedInUser",
   "getOAuthToken",
@@ -130,17 +131,17 @@ AccountState.prototype = {
     this.oauthTokens = null;
     let storageManager = this.storageManager;
     this.storageManager = null;
     return storageManager.deleteAccountData().then(() => {
       return storageManager.finalize();
     });
   },
 
-  // Get user account data. Optionally specify explcit field names to fetch
+  // Get user account data. Optionally specify explicit field names to fetch
   // (and note that if you require an in-memory field you *must* specify the
   // field name(s).)
   getUserAccountData(fieldNames = null) {
     if (!this.isCurrent) {
       return Promise.reject(new Error("Another user has signed in"));
     }
     return this.storageManager.getAccountData(fieldNames).then(result => {
       return this.resolve(result);
@@ -310,16 +311,26 @@ this.FxAccounts = function (mockInternal
     copyObjectProperties(mockInternal, internal);
   }
 
   if (mockInternal) {
     // Exposes the internal object for testing only.
     external.internal = internal;
   }
 
+  if (!internal.fxaPushService) {
+    // internal.fxaPushService option is used in testing.
+    // Otherwise we load the service lazily.
+    XPCOMUtils.defineLazyGetter(internal, "fxaPushService", function () {
+      return Components.classes["@mozilla.org/fxaccounts/push;1"]
+        .getService(Components.interfaces.nsISupports)
+        .wrappedJSObject;
+    });
+  }
+
   // wait until after the mocks are setup before initializing.
   internal.initialize();
 
   return Object.freeze(external);
 }
 
 /**
  * The internal API's constructor.
@@ -332,19 +343,19 @@ function FxAccountsInternal() {
   // below as it helps with testing.
 }
 
 /**
  * The internal API's prototype.
  */
 FxAccountsInternal.prototype = {
   // The timeout (in ms) we use to poll for a verified mail for the first 2 mins.
-  VERIFICATION_POLL_TIMEOUT_INITIAL: 5000, // 5 seconds
+  VERIFICATION_POLL_TIMEOUT_INITIAL: 15000, // 15 seconds
   // And how often we poll after the first 2 mins.
-  VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 15000, // 15 seconds.
+  VERIFICATION_POLL_TIMEOUT_SUBSEQUENT: 30000, // 30 seconds.
 
   _fxAccountsClient: null,
 
   // All significant initialization should be done in this initialize() method,
   // as it's called after this object has been mocked for tests.
   initialize() {
     this.currentTimer = null;
     this.currentAccountState = this.newAccountState();
@@ -490,24 +501,26 @@ FxAccountsInternal.prototype = {
     return this.abortExistingFlow().then(() => {
       let currentAccountState = this.currentAccountState = this.newAccountState(
         Cu.cloneInto(credentials, {}) // Pass a clone of the credentials object.
       );
       // This promise waits for storage, but not for verification.
       // We're telling the caller that this is durable now (although is that
       // really something we should commit to? Why not let the write happen in
       // the background? Already does for updateAccountData ;)
-      return currentAccountState.promiseInitialized.then(() =>
-        this.updateDeviceRegistration()
-      ).then(() => {
-        Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
-        this.notifyObservers(ONLOGIN_NOTIFICATION);
+      return currentAccountState.promiseInitialized.then(() => {
+        // Starting point for polling if new user
         if (!this.isUserEmailVerified(credentials)) {
           this.startVerifiedCheck(credentials);
         }
+
+        return this.updateDeviceRegistration();
+      }).then(() => {
+        Services.telemetry.getHistogramById("FXA_CONFIGURED").add(1);
+        this.notifyObservers(ONLOGIN_NOTIFICATION);
       }).then(() => {
         return currentAccountState.resolve();
       });
     })
   },
 
 
   /**
@@ -606,16 +619,32 @@ FxAccountsInternal.prototype = {
     return this.currentAccountState.getUserAccountData().then(data => {
       if (!data) {
         return false;
       }
       return this.fxAccountsClient.accountStatus(data.uid);
     });
   },
 
+  checkVerificationStatus: function() {
+    log.trace('checkVerificationStatus');
+    let currentState = this.currentAccountState;
+    return currentState.getUserAccountData().then(data => {
+      if (!data) {
+        log.trace("checkVerificationStatus - no user data");
+        return null;
+      }
+
+      if (!this.isUserEmailVerified(data)) {
+        log.trace("checkVerificationStatus - forcing verification status check");
+        this.pollEmailStatus(currentState, data.sessionToken, "start");
+      }
+    });
+  },
+
   _destroyOAuthToken: function(tokenData) {
     let client = new FxAccountsOAuthGrantClient({
       serverURL: tokenData.server,
       client_id: FX_OAUTH_CLIENT_ID
     });
     return client.destroyToken(tokenData.token)
   },
 
@@ -1061,17 +1090,17 @@ FxAccountsInternal.prototype = {
       });
   },
 
   // Poll email status using truncated exponential back-off.
   pollEmailStatusAgain: function (currentState, sessionToken, timeoutMs) {
     let ageMs = Date.now() - this.pollStartDate;
     if (ageMs >= this.POLL_SESSION) {
       if (currentState.whenVerifiedDeferred) {
-        let error = new Error("User email verification timed out.")
+        let error = new Error("User email verification timed out.");
         currentState.whenVerifiedDeferred.reject(error);
         delete currentState.whenVerifiedDeferred;
       }
       log.debug("polling session exceeded, giving up");
       return;
     }
     if (timeoutMs === undefined) {
       let currentMinute = Math.ceil(ageMs / 60000);
@@ -1395,28 +1424,34 @@ FxAccountsInternal.prototype = {
       //   1. It makes remote requests to the auth server.
       //   2. _getDeviceName does not work from xpcshell.
       //   3. The B2G tests fail when attempting to import services-sync/util.js.
       if (Services.prefs.getBoolPref("identity.fxaccounts.skipDeviceRegistration")) {
         return Promise.resolve();
       }
     } catch(ignore) {}
 
-    return Promise.resolve().then(() => {
+    return this.fxaPushService.registerPushEndpoint().then(subscription => {
       const deviceName = this._getDeviceName();
+      let deviceOptions = {};
+
+      // if we were able to obtain a subscription
+      if (subscription && subscription.endpoint) {
+        deviceOptions.pushCallback = subscription.endpoint;
+      }
 
       if (signedInUser.deviceId) {
         log.debug("updating existing device details");
         return this.fxAccountsClient.updateDevice(
-          signedInUser.sessionToken, signedInUser.deviceId, deviceName);
+          signedInUser.sessionToken, signedInUser.deviceId, deviceName, deviceOptions);
       }
 
       log.debug("registering new device details");
       return this.fxAccountsClient.registerDevice(
-        signedInUser.sessionToken, deviceName, this._getDeviceType());
+        signedInUser.sessionToken, deviceName, this._getDeviceType(), deviceOptions);
     }).then(device =>
       this.currentAccountState.updateUserAccountData({
         deviceId: device.id,
         isDeviceStale: null
       }).then(() => device.id)
     ).catch(error => this._handleDeviceError(error, signedInUser.sessionToken));
   },
 
@@ -1463,17 +1498,17 @@ FxAccountsInternal.prototype = {
     //      sync or next sign-in, registration is retried and should succeed.
     //   4. If we don't find a match, log the original error.
     log.warn("device session conflict, attempting to ascertain the correct device id");
     return this.fxAccountsClient.getDeviceList(sessionToken)
       .then(devices => {
         const matchingDevices = devices.filter(device => device.isCurrentDevice);
         const length = matchingDevices.length;
         if (length === 1) {
-          const deviceId = matchingDevices[0].id
+          const deviceId = matchingDevices[0].id;
           return this.currentAccountState.updateUserAccountData({
             deviceId,
             isDeviceStale: true
           }).then(() => deviceId);
         }
         if (length > 1) {
           log.error("insane server state, " + length + " devices for this session");
         }
--- a/services/fxaccounts/FxAccountsClient.jsm
+++ b/services/fxaccounts/FxAccountsClient.jsm
@@ -353,56 +353,71 @@ this.FxAccountsClient.prototype = {
    *
    * @method registerDevice
    * @param  sessionTokenHex
    *         Session token obtained from signIn
    * @param  name
    *         Device name
    * @param  type
    *         Device type (mobile|desktop)
+   * @param  [options]
+   *         Extra device options
+   * @param  [options.pushCallback]
+   *         `pushCallback` push endpoint callback
    * @return Promise
    *         Resolves to an object:
    *         {
    *           id: Device identifier
    *           createdAt: Creation time (milliseconds since epoch)
    *           name: Name of device
    *           type: Type of device (mobile|desktop)
    *         }
    */
-  registerDevice(sessionTokenHex, name, type) {
+  registerDevice(sessionTokenHex, name, type, options = {}) {
     let path = "/account/device";
 
     let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
     let body = { name, type };
 
+    if (options.pushCallback) {
+      body.pushCallback = options.pushCallback;
+    }
+
     return this._request(path, "POST", creds, body);
   },
 
   /**
    * Update the session or name for an existing device
    *
    * @method updateDevice
    * @param  sessionTokenHex
    *         Session token obtained from signIn
    * @param  id
    *         Device identifier
    * @param  name
    *         Device name
+   * @param  [options]
+   *         Extra device options
+   * @param  [options.pushCallback]
+   *         `pushCallback` push endpoint callback
    * @return Promise
    *         Resolves to an object:
    *         {
    *           id: Device identifier
    *           name: Device name
    *         }
    */
-  updateDevice(sessionTokenHex, id, name) {
+  updateDevice(sessionTokenHex, id, name, options = {}) {
     let path = "/account/device";
 
     let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
     let body = { id, name };
+    if (options.pushCallback) {
+      body.pushCallback = options.pushCallback;
+    }
 
     return this._request(path, "POST", creds, body);
   },
 
   /**
    * Delete a device and its associated session token, signing the user
    * out of the server.
    *
@@ -414,17 +429,17 @@ this.FxAccountsClient.prototype = {
    * @param  [options]
    *         Options object
    * @param  [options.service]
    *         `service` query parameter
    * @return Promise
    *         Resolves to an empty object:
    *         {}
    */
-  signOutAndDestroyDevice(sessionTokenHex, id, options={}) {
+  signOutAndDestroyDevice(sessionTokenHex, id, options = {}) {
     let path = "/account/device/destroy";
 
     if (options.service) {
       path += "?service=" + encodeURIComponent(options.service);
     }
 
     let creds = deriveHawkCredentials(sessionTokenHex, "sessionToken");
     let body = { id };
--- a/services/fxaccounts/FxAccountsCommon.js
+++ b/services/fxaccounts/FxAccountsCommon.js
@@ -85,16 +85,18 @@ exports.POLL_SESSION       = 1000 * 60 *
 
 // Observer notifications.
 exports.ONLOGIN_NOTIFICATION = "fxaccounts:onlogin";
 exports.ONVERIFIED_NOTIFICATION = "fxaccounts:onverified";
 exports.ONLOGOUT_NOTIFICATION = "fxaccounts:onlogout";
 // Internal to services/fxaccounts only
 exports.ON_FXA_UPDATE_NOTIFICATION = "fxaccounts:update";
 
+exports.FXA_PUSH_SCOPE_ACCOUNT_UPDATE = "chrome://fxa-device-update";
+
 exports.ON_PROFILE_CHANGE_NOTIFICATION = "fxaccounts:profilechange";
 
 // UI Requests.
 exports.UI_REQUEST_SIGN_IN_FLOW = "signInFlow";
 exports.UI_REQUEST_REFRESH_AUTH = "refreshAuthentication";
 
 // The OAuth client ID for Firefox Desktop
 exports.FX_OAUTH_CLIENT_ID = "5882386c6d801776";
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsComponents.manifest
@@ -0,0 +1,4 @@
+# FxAccountsPush.js
+component {1b7db999-2ecd-4abf-bb95-a726896798ca} FxAccountsPush.js
+contract @mozilla.org/fxaccounts/push;1 {1b7db999-2ecd-4abf-bb95-a726896798ca}
+category push chrome://fxa-device-update @mozilla.org/fxaccounts/push;1
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/FxAccountsPush.js
@@ -0,0 +1,195 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const Cc = Components.classes;
+const Ci = Components.interfaces;
+const Cu = Components.utils;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://services-sync/util.js");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+/**
+ * FxAccountsPushService manages Push notifications for Firefox Accounts in the browser
+ *
+ * @param [options]
+ *        Object, custom options that used for testing
+ * @constructor
+ */
+function FxAccountsPushService(options = {}) {
+  this.log = log;
+
+  if (options.log) {
+    // allow custom log for testing purposes
+    this.log = options.log;
+  }
+
+  this.log.debug("FxAccountsPush loading service");
+  this.wrappedJSObject = this;
+  this.initialize(options);
+}
+
+FxAccountsPushService.prototype = {
+  /**
+   * Helps only initialize observers once.
+   */
+  _initialized: false,
+  /**
+   * Instance of the nsIPushService or a mocked object.
+   */
+  pushService: null,
+  /**
+   * Instance of FxAccounts or a mocked object.
+   */
+  fxAccounts: null,
+  /**
+   * Component ID of this service, helps register this component.
+   */
+  classID: Components.ID("{1b7db999-2ecd-4abf-bb95-a726896798ca}"),
+  /**
+   * Register used interfaces in this service
+   */
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
+  /**
+   * Initialize the service and register all the required observers.
+   *
+   * @param [options]
+   */
+  initialize(options) {
+    if (this._initialized) {
+      return false;
+    }
+
+    this._initialized = true;
+
+    if (options.pushService) {
+      this.pushService = options.pushService;
+    } else {
+      this.pushService = Cc["@mozilla.org/push/Service;1"].getService(Ci.nsIPushService);
+    }
+
+    if (options.fxAccounts) {
+      this.fxAccounts = options.fxAccounts;
+    } else {
+      XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+        "resource://gre/modules/FxAccounts.jsm");
+    }
+
+    // listen to new push messages, push changes and logout events
+    Services.obs.addObserver(this, this.pushService.pushTopic, false);
+    Services.obs.addObserver(this, this.pushService.subscriptionChangeTopic, false);
+    Services.obs.addObserver(this, ONLOGOUT_NOTIFICATION, false);
+
+    this.log.debug("FxAccountsPush initialized");
+  },
+  /**
+   * Registers a new endpoint with the Push Server
+   *
+   * @returns {Promise}
+   *          Promise always resolves with a subscription or a null if failed to subscribe.
+   */
+  registerPushEndpoint() {
+    this.log.trace("FxAccountsPush registerPushEndpoint");
+
+    return new Promise((resolve) => {
+      this.pushService.subscribe(FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+        Services.scriptSecurityManager.getSystemPrincipal(),
+        (result, subscription) => {
+          if (Components.isSuccessCode(result)) {
+            this.log.debug("FxAccountsPush got subscription");
+            resolve(subscription);
+          } else {
+            this.log.warn("FxAccountsPush failed to subscribe", result);
+            resolve(null);
+          }
+        });
+    });
+  },
+  /**
+   * Standard observer interface to listen to push messages, changes and logout.
+   *
+   * @param subject
+   * @param topic
+   * @param data
+   */
+  observe(subject, topic, data) {
+   this.log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`);
+   switch (topic) {
+     case this.pushService.pushTopic:
+       if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
+         this._onPushMessage();
+       }
+       break;
+     case this.pushService.subscriptionChangeTopic:
+       if (data === FXA_PUSH_SCOPE_ACCOUNT_UPDATE) {
+         this._onPushSubscriptionChange();
+       }
+       break;
+     case ONLOGOUT_NOTIFICATION:
+       // user signed out, we need to stop polling the Push Server
+       this.unsubscribe().catch(err => {
+         this.log.error("Error during unsubscribe", err);
+       });
+       break;
+     default:
+       break;
+   }
+  },
+  /**
+   * Fired when the Push server sends a notification.
+   *
+   * @private
+   */
+  _onPushMessage() {
+    this.log.trace("FxAccountsPushService _onPushMessage");
+    // Use this signal to check the verification state of the account right away
+    this.fxAccounts.checkVerificationStatus();
+  },
+  /**
+   * Fired when the Push server drops a subscription, or the subscription identifier changes.
+   *
+   * https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#Receiving_Push_Messages
+   *
+   * @private
+   */
+  _onPushSubscriptionChange() {
+    this.log.trace("FxAccountsPushService _onPushSubscriptionChange");
+    this.fxAccounts.updateDeviceRegistration();
+  },
+  /**
+   * Unsubscribe from the Push server
+   *
+   * Ref: https://developer.mozilla.org/en-US/docs/Mozilla/Tech/XPCOM/Reference/Interface/nsIPushService#unsubscribe()
+   *
+   * @returns {Promise}
+   * @private
+   */
+  unsubscribe() {
+    this.log.trace("FxAccountsPushService unsubscribe");
+    return new Promise((resolve) => {
+      this.pushService.unsubscribe(FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+        Services.scriptSecurityManager.getSystemPrincipal(),
+        (result, ok) => {
+          if (Components.isSuccessCode(result)) {
+            if (ok === true) {
+              this.log.debug("FxAccountsPushService unsubscribed");
+            } else {
+              this.log.debug("FxAccountsPushService had no subscription to unsubscribe");
+            }
+          } else {
+            this.log.warn("FxAccountsPushService failed to unsubscribe", result);
+          }
+          return resolve(ok);
+        })
+    })
+  },
+};
+
+// Service registration below registers with FxAccountsComponents.manifest
+const components = [FxAccountsPushService];
+this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
+
+// The following registration below helps with testing this service.
+this.EXPORTED_SYMBOLS=["FxAccountsPushService"];
--- a/services/fxaccounts/moz.build
+++ b/services/fxaccounts/moz.build
@@ -5,24 +5,30 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DIRS += ['interfaces']
 
 MOCHITEST_CHROME_MANIFESTS += ['tests/mochitest/chrome.ini']
 
 XPCSHELL_TESTS_MANIFESTS += ['tests/xpcshell/xpcshell.ini']
 
+EXTRA_COMPONENTS += [
+  'FxAccountsComponents.manifest',
+  'FxAccountsPush.js',
+]
+
 EXTRA_JS_MODULES += [
   'Credentials.jsm',
   'FxAccounts.jsm',
   'FxAccountsClient.jsm',
   'FxAccountsCommon.js',
   'FxAccountsOAuthClient.jsm',
   'FxAccountsOAuthGrantClient.jsm',
   'FxAccountsProfile.jsm',
   'FxAccountsProfileClient.jsm',
+  'FxAccountsPush.js',
   'FxAccountsStorage.jsm',
   'FxAccountsWebChannel.jsm',
 ]
 
 # For now, we will only be using the FxA manager in B2G.
 if CONFIG['MOZ_B2G']:
   EXTRA_JS_MODULES += ['FxAccountsManager.jsm']
--- a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -94,17 +94,26 @@ MockFxAccountsClient.prototype = {
   __proto__: FxAccountsClient.prototype
 }
 
 function MockFxAccounts(device = {}) {
   return new FxAccounts({
     _getDeviceName() {
       return device.name || "mock device name";
     },
-    fxAccountsClient: new MockFxAccountsClient(device)
+    fxAccountsClient: new MockFxAccountsClient(device),
+    fxaPushService: {
+      registerPushEndpoint() {
+        return new Promise((resolve) => {
+          resolve({
+            endpoint: "http://mochi.test:8888"
+          });
+        });
+      },
+    },
   });
 }
 
 add_task(function* test_updateDeviceRegistration_with_new_device() {
   const deviceName = "foo";
   const deviceType = "bar";
 
   const credentials = getTestUser("baz");
@@ -140,20 +149,21 @@ add_task(function* test_updateDeviceRegi
   };
 
   const result = yield fxa.updateDeviceRegistration();
 
   do_check_eq(result, "newly-generated device id");
   do_check_eq(spy.updateDevice.count, 0);
   do_check_eq(spy.getDeviceList.count, 0);
   do_check_eq(spy.registerDevice.count, 1);
-  do_check_eq(spy.registerDevice.args[0].length, 3);
+  do_check_eq(spy.registerDevice.args[0].length, 4);
   do_check_eq(spy.registerDevice.args[0][0], credentials.sessionToken);
   do_check_eq(spy.registerDevice.args[0][1], deviceName);
   do_check_eq(spy.registerDevice.args[0][2], "desktop");
+  do_check_eq(spy.registerDevice.args[0][3].pushCallback, "http://mochi.test:8888");
 
   const state = fxa.internal.currentAccountState;
   const data = yield state.getUserAccountData();
 
   do_check_eq(data.deviceId, "newly-generated device id");
   do_check_false(data.isDeviceStale);
 });
 
@@ -190,20 +200,21 @@ add_task(function* test_updateDeviceRegi
     return Promise.resolve([]);
   };
   const result = yield fxa.updateDeviceRegistration();
 
   do_check_eq(result, credentials.deviceId);
   do_check_eq(spy.registerDevice.count, 0);
   do_check_eq(spy.getDeviceList.count, 0);
   do_check_eq(spy.updateDevice.count, 1);
-  do_check_eq(spy.updateDevice.args[0].length, 3);
+  do_check_eq(spy.updateDevice.args[0].length, 4);
   do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
   do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
   do_check_eq(spy.updateDevice.args[0][2], deviceName);
+  do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888");
 
   const state = fxa.internal.currentAccountState;
   const data = yield state.getUserAccountData();
 
   do_check_eq(data.deviceId, credentials.deviceId);
   do_check_false(data.isDeviceStale);
 });
 
@@ -246,20 +257,22 @@ add_task(function* test_updateDeviceRegi
   };
 
   const result = yield fxa.updateDeviceRegistration();
 
   do_check_null(result);
   do_check_eq(spy.getDeviceList.count, 0);
   do_check_eq(spy.registerDevice.count, 0);
   do_check_eq(spy.updateDevice.count, 1);
-  do_check_eq(spy.updateDevice.args[0].length, 3);
+  do_check_eq(spy.updateDevice.args[0].length, 4);
   do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
   do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
   do_check_eq(spy.updateDevice.args[0][2], deviceName);
+  do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888");
+
 
   const state = fxa.internal.currentAccountState;
   const data = yield state.getUserAccountData();
 
   do_check_null(data.deviceId);
   do_check_false(data.isDeviceStale);
 });
 
@@ -307,20 +320,21 @@ add_task(function* test_updateDeviceRegi
     ]);
   };
 
   const result = yield fxa.updateDeviceRegistration();
 
   do_check_eq(result, credentials.deviceId);
   do_check_eq(spy.registerDevice.count, 0);
   do_check_eq(spy.updateDevice.count, 1);
-  do_check_eq(spy.updateDevice.args[0].length, 3);
+  do_check_eq(spy.updateDevice.args[0].length, 4);
   do_check_eq(spy.updateDevice.args[0][0], credentials.sessionToken);
   do_check_eq(spy.updateDevice.args[0][1], credentials.deviceId);
   do_check_eq(spy.updateDevice.args[0][2], deviceName);
+  do_check_eq(spy.updateDevice.args[0][3].pushCallback, "http://mochi.test:8888");
   do_check_eq(spy.getDeviceList.count, 1);
   do_check_eq(spy.getDeviceList.args[0].length, 1);
   do_check_eq(spy.getDeviceList.args[0][0], credentials.sessionToken);
   do_check_true(spy.getDeviceList.time >= spy.updateDevice.time);
 
   const state = fxa.internal.currentAccountState;
   const data = yield state.getUserAccountData();
 
@@ -363,17 +377,17 @@ add_task(function* test_updateDeviceRegi
   };
 
   const result = yield fxa.updateDeviceRegistration();
 
   do_check_null(result);
   do_check_eq(spy.getDeviceList.count, 0);
   do_check_eq(spy.updateDevice.count, 0);
   do_check_eq(spy.registerDevice.count, 1);
-  do_check_eq(spy.registerDevice.args[0].length, 3);
+  do_check_eq(spy.registerDevice.args[0].length, 4);
 
   const state = fxa.internal.currentAccountState;
   const data = yield state.getUserAccountData();
 
   do_check_null(data.deviceId);
 });
 
 add_task(function* test_getDeviceId_with_no_device_id_invokes_device_registration() {
--- a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -43,16 +43,25 @@ function getLoginMgrData() {
   Assert.equal(logins.length, 1, "only 1 login available");
   return logins[0];
 }
 
 function createFxAccounts() {
   return new FxAccounts({
     _getDeviceName() {
       return "mock device name";
+    },
+    fxaPushService: {
+      registerPushEndpoint() {
+        return new Promise((resolve) => {
+          resolve({
+            endpoint: "http://mochi.test:8888"
+          });
+        });
+      },
     }
   });
 }
 
 add_task(function* test_simple() {
   let fxa = createFxAccounts();
 
   let creds = {
--- a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
@@ -88,17 +88,26 @@ function MockFxAccounts(device={}) {
     newAccountState(credentials) {
       // we use a real accountState but mocked storage.
       let storage = new MockStorageManager();
       storage.initialize(credentials);
       return new AccountState(storage);
     },
     _getDeviceName() {
       return "mock device name";
-    }
+    },
+    fxaPushService: {
+      registerPushEndpoint() {
+        return new Promise((resolve) => {
+          resolve({
+            endpoint: "http://mochi.test:8888"
+          });
+        });
+      },
+    },
   });
 }
 
 function* createMockFxA() {
   let fxa = new MockFxAccounts();
   let credentials = {
     email: "foo@example.com",
     uid: "1234@lcip.org",
--- a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
@@ -92,17 +92,26 @@ function MockFxAccounts(mockGrantClient)
     _destroyOAuthToken: function(tokenData) {
       // somewhat sad duplication of _destroyOAuthToken, but hard to avoid.
       return mockGrantClient.destroyToken(tokenData.token).then( () => {
         Services.obs.notifyObservers(null, "testhelper-fxa-revoke-complete", null);
       });
     },
     _getDeviceName() {
       return "mock device name";
-    }
+    },
+    fxaPushService: {
+      registerPushEndpoint() {
+        return new Promise((resolve) => {
+          resolve({
+            endpoint: "http://mochi.test:8888"
+          });
+        });
+      },
+    },
   });
 }
 
 function* createMockFxA(mockGrantClient) {
   let fxa = new MockFxAccounts(mockGrantClient);
   let credentials = {
     email: "foo@example.com",
     uid: "1234@lcip.org",
new file mode 100644
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA push service.
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsPush.js");
+Cu.import("resource://gre/modules/Log.jsm");
+
+XPCOMUtils.defineLazyServiceGetter(this, "pushService",
+  "@mozilla.org/push/Service;1", "nsIPushService");
+
+initTestLogging("Trace");
+log.level = Log.Level.Trace;
+
+const MOCK_ENDPOINT = "http://mochi.test:8888";
+
+// tests do not allow external connections, mock the PushService
+let mockPushService = {
+  pushTopic: this.pushService.pushTopic,
+  subscriptionChangeTopic: this.pushService.subscriptionChangeTopic,
+  subscribe(scope, principal, cb) {
+    cb(Components.results.NS_OK, {
+      endpoint: MOCK_ENDPOINT
+    });
+  },
+  unsubscribe(scope, principal, cb) {
+    cb(Components.results.NS_OK, true);
+  }
+};
+
+let mockFxAccounts = {
+  checkVerificationStatus() {},
+  updateDeviceRegistration() {}
+};
+
+let mockLog = {
+  trace() {},
+  debug() {},
+  warn() {},
+  error() {}
+};
+
+
+add_task(function* initialize() {
+  let pushService = new FxAccountsPushService();
+  do_check_eq(pushService.initialize(), false);
+});
+
+add_task(function* registerPushEndpointSuccess() {
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    fxAccounts: mockFxAccounts,
+  });
+
+  let subscription = yield pushService.registerPushEndpoint();
+  do_check_eq(subscription.endpoint, MOCK_ENDPOINT);
+});
+
+add_task(function* registerPushEndpointFailure() {
+  let failPushService = Object.assign(mockPushService, {
+    subscribe(scope, principal, cb) {
+      cb(Components.results.NS_ERROR_ABORT);
+    }
+  });
+
+  let pushService = new FxAccountsPushService({
+    pushService: failPushService,
+    fxAccounts: mockFxAccounts,
+  });
+
+  let subscription = yield pushService.registerPushEndpoint();
+  do_check_eq(subscription, null);
+});
+
+add_task(function* unsubscribeSuccess() {
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    fxAccounts: mockFxAccounts,
+  });
+
+  let result = yield pushService.unsubscribe();
+  do_check_eq(result, true);
+});
+
+add_task(function* unsubscribeFailure() {
+  let failPushService = Object.assign(mockPushService, {
+    unsubscribe(scope, principal, cb) {
+      cb(Components.results.NS_ERROR_ABORT);
+    }
+  });
+
+  let pushService = new FxAccountsPushService({
+    pushService: failPushService,
+    fxAccounts: mockFxAccounts,
+  });
+
+  let result = yield pushService.unsubscribe();
+  do_check_eq(result, null);
+});
+
+add_test(function observeLogout() {
+  let customLog = Object.assign(mockLog, {
+    trace: function (msg) {
+      if (msg === "FxAccountsPushService unsubscribe") {
+        // logout means we unsubscribe
+        run_next_test();
+      }
+    }
+  });
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    log: customLog
+  });
+
+  pushService.observe(null, ONLOGOUT_NOTIFICATION);
+});
+
+add_test(function observePushTopic() {
+  let customAccounts = Object.assign(mockFxAccounts, {
+    checkVerificationStatus: function () {
+      // checking verification status on push messages
+      run_next_test();
+    }
+  });
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    fxAccounts: customAccounts,
+  });
+
+  pushService.observe(null, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observeSubscriptionChangeTopic() {
+  let customAccounts = Object.assign(mockFxAccounts, {
+    updateDeviceRegistration: function () {
+      // subscription change means updating the device registration
+      run_next_test();
+    }
+  });
+
+  let pushService = new FxAccountsPushService({
+    pushService: mockPushService,
+    fxAccounts: customAccounts,
+  });
+
+  pushService.observe(null, mockPushService.subscriptionChangeTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+
+function run_test() {
+  run_next_test();
+}
--- a/services/fxaccounts/tests/xpcshell/xpcshell.ini
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -15,12 +15,13 @@ skip-if = appname == 'b2g' # login manag
 skip-if = appname != 'b2g'
 reason = FxAccountsManager is only available for B2G for now
 [test_oauth_client.js]
 [test_oauth_grant_client.js]
 [test_oauth_grant_client_server.js]
 [test_oauth_tokens.js]
 [test_oauth_token_storage.js]
 [test_profile_client.js]
+[test_push_service.js]
 [test_web_channel.js]
 skip-if = (appname == 'b2g' || appname == 'thunderbird') # fxa web channels only used on desktop
 [test_profile.js]
 [test_storage_manager.js]