Bug 783721 - Refactor User API service interaction; r=rnewman
authorGregory Szorc <gps@mozilla.com>
Wed, 29 Aug 2012 14:43:39 -0700
changeset 111047 b78552875522d395c649e41eb3389fbd6be5d5ec
parent 111046 3818219abd6bbc4a65026b900e0c18ca9f575017
child 111048 e10e5cf8fafa03f6a391f36f2defd109479b84e1
push id93
push usernmatsakis@mozilla.com
push dateWed, 31 Oct 2012 21:26:57 +0000
reviewersrnewman
bugs783721
milestone18.0a1
Bug 783721 - Refactor User API service interaction; r=rnewman
services/sync/Makefile.in
services/sync/modules/service.js
services/sync/modules/stages/cluster.js
services/sync/modules/userapi.js
services/sync/services-sync.js
services/sync/tests/unit/test_load_modules.js
services/sync/tests/unit/test_node_reassignment.js
services/sync/tests/unit/test_service_attributes.js
services/sync/tests/unit/test_service_createAccount.js
--- a/services/sync/Makefile.in
+++ b/services/sync/Makefile.in
@@ -34,16 +34,17 @@ sync_modules := \
   main.js \
   notifications.js \
   policies.js \
   record.js \
   resource.js \
   rest.js \
   service.js \
   status.js \
+  userapi.js \
   util.js \
   $(NULL)
 
 # The set of JavaScript modules provide engines for Sync. These are
 # copied as-is.
 sync_engine_modules := \
   addons.js \
   apps.js \
--- a/services/sync/modules/service.js
+++ b/services/sync/modules/service.js
@@ -15,32 +15,33 @@ const CLUSTER_BACKOFF = 5 * 60 * 1000; /
 
 // How long a key to generate from an old passphrase.
 const PBKDF2_KEY_BYTES = 16;
 
 const CRYPTO_COLLECTION = "crypto";
 const KEYS_WBO = "keys";
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-common/preferences.js");
 Cu.import("resource://services-common/utils.js");
-Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/constants.js");
 Cu.import("resource://services-sync/engines.js");
 Cu.import("resource://services-sync/engines/clients.js");
-Cu.import("resource://services-common/preferences.js");
 Cu.import("resource://services-sync/identity.js");
-Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-sync/main.js");
+Cu.import("resource://services-sync/policies.js");
+Cu.import("resource://services-sync/record.js");
 Cu.import("resource://services-sync/resource.js");
 Cu.import("resource://services-sync/rest.js");
-Cu.import("resource://services-sync/status.js");
-Cu.import("resource://services-sync/policies.js");
-Cu.import("resource://services-sync/util.js");
-Cu.import("resource://services-sync/main.js");
 Cu.import("resource://services-sync/stages/cluster.js");
 Cu.import("resource://services-sync/stages/enginesync.js");
+Cu.import("resource://services-sync/status.js");
+Cu.import("resource://services-sync/userapi.js");
+Cu.import("resource://services-sync/util.js");
 
 const STORAGE_INFO_TYPES = [INFO_COLLECTIONS,
                             INFO_COLLECTION_USAGE,
                             INFO_COLLECTION_COUNTS,
                             INFO_QUOTA];
 
 /*
  * Service singleton
@@ -83,22 +84,31 @@ WeaveSvc.prototype = {
   get miscAPI() {
     // Append to the serverURL if it's a relative fragment
     let misc = Svc.Prefs.get("miscURL");
     if (misc.indexOf(":") == -1)
       misc = this.serverURL + misc;
     return misc + MISC_API_VERSION + "/";
   },
 
-  get userAPI() {
-    // Append to the serverURL if it's a relative fragment
-    let user = Svc.Prefs.get("userURL");
-    if (user.indexOf(":") == -1)
-      user = this.serverURL + user;
-    return user + USER_API_VERSION + "/";
+  /**
+   * The URI of the User API service.
+   *
+   * This is the base URI of the service as applicable to all users up to
+   * and including the server version path component, complete with trailing
+   * forward slash.
+   */
+  get userAPIURI() {
+    // Append to the serverURL if it's a relative fragment.
+    let url = Svc.Prefs.get("userURL");
+    if (!url.contains(":")) {
+      url = this.serverURL + url;
+    }
+
+    return url + USER_API_VERSION + "/";
   },
 
   get pwResetURL() {
     return this.serverURL + "weave-password-reset";
   },
 
   get updatedURL() {
     return WEAVE_CHANNEL == "dev" ? UPDATED_DEV_URL : UPDATED_REL_URL;
@@ -706,33 +716,32 @@ WeaveSvc.prototype = {
     }
     let keysChanged = this.handleFetchedKeys(this._identity.syncKeyBundle,
                                              cryptoKeys, true);
     if (keysChanged) {
       this._log.info("Downloaded keys differed, as expected.");
     }
   },
 
-  changePassword: function changePassword(newpass) {
-    let url = this.userAPI + this._identity.username + "/password";
+  changePassword: function changePassword(newPassword) {
+    let client = new UserAPI10Client(this.userAPIURI);
+    let cb = Async.makeSpinningCallback();
+    client.changePassword(this._identity.username,
+                          this._identity.basicPassword, newPassword, cb);
+
     try {
-      let resp = new Resource(url).post(Utils.encodeUTF8(newpass));
-      if (resp.status != 200) {
-        this._log.debug("Password change failed: " + resp);
-        return false;
-      }
-    }
-    catch(ex) {
-      // Must have failed on some network issue
-      this._log.debug("changePassword failed: " + Utils.exceptionStr(ex));
+      cb.wait();
+    } catch (ex) {
+      this._log.debug("Password change failed: " +
+                      CommonUtils.exceptionStr(ex));
       return false;
     }
 
     // Save the new password for requests and login manager.
-    this._identity.basicPassword = newpass;
+    this._identity.basicPassword = newPassword;
     this.persistLogin();
     return true;
   },
 
   changePassphrase: function changePassphrase(newphrase) {
     return this._catch(function doChangePasphrase() {
       /* Wipe. */
       this.wipeServer();
@@ -861,72 +870,52 @@ WeaveSvc.prototype = {
 
     this._log.info("Logging out");
     this._loggedIn = false;
 
     Svc.Obs.notify("weave:service:logout:finish");
   },
 
   checkAccount: function checkAccount(account) {
+    let client = new UserAPI10Client(this.userAPIURI);
+    let cb = Async.makeSpinningCallback();
+
     let username = this._identity.usernameFromAccount(account);
-    let url = this.userAPI + username;
-    let res = new Resource(url);
+    client.usernameExists(username, cb);
 
-    let data = "";
     try {
-      data = res.get();
-      if (data.status == 200) {
-        if (data == "0")
-          return "available";
-        else if (data == "1")
-          return "notAvailable";
-      }
-
+      let exists = cb.wait();
+      return exists ? "notAvailable" : "available";
+    } catch (ex) {
+      // TODO fix API convention.
+      return ErrorHandler.errorStr(ex);
     }
-    catch(ex) {}
-
-    // Convert to the error string, or default to generic on exception.
-    return ErrorHandler.errorStr(data);
   },
 
   createAccount: function createAccount(email, password,
                                         captchaChallenge, captchaResponse) {
-    let username = this._identity.usernameFromAccount(email);
-    let payload = JSON.stringify({
-      "password": Utils.encodeUTF8(password),
-      "email": email,
-      "captcha-challenge": captchaChallenge,
-      "captcha-response": captchaResponse
-    });
-
-    let url = this.userAPI + username;
-    let res = new Resource(url);
+    let client = new UserAPI10Client(this.userAPIURI);
 
     // Hint to server to allow scripted user creation or otherwise
     // ignore captcha.
-    if (Svc.Prefs.isSet("admin-secret"))
-      res.setHeader("X-Weave-Secret", Svc.Prefs.get("admin-secret", ""));
-
-    let error = "generic-server-error";
-    try {
-      let register = res.put(payload);
-      if (register.success) {
-        this._log.info("Account created: " + register);
-        return null;
-      }
-
-      // Must have failed, so figure out the reason
-      if (register.status == 400)
-        error = ErrorHandler.errorStr(register);
-    }
-    catch(ex) {
-      this._log.warn("Failed to create account: " + ex);
+    if (Svc.Prefs.isSet("admin-secret")) {
+      client.adminSecret = Svc.Prefs.get("admin-secret", "");
     }
 
-    return error;
+    let cb = Async.makeSpinningCallback();
+
+    client.createAccount(email, password, captchaChallenge, captchaResponse,
+                         cb);
+
+    try {
+      cb.wait();
+      return null;
+    } catch (ex) {
+      return ErrorHandler.errorStr(ex.body);
+    }
   },
 
   // Stuff we need to do after login, before we can really do
   // anything (e.g. key setup).
   _remoteSetup: function _remoteSetup(infoResponse) {
     let reset = false;
 
     this._log.debug("Fetching global metadata record");
--- a/services/sync/modules/stages/cluster.js
+++ b/services/sync/modules/stages/cluster.js
@@ -30,18 +30,20 @@ ClusterManager.prototype = {
   /**
    * Obtain the cluster for the current user.
    *
    * Returns the string URL of the cluster or null on error.
    */
   _findCluster: function _findCluster() {
     this._log.debug("Finding cluster for user " + this.identity.username);
 
+    // This should ideally use UserAPI10Client but the legacy hackiness is
+    // strong with this code.
     let fail;
-    let res = new Resource(this.service.userAPI + this.identity.username +
+    let res = new Resource(this.service.userAPIURI + this.identity.username +
                            "/node/weave");
     try {
       let node = res.get();
       switch (node.status) {
         case 400:
           Status.login = LOGIN_FAILED_LOGIN_REJECTED;
           fail = "Find cluster denied: " + ErrorHandler.errorStr(node);
           break;
new file mode 100644
--- /dev/null
+++ b/services/sync/modules/userapi.js
@@ -0,0 +1,224 @@
+/* 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 EXPORTED_SYMBOLS = [
+  "UserAPI10Client",
+];
+
+const {utils: Cu} = Components;
+
+Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-common/rest.js");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-sync/identity.js");
+Cu.import("resource://services-sync/util.js");
+
+/**
+ * A generic client for the user API 1.0 service.
+ *
+ * http://docs.services.mozilla.com/reg/apis.html
+ *
+ * Instances are constructed with the base URI of the service.
+ */
+function UserAPI10Client(baseURI) {
+  this._log = Log4Moz.repository.getLogger("Sync.UserAPI");
+  this._log.level = Log4Moz.Level[Svc.Prefs.get("log.logger.userapi")];
+
+  this.baseURI = baseURI;
+}
+UserAPI10Client.prototype = {
+  USER_CREATE_ERROR_CODES: {
+    2: "Incorrect or missing captcha.",
+    4: "User exists.",
+    6: "JSON parse failure.",
+    7: "Missing password field.",
+    9: "Requested password not strong enough.",
+    12: "No email address on file.",
+  },
+
+  /**
+   * Determine whether a specified username exists.
+   *
+   * Callback receives the following arguments:
+   *
+   *   (Error) Describes error that occurred or null if request was
+   *           successful.
+   *   (boolean) True if user exists. False if not. null if there was an error.
+   */
+  usernameExists: function usernameExists(username, cb) {
+    if (typeof(cb) != "function") {
+      throw new Error("cb must be a function.");
+    }
+
+    let url = this.baseURI + username;
+    let request = new RESTRequest(url);
+    request.get(this._onUsername.bind(this, cb, request));
+  },
+
+  /**
+   * Obtain the Weave (Sync) node for a specified user.
+   *
+   * The callback receives the following arguments:
+   *
+   *   (Error)  Describes error that occurred or null if request was successful.
+   *   (string) Username request is for.
+   *   (string) URL of user's node. If null and there is no error, no node could
+   *            be assigned at the time of the request.
+   */
+  getWeaveNode: function getWeaveNode(username, password, cb) {
+    if (typeof(cb) != "function") {
+      throw new Error("cb must be a function.");
+    }
+
+    let request = this._getRequest(username, "/node/weave", password);
+    request.get(this._onWeaveNode.bind(this, cb, request));
+  },
+
+  /**
+   * Change a password for the specified user.
+   *
+   * @param username
+   *        (string) The username whose password to change.
+   * @param oldPassword
+   *        (string) The old, current password.
+   * @param newPassword
+   *        (string) The new password to switch to.
+   */
+  changePassword: function changePassword(username, oldPassword, newPassword, cb) {
+    let request = this._getRequest(username, "/password", oldPassword);
+    request.onComplete = this._onChangePassword.bind(this, cb, request);
+    request.post(CommonUtils.encodeUTF8(newPassword));
+  },
+
+  createAccount: function createAccount(email, password, captchaChallenge,
+                                        captchaResponse, cb) {
+    let username = Identity.usernameFromAccount(email);
+    let body = JSON.stringify({
+      "email":             email,
+      "password":          Utils.encodeUTF8(password),
+      "captcha-challenge": captchaChallenge,
+      "captcha-response":  captchaResponse
+    });
+
+    let url = this.baseURI + username;
+    let request = new RESTRequest(url);
+
+    if (this.adminSecret) {
+      request.setHeader("X-Weave-Secret", this.adminSecret);
+    }
+
+    request.onComplete = this._onCreateAccount.bind(this, cb, request);
+    request.put(body);
+  },
+
+  _getRequest: function _getRequest(username, path, password=null) {
+    let url = this.baseURI + username + path;
+    let request = new RESTRequest(url);
+
+    if (password) {
+      let up = username + ":" + password;
+      request.setHeader("authorization", "Basic " + btoa(up));
+    }
+
+    return request;
+  },
+
+  _onUsername: function _onUsername(cb, request, error) {
+    if (error) {
+      cb(error, null);
+      return;
+    }
+
+    let body = request.response.body;
+    if (body == "0") {
+      cb(null, false);
+      return;
+    } else if (body == "1") {
+      cb(null, true);
+      return;
+    } else {
+      cb(new Error("Unknown response from server: " + body), null);
+      return;
+    }
+  },
+
+  _onWeaveNode: function _onWeaveNode(cb, request, error) {
+    if (error) {
+      cb.network = true;
+      cb(error, null);
+      return;
+    }
+
+    let response = request.response;
+
+    if (response.status == 200) {
+      let body = response.body;
+      if (body == "null") {
+        cb(null, null);
+        return;
+      }
+
+      cb(null, body);
+      return;
+    }
+
+    let error = new Error("Sync node retrieval failed.");
+    switch (response.status) {
+      case 400:
+        error.denied = true;
+        break;
+      case 404:
+        error.notFound = true;
+        break;
+      default:
+        error.message = "Unexpected response code: " + response.status;
+    }
+
+    cb(error, null);
+    return;
+  },
+
+  _onChangePassword: function _onChangePassword(cb, request, error) {
+    this._log.info("Password change response received: " +
+                   request.response.status);
+    if (error) {
+      cb(error);
+      return;
+    }
+
+    let response = request.response;
+    if (response.status != 200) {
+      cb(new Error("Password changed failed: " + response.body));
+      return;
+    }
+
+    cb(null);
+  },
+
+  _onCreateAccount: function _onCreateAccount(cb, request, error) {
+    let response = request.response;
+
+    this._log.info("Create account response: " + response.status + " " +
+                   response.body);
+
+    if (error) {
+      cb(new Error("HTTP transport error."), null);
+      return;
+    }
+
+    if (response.status == 200) {
+      cb(null, response.body);
+      return;
+    }
+
+    let error = new Error("Could not create user.");
+    error.body = response.body;
+
+    cb(error, null);
+    return;
+  },
+};
+Object.freeze(UserAPI10Client.prototype);
--- a/services/sync/services-sync.js
+++ b/services/sync/services-sync.js
@@ -64,9 +64,10 @@ pref("services.sync.log.logger.engine.bo
 pref("services.sync.log.logger.engine.clients", "Debug");
 pref("services.sync.log.logger.engine.forms", "Debug");
 pref("services.sync.log.logger.engine.history", "Debug");
 pref("services.sync.log.logger.engine.passwords", "Debug");
 pref("services.sync.log.logger.engine.prefs", "Debug");
 pref("services.sync.log.logger.engine.tabs", "Debug");
 pref("services.sync.log.logger.engine.addons", "Debug");
 pref("services.sync.log.logger.engine.apps", "Debug");
+pref("services.sync.log.logger.userapi", "Debug");
 pref("services.sync.log.cryptoDebug", false);
--- a/services/sync/tests/unit/test_load_modules.js
+++ b/services/sync/tests/unit/test_load_modules.js
@@ -19,18 +19,20 @@ const modules = [
   "keys.js",
   "main.js",
   "notifications.js",
   "policies.js",
   "record.js",
   "resource.js",
   "rest.js",
   "service.js",
+  "stages/cluster.js",
   "stages/enginesync.js",
   "status.js",
+  "userapi.js",
   "util.js",
 ];
 
 function run_test() {
   for each (let m in modules) {
     _("Attempting to load resource://services-sync/" + m);
     Cu.import("resource://services-sync/" + m, {});
   }
--- a/services/sync/tests/unit/test_node_reassignment.js
+++ b/services/sync/tests/unit/test_node_reassignment.js
@@ -72,17 +72,17 @@ function installNodeHandler(server, next
   _("Registered node handler at " + nodePath);
 }
 
 function prepareServer() {
   setBasicCredentials("johndoe", "ilovejane", "abcdeabcdeabcdeabcdeabcdea");
   Service.serverURL  = TEST_SERVER_URL;
   Service.clusterURL = TEST_CLUSTER_URL;
 
-  do_check_eq(Service.userAPI, "http://localhost:8080/user/1.0/");
+  do_check_eq(Service.userAPIURI, "http://localhost:8080/user/1.0/");
   let server = new SyncServer();
   server.registerUser("johndoe");
   server.start();
   return server;
 }
 
 function getReassigned() {
   try {
--- a/services/sync/tests/unit/test_service_attributes.js
+++ b/services/sync/tests/unit/test_service_attributes.js
@@ -38,23 +38,23 @@ function test_urls() {
     do_check_eq(Service.metaURL,
                 "http://weave.cluster/1.1/johndoe/storage/meta/global");
 
     _("The 'miscURL' and 'userURL' attributes can be relative to 'serverURL' or absolute.");
     Svc.Prefs.set("miscURL", "relative/misc/");
     Svc.Prefs.set("userURL", "relative/user/");
     do_check_eq(Service.miscAPI,
                 "http://weave.server/relative/misc/1.0/");
-    do_check_eq(Service.userAPI,
+    do_check_eq(Service.userAPIURI,
                 "http://weave.server/relative/user/1.0/");
 
     Svc.Prefs.set("miscURL", "http://weave.misc.services/");
     Svc.Prefs.set("userURL", "http://weave.user.services/");
     do_check_eq(Service.miscAPI, "http://weave.misc.services/1.0/");
-    do_check_eq(Service.userAPI, "http://weave.user.services/1.0/");
+    do_check_eq(Service.userAPIURI, "http://weave.user.services/1.0/");
 
     do_check_eq(Service.pwResetURL,
                 "http://weave.server/weave-password-reset");
 
     _("Empty/false value for 'username' resets preference.");
     Identity.username = "";
     do_check_eq(Svc.Prefs.get("username"), undefined);
     do_check_eq(Identity.username, null);
--- a/services/sync/tests/unit/test_service_createAccount.js
+++ b/services/sync/tests/unit/test_service_createAccount.js
@@ -1,14 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
 Cu.import("resource://services-sync/util.js");
 Cu.import("resource://services-sync/service.js");
 
 function run_test() {
-  var requestBody;
-  var secretHeader;
+  initTestLogging("Trace");
+
+  let requestBody;
+  let secretHeader;
   function send(statusCode, status, body) {
     return function(request, response) {
       requestBody = readBytesFromInputStream(request.bodyInputStream);
       if (request.hasHeader("X-Weave-Secret")) {
         secretHeader = request.getHeader("X-Weave-Secret");
       }
 
       response.setStatusLine(request.httpVersion, statusCode, status);