summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/tests/xpcshell
diff options
context:
space:
mode:
Diffstat (limited to 'services/fxaccounts/tests/xpcshell')
-rw-r--r--services/fxaccounts/tests/xpcshell/head.js18
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts.js1531
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js526
-rw-r--r--services/fxaccounts/tests/xpcshell/test_client.js917
-rw-r--r--services/fxaccounts/tests/xpcshell/test_credentials.js110
-rw-r--r--services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js214
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_client.js55
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js292
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js73
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js165
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_tokens.js251
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile.js409
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile_client.js411
-rw-r--r--services/fxaccounts/tests/xpcshell/test_push_service.js236
-rw-r--r--services/fxaccounts/tests/xpcshell/test_storage_manager.js477
-rw-r--r--services/fxaccounts/tests/xpcshell/test_web_channel.js499
-rw-r--r--services/fxaccounts/tests/xpcshell/xpcshell.ini23
17 files changed, 6207 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js
new file mode 100644
index 000000000..ed70fdac5
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/head.js
@@ -0,0 +1,18 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
+
+"use strict";
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+(function initFxAccountsTestingInfrastructure() {
+ do_get_profile();
+
+ let ns = {};
+ Cu.import("resource://testing-common/services/common/logging.js", ns);
+
+ ns.initTestLogging("Trace");
+}).call(this);
+
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts.js b/services/fxaccounts/tests/xpcshell/test_accounts.js
new file mode 100644
index 000000000..d6139a076
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -0,0 +1,1531 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+// We grab some additional stuff via backstage passes.
+var {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
+
+const ONE_HOUR_MS = 1000 * 60 * 60;
+const ONE_DAY_MS = ONE_HOUR_MS * 24;
+const TWO_MINUTES_MS = 1000 * 60 * 2;
+
+initTestLogging("Trace");
+
+// XXX until bug 937114 is fixed
+Cu.importGlobalProperties(['atob']);
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+
+// The oauth server is mocked, but set these prefs to pass param checks
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
+
+
+const PROFILE_SERVER_URL = "http://example.com/v1";
+const CONTENT_URL = "http://accounts.example.com/";
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", PROFILE_SERVER_URL);
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", CONTENT_URL);
+
+/*
+ * The FxAccountsClient communicates with the remote Firefox
+ * Accounts auth server. Mock the server calls, with a little
+ * lag time to simulate some latency.
+ *
+ * We add the _verified attribute to mock the change in verification
+ * state on the FXA server.
+ */
+
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+ this._deletedOnServer = false; // for testing accountStatus
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return Promise.resolve({
+ email: this._email,
+ verified: this._verified
+ });
+ };
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ this.accountKeys = function (keyFetchToken) {
+ let deferred = Promise.defer();
+
+ do_timeout(50, () => {
+ let response = {
+ kA: expandBytes("11"),
+ wrapKB: expandBytes("22")
+ };
+ deferred.resolve(response);
+ });
+ return deferred.promise;
+ };
+
+ this.resendVerificationEmail = function(sessionToken) {
+ // Return the session token to show that we received it in the first place
+ return Promise.resolve(sessionToken);
+ };
+
+ this.signCertificate = function() { throw "no" };
+
+ this.signOut = () => Promise.resolve();
+ this.signOutAndDestroyDevice = () => Promise.resolve({});
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+/*
+ * We need to mock the FxAccounts module's interfaces to external
+ * services, such as storage and the FxAccounts client. We also
+ * mock the now() method, so that we can simulate the passing of
+ * time and verify that signatures expire correctly.
+ */
+function MockFxAccounts() {
+ return new FxAccounts({
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms
+
+ _getCertificateSigned_calls: [],
+ _d_signCertificate: Promise.defer(),
+ _now_is: new Date(),
+ now: function () {
+ return this._now_is;
+ },
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ getCertificateSigned: function (sessionToken, serializedPublicKey) {
+ _("mock getCertificateSigned\n");
+ this._getCertificateSigned_calls.push([sessionToken, serializedPublicKey]);
+ return this._d_signCertificate.promise;
+ },
+ _registerOrUpdateDevice() {
+ return Promise.resolve();
+ },
+ fxAccountsClient: new MockFxAccountsClient()
+ });
+}
+
+/*
+ * Some tests want a "real" fxa instance - however, we still mock the storage
+ * to keep the tests fast on b2g.
+ */
+function MakeFxAccounts(internal = {}) {
+ if (!internal.newAccountState) {
+ // we use a real accountState but mocked storage.
+ internal.newAccountState = function(credentials) {
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ };
+ }
+ if (!internal._signOutServer) {
+ internal._signOutServer = () => Promise.resolve();
+ }
+ if (!internal._registerOrUpdateDevice) {
+ internal._registerOrUpdateDevice = () => Promise.resolve();
+ }
+ return new FxAccounts(internal);
+}
+
+add_task(function* test_non_https_remote_server_uri_with_requireHttps_false() {
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.allowHttp",
+ true);
+ Services.prefs.setCharPref(
+ "identity.fxaccounts.remote.signup.uri",
+ "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+ do_check_eq(yield fxAccounts.promiseAccountsSignUpURI(),
+ "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
+ Services.prefs.clearUserPref("identity.fxaccounts.allowHttp");
+});
+
+add_task(function* test_non_https_remote_server_uri() {
+ Services.prefs.setCharPref(
+ "identity.fxaccounts.remote.signup.uri",
+ "http://example.com/browser/browser/base/content/test/general/accounts_testRemoteCommands.html");
+ rejects(fxAccounts.promiseAccountsSignUpURI(), null, "Firefox Accounts server must use HTTPS");
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.signup.uri");
+});
+
+add_task(function* test_get_signed_in_user_initially_unset() {
+ _("Check getSignedInUser initially and after signout reports no user");
+ let account = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ let result = yield account.getSignedInUser();
+ do_check_eq(result, null);
+
+ yield account.setSignedInUser(credentials);
+ let histogram = Services.telemetry.getHistogramById("FXA_CONFIGURED");
+ do_check_eq(histogram.snapshot().sum, 1);
+ histogram.clear();
+
+ result = yield account.getSignedInUser();
+ do_check_eq(result.email, credentials.email);
+ do_check_eq(result.assertion, credentials.assertion);
+ do_check_eq(result.kB, credentials.kB);
+
+ // Delete the memory cache and force the user
+ // to be read and parsed from storage (e.g. disk via JSONStorage).
+ delete account.internal.signedInUser;
+ result = yield account.getSignedInUser();
+ do_check_eq(result.email, credentials.email);
+ do_check_eq(result.assertion, credentials.assertion);
+ do_check_eq(result.kB, credentials.kB);
+
+ // sign out
+ let localOnly = true;
+ yield account.signOut(localOnly);
+
+ // user should be undefined after sign out
+ result = yield account.getSignedInUser();
+ do_check_eq(result, null);
+});
+
+add_task(function* test_update_account_data() {
+ _("Check updateUserAccountData does the right thing.");
+ let account = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ yield account.setSignedInUser(credentials);
+
+ let newCreds = {
+ email: credentials.email,
+ uid: credentials.uid,
+ assertion: "new_assertion",
+ }
+ yield account.updateUserAccountData(newCreds);
+ do_check_eq((yield account.getSignedInUser()).assertion, "new_assertion",
+ "new field value was saved");
+
+ // but we should fail attempting to change email or uid.
+ newCreds = {
+ email: "someoneelse@example.com",
+ uid: credentials.uid,
+ assertion: "new_assertion",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+ newCreds = {
+ email: credentials.email,
+ uid: "another_uid",
+ assertion: "new_assertion",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+
+ // should fail without email or uid.
+ newCreds = {
+ assertion: "new_assertion",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+
+ // and should fail with a field name that's not known by storage.
+ newCreds = {
+ email: credentials.email,
+ uid: "another_uid",
+ foo: "bar",
+ }
+ yield Assert.rejects(account.updateUserAccountData(newCreds));
+});
+
+add_task(function* test_getCertificateOffline() {
+ _("getCertificateOffline()");
+ let fxa = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ };
+
+ yield fxa.setSignedInUser(credentials);
+
+ // Test that an expired cert throws if we're offline.
+ let offline = Services.io.offline;
+ Services.io.offline = true;
+ yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState).then(
+ result => {
+ Services.io.offline = offline;
+ do_throw("Unexpected success");
+ },
+ err => {
+ Services.io.offline = offline;
+ // ... so we have to check the error string.
+ do_check_eq(err, "Error: OFFLINE");
+ }
+ );
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+add_task(function* test_getCertificateCached() {
+ _("getCertificateCached()");
+ let fxa = MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ // A cached keypair and cert that remain valid.
+ keyPair: {
+ validUntil: Date.now() + KEY_LIFETIME + 10000,
+ rawKeyPair: "good-keypair",
+ },
+ cert: {
+ validUntil: Date.now() + CERT_LIFETIME + 10000,
+ rawCert: "good-cert",
+ },
+ };
+
+ yield fxa.setSignedInUser(credentials);
+ let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState);
+ // should have the same keypair and cert.
+ do_check_eq(keyPair, credentials.keyPair.rawKeyPair);
+ do_check_eq(certificate, credentials.cert.rawCert);
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+add_task(function* test_getCertificateExpiredCert() {
+ _("getCertificateExpiredCert()");
+ let fxa = MakeFxAccounts({
+ getCertificateSigned() {
+ return "new cert";
+ }
+ });
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ // A cached keypair that remains valid.
+ keyPair: {
+ validUntil: Date.now() + KEY_LIFETIME + 10000,
+ rawKeyPair: "good-keypair",
+ },
+ // A cached certificate which has expired.
+ cert: {
+ validUntil: Date.parse("Mon, 13 Jan 2000 21:45:06 GMT"),
+ rawCert: "expired-cert",
+ },
+ };
+ yield fxa.setSignedInUser(credentials);
+ let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState);
+ // should have the same keypair but a new cert.
+ do_check_eq(keyPair, credentials.keyPair.rawKeyPair);
+ do_check_neq(certificate, credentials.cert.rawCert);
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+add_task(function* test_getCertificateExpiredKeypair() {
+ _("getCertificateExpiredKeypair()");
+ let fxa = MakeFxAccounts({
+ getCertificateSigned() {
+ return "new cert";
+ },
+ });
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ // A cached keypair that has expired.
+ keyPair: {
+ validUntil: Date.now() - 1000,
+ rawKeyPair: "expired-keypair",
+ },
+ // A cached certificate which remains valid.
+ cert: {
+ validUntil: Date.now() + CERT_LIFETIME + 10000,
+ rawCert: "expired-cert",
+ },
+ };
+
+ yield fxa.setSignedInUser(credentials);
+ let {keyPair, certificate} = yield fxa.internal.getKeypairAndCertificate(fxa.internal.currentAccountState);
+ // even though the cert was valid, the fact the keypair was not means we
+ // should have fetched both.
+ do_check_neq(keyPair, credentials.keyPair.rawKeyPair);
+ do_check_neq(certificate, credentials.cert.rawCert);
+ yield fxa.signOut(/*localOnly = */true);
+});
+
+// Sanity-check that our mocked client is working correctly
+add_test(function test_client_mock() {
+ let fxa = new MockFxAccounts();
+ let client = fxa.internal.fxAccountsClient;
+ do_check_eq(client._verified, false);
+ do_check_eq(typeof client.signIn, "function");
+
+ // The recoveryEmailStatus function eventually fulfills its promise
+ client.recoveryEmailStatus()
+ .then(response => {
+ do_check_eq(response.verified, false);
+ run_next_test();
+ });
+});
+
+// Sign in a user, and after a little while, verify the user's email.
+// Right after signing in the user, we should get the 'onlogin' notification.
+// Polling should detect that the email is verified, and eventually
+// 'onverified' should be observed
+add_test(function test_verification_poll() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("francine");
+ let login_notification_received = false;
+
+ makeObserver(ONVERIFIED_NOTIFICATION, function() {
+ log.debug("test_verification_poll observed onverified");
+ // Once email verification is complete, we will observe onverified
+ fxa.internal.getUserAccountData().then(user => {
+ // And confirm that the user's state has changed
+ do_check_eq(user.verified, true);
+ do_check_eq(user.email, test_user.email);
+ do_check_true(login_notification_received);
+ run_next_test();
+ });
+ });
+
+ makeObserver(ONLOGIN_NOTIFICATION, function() {
+ log.debug("test_verification_poll observer onlogin");
+ login_notification_received = true;
+ });
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa.internal.getUserAccountData().then(user => {
+ // The user is signing in, but email has not been verified yet
+ do_check_eq(user.verified, false);
+ do_timeout(200, function() {
+ log.debug("Mocking verification of francine's email");
+ fxa.internal.fxAccountsClient._email = test_user.email;
+ fxa.internal.fxAccountsClient._verified = true;
+ });
+ });
+ });
+});
+
+// Sign in the user, but never verify the email. The check-email
+// poll should time out. No verifiedlogin event should be observed, and the
+// internal whenVerified promise should be rejected
+add_test(function test_polling_timeout() {
+ // This test could be better - the onverified observer might fire on
+ // somebody else's stack, and we're not making sure that we're not receiving
+ // such a message. In other words, this tests either failure, or success, but
+ // not both.
+
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ let removeObserver = makeObserver(ONVERIFIED_NOTIFICATION, function() {
+ do_throw("We should not be getting a login event!");
+ });
+
+ fxa.internal.POLL_SESSION = 1;
+
+ let p = fxa.internal.whenVerified({});
+
+ fxa.setSignedInUser(test_user).then(() => {
+ p.then(
+ (success) => {
+ do_throw("this should not succeed");
+ },
+ (fail) => {
+ removeObserver();
+ fxa.signOut().then(run_next_test);
+ }
+ );
+ });
+});
+
+add_test(function test_getKeys() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+
+ // Once email has been verified, we will be able to get keys
+ user.verified = true;
+
+ fxa.setSignedInUser(user).then(() => {
+ fxa.getSignedInUser().then((user) => {
+ // Before getKeys, we have no keys
+ do_check_eq(!!user.kA, false);
+ do_check_eq(!!user.kB, false);
+ // And we still have a key-fetch token and unwrapBKey to use
+ do_check_eq(!!user.keyFetchToken, true);
+ do_check_eq(!!user.unwrapBKey, true);
+
+ fxa.internal.getKeys().then(() => {
+ fxa.getSignedInUser().then((user) => {
+ // Now we should have keys
+ do_check_eq(fxa.internal.isUserEmailVerified(user), true);
+ do_check_eq(!!user.verified, true);
+ do_check_eq(user.kA, expandHex("11"));
+ do_check_eq(user.kB, expandHex("66"));
+ do_check_eq(user.keyFetchToken, undefined);
+ do_check_eq(user.unwrapBKey, undefined);
+ run_next_test();
+ });
+ });
+ });
+ });
+});
+
+add_task(function* test_getKeys_nonexistent_account() {
+ let fxa = new MockFxAccounts();
+ let bismarck = getTestUser("bismarck");
+
+ let client = fxa.internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(false);
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ yield fxa.setSignedInUser(bismarck);
+
+ let promiseLogout = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, function() {
+ log.debug("test_getKeys_nonexistent_account observed logout");
+ resolve();
+ });
+ });
+
+ try {
+ yield fxa.internal.getKeys();
+ do_check_true(false);
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ yield promiseLogout;
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user, null);
+});
+
+// getKeys with invalid keyFetchToken should delete keyFetchToken from storage
+add_task(function* test_getKeys_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let yusuf = getTestUser("yusuf");
+
+ let client = fxa.internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(true);
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ yield fxa.setSignedInUser(yusuf);
+
+ try {
+ yield fxa.internal.getKeys();
+ do_check_true(false);
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, yusuf.email);
+ do_check_eq(user.keyFetchToken, null);
+});
+
+// fetchAndUnwrapKeys with no keyFetchToken should trigger signOut
+add_test(function test_fetchAndUnwrapKeys_no_token() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("lettuce.protheroe");
+ delete user.keyFetchToken
+
+ makeObserver(ONLOGOUT_NOTIFICATION, function() {
+ log.debug("test_fetchAndUnwrapKeys_no_token observed logout");
+ fxa.internal.getUserAccountData().then(user => {
+ run_next_test();
+ });
+ });
+
+ fxa.setSignedInUser(user).then(
+ user => {
+ return fxa.internal.fetchAndUnwrapKeys();
+ }
+ ).then(
+ null,
+ error => {
+ log.info("setSignedInUser correctly rejected");
+ }
+ )
+});
+
+// Alice (User A) signs up but never verifies her email. Then Bob (User B)
+// signs in with a verified email. Ensure that no sign-in events are triggered
+// on Alice's behalf. In the end, Bob should be the signed-in user.
+add_test(function test_overlapping_signins() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ let bob = getTestUser("bob");
+
+ makeObserver(ONVERIFIED_NOTIFICATION, function() {
+ log.debug("test_overlapping_signins observed onverified");
+ // Once email verification is complete, we will observe onverified
+ fxa.internal.getUserAccountData().then(user => {
+ do_check_eq(user.email, bob.email);
+ do_check_eq(user.verified, true);
+ run_next_test();
+ });
+ });
+
+ // Alice is the user signing in; her email is unverified.
+ fxa.setSignedInUser(alice).then(() => {
+ log.debug("Alice signing in ...");
+ fxa.internal.getUserAccountData().then(user => {
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.verified, false);
+ log.debug("Alice has not verified her email ...");
+
+ // Now Bob signs in instead and actually verifies his email
+ log.debug("Bob signing in ...");
+ fxa.setSignedInUser(bob).then(() => {
+ do_timeout(200, function() {
+ // Mock email verification ...
+ log.debug("Bob verifying his email ...");
+ fxa.internal.fxAccountsClient._verified = true;
+ });
+ });
+ });
+ });
+});
+
+add_task(function* test_getAssertion_invalid_token() {
+ let fxa = new MockFxAccounts();
+
+ let client = fxa.internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(true);
+
+ let creds = {
+ sessionToken: "sessionToken",
+ kA: expandHex("11"),
+ kB: expandHex("66"),
+ verified: true,
+ email: "sonia@example.com",
+ };
+ yield fxa.setSignedInUser(creds);
+
+ try {
+ let promiseAssertion = fxa.getAssertion("audience.example.com");
+ fxa.internal._d_signCertificate.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ yield promiseAssertion;
+ do_check_true(false, "getAssertion should reject invalid session token");
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, creds.email);
+ do_check_eq(user.sessionToken, null);
+});
+
+add_task(function* test_getAssertion() {
+ let fxa = new MockFxAccounts();
+
+ do_check_throws(function* () {
+ yield fxa.getAssertion("nonaudience");
+ });
+
+ let creds = {
+ sessionToken: "sessionToken",
+ kA: expandHex("11"),
+ kB: expandHex("66"),
+ verified: true
+ };
+ // By putting kA/kB/verified in "creds", we skip ahead
+ // to the "we're ready" stage.
+ yield fxa.setSignedInUser(creds);
+
+ _("== ready to go\n");
+ // Start with a nice arbitrary but realistic date. Here we use a nice RFC
+ // 1123 date string like we would get from an HTTP header. Over the course of
+ // the test, we will update 'now', but leave 'start' where it is.
+ let now = Date.parse("Mon, 13 Jan 2014 21:45:06 GMT");
+ let start = now;
+ fxa.internal._now_is = now;
+
+ let d = fxa.getAssertion("audience.example.com");
+ // At this point, a thread has been spawned to generate the keys.
+ _("-- back from fxa.getAssertion\n");
+ fxa.internal._d_signCertificate.resolve("cert1");
+ let assertion = yield d;
+ do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1);
+ do_check_eq(fxa.internal._getCertificateSigned_calls[0][0], "sessionToken");
+ do_check_neq(assertion, null);
+ _("ASSERTION: " + assertion + "\n");
+ let pieces = assertion.split("~");
+ do_check_eq(pieces[0], "cert1");
+ let userData = yield fxa.getSignedInUser();
+ let keyPair = userData.keyPair;
+ let cert = userData.cert;
+ do_check_neq(keyPair, undefined);
+ _(keyPair.validUntil + "\n");
+ let p2 = pieces[1].split(".");
+ let header = JSON.parse(atob(p2[0]));
+ _("HEADER: " + JSON.stringify(header) + "\n");
+ do_check_eq(header.alg, "DS128");
+ let payload = JSON.parse(atob(p2[1]));
+ _("PAYLOAD: " + JSON.stringify(payload) + "\n");
+ do_check_eq(payload.aud, "audience.example.com");
+ do_check_eq(keyPair.validUntil, start + KEY_LIFETIME);
+ do_check_eq(cert.validUntil, start + CERT_LIFETIME);
+ _("delta: " + Date.parse(payload.exp - start) + "\n");
+ let exp = Number(payload.exp);
+
+ do_check_eq(exp, now + ASSERTION_LIFETIME);
+
+ // Reset for next call.
+ fxa.internal._d_signCertificate = Promise.defer();
+
+ // Getting a new assertion "soon" (i.e., w/o incrementing "now"), even for
+ // a new audience, should not provoke key generation or a signing request.
+ assertion = yield fxa.getAssertion("other.example.com");
+
+ // There were no additional calls - same number of getcert calls as before
+ do_check_eq(fxa.internal._getCertificateSigned_calls.length, 1);
+
+ // Wait an hour; assertion use period expires, but not the certificate
+ now += ONE_HOUR_MS;
+ fxa.internal._now_is = now;
+
+ // This won't block on anything - will make an assertion, but not get a
+ // new certificate.
+ assertion = yield fxa.getAssertion("third.example.com");
+
+ // Test will time out if that failed (i.e., if that had to go get a new cert)
+ pieces = assertion.split("~");
+ do_check_eq(pieces[0], "cert1");
+ p2 = pieces[1].split(".");
+ header = JSON.parse(atob(p2[0]));
+ payload = JSON.parse(atob(p2[1]));
+ do_check_eq(payload.aud, "third.example.com");
+
+ // The keypair and cert should have the same validity as before, but the
+ // expiration time of the assertion should be different. We compare this to
+ // the initial start time, to which they are relative, not the current value
+ // of "now".
+ userData = yield fxa.getSignedInUser();
+
+ keyPair = userData.keyPair;
+ cert = userData.cert;
+ do_check_eq(keyPair.validUntil, start + KEY_LIFETIME);
+ do_check_eq(cert.validUntil, start + CERT_LIFETIME);
+ exp = Number(payload.exp);
+ do_check_eq(exp, now + ASSERTION_LIFETIME);
+
+ // Now we wait even longer, and expect both assertion and cert to expire. So
+ // we will have to get a new keypair and cert.
+ now += ONE_DAY_MS;
+ fxa.internal._now_is = now;
+ d = fxa.getAssertion("fourth.example.com");
+ fxa.internal._d_signCertificate.resolve("cert2");
+ assertion = yield d;
+ do_check_eq(fxa.internal._getCertificateSigned_calls.length, 2);
+ do_check_eq(fxa.internal._getCertificateSigned_calls[1][0], "sessionToken");
+ pieces = assertion.split("~");
+ do_check_eq(pieces[0], "cert2");
+ p2 = pieces[1].split(".");
+ header = JSON.parse(atob(p2[0]));
+ payload = JSON.parse(atob(p2[1]));
+ do_check_eq(payload.aud, "fourth.example.com");
+ userData = yield fxa.getSignedInUser();
+ keyPair = userData.keyPair;
+ cert = userData.cert;
+ do_check_eq(keyPair.validUntil, now + KEY_LIFETIME);
+ do_check_eq(cert.validUntil, now + CERT_LIFETIME);
+ exp = Number(payload.exp);
+
+ do_check_eq(exp, now + ASSERTION_LIFETIME);
+ _("----- DONE ----\n");
+});
+
+add_task(function* test_resend_email_not_signed_in() {
+ let fxa = new MockFxAccounts();
+
+ try {
+ yield fxa.resendVerificationEmail();
+ } catch(err) {
+ do_check_eq(err.message,
+ "Cannot resend verification email; no signed-in user");
+ return;
+ }
+ do_throw("Should not be able to resend email when nobody is signed in");
+});
+
+add_test(function test_accountStatus() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ // If we have no user, we have no account server-side
+ fxa.accountStatus().then(
+ (result) => {
+ do_check_false(result);
+ }
+ ).then(
+ () => {
+ fxa.setSignedInUser(alice).then(
+ () => {
+ fxa.accountStatus().then(
+ (result) => {
+ // FxAccounts.accountStatus() should match Client.accountStatus()
+ do_check_true(result);
+ fxa.internal.fxAccountsClient._deletedOnServer = true;
+ fxa.accountStatus().then(
+ (result) => {
+ do_check_false(result);
+ fxa.internal.fxAccountsClient._deletedOnServer = false;
+ fxa.signOut().then(run_next_test);
+ }
+ );
+ }
+ )
+ }
+ );
+ }
+ );
+});
+
+add_task(function* test_resend_email_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let sophia = getTestUser("sophia");
+ do_check_neq(sophia.sessionToken, null);
+
+ let client = fxa.internal.fxAccountsClient;
+ client.resendVerificationEmail = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ client.accountStatus = () => Promise.resolve(true);
+
+ yield fxa.setSignedInUser(sophia);
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, sophia.email);
+ do_check_eq(user.verified, false);
+ log.debug("Sophia wants verification email resent");
+
+ try {
+ yield fxa.resendVerificationEmail();
+ do_check_true(false, "resendVerificationEmail should reject invalid session token");
+ } catch (err) {
+ do_check_eq(err.code, 401);
+ do_check_eq(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, sophia.email);
+ do_check_eq(user.sessionToken, null);
+});
+
+add_test(function test_resend_email() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ let initialState = fxa.internal.currentAccountState;
+
+ // Alice is the user signing in; her email is unverified.
+ fxa.setSignedInUser(alice).then(() => {
+ log.debug("Alice signing in");
+
+ // We're polling for the first email
+ do_check_true(fxa.internal.currentAccountState !== initialState);
+ let aliceState = fxa.internal.currentAccountState;
+
+ // The polling timer is ticking
+ do_check_true(fxa.internal.currentTimer > 0);
+
+ fxa.internal.getUserAccountData().then(user => {
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.verified, false);
+ log.debug("Alice wants verification email resent");
+
+ fxa.resendVerificationEmail().then((result) => {
+ // Mock server response; ensures that the session token actually was
+ // passed to the client to make the hawk call
+ do_check_eq(result, "alice's session token");
+
+ // Timer was not restarted
+ do_check_true(fxa.internal.currentAccountState === aliceState);
+
+ // Timer is still ticking
+ do_check_true(fxa.internal.currentTimer > 0);
+
+ // Ok abort polling before we go on to the next test
+ fxa.internal.abortExistingFlow();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_task(function* test_sign_out_with_device() {
+ const fxa = new MockFxAccounts();
+
+ const credentials = getTestUser("alice");
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const user = yield fxa.internal.getUserAccountData();
+ do_check_true(user);
+ Object.keys(credentials).forEach(key => do_check_eq(credentials[key], user[key]));
+
+ const spy = {
+ signOut: { count: 0 },
+ signOutAndDeviceDestroy: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.signOut = function () {
+ spy.signOut.count += 1;
+ return Promise.resolve();
+ };
+ client.signOutAndDestroyDevice = function () {
+ spy.signOutAndDeviceDestroy.count += 1;
+ spy.signOutAndDeviceDestroy.args.push(arguments);
+ return Promise.resolve();
+ };
+
+ const promise = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, () => {
+ log.debug("test_sign_out_with_device observed onlogout");
+ // user should be undefined after sign out
+ fxa.internal.getUserAccountData().then(user2 => {
+ do_check_eq(user2, null);
+ do_check_eq(spy.signOut.count, 0);
+ do_check_eq(spy.signOutAndDeviceDestroy.count, 1);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0].length, 3);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0][0], credentials.sessionToken);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0][1], credentials.deviceId);
+ do_check_true(spy.signOutAndDeviceDestroy.args[0][2]);
+ do_check_eq(spy.signOutAndDeviceDestroy.args[0][2].service, "sync");
+ resolve();
+ });
+ });
+ });
+
+ yield fxa.signOut();
+
+ yield promise;
+});
+
+add_task(function* test_sign_out_without_device() {
+ const fxa = new MockFxAccounts();
+
+ const credentials = getTestUser("alice");
+ delete credentials.deviceId;
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const user = yield fxa.internal.getUserAccountData();
+
+ const spy = {
+ signOut: { count: 0, args: [] },
+ signOutAndDeviceDestroy: { count: 0 }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.signOut = function () {
+ spy.signOut.count += 1;
+ spy.signOut.args.push(arguments);
+ return Promise.resolve();
+ };
+ client.signOutAndDestroyDevice = function () {
+ spy.signOutAndDeviceDestroy.count += 1;
+ return Promise.resolve();
+ };
+
+ const promise = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, () => {
+ log.debug("test_sign_out_without_device observed onlogout");
+ // user should be undefined after sign out
+ fxa.internal.getUserAccountData().then(user2 => {
+ do_check_eq(user2, null);
+ do_check_eq(spy.signOut.count, 1);
+ do_check_eq(spy.signOut.args[0].length, 2);
+ do_check_eq(spy.signOut.args[0][0], credentials.sessionToken);
+ do_check_true(spy.signOut.args[0][1]);
+ do_check_eq(spy.signOut.args[0][1].service, "sync");
+ do_check_eq(spy.signOutAndDeviceDestroy.count, 0);
+ resolve();
+ });
+ });
+ });
+
+ yield fxa.signOut();
+
+ yield promise;
+});
+
+add_task(function* test_sign_out_with_remote_error() {
+ let fxa = new MockFxAccounts();
+ let client = fxa.internal.fxAccountsClient;
+ let remoteSignOutCalled = false;
+ // Force remote sign out to trigger an error
+ client.signOutAndDestroyDevice = function() { remoteSignOutCalled = true; throw "Remote sign out error"; };
+ let promiseLogout = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, function() {
+ log.debug("test_sign_out_with_remote_error observed onlogout");
+ resolve();
+ });
+ });
+
+ let jane = getTestUser("jane");
+ yield fxa.setSignedInUser(jane);
+ yield fxa.signOut();
+ yield promiseLogout;
+
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user, null);
+ do_check_true(remoteSignOutCalled);
+});
+
+add_test(function test_getOAuthToken() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let getTokenFromAssertionCalled = false;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ getTokenFromAssertionCalled = true;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ fxa.setSignedInUser(alice).then(
+ () => {
+ fxa.getOAuthToken({ scope: "profile", client: client }).then(
+ (result) => {
+ do_check_true(getTokenFromAssertionCalled);
+ do_check_eq(result, "token");
+ run_next_test();
+ }
+ )
+ }
+ );
+
+});
+
+add_test(function test_getOAuthTokenScoped() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let getTokenFromAssertionCalled = false;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function (assertion, scopeString) {
+ equal(scopeString, "foo bar");
+ getTokenFromAssertionCalled = true;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ fxa.setSignedInUser(alice).then(
+ () => {
+ fxa.getOAuthToken({ scope: ["foo", "bar"], client: client }).then(
+ (result) => {
+ do_check_true(getTokenFromAssertionCalled);
+ do_check_eq(result, "token");
+ run_next_test();
+ }
+ )
+ }
+ );
+
+});
+
+add_task(function* test_getOAuthTokenCached() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numTokenFromAssertionCalls = 0;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ numTokenFromAssertionCalls += 1;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ yield fxa.setSignedInUser(alice);
+ let result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+
+ // requesting it again should not re-fetch the token.
+ result = yield fxa.getOAuthToken({ scope: "profile", client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+ // But requesting the same service and a different scope *will* get a new one.
+ result = yield fxa.getOAuthToken({ scope: "something-else", client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 2);
+ do_check_eq(result, "token");
+});
+
+add_task(function* test_getOAuthTokenCachedScopeNormalization() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numTokenFromAssertionCalls = 0;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ numTokenFromAssertionCalls += 1;
+ return Promise.resolve({ access_token: "token" });
+ };
+
+ yield fxa.setSignedInUser(alice);
+ let result = yield fxa.getOAuthToken({ scope: ["foo", "bar"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+
+ // requesting it again with the scope array in a different order not re-fetch the token.
+ result = yield fxa.getOAuthToken({ scope: ["bar", "foo"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+ // requesting it again with the scope array in different case not re-fetch the token.
+ result = yield fxa.getOAuthToken({ scope: ["Bar", "Foo"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 1);
+ do_check_eq(result, "token");
+ // But requesting with a new entry in the array does fetch one.
+ result = yield fxa.getOAuthToken({ scope: ["foo", "bar", "etc"], client: client, service: "test-service" });
+ do_check_eq(numTokenFromAssertionCalls, 2);
+ do_check_eq(result, "token");
+});
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+add_test(function test_getOAuthToken_invalid_param() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken()
+ .then(null, err => {
+ do_check_eq(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_invalid_scope_array() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken({scope: []})
+ .then(null, err => {
+ do_check_eq(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_misconfigure_oauth_uri() {
+ let fxa = new MockFxAccounts();
+
+ Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri");
+
+ fxa.getOAuthToken()
+ .then(null, err => {
+ do_check_eq(err.message, "INVALID_PARAMETER");
+ // revert the pref
+ Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_no_account() {
+ let fxa = new MockFxAccounts();
+
+ fxa.internal.currentAccountState.getUserAccountData = function () {
+ return Promise.resolve(null);
+ };
+
+ fxa.getOAuthToken({ scope: "profile" })
+ .then(null, err => {
+ do_check_eq(err.message, "NO_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_unverified() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile" })
+ .then(null, err => {
+ do_check_eq(err.message, "UNVERIFIED_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_network_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ return Promise.reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_NETWORK,
+ errno: ERRNO_NETWORK
+ }));
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile", client: client })
+ .then(null, err => {
+ do_check_eq(err.message, "NETWORK_ERROR");
+ do_check_eq(err.details.errno, ERRNO_NETWORK);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_auth_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ return Promise.reject(new FxAccountsOAuthGrantClientError({
+ error: ERROR_INVALID_FXA_ASSERTION,
+ errno: ERRNO_INVALID_FXA_ASSERTION
+ }));
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile", client: client })
+ .then(null, err => {
+ do_check_eq(err.message, "AUTH_ERROR");
+ do_check_eq(err.details.errno, ERRNO_INVALID_FXA_ASSERTION);
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_unknown_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal._d_signCertificate.resolve("cert1");
+
+ // create a mock oauth client
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://example.com/v1",
+ client_id: "abc123"
+ });
+ client.getTokenFromAssertion = function () {
+ return Promise.reject("boom");
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile", client: client })
+ .then(null, err => {
+ do_check_eq(err.message, "UNKNOWN_ERROR");
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getSignedInUserProfile() {
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let mockProfile = {
+ getProfile: function () {
+ return Promise.resolve({ avatar: "image" });
+ },
+ tearDown: function() {},
+ };
+ let fxa = new FxAccounts({
+ _signOutServer() { return Promise.resolve(); },
+ _registerOrUpdateDevice() { return Promise.resolve(); }
+ });
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.internal._profile = mockProfile;
+ fxa.getSignedInUserProfile()
+ .then(result => {
+ do_check_true(!!result);
+ do_check_eq(result.avatar, "image");
+ run_next_test();
+ });
+ });
+});
+
+add_test(function test_getSignedInUserProfile_error_uses_account_data() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ fxa.internal.getSignedInUser = function () {
+ return Promise.resolve({ email: "foo@bar.com" });
+ };
+
+ let teardownCalled = false;
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.internal._profile = {
+ getProfile: function () {
+ return Promise.reject("boom");
+ },
+ tearDown: function() {
+ teardownCalled = true;
+ }
+ };
+
+ fxa.getSignedInUserProfile()
+ .catch(error => {
+ do_check_eq(error.message, "UNKNOWN_ERROR");
+ fxa.signOut().then(() => {
+ do_check_true(teardownCalled);
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_getSignedInUserProfile_unverified_account() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getSignedInUserProfile()
+ .catch(error => {
+ do_check_eq(error.message, "UNVERIFIED_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+ });
+
+});
+
+add_test(function test_getSignedInUserProfile_no_account_data() {
+ let fxa = new MockFxAccounts();
+
+ fxa.internal.getSignedInUser = function () {
+ return Promise.resolve(null);
+ };
+
+ fxa.getSignedInUserProfile()
+ .catch(error => {
+ do_check_eq(error.message, "NO_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+
+});
+
+add_task(function* test_checkVerificationStatusFailed() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa.internal.fxAccountsClient;
+ client.recoveryEmailStatus = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ client.accountStatus = () => Promise.resolve(true);
+
+ yield fxa.setSignedInUser(alice);
+ let user = yield fxa.internal.getUserAccountData();
+ do_check_neq(alice.sessionToken, null);
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.verified, true);
+
+ yield fxa.checkVerificationStatus();
+
+ user = yield fxa.internal.getUserAccountData();
+ do_check_eq(user.email, alice.email);
+ do_check_eq(user.sessionToken, null);
+});
+
+/*
+ * End of tests.
+ * Utility functions follow.
+ */
+
+function expandHex(two_hex) {
+ // Return a 64-character hex string, encoding 32 identical bytes.
+ let eight_hex = two_hex + two_hex + two_hex + two_hex;
+ let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
+ return thirtytwo_hex + thirtytwo_hex;
+};
+
+function expandBytes(two_hex) {
+ return CommonUtils.hexToBytes(expandHex(two_hex));
+};
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+ deviceId: name + "'s device id",
+ sessionToken: name + "'s session token",
+ keyFetchToken: name + "'s keyfetch token",
+ unwrapBKey: expandHex("44"),
+ verified: false
+ };
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let observer = {
+ // nsISupports provides type management in C++
+ // nsIObserver is to be an observer
+ QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),
+
+ observe: function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(observer, aObserveTopic);
+ }
+
+ Services.obs.addObserver(observer, aObserveTopic, false);
+ return removeMe;
+}
+
+function do_check_throws(func, result, stack)
+{
+ if (!stack)
+ stack = Components.stack.caller;
+
+ try {
+ func();
+ } catch (ex) {
+ if (ex.name == result) {
+ return;
+ }
+ do_throw("Expected result " + result + ", caught " + ex.name, stack);
+ }
+
+ if (result) {
+ do_throw("Expected result " + result + ", none thrown", stack);
+ }
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
new file mode 100644
index 000000000..9a2d2c127
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -0,0 +1,526 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/Log.jsm");
+
+initTestLogging("Trace");
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+const BOGUS_PUBLICKEY = "BBXOKjUb84pzws1wionFpfCBjDuCh4-s_1b52WA46K5wYL2gCWEOmFKWn_NkS5nmJwTBuO8qxxdjAIDtNeklvQc";
+const BOGUS_AUTHKEY = "GSsIiaD2Mr83iPqwFNK4rw";
+
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+
+Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", "https://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.oauth.client_id", "abc123");
+Services.prefs.setCharPref("identity.fxaccounts.remote.profile.uri", "http://example.com/v1");
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "http://accounts.example.com/");
+
+const DEVICE_REGISTRATION_VERSION = 42;
+
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+function MockFxAccountsClient(device) {
+ this._email = "nobody@example.com";
+ this._verified = false;
+ this._deletedOnServer = false; // for testing accountStatus
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return Promise.resolve({
+ email: this._email,
+ verified: this._verified
+ });
+ };
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ const { id: deviceId, name: deviceName, type: deviceType, sessionToken } = device;
+
+ this.registerDevice = (st, name, type) => Promise.resolve({ id: deviceId, name });
+ this.updateDevice = (st, id, name) => Promise.resolve({ id, name });
+ this.signOutAndDestroyDevice = () => Promise.resolve({});
+ this.getDeviceList = (st) =>
+ Promise.resolve([
+ { id: deviceId, name: deviceName, type: deviceType, isCurrentDevice: st === sessionToken }
+ ]);
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(device = {}) {
+ return new FxAccounts({
+ _getDeviceName() {
+ return device.name || "mock device name";
+ },
+ fxAccountsClient: new MockFxAccountsClient(device),
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise((resolve) => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey: function(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" });
+ }
+ });
+ });
+ },
+ },
+ DEVICE_REGISTRATION_VERSION
+ });
+}
+
+add_task(function* test_updateDeviceRegistration_with_new_device() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ delete credentials.deviceId;
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({
+ id: "newly-generated device id",
+ createdAt: Date.now(),
+ name: deviceName,
+ type: deviceType
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ 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, 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");
+ do_check_eq(spy.registerDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.registerDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_eq(data.deviceId, "newly-generated device id");
+ do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+});
+
+add_task(function* test_updateDeviceRegistration_with_existing_device() {
+ const deviceName = "phil's device";
+ const deviceType = "desktop";
+
+ const credentials = getTestUser("pb");
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({
+ id: credentials.deviceId,
+ name: deviceName
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ 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, 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.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_eq(data.deviceId, credentials.deviceId);
+ do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+});
+
+add_task(function* test_updateDeviceRegistration_with_unknown_device_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({
+ id: "a different newly-generated device id",
+ createdAt: Date.now(),
+ name: deviceName,
+ type: deviceType
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_UNKNOWN_DEVICE
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ 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, 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.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+
+ const state = fxa.internal.currentAccountState;
+ const data = yield state.getUserAccountData();
+
+ do_check_null(data.deviceId);
+ do_check_eq(data.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+});
+
+add_task(function* test_updateDeviceRegistration_with_device_session_conflict_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [], times: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ spy.updateDevice.time = Date.now();
+ if (spy.updateDevice.count === 1) {
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_DEVICE_SESSION_CONFLICT
+ });
+ }
+ return Promise.resolve({
+ id: credentials.deviceId,
+ name: deviceName
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ spy.getDeviceList.time = Date.now();
+ return Promise.resolve([
+ { id: "ignore", name: "ignore", type: "ignore", isCurrentDevice: false },
+ { id: credentials.deviceId, name: deviceName, type: deviceType, isCurrentDevice: true }
+ ]);
+ };
+
+ 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, 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.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ do_check_eq(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+ 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();
+
+ do_check_eq(data.deviceId, credentials.deviceId);
+ do_check_eq(data.deviceRegistrationVersion, null);
+});
+
+add_task(function* test_updateDeviceRegistration_with_unrecoverable_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ delete credentials.deviceId;
+ const fxa = new MockFxAccounts({ name: deviceName });
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = {
+ registerDevice: { count: 0, args: [] },
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] }
+ };
+ const client = fxa.internal.fxAccountsClient;
+ client.registerDevice = function () {
+ spy.registerDevice.count += 1;
+ spy.registerDevice.args.push(arguments);
+ return Promise.reject({
+ code: 400,
+ errno: ERRNO_TOO_MANY_CLIENT_REQUESTS
+ });
+ };
+ client.updateDevice = function () {
+ spy.updateDevice.count += 1;
+ spy.updateDevice.args.push(arguments);
+ return Promise.resolve({});
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+
+ 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, 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() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ delete credentials.deviceId;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ email: credentials.email,
+ deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("bar");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 1);
+ do_check_eq(spy.args[0].length, 1);
+ do_check_eq(spy.args[0][0].email, credentials.email);
+ do_check_null(spy.args[0][0].deviceId);
+ do_check_eq(result, "bar");
+});
+
+add_task(function* test_getDeviceId_with_registration_version_outdated_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ deviceId: credentials.deviceId, deviceRegistrationVersion: 0 });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 1);
+ do_check_eq(spy.args[0].length, 1);
+ do_check_eq(spy.args[0][0].deviceId, credentials.deviceId);
+ do_check_eq(result, "wibble");
+});
+
+add_task(function* test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0 };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ deviceId: credentials.deviceId, deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ return Promise.resolve("bar");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 0);
+ do_check_eq(result, "foo's device id");
+});
+
+add_task(function* test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = new MockFxAccounts();
+ yield fxa.internal.setSignedInUser(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa.internal.currentAccountState.getUserAccountData =
+ () => Promise.resolve({ deviceId: credentials.deviceId });
+ fxa.internal._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = yield fxa.internal.getDeviceId();
+
+ do_check_eq(spy.count, 1);
+ do_check_eq(spy.args[0].length, 1);
+ do_check_eq(spy.args[0][0].deviceId, credentials.deviceId);
+ do_check_eq(result, "wibble");
+});
+
+function expandHex(two_hex) {
+ // Return a 64-character hex string, encoding 32 identical bytes.
+ let eight_hex = two_hex + two_hex + two_hex + two_hex;
+ let thirtytwo_hex = eight_hex + eight_hex + eight_hex + eight_hex;
+ return thirtytwo_hex + thirtytwo_hex;
+};
+
+function expandBytes(two_hex) {
+ return CommonUtils.hexToBytes(expandHex(two_hex));
+};
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+ deviceId: name + "'s device id",
+ sessionToken: name + "'s session token",
+ keyFetchToken: name + "'s keyfetch token",
+ unwrapBKey: expandHex("44"),
+ verified: false
+ };
+}
+
diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js
new file mode 100644
index 000000000..83f42bdf5
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -0,0 +1,917 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-common/hawkrequest.js");
+Cu.import("resource://services-crypto/utils.js");
+
+const FAKE_SESSION_TOKEN = "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+
+function run_test() {
+ run_next_test();
+}
+
+// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys
+var ACCOUNT_KEYS = {
+ keyFetch: h("8081828384858687 88898a8b8c8d8e8f"+
+ "9091929394959697 98999a9b9c9d9e9f"),
+
+ response: h("ee5c58845c7c9412 b11bbd20920c2fdd"+
+ "d83c33c9cd2c2de2 d66b222613364636"+
+ "c2c0f8cfbb7c6304 72c0bd88451342c6"+
+ "c05b14ce342c5ad4 6ad89e84464c993c"+
+ "3927d30230157d08 17a077eef4b20d97"+
+ "6f7a97363faf3f06 4c003ada7d01aa70"),
+
+ kA: h("2021222324252627 28292a2b2c2d2e2f"+
+ "3031323334353637 38393a3b3c3d3e3f"),
+
+ wrapKB: h("4041424344454647 48494a4b4c4d4e4f"+
+ "5051525354555657 58595a5b5c5d5e5f"),
+};
+
+function deferredStop(server) {
+ let deferred = Promise.defer();
+ server.stop(deferred.resolve);
+ return deferred.promise;
+}
+
+add_task(function* test_authenticated_get_request() {
+ let message = "{\"msg\": \"Great Success!\"}";
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256"
+ };
+ let method = "GET";
+
+ let server = httpd_setup({"/foo": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ }
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = yield client._request("/foo", method, credentials);
+ do_check_eq("Great Success!", result.msg);
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_authenticated_post_request() {
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256"
+ };
+ let method = "POST";
+
+ let server = httpd_setup({"/foo": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.setHeader("Content-Type", "application/json");
+ response.bodyOutputStream.writeFrom(request.bodyInputStream, request.bodyInputStream.available());
+ }
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = yield client._request("/foo", method, credentials, {foo: "bar"});
+ do_check_eq("bar", result.foo);
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_500_error() {
+ let message = "<h1>Ooops!</h1>";
+ let method = "GET";
+
+ let server = httpd_setup({"/foo": function(request, response) {
+ response.setStatusLine(request.httpVersion, 500, "Internal Server Error");
+ response.bodyOutputStream.write(message, message.length);
+ }
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ try {
+ yield client._request("/foo", method);
+ do_throw("Expected to catch an exception");
+ } catch (e) {
+ do_check_eq(500, e.code);
+ do_check_eq("Internal Server Error", e.message);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_backoffError() {
+ let method = "GET";
+ let server = httpd_setup({
+ "/retryDelay": function(request, response) {
+ response.setHeader("Retry-After", "30");
+ response.setStatusLine(request.httpVersion, 429, "Client has sent too many requests");
+ let message = "<h1>Ooops!</h1>";
+ response.bodyOutputStream.write(message, message.length);
+ },
+ "/duringDelayIShouldNotBeCalled": function(request, response) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ let jsonMessage = "{\"working\": \"yes\"}";
+ response.bodyOutputStream.write(jsonMessage, jsonMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ // Retry-After header sets client.backoffError
+ do_check_eq(client.backoffError, null);
+ try {
+ yield client._request("/retryDelay", method);
+ } catch (e) {
+ do_check_eq(429, e.code);
+ do_check_eq(30, e.retryAfter);
+ do_check_neq(typeof(client.fxaBackoffTimer), "undefined");
+ do_check_neq(client.backoffError, null);
+ }
+ // While delay is in effect, client short-circuits any requests
+ // and re-rejects with previous error.
+ try {
+ yield client._request("/duringDelayIShouldNotBeCalled", method);
+ throw new Error("I should not be reached");
+ } catch (e) {
+ do_check_eq(e.retryAfter, 30);
+ do_check_eq(e.message, "Client has sent too many requests");
+ do_check_neq(client.backoffError, null);
+ }
+ // Once timer fires, client nulls error out and HTTP calls work again.
+ client._clearBackoff();
+ let result = yield client._request("/duringDelayIShouldNotBeCalled", method);
+ do_check_eq(client.backoffError, null);
+ do_check_eq(result.working, "yes");
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signUp() {
+ let creationMessage_noKey = JSON.stringify({
+ uid: "uid",
+ sessionToken: "sessionToken"
+ });
+ let creationMessage_withKey = JSON.stringify({
+ uid: "uid",
+ sessionToken: "sessionToken",
+ keyFetchToken: "keyFetchToken"
+ });
+ let errorMessage = JSON.stringify({code: 400, errno: 101, error: "account exists"});
+ let created = false;
+
+ // Note these strings must be unicode and not already utf-8 encoded.
+ let unicodeUsername = "andr\xe9@example.org"; // 'andré@example.org'
+ let unicodePassword = "p\xe4ssw\xf6rd"; // 'pässwörd'
+ let server = httpd_setup({
+ "/account/create": function(request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ body = CommonUtils.decodeUTF8(body);
+ let jsonBody = JSON.parse(body);
+
+ // https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
+
+ if (created) {
+ // Error trying to create same account a second time
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ }
+
+ if (jsonBody.email == unicodeUsername) {
+ do_check_eq("", request._queryString);
+ do_check_eq(jsonBody.authPW, "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375");
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(creationMessage_noKey,
+ creationMessage_noKey.length);
+ return;
+ }
+
+ if (jsonBody.email == "you@example.org") {
+ do_check_eq("keys=true", request._queryString);
+ do_check_eq(jsonBody.authPW, "e5c1cdfdaa5fcee06142db865b212cc8ba8abee2a27d639d42c139f006cdb930");
+ created = true;
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(creationMessage_withKey,
+ creationMessage_withKey.length);
+ return;
+ }
+ // just throwing here doesn't make any log noise, so have an assertion
+ // fail instead.
+ do_check_true(false, "unexpected email: " + jsonBody.email);
+ },
+ });
+
+ // Try to create an account without retrieving optional keys.
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signUp(unicodeUsername, unicodePassword);
+ do_check_eq("uid", result.uid);
+ do_check_eq("sessionToken", result.sessionToken);
+ do_check_eq(undefined, result.keyFetchToken);
+ do_check_eq(result.unwrapBKey,
+ "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28");
+
+ // Try to create an account retrieving optional keys.
+ result = yield client.signUp('you@example.org', 'pässwörd', true);
+ do_check_eq("uid", result.uid);
+ do_check_eq("sessionToken", result.sessionToken);
+ do_check_eq("keyFetchToken", result.keyFetchToken);
+ do_check_eq(result.unwrapBKey,
+ "f589225b609e56075d76eb74f771ff9ab18a4dc0e901e131ba8f984c7fb0ca8c");
+
+ // Try to create an existing account. Triggers error path.
+ try {
+ result = yield client.signUp(unicodeUsername, unicodePassword);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(101, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signIn() {
+ let sessionMessage_noKey = JSON.stringify({
+ sessionToken: FAKE_SESSION_TOKEN
+ });
+ let sessionMessage_withKey = JSON.stringify({
+ sessionToken: FAKE_SESSION_TOKEN,
+ keyFetchToken: "keyFetchToken"
+ });
+ let errorMessage_notExistent = JSON.stringify({
+ code: 400,
+ errno: 102,
+ error: "doesn't exist"
+ });
+ let errorMessage_wrongCap = JSON.stringify({
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: "you@example.com"
+ });
+
+ // Note this strings must be unicode and not already utf-8 encoded.
+ let unicodeUsername = "m\xe9@example.com" // 'mé@example.com'
+ let server = httpd_setup({
+ "/account/login": function(request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ body = CommonUtils.decodeUTF8(body);
+ let jsonBody = JSON.parse(body);
+
+ if (jsonBody.email == unicodeUsername) {
+ do_check_eq("", request._queryString);
+ do_check_eq(jsonBody.authPW, "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6");
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(sessionMessage_noKey,
+ sessionMessage_noKey.length);
+ return;
+ }
+ else if (jsonBody.email == "you@example.com") {
+ do_check_eq("keys=true", request._queryString);
+ do_check_eq(jsonBody.authPW, "93d20ec50304d496d0707ec20d7e8c89459b6396ec5dd5b9e92809c5e42856c7");
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(sessionMessage_withKey,
+ sessionMessage_withKey.length);
+ return;
+ }
+ else if (jsonBody.email == "You@example.com") {
+ // Error trying to sign in with a wrong capitalization
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage_wrongCap,
+ errorMessage_wrongCap.length);
+ return;
+ }
+ else {
+ // Error trying to sign in to nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage_notExistent,
+ errorMessage_notExistent.length);
+ return;
+ }
+ },
+ });
+
+ // Login without retrieving optional keys
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signIn(unicodeUsername, 'bigsecret');
+ do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
+ do_check_eq(result.unwrapBKey,
+ "c076ec3f4af123a615157154c6e1d0d6293e514fd7b0221e32d50517ecf002b8");
+ do_check_eq(undefined, result.keyFetchToken);
+
+ // Login with retrieving optional keys
+ result = yield client.signIn('you@example.com', 'bigsecret', true);
+ do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
+ do_check_eq(result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624");
+ do_check_eq("keyFetchToken", result.keyFetchToken);
+
+ // Retry due to wrong email capitalization
+ result = yield client.signIn('You@example.com', 'bigsecret', true);
+ do_check_eq(FAKE_SESSION_TOKEN, result.sessionToken);
+ do_check_eq(result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624");
+ do_check_eq("keyFetchToken", result.keyFetchToken);
+
+ // Trigger error path
+ try {
+ result = yield client.signIn("yøü@bad.example.org", "nofear");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signOut() {
+ let signoutMessage = JSON.stringify({});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let signedOut = false;
+
+ let server = httpd_setup({
+ "/session/destroy": function(request, response) {
+ if (!signedOut) {
+ signedOut = true;
+ do_check_true(request.hasHeader("Authorization"));
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(signoutMessage, signoutMessage.length);
+ return;
+ }
+
+ // Error trying to sign out of nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signOut("FakeSession");
+ do_check_eq(typeof result, "object");
+
+ // Trigger error path
+ try {
+ result = yield client.signOut("FakeSession");
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_recoveryEmailStatus() {
+ let emailStatus = JSON.stringify({verified: true});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/recovery_email/status": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ do_check_eq("", request._queryString);
+
+ if (tries === 0) {
+ tries += 1;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ return;
+ }
+
+ // Second call gets an error trying to query a nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN);
+ do_check_eq(result.verified, true);
+
+ // Trigger error path
+ try {
+ result = yield client.recoveryEmailStatus("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_recoveryEmailStatusWithReason() {
+ let emailStatus = JSON.stringify({verified: true});
+ let server = httpd_setup({
+ "/recovery_email/status": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ // if there is a query string then it will have a reason
+ do_check_eq("reason=push", request._queryString);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.recoveryEmailStatus(FAKE_SESSION_TOKEN, {
+ reason: "push",
+ });
+ do_check_eq(result.verified, true);
+ yield deferredStop(server);
+});
+
+add_task(function* test_resendVerificationEmail() {
+ let emptyMessage = "{}";
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/recovery_email/resend_code": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ if (tries === 0) {
+ tries += 1;
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ return;
+ }
+
+ // Second call gets an error trying to query a nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.resendVerificationEmail(FAKE_SESSION_TOKEN);
+ do_check_eq(JSON.stringify(result), emptyMessage);
+
+ // Trigger error path
+ try {
+ result = yield client.resendVerificationEmail("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_accountKeys() {
+ // Four calls to accountKeys(). The first one should work correctly, and we
+ // should get a valid bundle back, in exchange for our keyFetch token, from
+ // which we correctly derive kA and wrapKB. The subsequent three calls
+ // should all trigger separate error paths.
+ let responseMessage = JSON.stringify({bundle: ACCOUNT_KEYS.response});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let emptyMessage = "{}";
+ let attempt = 0;
+
+ let server = httpd_setup({
+ "/account/keys": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+ attempt += 1;
+
+ switch(attempt) {
+ case 1:
+ // First time succeeds
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ break;
+
+ case 2:
+ // Second time, return no bundle to trigger client error
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ break;
+
+ case 3:
+ // Return gibberish to trigger client MAC error
+ // Tweak a byte
+ let garbageResponse = JSON.stringify({
+ bundle: ACCOUNT_KEYS.response.slice(0, -1) + "1"
+ });
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(garbageResponse, garbageResponse.length);
+ break;
+
+ case 4:
+ // Trigger error for nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ break;
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ // First try, all should be good
+ let result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_check_eq(CommonUtils.hexToBytes(ACCOUNT_KEYS.kA), result.kA);
+ do_check_eq(CommonUtils.hexToBytes(ACCOUNT_KEYS.wrapKB), result.wrapKB);
+
+ // Second try, empty bundle should trigger error
+ try {
+ result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(expectedError.message, "failed to retrieve keys");
+ }
+
+ // Third try, bad bundle results in MAC error
+ try {
+ result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(expectedError.message, "error unbundling encryption keys");
+ }
+
+ // Fourth try, pretend account doesn't exist
+ try {
+ result = yield client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signCertificate() {
+ let certSignMessage = JSON.stringify({cert: {bar: "baz"}});
+ let errorMessage = JSON.stringify({code: 400, errno: 102, error: "doesn't exist"});
+ let tries = 0;
+
+ let server = httpd_setup({
+ "/certificate/sign": function(request, response) {
+ do_check_true(request.hasHeader("Authorization"));
+
+ if (tries === 0) {
+ tries += 1;
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+ do_check_eq(JSON.parse(jsonBody.publicKey).foo, "bar");
+ do_check_eq(jsonBody.duration, 600);
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(certSignMessage, certSignMessage.length);
+ return;
+ }
+
+ // Second attempt, trigger error
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(errorMessage, errorMessage.length);
+ return;
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = yield client.signCertificate(FAKE_SESSION_TOKEN, JSON.stringify({foo: "bar"}), 600);
+ do_check_eq("baz", result.bar);
+
+ // Account doesn't exist
+ try {
+ result = yield client.signCertificate("bogus", JSON.stringify({foo: "bar"}), 600);
+ do_throw("Expected to catch an exception");
+ } catch(expectedError) {
+ do_check_eq(102, expectedError.errno);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_accountExists() {
+ let sessionMessage = JSON.stringify({sessionToken: FAKE_SESSION_TOKEN});
+ let existsMessage = JSON.stringify({error: "wrong password", code: 400, errno: 103});
+ let doesntExistMessage = JSON.stringify({error: "no such account", code: 400, errno: 102});
+ let emptyMessage = "{}";
+
+ let server = httpd_setup({
+ "/account/login": function(request, response) {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+
+ switch (jsonBody.email) {
+ // We'll test that these users' accounts exist
+ case "i.exist@example.com":
+ case "i.also.exist@example.com":
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(existsMessage, existsMessage.length);
+ break;
+
+ // This user's account doesn't exist
+ case "i.dont.exist@example.com":
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(doesntExistMessage, doesntExistMessage.length);
+ break;
+
+ // This user throws an unexpected response
+ // This will reject the client signIn promise
+ case "i.break.things@example.com":
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ break;
+
+ default:
+ throw new Error("Unexpected login from " + jsonBody.email);
+ break;
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result;
+
+ result = yield client.accountExists("i.exist@example.com");
+ do_check_true(result);
+
+ result = yield client.accountExists("i.also.exist@example.com");
+ do_check_true(result);
+
+ result = yield client.accountExists("i.dont.exist@example.com");
+ do_check_false(result);
+
+ try {
+ result = yield client.accountExists("i.break.things@example.com");
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_registerDevice() {
+ const DEVICE_ID = "device id";
+ const DEVICE_NAME = "device name";
+ const DEVICE_TYPE = "device type";
+ const ERROR_NAME = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device": function(request, response) {
+ const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ if (body.id || !body.name || !body.type || Object.keys(body).length !== 2) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ if (body.name === ERROR_NAME) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ body.id = DEVICE_ID;
+ body.createdAt = Date.now();
+
+ const responseMessage = JSON.stringify(body);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = yield client.registerDevice(FAKE_SESSION_TOKEN, DEVICE_NAME, DEVICE_TYPE);
+
+ do_check_true(result);
+ do_check_eq(Object.keys(result).length, 4);
+ do_check_eq(result.id, DEVICE_ID);
+ do_check_eq(typeof result.createdAt, 'number');
+ do_check_true(result.createdAt > 0);
+ do_check_eq(result.name, DEVICE_NAME);
+ do_check_eq(result.type, DEVICE_TYPE);
+
+ try {
+ yield client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_updateDevice() {
+ const DEVICE_ID = "some other id";
+ const DEVICE_NAME = "some other name";
+ const ERROR_ID = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device": function(request, response) {
+ const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ if (!body.id || !body.name || body.type || Object.keys(body).length !== 2) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ if (body.id === ERROR_ID) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ const responseMessage = JSON.stringify(body);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = yield client.updateDevice(FAKE_SESSION_TOKEN, DEVICE_ID, DEVICE_NAME);
+
+ do_check_true(result);
+ do_check_eq(Object.keys(result).length, 2);
+ do_check_eq(result.id, DEVICE_ID);
+ do_check_eq(result.name, DEVICE_NAME);
+
+ try {
+ yield client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_signOutAndDestroyDevice() {
+ const DEVICE_ID = "device id";
+ const ERROR_ID = "test that the client promise rejects";
+
+ const server = httpd_setup({
+ "/account/device/destroy": function(request, response) {
+ const body = JSON.parse(CommonUtils.readBytesFromInputStream(request.bodyInputStream));
+
+ if (!body.id) {
+ response.setStatusLine(request.httpVersion, 400, "Invalid request");
+ return response.bodyOutputStream.write(emptyMessage, emptyMessage.length);
+ }
+
+ if (body.id === ERROR_ID) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ return response.bodyOutputStream.write("{}", 2);
+ }
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write("{}", 2);
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+ const result = yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, DEVICE_ID);
+
+ do_check_true(result);
+ do_check_eq(Object.keys(result).length, 0);
+
+ try {
+ yield client.signOutAndDestroyDevice(FAKE_SESSION_TOKEN, ERROR_ID);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_getDeviceList() {
+ let canReturnDevices;
+
+ const server = httpd_setup({
+ "/account/devices": function(request, response) {
+ if (canReturnDevices) {
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write("[]", 2);
+ } else {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ }
+ },
+ });
+
+ const client = new FxAccountsClient(server.baseURI);
+
+ canReturnDevices = true;
+ const result = yield client.getDeviceList(FAKE_SESSION_TOKEN);
+ do_check_true(Array.isArray(result));
+ do_check_eq(result.length, 0);
+
+ try {
+ canReturnDevices = false;
+ yield client.getDeviceList(FAKE_SESSION_TOKEN);
+ do_throw("Expected to catch an exception");
+ } catch(unexpectedError) {
+ do_check_eq(unexpectedError.code, 500);
+ }
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_client_metrics() {
+ function writeResp(response, msg) {
+ if (typeof msg === "object") {
+ msg = JSON.stringify(msg);
+ }
+ response.bodyOutputStream.write(msg, msg.length);
+ }
+
+ let server = httpd_setup(
+ {
+ "/session/destroy": function(request, response) {
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ writeResp(response, {
+ error: "invalid authentication timestamp",
+ code: 401,
+ errno: 111,
+ });
+ },
+ }
+ );
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ yield rejects(client.signOut(FAKE_SESSION_TOKEN, {
+ service: "sync",
+ }), function(err) {
+ return err.errno == 111;
+ });
+
+ yield deferredStop(server);
+});
+
+add_task(function* test_email_case() {
+ let canonicalEmail = "greta.garbo@gmail.com";
+ let clientEmail = "Greta.Garbo@gmail.COM";
+ let attempts = 0;
+
+ function writeResp(response, msg) {
+ if (typeof msg === "object") {
+ msg = JSON.stringify(msg);
+ }
+ response.bodyOutputStream.write(msg, msg.length);
+ }
+
+ let server = httpd_setup(
+ {
+ "/account/login": function(request, response) {
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
+ attempts += 1;
+ if (attempts > 2) {
+ response.setStatusLine(request.httpVersion, 429, "Sorry, you had your chance");
+ return writeResp(response, "");
+ }
+
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let jsonBody = JSON.parse(body);
+ let email = jsonBody.email;
+
+ // If the client has the wrong case on the email, we return a 400, with
+ // the capitalization of the email as saved in the accounts database.
+ if (email == canonicalEmail) {
+ response.setStatusLine(request.httpVersion, 200, "Yay");
+ return writeResp(response, {areWeHappy: "yes"});
+ }
+
+ response.setStatusLine(request.httpVersion, 400, "Incorrect email case");
+ return writeResp(response, {
+ code: 400,
+ errno: 120,
+ error: "Incorrect email case",
+ email: canonicalEmail
+ });
+ },
+ }
+ );
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = yield client.signIn(clientEmail, "123456");
+ do_check_eq(result.areWeHappy, "yes");
+ do_check_eq(attempts, 2);
+
+ yield deferredStop(server);
+});
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_credentials.js b/services/fxaccounts/tests/xpcshell/test_credentials.js
new file mode 100644
index 000000000..cbd9e4c7a
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_credentials.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+Cu.import("resource://gre/modules/Credentials.jsm");
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-crypto/utils.js");
+
+var {hexToBytes: h2b,
+ hexAsString: h2s,
+ stringAsHex: s2h,
+ bytesAsHex: b2h} = CommonUtils;
+
+// Test vectors for the "onepw" protocol:
+// https://github.com/mozilla/fxa-auth-server/wiki/onepw-protocol#wiki-test-vectors
+var vectors = {
+ "client stretch-KDF": {
+ email:
+ h("616e6472c3a94065 78616d706c652e6f 7267"),
+ password:
+ h("70c3a4737377c3b6 7264"),
+ quickStretchedPW:
+ h("e4e8889bd8bd61ad 6de6b95c059d56e7 b50dacdaf62bd846 44af7e2add84345d"),
+ authPW:
+ h("247b675ffb4c4631 0bc87e26d712153a be5e1c90ef00a478 4594f97ef54f2375"),
+ authSalt:
+ h("00f0000000000000 0000000000000000 0000000000000000 0000000000000000"),
+ },
+};
+
+// A simple test suite with no utf8 encoding madness.
+add_task(function* test_onepw_setup_credentials() {
+ let email = "francine@example.org";
+ let password = CommonUtils.encodeUTF8("i like pie");
+
+ let pbkdf2 = CryptoUtils.pbkdf2Generate;
+ let hkdf = CryptoUtils.hkdf;
+
+ // quickStretch the email
+ let saltyEmail = Credentials.keyWordExtended("quickStretch", email);
+
+ do_check_eq(b2h(saltyEmail), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267");
+
+ let pbkdf2Rounds = 1000;
+ let pbkdf2Len = 32;
+
+ let quickStretchedPW = pbkdf2(password, saltyEmail, pbkdf2Rounds, pbkdf2Len, Ci.nsICryptoHMAC.SHA256, 32);
+ let quickStretchedActual = "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4";
+ do_check_eq(b2h(quickStretchedPW), quickStretchedActual);
+
+ // obtain hkdf info
+ let authKeyInfo = Credentials.keyWord('authPW');
+ do_check_eq(b2h(authKeyInfo), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057");
+
+ // derive auth password
+ let hkdfSalt = h2b("00");
+ let hkdfLen = 32;
+ let authPW = hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen);
+
+ do_check_eq(b2h(authPW), "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342");
+
+ // derive unwrap key
+ let unwrapKeyInfo = Credentials.keyWord('unwrapBkey');
+ let unwrapKey = hkdf(quickStretchedPW, hkdfSalt, unwrapKeyInfo, hkdfLen);
+
+ do_check_eq(b2h(unwrapKey), "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9");
+});
+
+add_task(function* test_client_stretch_kdf() {
+ let pbkdf2 = CryptoUtils.pbkdf2Generate;
+ let hkdf = CryptoUtils.hkdf;
+ let expected = vectors["client stretch-KDF"];
+
+ let email = h2s(expected.email);
+ let password = h2s(expected.password);
+
+ // Intermediate value from sjcl implementation in fxa-js-client
+ // The key thing is the c3a9 sequence in "andré"
+ let salt = Credentials.keyWordExtended("quickStretch", email);
+ do_check_eq(b2h(salt), "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267");
+
+ let options = {
+ stretchedPassLength: 32,
+ pbkdf2Rounds: 1000,
+ hmacAlgorithm: Ci.nsICryptoHMAC.SHA256,
+ hmacLength: 32,
+ hkdfSalt: h2b("00"),
+ hkdfLength: 32,
+ };
+
+ let results = yield Credentials.setup(email, password, options);
+
+ do_check_eq(expected.quickStretchedPW, b2h(results.quickStretchedPW),
+ "quickStretchedPW is wrong");
+
+ do_check_eq(expected.authPW, b2h(results.authPW),
+ "authPW is wrong");
+});
+
+// End of tests
+// Utility functions follow
+
+function run_test() {
+ run_next_test();
+}
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
new file mode 100644
index 000000000..64ddb1fd1
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for FxAccounts, storage and the master password.
+
+// Stop us hitting the real auth server.
+Services.prefs.setCharPref("identity.fxaccounts.auth.uri", "http://localhost");
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setCharPref("identity.fxaccounts.loglevel", "Trace");
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+
+// Use a backstage pass to get at our LoginManagerStorage object, so we can
+// mock the prototype.
+var {LoginManagerStorage} = Cu.import("resource://gre/modules/FxAccountsStorage.jsm", {});
+var isLoggedIn = true;
+LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn);
+
+function setLoginMgrLoggedInState(loggedIn) {
+ isLoggedIn = loggedIn;
+}
+
+
+initTestLogging("Trace");
+
+function run_test() {
+ run_next_test();
+}
+
+function getLoginMgrData() {
+ let logins = Services.logins.findLogins({}, FXA_PWDMGR_HOST, null, FXA_PWDMGR_REALM);
+ if (logins.length == 0) {
+ return null;
+ }
+ 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 = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ kA: "the kA value",
+ kB: "the kB value",
+ verified: true
+ };
+ yield fxa.setSignedInUser(creds);
+
+ // This should have stored stuff in both the .json file in the profile
+ // dir, and the login dir.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json");
+ let data = yield CommonUtils.readJSON(path);
+
+ Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text");
+ Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text");
+ Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag");
+
+ Assert.ok(!("kA" in data.accountData), "kA not stored in clear text");
+ Assert.ok(!("kB" in data.accountData), "kB not stored in clear text");
+
+ let login = getLoginMgrData();
+ Assert.strictEqual(login.username, creds.uid, "uid used for username");
+ let loginData = JSON.parse(login.password);
+ Assert.strictEqual(loginData.version, data.version, "same version flag in both places");
+ Assert.strictEqual(loginData.accountData.kA, creds.kA, "correct kA in the login mgr");
+ Assert.strictEqual(loginData.accountData.kB, creds.kB, "correct kB in the login mgr");
+
+ Assert.ok(!("email" in loginData), "email not stored in the login mgr json");
+ Assert.ok(!("sessionToken" in loginData), "sessionToken not stored in the login mgr json");
+ Assert.ok(!("verified" in loginData), "verified not stored in the login mgr json");
+
+ yield fxa.signOut(/* localOnly = */ true);
+ Assert.strictEqual(getLoginMgrData(), null, "login mgr data deleted on logout");
+});
+
+add_task(function* test_MPLocked() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ kA: "the kA value",
+ kB: "the kB value",
+ verified: true
+ };
+
+ Assert.strictEqual(getLoginMgrData(), null, "no login mgr at the start");
+ // tell the storage that the MP is locked.
+ setLoginMgrLoggedInState(false);
+ yield fxa.setSignedInUser(creds);
+
+ // This should have stored stuff in the .json, and the login manager stuff
+ // will not exist.
+ let path = OS.Path.join(OS.Constants.Path.profileDir, "signedInUser.json");
+ let data = yield CommonUtils.readJSON(path);
+
+ Assert.strictEqual(data.accountData.email, creds.email, "correct email in the clear text");
+ Assert.strictEqual(data.accountData.sessionToken, creds.sessionToken, "correct sessionToken in the clear text");
+ Assert.strictEqual(data.accountData.verified, creds.verified, "correct verified flag");
+
+ Assert.ok(!("kA" in data.accountData), "kA not stored in clear text");
+ Assert.ok(!("kB" in data.accountData), "kB not stored in clear text");
+
+ Assert.strictEqual(getLoginMgrData(), null, "login mgr data doesn't exist");
+ yield fxa.signOut(/* localOnly = */ true)
+});
+
+
+add_task(function* test_consistentWithMPEdgeCases() {
+ setLoginMgrLoggedInState(true);
+
+ let fxa = createFxAccounts();
+
+ let creds1 = {
+ uid: "uid1",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ kA: "the kA value",
+ kB: "the kB value",
+ verified: true
+ };
+
+ let creds2 = {
+ uid: "uid2",
+ email: "test2@example.com",
+ sessionToken: "sessionToken2",
+ kA: "the kA value2",
+ kB: "the kB value2",
+ verified: false,
+ };
+
+ // Log a user in while MP is unlocked.
+ yield fxa.setSignedInUser(creds1);
+
+ // tell the storage that the MP is locked - this will prevent logout from
+ // being able to clear the data.
+ setLoginMgrLoggedInState(false);
+
+ // now set the second credentials.
+ yield fxa.setSignedInUser(creds2);
+
+ // We should still have creds1 data in the login manager.
+ let login = getLoginMgrData();
+ Assert.strictEqual(login.username, creds1.uid);
+ // and that we do have the first kA in the login manager.
+ Assert.strictEqual(JSON.parse(login.password).accountData.kA, creds1.kA,
+ "stale data still in login mgr");
+
+ // Make a new FxA instance (otherwise the values in memory will be used)
+ // and we want the login manager to be unlocked.
+ setLoginMgrLoggedInState(true);
+ fxa = createFxAccounts();
+
+ let accountData = yield fxa.getSignedInUser();
+ Assert.strictEqual(accountData.email, creds2.email);
+ // we should have no kA at all.
+ Assert.strictEqual(accountData.kA, undefined, "stale kA wasn't used");
+ yield fxa.signOut(/* localOnly = */ true)
+});
+
+// A test for the fact we will accept either a UID or email when looking in
+// the login manager.
+add_task(function* test_uidMigration() {
+ setLoginMgrLoggedInState(true);
+ Assert.strictEqual(getLoginMgrData(), null, "expect no logins at the start");
+
+ // create the login entry using email as a key.
+ let contents = {kA: "kA"};
+
+ let loginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
+ let login = new loginInfo(FXA_PWDMGR_HOST,
+ null, // aFormSubmitURL,
+ FXA_PWDMGR_REALM, // aHttpRealm,
+ "foo@bar.com", // aUsername
+ JSON.stringify(contents), // aPassword
+ "", // aUsernameField
+ "");// aPasswordField
+ Services.logins.addLogin(login);
+
+ // ensure we read it.
+ let storage = new LoginManagerStorage();
+ let got = yield storage.get("uid", "foo@bar.com");
+ Assert.deepEqual(got, contents);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_client.js b/services/fxaccounts/tests/xpcshell/test_oauth_client.js
new file mode 100644
index 000000000..9bcb1b1ab
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_client.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm");
+
+function run_test() {
+ validationHelper(undefined,
+ "Error: Missing 'parameters' configuration option");
+
+ validationHelper({},
+ "Error: Missing 'parameters' configuration option");
+
+ validationHelper({ parameters: {} },
+ "Error: Missing 'parameters.oauth_uri' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1"
+ }},
+ "Error: Missing 'parameters.client_id' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1",
+ client_id: "client_id"
+ }},
+ "Error: Missing 'parameters.content_uri' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1",
+ client_id: "client_id",
+ content_uri: "http://content.test"
+ }},
+ "Error: Missing 'parameters.state' parameter");
+
+ validationHelper({ parameters: {
+ oauth_uri: "http://oauth.test/v1",
+ client_id: "client_id",
+ content_uri: "http://content.test",
+ state: "complete",
+ action: "force_auth"
+ }},
+ "Error: parameters.email is required for action 'force_auth'");
+
+ run_next_test();
+}
+
+function validationHelper(params, expected) {
+ try {
+ new FxAccountsOAuthClient(params);
+ } catch (e) {
+ return do_check_eq(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
new file mode 100644
index 000000000..244b79a5e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client.js
@@ -0,0 +1,292 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+
+const CLIENT_OPTIONS = {
+ serverURL: "http://127.0.0.1:9010/v1",
+ client_id: 'abc123'
+};
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+var mockResponse = function (response) {
+ return function () {
+ return {
+ setHeader: function () {},
+ post: function () {
+ this.response = response;
+ this.onComplete();
+ }
+ };
+ };
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+var mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader: function () {},
+ post: function () {
+ this.onComplete(error);
+ }
+ };
+ };
+};
+
+add_test(function missingParams () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ try {
+ client.getTokenFromAssertion()
+ } catch (e) {
+ do_check_eq(e.message, "Missing 'assertion' parameter");
+ }
+
+ try {
+ client.getTokenFromAssertion("assertion")
+ } catch (e) {
+ do_check_eq(e.message, "Missing 'scope' parameter");
+ }
+
+ run_next_test();
+});
+
+add_test(function successfulResponse () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"access_token\":\"http://example.com/image.jpeg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ function (result) {
+ do_check_eq(result.access_token, "http://example.com/image.jpeg");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function successfulDestroy () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.destroyToken("deadbeef").then(run_next_test);
+});
+
+add_test(function parseErrorResponse () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "unexpected",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, STATUS_SUCCESS);
+ do_check_eq(e.errno, ERRNO_PARSE);
+ do_check_eq(e.error, ERROR_PARSE);
+ do_check_eq(e.message, "unexpected");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function serverErrorResponse () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ status: 400,
+ body: "{ \"code\": 400, \"errno\": 104, \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("blah", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, 400);
+ do_check_eq(e.errno, ERRNO_INVALID_FXA_ASSERTION);
+ do_check_eq(e.error, "Bad Request");
+ do_check_eq(e.message, "Unauthorized");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function networkErrorResponse () {
+ let client = new FxAccountsOAuthGrantClient({
+ serverURL: "http://",
+ client_id: "abc123"
+ });
+ Services.prefs.setBoolPref("identity.fxaccounts.skipDeviceRegistration", true);
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ run_next_test();
+ }
+ ).catch(() => {}).then(() =>
+ Services.prefs.clearUserPref("identity.fxaccounts.skipDeviceRegistration"));
+});
+
+add_test(function unsupportedMethod () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+
+ return client._createRequest("/", "PUT")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function onCompleteRequestError () {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ client._Request = new mockResponseError(new Error("onComplete error"));
+ client.getTokenFromAssertion("assertion", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, "Error: onComplete error");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function incorrectErrno() {
+ let client = new FxAccountsOAuthGrantClient(CLIENT_OPTIONS);
+ let response = {
+ status: 400,
+ body: "{ \"code\": 400, \"errno\": \"bad errno\", \"error\": \"Bad Request\", \"message\": \"Unauthorized\", \"reason\": \"Invalid fxa assertion\" }",
+ };
+
+ client._Request = new mockResponse(response);
+ client.getTokenFromAssertion("blah", "scope")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(e.code, 400);
+ do_check_eq(e.errno, ERRNO_UNKNOWN_ERROR);
+ do_check_eq(e.error, "Bad Request");
+ do_check_eq(e.message, "Unauthorized");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function constructorTests() {
+ validationHelper(undefined,
+ "Error: Missing configuration options");
+
+ validationHelper({},
+ "Error: Missing 'serverURL' parameter");
+
+ validationHelper({ serverURL: "http://example.com" },
+ "Error: Missing 'client_id' parameter");
+
+ validationHelper({ client_id: "123ABC" },
+ "Error: Missing 'serverURL' parameter");
+
+ validationHelper({ client_id: "123ABC", serverURL: "badUrl" },
+ "Error: Invalid 'serverURL'");
+
+ run_next_test();
+});
+
+add_test(function errorTests() {
+ let error1 = new FxAccountsOAuthGrantClientError();
+ do_check_eq(error1.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(error1.code, null);
+ do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR);
+ do_check_eq(error1.error, ERROR_UNKNOWN);
+ do_check_eq(error1.message, null);
+
+ let error2 = new FxAccountsOAuthGrantClientError({
+ code: STATUS_SUCCESS,
+ errno: 1,
+ error: "Error",
+ message: "Something",
+ });
+ let fields2 = error2._toStringFields();
+ let statusCode = 1;
+
+ do_check_eq(error2.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(error2.code, STATUS_SUCCESS);
+ do_check_eq(error2.errno, statusCode);
+ do_check_eq(error2.error, "Error");
+ do_check_eq(error2.message, "Something");
+
+ do_check_eq(fields2.name, "FxAccountsOAuthGrantClientError");
+ do_check_eq(fields2.code, STATUS_SUCCESS);
+ do_check_eq(fields2.errno, statusCode);
+ do_check_eq(fields2.error, "Error");
+ do_check_eq(fields2.message, "Something");
+
+ do_check_true(error2.toString().indexOf("Something") >= 0);
+ run_next_test();
+});
+
+function run_test() {
+ run_next_test();
+}
+
+/**
+ * Quick way to test the "FxAccountsOAuthGrantClient" constructor.
+ *
+ * @param {Object} options
+ * FxAccountsOAuthGrantClient constructor options
+ * @param {String} expected
+ * Expected error message
+ * @returns {*}
+ */
+function validationHelper(options, expected) {
+ try {
+ new FxAccountsOAuthGrantClient(options);
+ } catch (e) {
+ return do_check_eq(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
new file mode 100644
index 000000000..bd446513e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_grant_client_server.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// A test of FxAccountsOAuthGrantClient but using a real server it can
+// hit.
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+
+// handlers for our server.
+var numTokenFetches;
+var activeTokens;
+
+function authorize(request, response) {
+ response.setStatusLine("1.1", 200, "OK");
+ let token = "token" + numTokenFetches;
+ numTokenFetches += 1;
+ activeTokens.add(token);
+ response.write(JSON.stringify({access_token: token}));
+}
+
+function destroy(request, response) {
+ // Getting the body seems harder than it should be!
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"]
+ .createInstance(Ci.nsIScriptableInputStream);
+ sis.init(request.bodyInputStream);
+ let body = JSON.parse(sis.read(sis.available()));
+ sis.close();
+ let token = body.token;
+ ok(activeTokens.delete(token));
+ print("after destroy have", activeTokens.size, "tokens left.")
+ response.setStatusLine("1.1", 200, "OK");
+ response.write('{}');
+}
+
+function startServer() {
+ numTokenFetches = 0;
+ activeTokens = new Set();
+ let srv = new HttpServer();
+ srv.registerPathHandler("/v1/authorization", authorize);
+ srv.registerPathHandler("/v1/destroy", destroy);
+ srv.start(-1);
+ return srv;
+}
+
+function promiseStopServer(server) {
+ return new Promise(resolve => {
+ server.stop(resolve);
+ });
+}
+
+add_task(function* getAndRevokeToken () {
+ let server = startServer();
+ let clientOptions = {
+ serverURL: "http://localhost:" + server.identity.primaryPort + "/v1",
+ client_id: 'abc123',
+ }
+
+ let client = new FxAccountsOAuthGrantClient(clientOptions);
+ let result = yield client.getTokenFromAssertion("assertion", "scope");
+ equal(result.access_token, "token0");
+ equal(numTokenFetches, 1, "we hit the server to fetch a token");
+ yield client.destroyToken("token0");
+ equal(activeTokens.size, 0, "We hit the server to revoke it");
+ yield promiseStopServer(server);
+});
+
+// XXX - TODO - we should probably add more tests for unexpected responses etc.
+
+function run_test() {
+ run_next_test();
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
new file mode 100644
index 000000000..08642846b
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/osfile.jsm");
+
+// We grab some additional stuff via backstage passes.
+var {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ }
+ Services.obs.addObserver(observe, topic, false);
+ });
+}
+
+// A storage manager that doesn't actually write anywhere.
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+
+// Just enough mocks so we can avoid hawk etc.
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ this.signOut = function() { return Promise.resolve(); };
+ this.registerDevice = function() { return Promise.resolve(); };
+ this.updateDevice = function() { return Promise.resolve(); };
+ this.signOutAndDestroyDevice = function() { return Promise.resolve(); };
+ this.getDeviceList = function() { return Promise.resolve(); };
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(device={}) {
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(),
+ 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",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+ yield fxa.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+function run_test() {
+ run_next_test();
+}
+
+add_task(function* testCacheStorage() {
+ let fxa = yield createMockFxA();
+
+ // Hook what the impl calls to save to disk.
+ let cas = fxa.internal.currentAccountState;
+ let origPersistCached = cas._persistCachedTokens.bind(cas)
+ cas._persistCachedTokens = function() {
+ return origPersistCached().then(() => {
+ Services.obs.notifyObservers(null, "testhelper-fxa-cache-persist-done", null);
+ });
+ };
+
+ let promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
+ let tokenData = {token: "token1", somethingelse: "something else"};
+ let scopeArray = ["foo", "bar"];
+ cas.setCachedToken(scopeArray, tokenData);
+ deepEqual(cas.getCachedToken(scopeArray), tokenData);
+
+ deepEqual(cas.oauthTokens, {"bar|foo": tokenData});
+ // wait for background write to complete.
+ yield promiseWritten;
+
+ // Check the token cache made it to our mocked storage.
+ deepEqual(cas.storageManager.accountData.oauthTokens, {"bar|foo": tokenData});
+
+ // Drop the token from the cache and ensure it is removed from the json.
+ promiseWritten = promiseNotification("testhelper-fxa-cache-persist-done");
+ yield cas.removeCachedToken("token1");
+ deepEqual(cas.oauthTokens, {});
+ yield promiseWritten;
+ deepEqual(cas.storageManager.accountData.oauthTokens, {});
+
+ // sign out and the token storage should end up with null.
+ let storageManager = cas.storageManager; // .signOut() removes the attribute.
+ yield fxa.signOut( /* localOnly = */ true);
+ deepEqual(storageManager.accountData, null);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
new file mode 100644
index 000000000..f758bf405
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
@@ -0,0 +1,251 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccounts.jsm");
+Cu.import("resource://gre/modules/FxAccountsClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsOAuthGrantClient.jsm");
+Cu.import("resource://services-common/utils.js");
+var {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ }
+ Services.obs.addObserver(observe, topic, false);
+ });
+}
+
+// Just enough mocks so we can avoid hawk and storage.
+function MockStorageManager() {
+}
+
+MockStorageManager.prototype = {
+ promiseInitialized: Promise.resolve(),
+
+ initialize(accountData) {
+ this.accountData = accountData;
+ },
+
+ finalize() {
+ return Promise.resolve();
+ },
+
+ getAccountData() {
+ return Promise.resolve(this.accountData);
+ },
+
+ updateAccountData(updatedFields) {
+ for (let [name, value] of Object.entries(updatedFields)) {
+ if (value == null) {
+ delete this.accountData[name];
+ } else {
+ this.accountData[name] = value;
+ }
+ }
+ return Promise.resolve();
+ },
+
+ deleteAccountData() {
+ this.accountData = null;
+ return Promise.resolve();
+ }
+}
+
+function MockFxAccountsClient() {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function(uid) {
+ let deferred = Promise.defer();
+ deferred.resolve(!!uid && (!this._deletedOnServer));
+ return deferred.promise;
+ };
+
+ this.signOut = function() { return Promise.resolve(); };
+ this.registerDevice = function() { return Promise.resolve(); };
+ this.updateDevice = function() { return Promise.resolve(); };
+ this.signOutAndDestroyDevice = function() { return Promise.resolve(); };
+ this.getDeviceList = function() { return Promise.resolve(); };
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {
+ __proto__: FxAccountsClient.prototype
+}
+
+function MockFxAccounts(mockGrantClient) {
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(),
+ getAssertion: () => Promise.resolve("assertion"),
+ newAccountState(credentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ return new AccountState(storage);
+ },
+ _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",
+ assertion: "foobar",
+ sessionToken: "dead",
+ kA: "beef",
+ kB: "cafe",
+ verified: true
+ };
+
+ yield fxa.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+function run_test() {
+ run_next_test();
+}
+
+function MockFxAccountsOAuthGrantClient() {
+ this.activeTokens = new Set();
+}
+
+MockFxAccountsOAuthGrantClient.prototype = {
+ serverURL: {href: "http://localhost"},
+ getTokenFromAssertion(assertion, scope) {
+ let token = "token" + this.numTokenFetches;
+ this.numTokenFetches += 1;
+ this.activeTokens.add(token);
+ print("getTokenFromAssertion returning token", token);
+ return Promise.resolve({access_token: token});
+ },
+ destroyToken(token) {
+ ok(this.activeTokens.delete(token));
+ print("after destroy have", this.activeTokens.size, "tokens left.");
+ return Promise.resolve({});
+ },
+ // and some stuff used only for tests.
+ numTokenFetches: 0,
+ activeTokens: null,
+}
+
+add_task(function* testRevoke() {
+ let client = new MockFxAccountsOAuthGrantClient();
+ let tokenOptions = { scope: "test-scope", client: client };
+ let fxa = yield createMockFxA(client);
+
+ // get our first token and check we hit the mock.
+ let token1 = yield fxa.getOAuthToken(tokenOptions);
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+ equal(token1, "token0");
+
+ // drop the new token from our cache.
+ yield fxa.removeCachedOAuthToken({token: token1});
+
+ // FxA fires an observer when the "background" revoke is complete.
+ yield promiseNotification("testhelper-fxa-revoke-complete");
+ // the revoke should have been successful.
+ equal(client.activeTokens.size, 0);
+ // fetching it again hits the server.
+ let token2 = yield fxa.getOAuthToken(tokenOptions);
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 1);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+});
+
+add_task(function* testSignOutDestroysTokens() {
+ let client = new MockFxAccountsOAuthGrantClient();
+ let fxa = yield createMockFxA(client);
+
+ // get our first token and check we hit the mock.
+ let token1 = yield fxa.getOAuthToken({ scope: "test-scope", client: client });
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+
+ // get another
+ let token2 = yield fxa.getOAuthToken({ scope: "test-scope-2", client: client });
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 2);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+
+ // now sign out - they should be removed.
+ yield fxa.signOut();
+ // FxA fires an observer when the "background" signout is complete.
+ yield promiseNotification("testhelper-fxa-signout-complete");
+ // No active tokens left.
+ equal(client.activeTokens.size, 0);
+});
+
+add_task(function* testTokenRaces() {
+ // Here we do 2 concurrent fetches each for 2 different token scopes (ie,
+ // 4 token fetches in total).
+ // This should provoke a potential race in the token fetching but we should
+ // handle and detect that leaving us with one of the fetch tokens being
+ // revoked and the same token value returned to both calls.
+ let client = new MockFxAccountsOAuthGrantClient();
+ let fxa = yield createMockFxA(client);
+
+ // We should see 2 notifications as part of this - set up the listeners
+ // now (and wait on them later)
+ let notifications = Promise.all([
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ ]);
+ let results = yield Promise.all([
+ fxa.getOAuthToken({scope: "test-scope", client: client}),
+ fxa.getOAuthToken({scope: "test-scope", client: client}),
+ fxa.getOAuthToken({scope: "test-scope-2", client: client}),
+ fxa.getOAuthToken({scope: "test-scope-2", client: client}),
+ ]);
+
+ equal(client.numTokenFetches, 4, "should have fetched 4 tokens.");
+ // We should see 2 of the 4 revoked due to the race.
+ yield notifications;
+
+ // Should have 2 unique tokens
+ results.sort();
+ equal(results[0], results[1]);
+ equal(results[2], results[3]);
+ // should be 2 active.
+ equal(client.activeTokens.size, 2);
+ // Which can each be revoked.
+ notifications = Promise.all([
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ promiseNotification("testhelper-fxa-revoke-complete"),
+ ]);
+ yield fxa.removeCachedOAuthToken({token: results[0]});
+ equal(client.activeTokens.size, 1);
+ yield fxa.removeCachedOAuthToken({token: results[2]});
+ equal(client.activeTokens.size, 0);
+ yield notifications;
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_profile.js b/services/fxaccounts/tests/xpcshell/test_profile.js
new file mode 100644
index 000000000..13adf8cbb
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -0,0 +1,409 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Promise.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm");
+Cu.import("resource://gre/modules/FxAccountsProfile.jsm");
+
+const URL_STRING = "https://example.com";
+Services.prefs.setCharPref("identity.fxaccounts.settings.uri", "https://example.com/settings");
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+var mockResponse = function (response) {
+ let Request = function (requestUri) {
+ // Store the request uri so tests can inspect it
+ Request._requestUri = requestUri;
+ return {
+ setHeader: function () {},
+ head: function () {
+ this.response = response;
+ this.onComplete();
+ }
+ };
+ };
+
+ return Request;
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+var mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader: function () {},
+ head: function () {
+ this.onComplete(error);
+ }
+ };
+ };
+};
+
+var mockClient = function (fxa) {
+ let options = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: fxa,
+ }
+ return new FxAccountsProfileClient(options);
+};
+
+const ACCOUNT_DATA = {
+ uid: "abc123"
+};
+
+function FxaMock() {
+}
+FxaMock.prototype = {
+ currentAccountState: {
+ profile: null,
+ get isCurrent() {
+ return true;
+ }
+ },
+
+ getSignedInUser: function () {
+ return Promise.resolve(ACCOUNT_DATA);
+ }
+};
+
+var mockFxa = function() {
+ return new FxaMock();
+};
+
+function CreateFxAccountsProfile(fxa = null, client = null) {
+ if (!fxa) {
+ fxa = mockFxa();
+ }
+ let options = {
+ fxa: fxa,
+ profileServerUrl: "http://127.0.0.1:1111/v1"
+ }
+ if (client) {
+ options.profileClient = client;
+ }
+ return new FxAccountsProfile(options);
+}
+
+add_test(function getCachedProfile() {
+ let profile = CreateFxAccountsProfile();
+ // a little pointless until bug 1157529 is fixed...
+ profile._cachedProfile = { avatar: "myurl" };
+
+ return profile._getCachedProfile()
+ .then(function (cached) {
+ do_check_eq(cached.avatar, "myurl");
+ run_next_test();
+ });
+});
+
+add_test(function cacheProfile_change() {
+ let fxa = mockFxa();
+/* Saving profile data disabled - bug 1157529
+ let setUserAccountDataCalled = false;
+ fxa.setUserAccountData = function (data) {
+ setUserAccountDataCalled = true;
+ do_check_eq(data.profile.avatar, "myurl");
+ return Promise.resolve();
+ };
+*/
+ let profile = CreateFxAccountsProfile(fxa);
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ do_check_eq(data, ACCOUNT_DATA.uid);
+// do_check_true(setUserAccountDataCalled); - bug 1157529
+ run_next_test();
+ });
+
+ return profile._cacheProfile({ avatar: "myurl" });
+});
+
+add_test(function cacheProfile_no_change() {
+ let fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa)
+ profile._cachedProfile = { avatar: "myurl" };
+// XXX - saving is disabled (but we can leave that in for now as we are
+// just checking it is *not* called)
+ fxa.setSignedInUser = function (data) {
+ throw new Error("should not update account data");
+ };
+
+ return profile._cacheProfile({ avatar: "myurl" })
+ .then((result) => {
+ do_check_false(!!result);
+ run_next_test();
+ });
+});
+
+add_test(function fetchAndCacheProfile_ok() {
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ return Promise.resolve({ avatar: "myimg"});
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+
+ profile._cacheProfile = function (toCache) {
+ do_check_eq(toCache.avatar, "myimg");
+ return Promise.resolve();
+ };
+
+ return profile._fetchAndCacheProfile()
+ .then(result => {
+ do_check_eq(result.avatar, "myimg");
+ run_next_test();
+ });
+});
+
+// Check that a second profile request when one is already in-flight reuses
+// the in-flight one.
+add_task(function* fetchAndCacheProfileOnce() {
+ // A promise that remains unresolved while we fire off 2 requests for
+ // a profile.
+ let resolveProfile;
+ let promiseProfile = new Promise(resolve => {
+ resolveProfile = resolve;
+ });
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return promiseProfile;
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ let request2 = profile._fetchAndCacheProfile();
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ do_check_eq(numFetches, 1);
+
+ // resolve the promise.
+ resolveProfile({ avatar: "myimg"});
+
+ // both requests should complete with the same data.
+ let got1 = yield request1;
+ do_check_eq(got1.avatar, "myimg");
+ let got2 = yield request1;
+ do_check_eq(got2.avatar, "myimg");
+
+ // and still only 1 request was made.
+ do_check_eq(numFetches, 1);
+});
+
+// Check that sharing a single fetch promise works correctly when the promise
+// is rejected.
+add_task(function* fetchAndCacheProfileOnce() {
+ // A promise that remains unresolved while we fire off 2 requests for
+ // a profile.
+ let rejectProfile;
+ let promiseProfile = new Promise((resolve,reject) => {
+ rejectProfile = reject;
+ });
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return promiseProfile;
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ let request2 = profile._fetchAndCacheProfile();
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ do_check_eq(numFetches, 1);
+
+ // reject the promise.
+ rejectProfile("oh noes");
+
+ // both requests should reject.
+ try {
+ yield request1;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+ try {
+ yield request2;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+
+ // but a new request should work.
+ client.fetchProfile = function () {
+ return Promise.resolve({ avatar: "myimg"});
+ };
+
+ let got = yield profile._fetchAndCacheProfile();
+ do_check_eq(got.avatar, "myimg");
+});
+
+// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
+// last one doesn't kick off a new request to check the cached copy is fresh.
+add_task(function* fetchAndCacheProfileAfterThreshold() {
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return Promise.resolve({ avatar: "myimg"});
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 1);
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 1);
+
+ yield new Promise(resolve => {
+ do_timeout(1000, resolve);
+ });
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 2);
+});
+
+// Check that a new profile request within PROFILE_FRESHNESS_THRESHOLD of the
+// last one *does* kick off a new request if ON_PROFILE_CHANGE_NOTIFICATION
+// is sent.
+add_task(function* fetchAndCacheProfileBeforeThresholdOnNotification() {
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ numFetches += 1;
+ return Promise.resolve({ avatar: "myimg"});
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 1);
+
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION, null);
+
+ yield profile.getProfile();
+ do_check_eq(numFetches, 2);
+});
+
+add_test(function tearDown_ok() {
+ let profile = CreateFxAccountsProfile();
+
+ do_check_true(!!profile.client);
+ do_check_true(!!profile.fxa);
+
+ profile.tearDown();
+ do_check_null(profile.fxa);
+ do_check_null(profile.client);
+
+ run_next_test();
+});
+
+add_test(function getProfile_ok() {
+ let cachedUrl = "myurl";
+ let didFetch = false;
+
+ let profile = CreateFxAccountsProfile();
+ profile._getCachedProfile = function () {
+ return Promise.resolve({ avatar: cachedUrl });
+ };
+
+ profile._fetchAndCacheProfile = function () {
+ didFetch = true;
+ return Promise.resolve();
+ };
+
+ return profile.getProfile()
+ .then(result => {
+ do_check_eq(result.avatar, cachedUrl);
+ do_check_true(didFetch);
+ run_next_test();
+ });
+});
+
+add_test(function getProfile_no_cache() {
+ let fetchedUrl = "newUrl";
+ let profile = CreateFxAccountsProfile();
+ profile._getCachedProfile = function () {
+ return Promise.resolve();
+ };
+
+ profile._fetchAndCacheProfile = function () {
+ return Promise.resolve({ avatar: fetchedUrl });
+ };
+
+ return profile.getProfile()
+ .then(result => {
+ do_check_eq(result.avatar, fetchedUrl);
+ run_next_test();
+ });
+});
+
+add_test(function getProfile_has_cached_fetch_deleted() {
+ let cachedUrl = "myurl";
+
+ let fxa = mockFxa();
+ let client = mockClient(fxa);
+ client.fetchProfile = function () {
+ return Promise.resolve({ avatar: null });
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ profile._cachedProfile = { avatar: cachedUrl };
+
+// instead of checking this in a mocked "save" function, just check after the
+// observer
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ profile.getProfile()
+ .then(profileData => {
+ do_check_null(profileData.avatar);
+ run_next_test();
+ });
+ });
+
+ return profile.getProfile()
+ .then(result => {
+ do_check_eq(result.avatar, "myurl");
+ });
+});
+
+function run_test() {
+ run_next_test();
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic, false);
+ return removeMe;
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_profile_client.js b/services/fxaccounts/tests/xpcshell/test_profile_client.js
new file mode 100644
index 000000000..2243da3aa
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js
@@ -0,0 +1,411 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/FxAccountsProfileClient.jsm");
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+var mockResponse = function (response) {
+ let Request = function (requestUri) {
+ // Store the request uri so tests can inspect it
+ Request._requestUri = requestUri;
+ return {
+ setHeader: function () {},
+ get: function () {
+ this.response = response;
+ this.onComplete();
+ }
+ };
+ };
+
+ return Request;
+};
+
+// A simple mock FxA that hands out tokens without checking them and doesn't
+// expect tokens to be revoked. We have specific token tests further down that
+// has more checks here.
+var mockFxa = {
+ getOAuthToken(options) {
+ do_check_eq(options.scope, "profile");
+ return "token";
+ }
+}
+
+const PROFILE_OPTIONS = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa,
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+var mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader: function () {},
+ get: function () {
+ this.onComplete(error);
+ }
+ };
+ };
+};
+
+add_test(function successfulResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"email\":\"someone@restmail.net\",\"uid\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile()
+ .then(
+ function (result) {
+ do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/profile");
+ do_check_eq(result.email, "someone@restmail.net");
+ do_check_eq(result.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function parseErrorResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "unexpected",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, STATUS_SUCCESS);
+ do_check_eq(e.errno, ERRNO_PARSE);
+ do_check_eq(e.error, ERROR_PARSE);
+ do_check_eq(e.message, "unexpected");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function serverErrorResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ status: 500,
+ body: "{ \"code\": 500, \"errno\": 100, \"error\": \"Bad Request\", \"message\": \"Something went wrong\", \"reason\": \"Because the internet\" }",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, 500);
+ do_check_eq(e.errno, 100);
+ do_check_eq(e.error, "Bad Request");
+ do_check_eq(e.message, "Something went wrong");
+ run_next_test();
+ }
+ );
+});
+
+// Test that we get a token, then if we get a 401 we revoke it, get a new one
+// and retry.
+add_test(function server401ResponseThenSuccess () {
+ // The last token we handed out.
+ let lastToken = -1;
+ // The number of times our removeCachedOAuthToken function was called.
+ let numTokensRemoved = 0;
+
+ let mockFxa = {
+ getOAuthToken(options) {
+ do_check_eq(options.scope, "profile");
+ return "" + ++lastToken; // tokens are strings.
+ },
+ removeCachedOAuthToken(options) {
+ // This test never has more than 1 token alive at once, so the token
+ // being revoked must always be the last token we handed out.
+ do_check_eq(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ }
+ }
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa,
+ };
+ let client = new FxAccountsProfileClient(profileOptions);
+
+ // 2 responses - first one implying the token has expired, second works.
+ let responses = [
+ {
+ status: 401,
+ body: "{ \"code\": 401, \"errno\": 100, \"error\": \"Token expired\", \"message\": \"That token is too old\", \"reason\": \"Because security\" }",
+ },
+ {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ },
+ ];
+
+ let numRequests = 0;
+ let numAuthHeaders = 0;
+ // Like mockResponse but we want access to headers etc.
+ client._Request = function(requestUri) {
+ return {
+ setHeader: function (name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ do_check_eq(value, "Bearer " + lastToken);
+ }
+ },
+ get: function () {
+ this.response = responses[numRequests];
+ ++numRequests;
+ this.onComplete();
+ }
+ };
+ }
+
+ client.fetchProfile()
+ .then(result => {
+ do_check_eq(result.avatar, "http://example.com/image.jpg");
+ do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ do_check_eq(numRequests, 2);
+ do_check_eq(numAuthHeaders, 2);
+ // and we should have seen one token revoked.
+ do_check_eq(numTokensRemoved, 1);
+
+ run_next_test();
+ }
+ );
+});
+
+// Test that we get a token, then if we get a 401 we revoke it, get a new one
+// and retry - but we *still* get a 401 on the retry, so the caller sees that.
+add_test(function server401ResponsePersists () {
+ // The last token we handed out.
+ let lastToken = -1;
+ // The number of times our removeCachedOAuthToken function was called.
+ let numTokensRemoved = 0;
+
+ let mockFxa = {
+ getOAuthToken(options) {
+ do_check_eq(options.scope, "profile");
+ return "" + ++lastToken; // tokens are strings.
+ },
+ removeCachedOAuthToken(options) {
+ // This test never has more than 1 token alive at once, so the token
+ // being revoked must always be the last token we handed out.
+ do_check_eq(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ }
+ }
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa,
+ };
+ let client = new FxAccountsProfileClient(profileOptions);
+
+ let response = {
+ status: 401,
+ body: "{ \"code\": 401, \"errno\": 100, \"error\": \"It's not your token, it's you!\", \"message\": \"I don't like you\", \"reason\": \"Because security\" }",
+ };
+
+ let numRequests = 0;
+ let numAuthHeaders = 0;
+ client._Request = function(requestUri) {
+ return {
+ setHeader: function (name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ do_check_eq(value, "Bearer " + lastToken);
+ }
+ },
+ get: function () {
+ this.response = response;
+ ++numRequests;
+ this.onComplete();
+ }
+ };
+ }
+
+ client.fetchProfile().then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, 401);
+ do_check_eq(e.errno, 100);
+ do_check_eq(e.error, "It's not your token, it's you!");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ do_check_eq(numRequests, 2);
+ do_check_eq(numAuthHeaders, 2);
+ // and we should have seen both tokens revoked.
+ do_check_eq(numTokensRemoved, 2);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function networkErrorResponse () {
+ let client = new FxAccountsProfileClient({
+ serverURL: "http://",
+ fxa: mockFxa,
+ });
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function unsupportedMethod () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+
+ return client._createRequest("/profile", "PUT")
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, ERROR_MSG_METHOD_NOT_ALLOWED);
+ run_next_test();
+ }
+ );
+});
+
+add_test(function onCompleteRequestError () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ client._Request = new mockResponseError(new Error("onComplete error"));
+ client.fetchProfile()
+ .then(
+ null,
+ function (e) {
+ do_check_eq(e.name, "FxAccountsProfileClientError");
+ do_check_eq(e.code, null);
+ do_check_eq(e.errno, ERRNO_NETWORK);
+ do_check_eq(e.error, ERROR_NETWORK);
+ do_check_eq(e.message, "Error: onComplete error");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function fetchProfileImage_successfulResponse () {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ body: "{\"avatar\":\"http://example.com/image.jpg\",\"id\":\"0d5c1a89b8c54580b8e3e8adadae864a\"}",
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfileImage()
+ .then(
+ function (result) {
+ do_check_eq(client._Request._requestUri, "http://127.0.0.1:1111/v1/avatar");
+ do_check_eq(result.avatar, "http://example.com/image.jpg");
+ do_check_eq(result.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function constructorTests() {
+ validationHelper(undefined,
+ "Error: Missing 'serverURL' configuration option");
+
+ validationHelper({},
+ "Error: Missing 'serverURL' configuration option");
+
+ validationHelper({ serverURL: "badUrl" },
+ "Error: Invalid 'serverURL'");
+
+ run_next_test();
+});
+
+add_test(function errorTests() {
+ let error1 = new FxAccountsProfileClientError();
+ do_check_eq(error1.name, "FxAccountsProfileClientError");
+ do_check_eq(error1.code, null);
+ do_check_eq(error1.errno, ERRNO_UNKNOWN_ERROR);
+ do_check_eq(error1.error, ERROR_UNKNOWN);
+ do_check_eq(error1.message, null);
+
+ let error2 = new FxAccountsProfileClientError({
+ code: STATUS_SUCCESS,
+ errno: 1,
+ error: "Error",
+ message: "Something",
+ });
+ let fields2 = error2._toStringFields();
+ let statusCode = 1;
+
+ do_check_eq(error2.name, "FxAccountsProfileClientError");
+ do_check_eq(error2.code, STATUS_SUCCESS);
+ do_check_eq(error2.errno, statusCode);
+ do_check_eq(error2.error, "Error");
+ do_check_eq(error2.message, "Something");
+
+ do_check_eq(fields2.name, "FxAccountsProfileClientError");
+ do_check_eq(fields2.code, STATUS_SUCCESS);
+ do_check_eq(fields2.errno, statusCode);
+ do_check_eq(fields2.error, "Error");
+ do_check_eq(fields2.message, "Something");
+
+ do_check_true(error2.toString().indexOf("Something") >= 0);
+ run_next_test();
+});
+
+function run_test() {
+ run_next_test();
+}
+
+/**
+ * Quick way to test the "FxAccountsProfileClient" constructor.
+ *
+ * @param {Object} options
+ * FxAccountsProfileClient constructor options
+ * @param {String} expected
+ * Expected error message
+ * @returns {*}
+ */
+function validationHelper(options, expected) {
+ // add fxa to options - that missing isn't what we are testing here.
+ if (options) {
+ options.fxa = mockFxa;
+ }
+ try {
+ new FxAccountsProfileClient(options);
+ } catch (e) {
+ return do_check_eq(e.toString(), expected);
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_push_service.js b/services/fxaccounts/tests/xpcshell/test_push_service.js
new file mode 100644
index 000000000..8d66f6fa8
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -0,0 +1,236 @@
+/* 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();
+ equal(pushService.initialize(), false);
+});
+
+add_task(function* registerPushEndpointSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: mockFxAccounts,
+ });
+
+ let subscription = yield pushService.registerPushEndpoint();
+ equal(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();
+ equal(subscription, null);
+});
+
+add_task(function* unsubscribeSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: mockFxAccounts,
+ });
+
+ let result = yield pushService.unsubscribe();
+ equal(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();
+ equal(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 observePushTopicVerify() {
+ let emptyMsg = {
+ QueryInterface: function() {
+ return this;
+ }
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ checkVerificationStatus: function () {
+ // checking verification status on push messages without data
+ run_next_test();
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: customAccounts,
+ });
+
+ pushService.observe(emptyMsg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicDeviceDisconnected() {
+ const deviceId = "bogusid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ data: {
+ id: deviceId
+ }
+ })
+ },
+ QueryInterface: function() {
+ return this;
+ }
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ handleDeviceDisconnection: function () {
+ // checking verification status on push messages without data
+ run_next_test();
+ }
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxAccounts: customAccounts,
+ });
+
+ pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicPasswordChanged() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_CHANGED_NOTIFICATION
+ })
+ },
+ QueryInterface: function() {
+ return this;
+ }
+ };
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ });
+
+ pushService._onPasswordChanged = function () {
+ run_next_test();
+ }
+
+ pushService.observe(msg, mockPushService.pushTopic, FXA_PUSH_SCOPE_ACCOUNT_UPDATE);
+});
+
+add_test(function observePushTopicPasswordReset() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_RESET_NOTIFICATION
+ })
+ },
+ QueryInterface: function() {
+ return this;
+ }
+ };
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService
+ });
+
+ pushService._onPasswordChanged = function () {
+ run_next_test();
+ }
+
+ pushService.observe(msg, 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);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_storage_manager.js b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
new file mode 100644
index 000000000..6a293a0ff
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
@@ -0,0 +1,477 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA storage manager.
+
+Cu.import("resource://gre/modules/Task.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FxAccountsStorage.jsm");
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+Cu.import("resource://gre/modules/Log.jsm");
+
+initTestLogging("Trace");
+log.level = Log.Level.Trace;
+
+const DEVICE_REGISTRATION_VERSION = 42;
+
+// A couple of mocks we can use.
+function MockedPlainStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData: accountData,
+ }
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+MockedPlainStorage.prototype = {
+ get: Task.async(function* () {
+ this.numReads++;
+ Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data");
+ return this.data;
+ }),
+
+ set: Task.async(function* (data) {
+ this.data = data;
+ }),
+};
+
+function MockedSecureStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData: accountData,
+ }
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+
+MockedSecureStorage.prototype = {
+ fetchCount: 0,
+ locked: false,
+ STORAGE_LOCKED: function() {},
+ get: Task.async(function* (uid, email) {
+ this.fetchCount++;
+ if (this.locked) {
+ throw new this.STORAGE_LOCKED();
+ }
+ this.numReads++;
+ Assert.equal(this.numReads, 1, "should only ever be 1 read of unlocked data");
+ return this.data;
+ }),
+
+ set: Task.async(function* (uid, contents) {
+ this.data = contents;
+ }),
+}
+
+function add_storage_task(testFunction) {
+ add_task(function* () {
+ print("Starting test with secure storage manager");
+ yield testFunction(new FxAccountsStorageManager());
+ });
+ add_task(function* () {
+ print("Starting test with simple storage manager");
+ yield testFunction(new FxAccountsStorageManager({useSecure: false}));
+ });
+}
+
+// initialized without account data and there's nothing to read. Not logged in.
+add_storage_task(function* checkInitializedEmpty(sm) {
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ yield sm.initialize();
+ Assert.strictEqual((yield sm.getAccountData()), null);
+ Assert.rejects(sm.updateAccountData({kA: "kA"}), "No user is logged in")
+});
+
+// Initialized with account data (ie, simulating a new user being logged in).
+// Should reflect the initial data and be written to storage.
+add_storage_task(function* checkNewUser(sm) {
+ let initialAccountData = {
+ uid: "uid",
+ email: "someone@somewhere.com",
+ kA: "kA",
+ deviceId: "device id"
+ };
+ sm.plainStorage = new MockedPlainStorage()
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ yield sm.initialize(initialAccountData);
+ let accountData = yield sm.getAccountData();
+ Assert.equal(accountData.uid, initialAccountData.uid);
+ Assert.equal(accountData.email, initialAccountData.email);
+ Assert.equal(accountData.kA, initialAccountData.kA);
+ Assert.equal(accountData.deviceId, initialAccountData.deviceId);
+
+ // and it should have been written to storage.
+ Assert.equal(sm.plainStorage.data.accountData.uid, initialAccountData.uid);
+ Assert.equal(sm.plainStorage.data.accountData.email, initialAccountData.email);
+ Assert.equal(sm.plainStorage.data.accountData.deviceId, initialAccountData.deviceId);
+ // check secure
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data.accountData.kA, initialAccountData.kA);
+ } else {
+ Assert.equal(sm.plainStorage.data.accountData.kA, initialAccountData.kA);
+ }
+});
+
+// Initialized without account data but storage has it available.
+add_storage_task(function* checkEverythingRead(sm) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ deviceId: "wibble",
+ deviceRegistrationVersion: null
+ });
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ yield sm.initialize();
+ let accountData = yield sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.equal(accountData.deviceId, "wibble");
+ Assert.equal(accountData.deviceRegistrationVersion, null);
+ // Update the data - we should be able to fetch it back and it should appear
+ // in our storage.
+ yield sm.updateAccountData({
+ verified: true,
+ kA: "kA",
+ kB: "kB",
+ deviceRegistrationVersion: DEVICE_REGISTRATION_VERSION
+ });
+ accountData = yield sm.getAccountData();
+ Assert.equal(accountData.kB, "kB");
+ Assert.equal(accountData.kA, "kA");
+ Assert.equal(accountData.deviceId, "wibble");
+ Assert.equal(accountData.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+ // Check the new value was written to storage.
+ yield sm._promiseStorageComplete; // storage is written in the background.
+ // "verified", "deviceId" and "deviceRegistrationVersion" are plain-text fields.
+ Assert.equal(sm.plainStorage.data.accountData.verified, true);
+ Assert.equal(sm.plainStorage.data.accountData.deviceId, "wibble");
+ Assert.equal(sm.plainStorage.data.accountData.deviceRegistrationVersion, DEVICE_REGISTRATION_VERSION);
+ // "kA" and "foo" are secure
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data.accountData.kA, "kA");
+ Assert.equal(sm.secureStorage.data.accountData.kB, "kB");
+ } else {
+ Assert.equal(sm.plainStorage.data.accountData.kA, "kA");
+ Assert.equal(sm.plainStorage.data.accountData.kB, "kB");
+ }
+});
+
+add_storage_task(function* checkInvalidUpdates(sm) {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ Assert.rejects(sm.updateAccountData({uid: "another"}), "Can't change");
+ Assert.rejects(sm.updateAccountData({email: "someoneelse"}), "Can't change");
+});
+
+add_storage_task(function* checkNullUpdatesRemovedUnlocked(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"});
+ } else {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com",
+ kA: "kA", kB: "kB"});
+ }
+ yield sm.initialize();
+
+ yield sm.updateAccountData({kA: null});
+ let accountData = yield sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ Assert.equal(accountData.kB, "kB");
+});
+
+add_storage_task(function* checkDelete(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"});
+ } else {
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com",
+ kA: "kA", kB: "kB"});
+ }
+ yield sm.initialize();
+
+ yield sm.deleteAccountData();
+ // Storage should have been reset to null.
+ Assert.equal(sm.plainStorage.data, null);
+ if (sm.secureStorage) {
+ Assert.equal(sm.secureStorage.data, null);
+ }
+ // And everything should reflect no user.
+ Assert.equal((yield sm.getAccountData()), null);
+});
+
+// Some tests only for the secure storage manager.
+add_task(function* checkNullUpdatesRemovedLocked() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA", kB: "kB"});
+ sm.secureStorage.locked = true;
+ yield sm.initialize();
+
+ yield sm.updateAccountData({kA: null});
+ let accountData = yield sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ // still no kB as we are locked.
+ Assert.ok(!accountData.kB);
+
+ // now unlock - should still be no kA but kB should appear.
+ sm.secureStorage.locked = false;
+ accountData = yield sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ Assert.equal(accountData.kB, "kB");
+ // And secure storage should have been written with our previously-cached
+ // data.
+ Assert.strictEqual(sm.secureStorage.data.accountData.kA, undefined);
+ Assert.strictEqual(sm.secureStorage.data.accountData.kB, "kB");
+});
+
+add_task(function* checkEverythingReadSecure() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ let accountData = yield sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.equal(accountData.kA, "kA");
+});
+
+add_task(function* checkMemoryFieldsNotReturnedByDefault() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ // keyPair is a memory field.
+ yield sm.updateAccountData({keyPair: "the keypair value"});
+ let accountData = yield sm.getAccountData();
+
+ // Requesting everything should *not* return in memory fields.
+ Assert.strictEqual(accountData.keyPair, undefined);
+ // But requesting them specifically does get them.
+ accountData = yield sm.getAccountData("keyPair");
+ Assert.strictEqual(accountData.keyPair, "the keypair value");
+});
+
+add_task(function* checkExplicitGet() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ let accountData = yield sm.getAccountData(["uid", "kA"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.kA, "kA");
+ // We didn't ask for email so shouldn't have got it.
+ Assert.strictEqual(accountData.email, undefined);
+});
+
+add_task(function* checkExplicitGetNoSecureRead() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ yield sm.initialize();
+
+ Assert.equal(sm.secureStorage.fetchCount, 0);
+ // request 2 fields in secure storage - it should have caused a single fetch.
+ let accountData = yield sm.getAccountData(["email", "uid"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.strictEqual(accountData.kA, undefined);
+ Assert.equal(sm.secureStorage.fetchCount, 1);
+});
+
+add_task(function* checkLockedUpdates() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "old-kA", kB: "kB"});
+ sm.secureStorage.locked = true;
+ yield sm.initialize();
+
+ let accountData = yield sm.getAccountData();
+ // requesting kA and kB will fail as storage is locked.
+ Assert.ok(!accountData.kA);
+ Assert.ok(!accountData.kB);
+ // While locked we can still update it and see the updated value.
+ sm.updateAccountData({kA: "new-kA"});
+ accountData = yield sm.getAccountData();
+ Assert.equal(accountData.kA, "new-kA");
+ // unlock.
+ sm.secureStorage.locked = false;
+ accountData = yield sm.getAccountData();
+ // should reflect the value we updated and the one we didn't.
+ Assert.equal(accountData.kA, "new-kA");
+ Assert.equal(accountData.kB, "kB");
+ // And storage should also reflect it.
+ Assert.strictEqual(sm.secureStorage.data.accountData.kA, "new-kA");
+ Assert.strictEqual(sm.secureStorage.data.accountData.kB, "kB");
+});
+
+// Some tests for the "storage queue" functionality.
+
+// A helper for our queued tests. It creates a StorageManager and then queues
+// an unresolved promise. The tests then do additional setup and checks, then
+// resolves or rejects the blocked promise.
+var setupStorageManagerForQueueTest = Task.async(function* () {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({uid: "uid", email: "someone@somewhere.com"})
+ sm.secureStorage = new MockedSecureStorage({kA: "kA"});
+ sm.secureStorage.locked = true;
+ yield sm.initialize();
+
+ let resolveBlocked, rejectBlocked;
+ let blockedPromise = new Promise((resolve, reject) => {
+ resolveBlocked = resolve;
+ rejectBlocked = reject;
+ });
+
+ sm._queueStorageOperation(() => blockedPromise);
+ return {sm, blockedPromise, resolveBlocked, rejectBlocked}
+});
+
+// First the general functionality.
+add_task(function* checkQueueSemantics() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+
+ // We've one unresolved promise in the queue - add another promise.
+ let resolveSubsequent;
+ let subsequentPromise = new Promise(resolve => {
+ resolveSubsequent = resolve;
+ });
+ let subsequentCalled = false;
+
+ sm._queueStorageOperation(() => {
+ subsequentCalled = true;
+ resolveSubsequent();
+ return subsequentPromise;
+ });
+
+ // Our "subsequent" function should not have been called yet.
+ Assert.ok(!subsequentCalled);
+
+ // Release our blocked promise.
+ resolveBlocked();
+
+ // Our subsequent promise should end up resolved.
+ yield subsequentPromise;
+ Assert.ok(subsequentCalled);
+ yield sm.finalize();
+});
+
+// Check that a queued promise being rejected works correctly.
+add_task(function* checkQueueSemanticsOnError() {
+ let { sm, blockedPromise, rejectBlocked } = yield setupStorageManagerForQueueTest();
+
+ let resolveSubsequent;
+ let subsequentPromise = new Promise(resolve => {
+ resolveSubsequent = resolve;
+ });
+ let subsequentCalled = false;
+
+ sm._queueStorageOperation(() => {
+ subsequentCalled = true;
+ resolveSubsequent();
+ return subsequentPromise;
+ });
+
+ // Our "subsequent" function should not have been called yet.
+ Assert.ok(!subsequentCalled);
+
+ // Reject our blocked promise - the subsequent operations should still work
+ // correctly.
+ rejectBlocked("oh no");
+
+ // Our subsequent promise should end up resolved.
+ yield subsequentPromise;
+ Assert.ok(subsequentCalled);
+
+ // But the first promise should reflect the rejection.
+ try {
+ yield blockedPromise;
+ Assert.ok(false, "expected this promise to reject");
+ } catch (ex) {
+ Assert.equal(ex, "oh no");
+ }
+ yield sm.finalize();
+});
+
+
+// And some tests for the specific operations that are queued.
+add_task(function* checkQueuedReadAndUpdate() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ // _doReadAndUpdateSecure is queued by _maybeReadAndUpdateSecure
+ let _doReadCalled = false;
+ sm._doReadAndUpdateSecure = () => {
+ _doReadCalled = true;
+ return Promise.resolve();
+ }
+
+ let resultPromise = sm._maybeReadAndUpdateSecure();
+ Assert.ok(!_doReadCalled);
+
+ resolveBlocked();
+ yield resultPromise;
+ Assert.ok(_doReadCalled);
+ yield sm.finalize();
+});
+
+add_task(function* checkQueuedWrite() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let __writeCalled = false;
+ sm.__write = () => {
+ __writeCalled = true;
+ return Promise.resolve();
+ }
+
+ let writePromise = sm._write();
+ Assert.ok(!__writeCalled);
+
+ resolveBlocked();
+ yield writePromise;
+ Assert.ok(__writeCalled);
+ yield sm.finalize();
+});
+
+add_task(function* checkQueuedDelete() {
+ let { sm, resolveBlocked } = yield setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let _deleteCalled = false;
+ sm._deleteAccountData = () => {
+ _deleteCalled = true;
+ return Promise.resolve();
+ }
+
+ let resultPromise = sm.deleteAccountData();
+ Assert.ok(!_deleteCalled);
+
+ resolveBlocked();
+ yield resultPromise;
+ Assert.ok(_deleteCalled);
+ yield sm.finalize();
+});
+
+function run_test() {
+ run_next_test();
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_web_channel.js b/services/fxaccounts/tests/xpcshell/test_web_channel.js
new file mode 100644
index 000000000..3cf566278
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js
@@ -0,0 +1,499 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/FxAccountsCommon.js");
+const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } =
+ Cu.import("resource://gre/modules/FxAccountsWebChannel.jsm");
+
+const URL_STRING = "https://example.com";
+
+const mockSendingContext = {
+ browser: {},
+ principal: {},
+ eventTarget: {}
+};
+
+add_test(function () {
+ validationHelper(undefined,
+ "Error: Missing configuration options");
+
+ validationHelper({
+ channel_id: WEBCHANNEL_ID
+ },
+ "Error: Missing 'content_uri' option");
+
+ validationHelper({
+ content_uri: 'bad uri',
+ channel_id: WEBCHANNEL_ID
+ },
+ /NS_ERROR_MALFORMED_URI/);
+
+ validationHelper({
+ content_uri: URL_STRING
+ },
+ 'Error: Missing \'channel_id\' option');
+
+ run_next_test();
+});
+
+add_task(function* test_rejection_reporting() {
+ let mockMessage = {
+ command: 'fxaccounts:login',
+ messageId: '1234',
+ data: { email: 'testuser@testuser.com' },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ login(accountData) {
+ equal(accountData.email, 'testuser@testuser.com',
+ 'Should forward incoming message data to the helper');
+ return Promise.reject(new Error('oops'));
+ },
+ },
+ });
+
+ let promiseSend = new Promise(resolve => {
+ channel._channel.send = (message, context) => {
+ resolve({ message, context });
+ };
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+
+ let { message, context } = yield promiseSend;
+
+ equal(context, mockSendingContext, 'Should forward the original context');
+ equal(message.command, 'fxaccounts:login',
+ 'Should include the incoming command');
+ equal(message.messageId, '1234', 'Should include the message ID');
+ equal(message.data.error.message, 'Error: oops',
+ 'Should convert the error message to a string');
+ notStrictEqual(message.data.error.stack, null,
+ 'Should include the stack for JS error rejections');
+});
+
+add_test(function test_exception_reporting() {
+ let mockMessage = {
+ command: 'fxaccounts:sync_preferences',
+ messageId: '5678',
+ data: { entryPoint: 'fxa:verification_complete' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ openSyncPreferences(browser, entryPoint) {
+ equal(entryPoint, 'fxa:verification_complete',
+ 'Should forward incoming message data to the helper');
+ throw new TypeError('splines not reticulated');
+ },
+ },
+ });
+
+ channel._channel.send = (message, context) => {
+ equal(context, mockSendingContext, 'Should forward the original context');
+ equal(message.command, 'fxaccounts:sync_preferences',
+ 'Should include the incoming command');
+ equal(message.messageId, '5678', 'Should include the message ID');
+ equal(message.data.error.message, 'TypeError: splines not reticulated',
+ 'Should convert the exception to a string');
+ notStrictEqual(message.data.error.stack, null,
+ 'Should include the stack for JS exceptions');
+
+ run_next_test();
+ };
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_profile_image_change_message() {
+ var mockMessage = {
+ command: "profile:change",
+ data: { uid: "foo" }
+ };
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ do_check_eq(data, "foo");
+ run_next_test();
+ });
+
+ var channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_login_message() {
+ let mockMessage = {
+ command: 'fxaccounts:login',
+ data: { email: 'testuser@testuser.com' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ login: function (accountData) {
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+ run_next_test();
+ return Promise.resolve();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_logout_message() {
+ let mockMessage = {
+ command: 'fxaccounts:logout',
+ data: { uid: "foo" }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ logout: function (uid) {
+ do_check_eq(uid, 'foo');
+ run_next_test();
+ return Promise.resolve();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_delete_message() {
+ let mockMessage = {
+ command: 'fxaccounts:delete',
+ data: { uid: "foo" }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ logout: function (uid) {
+ do_check_eq(uid, 'foo');
+ run_next_test();
+ return Promise.resolve();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_can_link_account_message() {
+ let mockMessage = {
+ command: 'fxaccounts:can_link_account',
+ data: { email: 'testuser@testuser.com' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ shouldAllowRelink: function (email) {
+ do_check_eq(email, 'testuser@testuser.com');
+ run_next_test();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_sync_preferences_message() {
+ let mockMessage = {
+ command: 'fxaccounts:sync_preferences',
+ data: { entryPoint: 'fxa:verification_complete' }
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ openSyncPreferences: function (browser, entryPoint) {
+ do_check_eq(entryPoint, 'fxa:verification_complete');
+ do_check_eq(browser, mockSendingContext.browser);
+ run_next_test();
+ }
+ }
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_unrecognized_message() {
+ let mockMessage = {
+ command: 'fxaccounts:unrecognized',
+ data: {}
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING
+ });
+
+ // no error is expected.
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+ run_next_test();
+});
+
+
+add_test(function test_helpers_should_allow_relink_same_email() {
+ let helpers = new FxAccountsWebChannelHelpers();
+
+ helpers.setPreviousAccountNameHashPref('testuser@testuser.com');
+ do_check_true(helpers.shouldAllowRelink('testuser@testuser.com'));
+
+ run_next_test();
+});
+
+add_test(function test_helpers_should_allow_relink_different_email() {
+ let helpers = new FxAccountsWebChannelHelpers();
+
+ helpers.setPreviousAccountNameHashPref('testuser@testuser.com');
+
+ helpers._promptForRelink = (acctName) => {
+ return acctName === 'allowed_to_relink@testuser.com';
+ };
+
+ do_check_true(helpers.shouldAllowRelink('allowed_to_relink@testuser.com'));
+ do_check_false(helpers.shouldAllowRelink('not_allowed_to_relink@testuser.com'));
+
+ run_next_test();
+});
+
+add_task(function* test_helpers_login_without_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ setSignedInUser: function(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+
+ // verifiedCanLinkAccount should be stripped in the data.
+ do_check_false('verifiedCanLinkAccount' in accountData);
+
+ // the customizeSync pref should not update
+ do_check_false(helpers.getShowCustomizeSyncPref());
+
+ // previously signed in user preference is updated.
+ do_check_eq(helpers.getPreviousAccountNameHashPref(), helpers.sha256('testuser@testuser.com'));
+
+ resolve();
+ });
+ }
+ }
+ });
+
+ // the show customize sync pref should stay the same
+ helpers.setShowCustomizeSyncPref(false);
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref('lastuser@testuser.com');
+
+ yield helpers.login({
+ email: 'testuser@testuser.com',
+ verifiedCanLinkAccount: true,
+ customizeSync: false
+ });
+});
+
+add_task(function* test_helpers_login_with_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ setSignedInUser: function(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+
+ // customizeSync should be stripped in the data.
+ do_check_false('customizeSync' in accountData);
+
+ // the customizeSync pref should not update
+ do_check_true(helpers.getShowCustomizeSyncPref());
+
+ resolve();
+ });
+ }
+ }
+ });
+
+ // the customize sync pref should be overwritten
+ helpers.setShowCustomizeSyncPref(false);
+
+ yield helpers.login({
+ email: 'testuser@testuser.com',
+ verifiedCanLinkAccount: true,
+ customizeSync: true
+ });
+});
+
+add_task(function* test_helpers_login_with_customize_sync_and_declined_engines() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ setSignedInUser: function(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ do_check_eq(accountData.email, 'testuser@testuser.com');
+
+ // customizeSync should be stripped in the data.
+ do_check_false('customizeSync' in accountData);
+ do_check_false('declinedSyncEngines' in accountData);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.addons"), false);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.bookmarks"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.history"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.passwords"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.prefs"), false);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+
+ // the customizeSync pref should be disabled
+ do_check_false(helpers.getShowCustomizeSyncPref());
+
+ resolve();
+ });
+ }
+ }
+ });
+
+ // the customize sync pref should be overwritten
+ helpers.setShowCustomizeSyncPref(true);
+
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.addons"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.bookmarks"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.history"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.passwords"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.prefs"), true);
+ do_check_eq(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+ yield helpers.login({
+ email: 'testuser@testuser.com',
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ declinedSyncEngines: ['addons', 'prefs']
+ });
+});
+
+add_test(function test_helpers_open_sync_preferences() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ }
+ });
+
+ let mockBrowser = {
+ loadURI(uri) {
+ do_check_eq(uri, "about:preferences?entrypoint=fxa%3Averification_complete#sync");
+ run_next_test();
+ }
+ };
+
+ helpers.openSyncPreferences(mockBrowser, "fxa:verification_complete");
+});
+
+add_task(function* test_helpers_change_password() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ updateUserAccountData(credentials) {
+ return new Promise(resolve => {
+ do_check_true(credentials.hasOwnProperty("email"));
+ do_check_true(credentials.hasOwnProperty("uid"));
+ do_check_true(credentials.hasOwnProperty("kA"));
+ do_check_true(credentials.hasOwnProperty("deviceId"));
+ do_check_null(credentials.deviceId);
+ // "foo" isn't a field known by storage, so should be dropped.
+ do_check_false(credentials.hasOwnProperty("foo"));
+ wasCalled.updateUserAccountData = true;
+
+ resolve();
+ });
+ },
+
+ updateDeviceRegistration() {
+ do_check_eq(arguments.length, 0);
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve()
+ }
+ }
+ });
+ yield helpers.changePassword({ email: "email", uid: "uid", kA: "kA", foo: "foo" });
+ do_check_true(wasCalled.updateUserAccountData);
+ do_check_true(wasCalled.updateDeviceRegistration);
+});
+
+add_task(function* test_helpers_change_password_with_error() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ updateUserAccountData() {
+ wasCalled.updateUserAccountData = true;
+ return Promise.reject();
+ },
+
+ updateDeviceRegistration() {
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve()
+ }
+ }
+ });
+ try {
+ yield helpers.changePassword({});
+ do_check_false('changePassword should have rejected');
+ } catch (_) {
+ do_check_true(wasCalled.updateUserAccountData);
+ do_check_false(wasCalled.updateDeviceRegistration);
+ }
+});
+
+function run_test() {
+ run_next_test();
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ log.debug("observed " + aTopic + " " + aData);
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ log.debug("removing observer for " + aObserveTopic);
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic, false);
+ return removeMe;
+}
+
+function validationHelper(params, expected) {
+ try {
+ new FxAccountsWebChannel(params);
+ } catch (e) {
+ if (typeof expected === 'string') {
+ return do_check_eq(e.toString(), expected);
+ } else {
+ return do_check_true(e.toString().match(expected));
+ }
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.ini b/services/fxaccounts/tests/xpcshell/xpcshell.ini
new file mode 100644
index 000000000..56a3d2947
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.ini
@@ -0,0 +1,23 @@
+[DEFAULT]
+head = head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js
+tail =
+skip-if = (toolkit == 'android' || appname == 'thunderbird')
+support-files =
+ !/services/common/tests/unit/head_helpers.js
+ !/services/common/tests/unit/head_http.js
+
+[test_accounts.js]
+[test_accounts_device_registration.js]
+[test_client.js]
+[test_credentials.js]
+[test_loginmgr_storage.js]
+[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]
+[test_profile.js]
+[test_storage_manager.js]