Bug 1019985 - Create module to drive sync migration process. r=adw

--HG--
extra : rebase_source : bfd0ab3dbeb6f230c1f4de88bdf8851560e9a44e
This commit is contained in:
Mark Hammond 2014-12-05 16:46:16 -08:00
parent 8d61fda380
commit fa6f303d45
6 changed files with 665 additions and 2 deletions

View File

@ -72,6 +72,11 @@ WeaveService.prototype = {
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.

View File

@ -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();

View File

@ -20,6 +20,7 @@ EXTRA_JS_MODULES['services-sync'] += [
'modules/addonutils.js',
'modules/browserid_identity.js',
'modules/engines.js',
'modules/FxaMigrator.jsm',
'modules/healthreport.jsm',
'modules/identity.js',
'modules/jpakeclient.js',

View File

@ -514,8 +514,11 @@ let SyncServerCallback = {
*
* 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) {},
};
/**
@ -796,7 +799,7 @@ SyncServer.prototype = {
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);

View File

@ -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();
}

View File

@ -172,3 +172,6 @@ skip-if = debug
skip-if = ! healthreport
[test_warn_on_truncated_response.js]
# FxA migration
[test_fxa_migration.js]