Bug 1019985 - Create module to drive sync migration process. r=adw
authorMark Hammond <mhammond@skippinet.com.au>
Fri, 05 Dec 2014 16:46:16 -0800
changeset 218663 ad283fac781cd1308bf61fbb406bf1d93ecf657a
parent 218662 2a4d6a91378ac9eab37617128cf23e277233db43
child 218664 95925d77a314a989c535ae1960ca1ae464be2911
push id52607
push usercbook@mozilla.com
push dateMon, 08 Dec 2014 12:08:02 +0000
treeherdermozilla-inbound@850d447ba305 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersadw
bugs1019985
milestone37.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1019985 - Create module to drive sync migration process. r=adw
services/sync/Weave.js
services/sync/modules/FxaMigrator.jsm
services/sync/moz.build
services/sync/tests/unit/head_http_server.js
services/sync/tests/unit/test_fxa_migration.js
services/sync/tests/unit/xpcshell.ini
--- a/services/sync/Weave.js
+++ b/services/sync/Weave.js
@@ -67,16 +67,21 @@ function WeaveService() {
 }
 WeaveService.prototype = {
   classID: Components.ID("{74b89fb0-f200-4ae8-a3ec-dd164117f6de}"),
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
                                          Ci.nsISupportsWeakReference]),
 
   ensureLoaded: function () {
+    // If we are loaded and not using FxA, load the migration module.
+    if (!this.fxAccountsEnabled) {
+      Cu.import("resource://services-sync/FxaMigrator.jsm");
+    }
+
     Components.utils.import("resource://services-sync/main.js");
 
     // Side-effect of accessing the service is that it is instantiated.
     Weave.Service;
   },
 
   whenLoaded: function() {
     if (this.ready) {
new file mode 100644
--- /dev/null
+++ b/services/sync/modules/FxaMigrator.jsm
@@ -0,0 +1,381 @@
+/* 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 {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
+  "resource://gre/modules/FxAccounts.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "WeaveService", function() {
+  return Cc["@mozilla.org/weave/service;1"]
+         .getService(Components.interfaces.nsISupports)
+         .wrappedJSObject;
+});
+
+XPCOMUtils.defineLazyModuleGetter(this, "Weave",
+  "resource://services-sync/main.js");
+
+// FxAccountsCommon.js doesn't use a "namespace", so create one here.
+let fxAccountsCommon = {};
+Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
+
+// We send this notification whenever the migration state changes.
+const OBSERVER_STATE_CHANGE_TOPIC = "fxa-migration:state-changed";
+// We also send the state notification when we *receive* this.  This allows
+// consumers to avoid loading this module until it receives a notification
+// from us (which may never happen if there's no migration to do)
+const OBSERVER_STATE_REQUEST_TOPIC = "fxa-migration:state-request";
+
+const OBSERVER_TOPICS = [
+  "xpcom-shutdown",
+  "weave:service:sync:start",
+  "weave:service:sync:finish",
+  "weave:service:sync:error",
+  "weave:eol",
+  OBSERVER_STATE_REQUEST_TOPIC,
+  fxAccountsCommon.ONLOGIN_NOTIFICATION,
+  fxAccountsCommon.ONLOGOUT_NOTIFICATION,
+  fxAccountsCommon.ONVERIFIED_NOTIFICATION,
+];
+
+// A list of preference names we write to the migration sentinel.  We only
+// write ones that have a user-set value.
+const FXA_SENTINEL_PREFS = [
+  "identity.fxaccounts.auth.uri",
+  "identity.fxaccounts.remote.force_auth.uri",
+  "identity.fxaccounts.remote.signup.uri",
+  "identity.fxaccounts.remote.signin.uri",
+  "identity.fxaccounts.settings.uri",
+  "services.sync.tokenServerURI",
+];
+
+function Migrator() {
+  // Leave the log-level as Debug - Sync will setup log appenders such that
+  // these messages generally will not be seen unless other log related
+  // prefs are set.
+  this.level = Log.Level.Debug;
+
+  this._nextUserStatePromise = Promise.resolve();
+
+  for (let topic of OBSERVER_TOPICS) {
+    Services.obs.addObserver(this, topic, false);
+  }
+  // ._state is an optimization so we avoid sending redundant observer
+  // notifications when the state hasn't actually changed.
+  this._state = null;
+}
+
+Migrator.prototype = {
+  log: Log.repository.getLogger("Sync.SyncMigration"),
+
+  // What user action is necessary to push the migration forward?
+  // A |null| state means there is nothing to do.  Note that a null state implies
+  // either. (a) no migration is necessary or (b) that the migrator module is
+  // waiting for something outside of the user's control - eg, sync to complete,
+  // the migration sentinel to be uploaded, etc.  In most cases the wait will be
+  // short, but edge cases (eg, no network, sync bugs that prevent it stopping
+  // until shutdown) may require a significantly longer wait.
+  STATE_USER_FXA: "waiting for user to be signed in to FxA",
+  STATE_USER_FXA_VERIFIED: "waiting for a verified FxA user",
+
+  finalize() {
+    for (let topic of OBSERVER_TOPICS) {
+      Services.obs.removeObserver(this, topic);
+    }
+  },
+
+  observe(subject, topic, data) {
+    this.log.debug("observed " + topic);
+    switch (topic) {
+      case "xpcom-shutdown":
+        this.finalize();
+        break;
+
+      case OBSERVER_STATE_REQUEST_TOPIC:
+        // someone has requested the state - send it.
+        this._queueCurrentUserState(true);
+        break;
+
+      default:
+        // some other observer that may affect our state has fired, so update.
+        this._queueCurrentUserState().then(
+          () => this.log.debug("update state from observer " + topic + " complete")
+        ).catch(err => {
+          let msg = "Failed to handle topic " + topic + ": " + err;
+          Cu.reportError(msg);
+          this.log.error(msg);
+        });
+    }
+  },
+
+  // Try and move to a state where we are blocked on a user action.
+  // This needs to be restartable, and the states may, in edge-cases, end
+  // up going backwards (eg, user logs out while we are waiting to be told
+  // about verification)
+  // This is called by our observer notifications - so if there is already
+  // a promise in-flight, it's possible we will miss something important - so
+  // we wait for the in-flight one to complete then fire another (ie, this
+  // is effectively a queue of promises)
+  _queueCurrentUserState(forceObserver = false) {
+    return this._nextUserStatePromise = this._nextUserStatePromise.then(
+      () => this._promiseCurrentUserState(forceObserver),
+      err => {
+        let msg = "Failed to determine the current user state: " + err;
+        Cu.reportError(msg);
+        this.log.error(msg);
+        return this._promiseCurrentUserState(forceObserver)
+      }
+    );
+  },
+
+  _promiseCurrentUserState: Task.async(function* (forceObserver) {
+    this.log.trace("starting _promiseCurrentUserState");
+    let update = newState => {
+      this.log.info("Migration state: '${state}' => '${newState}'",
+                    {state: this._state, newState: newState});
+      if (forceObserver || newState !== this._state) {
+        this._state = newState;
+        Services.obs.notifyObservers(null, OBSERVER_STATE_CHANGE_TOPIC, newState);
+      }
+      return newState;
+    }
+
+    // If we have no sync user, or are already using an FxA account we must
+    // be done.
+    if (WeaveService.fxAccountsEnabled) {
+      // should not be necessary, but if we somehow ended up with FxA enabled
+      // and sync blocked it would be bad - so better safe than sorry.
+      this._unblockSync();
+      return update(null);
+    }
+
+    // so we need to migrate - let's see how far along we are.
+    // If sync isn't in EOL mode, then we are still waiting for the server
+    // to offer the migration process - so no user action necessary.
+    let isEOL = false;
+    try {
+      isEOL = !!Services.prefs.getCharPref("services.sync.errorhandler.alert.mode");
+    } catch (e) {}
+
+    if (!isEOL) {
+      return update(null);
+    }
+
+    // So we are in EOL mode - have we a user?
+    let fxauser = yield fxAccounts.getSignedInUser();
+    if (!fxauser) {
+      return update(this.STATE_USER_FXA);
+    }
+    if (!fxauser.verified) {
+      return update(this.STATE_USER_FXA_VERIFIED);
+    }
+
+    // So we just have housekeeping to do - we aren't blocked on a user, so
+    // reflect that.
+    this.log.info("No next user state - doing some housekeeping");
+    update(null);
+
+    // We need to disable sync from automatically starting,
+    // and if we are currently syncing wait for it to complete.
+    this._blockSync();
+
+    // Are we currently syncing?
+    if (Weave.Service._locked) {
+      // our observers will kick us further along when complete.
+      this.log.info("waiting for sync to complete")
+      return null;
+    }
+
+    // Write the migration sentinel if necessary.
+    yield this._setMigrationSentinelIfNecessary();
+
+    // Must be ready to perform the actual migration.
+    this.log.info("Performing final sync migration steps");
+    // Do the actual migration.
+    let startOverComplete = new Promise((resolve, reject) => {
+      Services.obs.addObserver(observe = () => {
+        this.log.info("observed that startOver is complete");
+        Services.obs.removeObserver(observe, "weave:service:start-over:finish");
+        resolve();
+      }, "weave:service:start-over:finish", false);
+    });
+
+    Weave.Service.startOver();
+    // need to wait for an observer.
+    yield startOverComplete;
+    // observer fired, now kick things off with the FxA user.
+    this.log.info("scheduling initial FxA sync.");
+    this._unblockSync();
+    Weave.Service.scheduler.scheduleNextSync(0);
+    return null;
+  }),
+
+  /* Return an object with the preferences we care about */
+  _getSentinelPrefs() {
+    let result = {};
+    for (let pref of FXA_SENTINEL_PREFS) {
+      if (Services.prefs.prefHasUserValue(pref)) {
+        result[pref] = Services.prefs.getCharPref(pref);
+      }
+    }
+    return result;
+  },
+
+  /* Apply any preferences we've obtained from the sentinel */
+  _applySentinelPrefs(savedPrefs) {
+    for (let pref of FXA_SENTINEL_PREFS) {
+      if (savedPrefs[pref]) {
+        Services.prefs.setCharPref(pref, savedPrefs[pref]);
+      }
+    }
+  },
+
+  /* Ask sync to upload the migration sentinel */
+  _setSyncMigrationSentinel: Task.async(function* () {
+    yield WeaveService.whenLoaded();
+    let signedInUser = yield fxAccounts.getSignedInUser();
+    let sentinel = {
+      email: signedInUser.email,
+      uid: signedInUser.uid,
+      verified: signedInUser.verified,
+      prefs: this._getSentinelPrefs(),
+    };
+    if (Weave.Service.setFxaMigrationSentinel) {
+      yield Weave.Service.setFxaMigrationSentinel(sentinel);
+    } else {
+      this.log.warn("Waiting on bug 1017433; no sync sentinel");
+    }
+  }),
+
+  /* Ask sync to upload the migration sentinal if we (or any other linked device)
+     haven't previously written one.
+   */
+  _setMigrationSentinelIfNecessary: Task.async(function* () {
+    if (!(yield this._getSyncMigrationSentinel())) {
+      this.log.info("writing the migration sentinel");
+      yield this._setSyncMigrationSentinel();
+    }
+  }),
+
+  /* Ask sync to return a migration sentinel if one exists, otherwise return null */
+  _getSyncMigrationSentinel: Task.async(function* () {
+    yield WeaveService.whenLoaded();
+    if (!Weave.Service.getFxaMigrationSentinel) {
+      this.log.warn("Waiting on bug 1017433; no sync sentinel");
+      return null;
+    }
+    let sentinel = yield Weave.Service.getFxaMigrationSentinel();
+    this.log.debug("got migration sentinel ${}", sentinel);
+    return sentinel;
+  }),
+
+  _getDefaultAccountName: Task.async(function* (sentinel) {
+    // Requires looking to see if other devices have written a migration
+    // sentinel (eg, see _haveSynchedMigrationSentinel), and if not, see if
+    // the legacy account name appears to be a valid email address (via the
+    // services.sync.account pref), otherwise return null.
+    // NOTE: Sync does all this synchronously via nested event loops, but we
+    // expose a promise to make future migration to an async-sync easier.
+    if (sentinel && sentinel.email) {
+      this.log.info("defaultAccountName found via sentinel: ${}", sentinel.email);
+      return sentinel.email;
+    }
+    // No previous migrations, so check the existing account name.
+    let account = Weave.Service.identity.account;
+    if (account && account.contains("@")) {
+      this.log.info("defaultAccountName found via legacy account name: {}", account);
+      return account;
+    }
+    this.log.info("defaultAccountName could not find an account");
+    return null;
+  }),
+
+  // Prevent sync from automatically starting
+  _blockSync() {
+    if (Weave.Service.scheduler.blockSync) {
+      Weave.Service.scheduler.blockSync();
+    } else {
+      this.log.warn("Waiting on bug 1019408; sync not blocked");
+    }
+  },
+
+  _unblockSync() {
+    if (Weave.Service.scheduler.unblockSync) {
+      Weave.Service.scheduler.unblockSync();
+    } else {
+      this.log.warn("Waiting on bug 1019408; sync not unblocked");
+    }
+  },
+
+  /*
+   * Some helpers for the UI to try and move to the next state.
+   */
+
+  // Open a UI for the user to create a Firefox Account.  This should only be
+  // called while we are in the STATE_USER_FXA state.  When the user completes
+  // the creation we'll see an ONLOGIN_NOTIFICATION notification from FxA and
+  // we'll move to either the STATE_USER_FXA_VERIFIED state or we'll just
+  // complete the migration if they login as an already verified user.
+  createFxAccount: Task.async(function* (win) {
+    // warn if we aren't in the expected state - but go ahead anyway!
+    if (this._state != this.STATE_USER_FXA) {
+      this.log.warn("createFxAccount called in an unexpected state: ${}", this._state);
+    }
+    // We need to obtain the sentinel and apply any prefs that might be
+    // specified *before* attempting to setup FxA as the prefs might
+    // specify custom servers etc.
+    let sentinel = yield this._getSyncMigrationSentinel();
+    if (sentinel && sentinel.prefs) {
+      this._applySentinelPrefs(sentinel.prefs);
+    }
+    // If we already have a sentinel then we assume the user has previously
+    // created the specified account, so just ask to sign-in.
+    let action = sentinel ? "signin" : "signup";
+    // See if we can find a default account name to use.
+    let email = yield this._getDefaultAccountName(sentinel);
+    let tail = email ? "&email=" + encodeURIComponent(email) : "";
+    win.switchToTabHavingURI("about:accounts?" + action + tail, true,
+                             {ignoreFragment: true, replaceQueryString: true});
+    // An FxA observer will fire when the user completes this, which will
+    // cause us to move to the next "user blocked" state and notify via our
+    // observer notification.
+  }),
+
+  // Ask the FxA servers to re-send a verification mail for the currently
+  // logged in user. This should only be called while we are in the
+  // STATE_USER_FXA_VERIFIED state.  When the user clicks on the link in
+  // the mail we should see an ONVERIFIED_NOTIFICATION which will cause us
+  // to complete the migration.
+  resendVerificationMail: Task.async(function * () {
+    // warn if we aren't in the expected state - but go ahead anyway!
+    if (this._state != this.STATE_USER_FXA_VERIFIED) {
+      this.log.warn("createFxAccount called in an unexpected state: ${}", this._state);
+    }
+    return fxAccounts.resendVerificationEmail();
+  }),
+
+  // "forget" about the current Firefox account. This should only be called
+  // while we are in the STATE_USER_FXA_VERIFIED state.  After this we will
+  // see an ONLOGOUT_NOTIFICATION, which will cause the migrator to return back
+  // to the STATE_USER_FXA state, from where they can choose a different account.
+  forgetFxAccount: Task.async(function * () {
+    // warn if we aren't in the expected state - but go ahead anyway!
+    if (this._state != this.STATE_USER_FXA_VERIFIED) {
+      this.log.warn("createFxAccount called in an unexpected state: ${}", this._state);
+    }
+    return fxAccounts.signOut();
+  }),
+
+}
+
+// We expose a singleton
+this.EXPORTED_SYMBOLS = ["fxaMigrator"];
+let fxaMigrator = new Migrator();
--- a/services/sync/moz.build
+++ b/services/sync/moz.build
@@ -15,16 +15,17 @@ EXTRA_PP_COMPONENTS += [
     'SyncComponents.manifest',
 ]
 
 EXTRA_JS_MODULES['services-sync'] += [
     'modules/addonsreconciler.js',
     'modules/addonutils.js',
     'modules/browserid_identity.js',
     'modules/engines.js',
+    'modules/FxaMigrator.jsm',
     'modules/healthreport.jsm',
     'modules/identity.js',
     'modules/jpakeclient.js',
     'modules/keys.js',
     'modules/main.js',
     'modules/notifications.js',
     'modules/policies.js',
     'modules/record.js',
--- a/services/sync/tests/unit/head_http_server.js
+++ b/services/sync/tests/unit/head_http_server.js
@@ -509,18 +509,21 @@ let SyncServerCallback = {
   onCollectionDeleted: function onCollectionDeleted(user, collection) {},
   onItemDeleted: function onItemDeleted(user, collection, wboID) {},
 
   /**
    * Called at the top of every request.
    *
    * Allows the test to inspect the request. Hooks should be careful not to
    * modify or change state of the request or they may impact future processing.
+   * The response is also passed so the callback can set headers etc - but care
+   * must be taken to not screw with the response body or headers that may
+   * conflict with normal operation of this server.
    */
-  onRequest: function onRequest(request) {},
+  onRequest: function onRequest(request, response) {},
 };
 
 /**
  * Construct a new test Sync server. Takes a callback object (e.g.,
  * SyncServerCallback) as input.
  */
 function SyncServer(callback) {
   this.callback = callback || {__proto__: SyncServerCallback};
@@ -791,17 +794,17 @@ SyncServer.prototype = {
       }
     }
   },
 
   _handleDefault: function _handleDefault(handler, req, resp) {
     this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path);
 
     if (this.callback.onRequest) {
-      this.callback.onRequest(req);
+      this.callback.onRequest(req, resp);
     }
 
     let parts = this.pathRE.exec(req.path);
     if (!parts) {
       this._log.debug("SyncServer: Unexpected request: bad URL " + req.path);
       throw HTTP_404;
     }
 
new file mode 100644
--- /dev/null
+++ b/services/sync/tests/unit/test_fxa_migration.js
@@ -0,0 +1,270 @@
+// Test the FxAMigration module
+Cu.import("resource://services-sync/FxaMigrator.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://services-sync/browserid_identity.js");
+
+// Set our username pref early so sync initializes with the legacy provider.
+Services.prefs.setCharPref("services.sync.username", "foo");
+// And ensure all debug messages end up being printed.
+Services.prefs.setCharPref("services.sync.log.appender.dump", "Debug");
+
+// Now import sync
+Cu.import("resource://services-sync/service.js");
+Cu.import("resource://services-sync/record.js");
+Cu.import("resource://services-sync/util.js");
+
+// And reset the username.
+Services.prefs.clearUserPref("services.sync.username");
+
+Cu.import("resource://testing-common/services/sync/utils.js");
+Cu.import("resource://testing-common/services/common/logging.js");
+Cu.import("resource://testing-common/services/sync/rotaryengine.js");
+
+const FXA_USERNAME = "someone@somewhere";
+
+// Utilities
+function promiseOneObserver(topic) {
+  return new Promise((resolve, reject) => {
+    let observer = function(subject, topic, data) {
+      Services.obs.removeObserver(observer, topic);
+      resolve(data);
+    }
+    Services.obs.addObserver(observer, topic, false);
+  });
+}
+
+function promiseStopServer(server) {
+  return new Promise((resolve, reject) => {
+    server.stop(resolve);
+  });
+}
+
+
+// Helpers
+function configureLegacySync() {
+  let engine = new RotaryEngine(Service);
+  engine.enabled = true;
+  Svc.Prefs.set("registerEngines", engine.name);
+  Svc.Prefs.set("log.logger.engine.rotary", "Trace");
+
+  let contents = {
+    meta: {global: {engines: {rotary: {version: engine.version,
+                                       syncID:  engine.syncID}}}},
+    crypto: {},
+    rotary: {}
+  };
+
+  const USER = "foo";
+  const PASSPHRASE = "abcdeabcdeabcdeabcdeabcdea";
+
+  setBasicCredentials(USER, "password", PASSPHRASE);
+
+  let onRequest = function(request, response) {
+    // ideally we'd only do this while a legacy user is configured, but WTH.
+    response.setHeader("x-weave-alert", JSON.stringify({code: "soft-eol"}));
+  }
+  let server = new SyncServer({onRequest: onRequest});
+  server.registerUser(USER, "password");
+  server.createContents(USER, contents);
+  server.start();
+
+  Service.serverURL = server.baseURI;
+  Service.clusterURL = server.baseURI;
+  Service.identity.username = USER;
+  Service._updateCachedURLs();
+
+  Service.engineManager._engines[engine.name] = engine;
+
+  return [engine, server];
+}
+
+function configureFxa() {
+  Services.prefs.setCharPref("identity.fxaccounts.auth.uri", "http://localhost");
+}
+
+add_task(function *testMigration() {
+  configureFxa();
+
+  // when we do a .startOver we want the new provider.
+  let oldValue = Services.prefs.getBoolPref("services.sync-testing.startOverKeepIdentity");
+  Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", false);
+  do_register_cleanup(() => {
+    Services.prefs.setBoolPref("services.sync-testing.startOverKeepIdentity", oldValue)
+  });
+
+  // No sync user - that should report no user-action necessary.
+  Assert.deepEqual((yield fxaMigrator._queueCurrentUserState()), null,
+                   "no user state when complete");
+
+  // Arrange for a legacy sync user and manually bump the migrator
+  let [engine, server] = configureLegacySync();
+
+  // monkey-patch the migration sentinel code so we know it was called.
+  let haveStartedSentinel = false;
+// (This is waiting on bug 1017433)
+/**
+  let origSetFxaMigrationSentinel = Service.setFxaMigrationSentinel;
+  let promiseSentinelWritten = new Promise((resolve, reject) => {
+    Service.setFxaMigrationSentinel = function(arg) {
+      haveStartedSentinel = true;
+      return origSetFxaMigrationSentinel.call(Service, arg).then(result => {
+        Service.setFxaMigrationSentinel = origSetFxaMigrationSentinel;
+        resolve(result);
+        return result;
+      });
+    }
+  });
+**/
+  // We are now configured for legacy sync, but we aren't in an EOL state yet,
+  // so should still be not waiting for a user.
+  Assert.deepEqual((yield fxaMigrator._queueCurrentUserState()), null,
+                   "no user state before server EOL");
+
+  // Start a sync - this will cause an EOL notification which the migrator's
+  // observer will notice.
+  let promise = promiseOneObserver("fxa-migration:state-changed");
+  _("Starting sync");
+  Service.sync();
+  _("Finished sync");
+
+  // We should have seen the observer, so be waiting for an FxA user.
+  Assert.equal((yield promise), fxaMigrator.STATE_USER_FXA, "now waiting for FxA.")
+
+  // Re-calling our user-state promise should also reflect the same state.
+  Assert.equal((yield fxaMigrator._queueCurrentUserState()),
+               fxaMigrator.STATE_USER_FXA,
+               "still waiting for FxA.");
+
+  // arrange for an unverified FxA user.
+  let config = makeIdentityConfig({username: FXA_USERNAME});
+  let fxa = new FxAccounts({});
+  config.fxaccount.user.email = config.username;
+  delete config.fxaccount.user.verified;
+  // *sob* - shouldn't need this boilerplate
+  fxa.internal.currentAccountState.getCertificate = function(data, keyPair, mustBeValidUntil) {
+    this.cert = {
+      validUntil: fxa.internal.now() + CERT_LIFETIME,
+      cert: "certificate",
+    };
+    return Promise.resolve(this.cert.cert);
+  };
+
+  // As soon as we set the FxA user the observers should fire and magically
+  // transition.
+  promise = promiseOneObserver("fxa-migration:state-changed");
+  fxAccounts.setSignedInUser(config.fxaccount.user);
+
+  Assert.equal((yield promise),
+               fxaMigrator.STATE_USER_FXA_VERIFIED,
+               "now waiting for verification");
+
+  // should have seen the user set, so state should automatically update.
+  Assert.equal((yield fxaMigrator._queueCurrentUserState()),
+               fxaMigrator.STATE_USER_FXA_VERIFIED,
+               "now waiting for verification");
+
+  // Before we verify the user, fire off a sync that calls us back during
+  // the sync and before it completes - this way we can ensure we do the right
+  // thing in terms of blocking sync and waiting for it to complete.
+
+  let wasWaiting = false;
+  // This is a PITA as sync is pseudo-blocking.
+  engine._syncFinish = function () {
+    // We aren't in a generator here, so use a helper to block on promises.
+    function getState() {
+      let cb = Async.makeSpinningCallback();
+      fxaMigrator._queueCurrentUserState().then(state => cb(null, state));
+      return cb.wait();
+    }
+    // should still be waiting for verification.
+    Assert.equal(getState(), fxaMigrator.STATE_USER_FXA_VERIFIED,
+                 "still waiting for verification");
+
+    // arrange for the user to be verified.  The fxAccount's mock story is
+    // broken, so go behind its back.
+    config.fxaccount.user.verified = true;
+    fxAccounts.setSignedInUser(config.fxaccount.user);
+    Services.obs.notifyObservers(null, ONVERIFIED_NOTIFICATION, null);
+
+    // spinningly wait for the migrator to catch up - sync is running so
+    // we should be in a 'null' user-state as there is no user-action
+    // necessary.
+    let cb = Async.makeSpinningCallback();
+    promiseOneObserver("fxa-migration:state-changed").then(state => cb(null, state));
+    Assert.equal(cb.wait(), null, "no user action necessary while sync completes.");
+
+    // We must not have started writing the sentinel yet.
+    Assert.ok(!haveStartedSentinel, "haven't written a sentinel yet");
+
+    // sync should be blocked from continuing
+// (This is waiting on bug 1019408)
+/**
+    Assert.ok(Service.scheduler.isBlocked, "sync is blocked.")
+**/
+
+    wasWaiting = true;
+    throw ex;
+  };
+
+  _("Starting sync");
+  Service.sync();
+  _("Finished sync");
+
+  // mock sync so we can ensure the final sync is scheduled with the FxA user.
+  // (letting a "normal" sync complete is a PITA without mocking huge amounts
+  // of FxA infra)
+  let promiseFinalSync = new Promise((resolve, reject) => {
+    let oldSync = Service.sync;
+    Service.sync = function() {
+      Service.sync = oldSync;
+      resolve();
+    }
+  });
+
+  Assert.ok(wasWaiting, "everything was good while sync was running.")
+
+  // The migration is now going to run to completion.
+  // sync should still be "blocked"
+// (This is waiting on bug 1019408)
+/**
+  Assert.ok(Service.scheduler.isBlocked, "sync is blocked.");
+**/
+
+  // We should see the migration sentinel written and it should return true.
+// (This is waiting on bug 1017433)
+/**
+  Assert.ok((yield promiseSentinelWritten), "wrote the sentinel");
+**/
+
+  // And we should see a new sync start
+  yield promiseFinalSync;
+
+  // and we should be configured for FxA
+  let WeaveService = Cc["@mozilla.org/weave/service;1"]
+         .getService(Components.interfaces.nsISupports)
+         .wrappedJSObject;
+  Assert.ok(WeaveService.fxAccountsEnabled, "FxA is enabled");
+  Assert.ok(Service.identity instanceof BrowserIDManager,
+            "sync is configured with the browserid_identity provider.");
+  Assert.equal(Service.identity.username, config.username, "correct user configured")
+  Assert.ok(!Service.scheduler.isBlocked, "sync is not blocked.")
+  // and the user state should remain null.
+  Assert.deepEqual((yield fxaMigrator._queueCurrentUserState()),
+                   null,
+                   "still no user action necessary");
+
+  // aaaand, we are done - clean up.
+  yield promiseStopServer(server);
+});
+
+
+function run_test() {
+  initTestLogging();
+  do_register_cleanup(() => {
+    fxaMigrator.finalize();
+    Svc.Prefs.resetBranch("");
+  });
+  run_next_test();
+}
--- a/services/sync/tests/unit/xpcshell.ini
+++ b/services/sync/tests/unit/xpcshell.ini
@@ -167,8 +167,11 @@ skip-if = debug
 [test_tab_engine.js]
 [test_tab_store.js]
 [test_tab_tracker.js]
 
 [test_healthreport.js]
 skip-if = ! healthreport
 
 [test_warn_on_truncated_response.js]
+
+# FxA migration
+[test_fxa_migration.js]