summaryrefslogtreecommitdiffstats
path: root/services/fxaccounts/tests/xpcshell
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /services/fxaccounts/tests/xpcshell
parentInitial commit. (diff)
downloadfirefox-upstream/124.0.1.tar.xz
firefox-upstream/124.0.1.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'services/fxaccounts/tests/xpcshell')
-rw-r--r--services/fxaccounts/tests/xpcshell/head.js38
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts.js1642
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts_config.js58
-rw-r--r--services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js1204
-rw-r--r--services/fxaccounts/tests/xpcshell/test_client.js966
-rw-r--r--services/fxaccounts/tests/xpcshell/test_commands.js708
-rw-r--r--services/fxaccounts/tests/xpcshell/test_credentials.js130
-rw-r--r--services/fxaccounts/tests/xpcshell/test_device.js127
-rw-r--r--services/fxaccounts/tests/xpcshell/test_keys.js182
-rw-r--r--services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js307
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_flow.js274
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js180
-rw-r--r--services/fxaccounts/tests/xpcshell/test_oauth_tokens.js255
-rw-r--r--services/fxaccounts/tests/xpcshell/test_pairing.js384
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile.js677
-rw-r--r--services/fxaccounts/tests/xpcshell/test_profile_client.js422
-rw-r--r--services/fxaccounts/tests/xpcshell/test_push_service.js522
-rw-r--r--services/fxaccounts/tests/xpcshell/test_storage_manager.js586
-rw-r--r--services/fxaccounts/tests/xpcshell/test_telemetry.js52
-rw-r--r--services/fxaccounts/tests/xpcshell/test_web_channel.js1380
-rw-r--r--services/fxaccounts/tests/xpcshell/xpcshell.toml49
21 files changed, 10143 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/xpcshell/head.js b/services/fxaccounts/tests/xpcshell/head.js
new file mode 100644
index 0000000000..921888e2e3
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/head.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* import-globals-from ../../../common/tests/unit/head_helpers.js */
+/* import-globals-from ../../../common/tests/unit/head_http.js */
+
+"use strict";
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const { SCOPE_OLD_SYNC } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+// Some mock key data, in both scoped-key and legacy field formats.
+const MOCK_ACCOUNT_KEYS = {
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "1234567890123-u7u7u7u7u7u7u7u7u7u7uw",
+ k: "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg",
+ kty: "oct",
+ },
+ },
+};
+
+(function initFxAccountsTestingInfrastructure() {
+ do_get_profile();
+
+ let { initTestLogging } = ChromeUtils.importESModule(
+ "resource://testing-common/services/common/logging.sys.mjs"
+ );
+
+ 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 0000000000..c4aec73a03
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts.js
@@ -0,0 +1,1642 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+const {
+ ERRNO_INVALID_AUTH_TOKEN,
+ ERROR_NO_ACCOUNT,
+ FX_OAUTH_CLIENT_ID,
+ ONLOGIN_NOTIFICATION,
+ ONLOGOUT_NOTIFICATION,
+ ONVERIFIED_NOTIFICATION,
+ DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY,
+ PREF_LAST_FXA_USER,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+// We grab some additional stuff via backstage passes.
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+const MOCK_TOKEN_RESPONSE = {
+ access_token:
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69",
+ token_type: "bearer",
+ scope: "https://identity.mozilla.com/apps/oldsync",
+ expires_in: 21600,
+ auth_at: 1589579900,
+};
+
+initTestLogging("Trace");
+
+var log = Log.repository.getLogger("Services.FxAccounts.test");
+log.level = Log.Level.Debug;
+
+// See verbose logging from FxAccounts.jsm and jwcrypto.jsm.
+Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace");
+Log.repository.getLogger("FirefoxAccounts").level = Log.Level.Trace;
+Services.prefs.setStringPref("services.crypto.jwcrypto.log.level", "Debug");
+
+/*
+ * 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(fields = null) {
+ let result;
+ if (!this.accountData) {
+ result = null;
+ } else if (fields == null) {
+ // can't use cloneInto as the keys get upset...
+ result = {};
+ for (let field of Object.keys(this.accountData)) {
+ result[field] = this.accountData[field];
+ }
+ } else {
+ if (!Array.isArray(fields)) {
+ fields = [fields];
+ }
+ result = {};
+ for (let field of fields) {
+ result[field] = this.accountData[field];
+ }
+ }
+ return Promise.resolve(result);
+ },
+
+ updateAccountData(updatedFields) {
+ if (!this.accountData) {
+ return Promise.resolve();
+ }
+ 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 our accountStatus mock
+
+ // mock calls up to the auth server to determine whether the
+ // user account has been verified
+ this.recoveryEmailStatus = async function (sessionToken) {
+ // simulate a call to /recovery_email/status
+ return {
+ email: this._email,
+ verified: this._verified,
+ };
+ };
+
+ this.accountStatus = async function (uid) {
+ return !!uid && !this._deletedOnServer;
+ };
+
+ this.sessionStatus = async function () {
+ // If the sessionStatus check says an account is OK, we typically will not
+ // end up calling accountStatus - so this must return false if accountStatus
+ // would.
+ return !this._deletedOnServer;
+ };
+
+ this.accountKeys = function (keyFetchToken) {
+ return new Promise(resolve => {
+ do_timeout(50, () => {
+ resolve({
+ kA: expandBytes("11"),
+ wrapKB: expandBytes("22"),
+ });
+ });
+ });
+ };
+
+ this.getScopedKeyData = function (sessionToken, client_id, scopes) {
+ Assert.ok(sessionToken);
+ Assert.equal(client_id, FX_OAUTH_CLIENT_ID);
+ Assert.equal(scopes, SCOPE_OLD_SYNC);
+ return new Promise(resolve => {
+ do_timeout(50, () => {
+ resolve({
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 1234567890123,
+ },
+ });
+ });
+ });
+ };
+
+ this.resendVerificationEmail = function (sessionToken) {
+ // Return the session token to show that we received it in the first place
+ return Promise.resolve(sessionToken);
+ };
+
+ this.signOut = () => Promise.resolve();
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ 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(credentials = null) {
+ let result = new FxAccounts({
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 100, // 100ms
+
+ _getCertificateSigned_calls: [],
+ _d_signCertificate: Promise.withResolvers(),
+ _now_is: new Date(),
+ now() {
+ return this._now_is;
+ },
+ newAccountState(newCredentials) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(newCredentials);
+ return new AccountState(storage);
+ },
+ fxAccountsClient: new MockFxAccountsClient(),
+ observerPreloads: [],
+ device: {
+ _registerOrUpdateDevice() {},
+ _checkRemoteCommandsUpdateNeeded: async () => false,
+ },
+ profile: {
+ getProfile() {
+ return null;
+ },
+ },
+ });
+ // and for convenience so we don't have to touch as many lines in this test
+ // when we refactored FxAccounts.jsm :)
+ result.setSignedInUser = function (creds) {
+ return result._internal.setSignedInUser(creds);
+ };
+ return result;
+}
+
+/*
+ * Some tests want a "real" fxa instance - however, we still mock the storage
+ * to keep the tests fast on b2g.
+ */
+async function MakeFxAccounts({ internal = {}, credentials } = {}) {
+ if (!internal.newAccountState) {
+ // we use a real accountState but mocked storage.
+ internal.newAccountState = function (newCredentials) {
+ let storage = new MockStorageManager();
+ storage.initialize(newCredentials);
+ return new AccountState(storage);
+ };
+ }
+ if (!internal._signOutServer) {
+ internal._signOutServer = () => Promise.resolve();
+ }
+ if (internal.device) {
+ if (!internal.device._registerOrUpdateDevice) {
+ internal.device._registerOrUpdateDevice = () => Promise.resolve();
+ internal.device._checkRemoteCommandsUpdateNeeded = async () => false;
+ }
+ } else {
+ internal.device = {
+ _registerOrUpdateDevice() {},
+ _checkRemoteCommandsUpdateNeeded: async () => false,
+ };
+ }
+ if (!internal.observerPreloads) {
+ internal.observerPreloads = [];
+ }
+ let result = new FxAccounts(internal);
+
+ if (credentials) {
+ await result._internal.setSignedInUser(credentials);
+ }
+ return result;
+}
+
+add_task(async function test_get_signed_in_user_initially_unset() {
+ _("Check getSignedInUser initially and after signout reports no user");
+ let account = await MakeFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234567890abcdef1234567890abcdef",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ let result = await account.getSignedInUser();
+ Assert.equal(result, null);
+
+ await account._internal.setSignedInUser(credentials);
+
+ // getSignedInUser only returns a subset.
+ result = await account.getSignedInUser();
+ Assert.deepEqual(result.email, credentials.email);
+ Assert.deepEqual(result.scopedKeys, undefined);
+
+ // for the sake of testing, use the low-level function to check it's all there
+ result = await account._internal.currentAccountState.getUserAccountData();
+ Assert.deepEqual(result.email, credentials.email);
+ Assert.deepEqual(result.scopedKeys, credentials.scopedKeys);
+
+ // sign out
+ let localOnly = true;
+ await account.signOut(localOnly);
+
+ // user should be undefined after sign out
+ result = await account.getSignedInUser();
+ Assert.equal(result, null);
+});
+
+add_task(async function test_set_signed_in_user_signs_out_previous_account() {
+ _("Check setSignedInUser signs out the previous account.");
+ let signOutServerCalled = false;
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234567890abcdef1234567890abcdef",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ let account = await MakeFxAccounts({ credentials });
+
+ account._internal._signOutServer = () => {
+ signOutServerCalled = true;
+ return Promise.resolve(true);
+ };
+
+ await account._internal.setSignedInUser(credentials);
+ Assert.ok(signOutServerCalled);
+});
+
+add_task(async function test_update_account_data() {
+ _("Check updateUserAccountData does the right thing.");
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234567890abcdef1234567890abcdef",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ let account = await MakeFxAccounts({ credentials });
+
+ let newCreds = {
+ email: credentials.email,
+ uid: credentials.uid,
+ sessionToken: "alive",
+ };
+ await account._internal.updateUserAccountData(newCreds);
+ Assert.equal(
+ (await account._internal.getUserAccountData()).sessionToken,
+ "alive",
+ "new field value was saved"
+ );
+
+ // but we should fail attempting to change the uid.
+ newCreds = {
+ email: credentials.email,
+ uid: "11111111111111111111222222222222",
+ sessionToken: "alive",
+ };
+ await Assert.rejects(
+ account._internal.updateUserAccountData(newCreds),
+ /The specified credentials aren't for the current user/
+ );
+
+ // should fail without the uid.
+ newCreds = {
+ sessionToken: "alive",
+ };
+ await Assert.rejects(
+ account._internal.updateUserAccountData(newCreds),
+ /The specified credentials have no uid/
+ );
+
+ // and should fail with a field name that's not known by storage.
+ newCreds = {
+ email: credentials.email,
+ uid: "11111111111111111111222222222222",
+ foo: "bar",
+ };
+ await Assert.rejects(
+ account._internal.updateUserAccountData(newCreds),
+ /The specified credentials aren't for the current user/
+ );
+});
+
+// Sanity-check that our mocked client is working correctly
+add_test(function test_client_mock() {
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+ Assert.equal(client._verified, false);
+ Assert.equal(typeof client.signIn, "function");
+
+ // The recoveryEmailStatus function eventually fulfills its promise
+ client.recoveryEmailStatus().then(response => {
+ Assert.equal(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
+ Assert.equal(user.verified, true);
+ Assert.equal(user.email, test_user.email);
+ Assert.equal(
+ Services.prefs.getStringPref(PREF_LAST_FXA_USER),
+ CryptoUtils.sha256Base64(test_user.email)
+ );
+ Assert.ok(login_notification_received);
+ })
+ .finally(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
+ Assert.equal(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);
+ }
+ );
+ });
+});
+
+// For bug 1585299 - ensure we only get a single ONVERIFIED notification.
+add_task(async function test_onverified_once() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("francine");
+
+ let numNotifications = 0;
+
+ function observe(aSubject, aTopic, aData) {
+ numNotifications += 1;
+ }
+ Services.obs.addObserver(observe, ONVERIFIED_NOTIFICATION);
+
+ fxa._internal.POLL_SESSION = 1;
+
+ await fxa.setSignedInUser(user);
+
+ Assert.ok(!(await fxa.getSignedInUser()).verified, "starts unverified");
+
+ await fxa._internal.startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ );
+
+ Assert.ok(!(await fxa.getSignedInUser()).verified, "still unverified");
+
+ log.debug("Mocking verification of francine's email");
+ fxa._internal.fxAccountsClient._email = user.email;
+ fxa._internal.fxAccountsClient._verified = true;
+
+ await fxa._internal.startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "again"
+ );
+
+ Assert.ok((await fxa.getSignedInUser()).verified, "now verified");
+
+ Assert.equal(numNotifications, 1, "expect exactly 1 ONVERIFIED");
+
+ Services.obs.removeObserver(observe, ONVERIFIED_NOTIFICATION);
+ await fxa.signOut();
+});
+
+add_test(function test_pollEmailStatus_start_verified() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 50000;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ fxa._internal.fxAccountsClient._email = test_user.email;
+ fxa._internal.fxAccountsClient._verified = true;
+ const mock = sinon.mock(fxa._internal);
+ mock.expects("_scheduleNextPollEmailStatus").never();
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_start() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock
+ .expects("_scheduleNextPollEmailStatus")
+ .once()
+ .withArgs(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ 123456,
+ "start"
+ );
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_start_subsequent() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_INITIAL = 123456;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321;
+ fxa._internal.VERIFICATION_POLL_START_SLOWDOWN_THRESHOLD = -1;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock
+ .expects("_scheduleNextPollEmailStatus")
+ .once()
+ .withArgs(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ 654321,
+ "start"
+ );
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "start"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_browser_startup() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa._internal.POLL_SESSION = 20 * 60000;
+ fxa._internal.VERIFICATION_POLL_TIMEOUT_SUBSEQUENT = 654321;
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock
+ .expects("_scheduleNextPollEmailStatus")
+ .once()
+ .withArgs(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ 654321,
+ "browser-startup"
+ );
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "browser-startup"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_pollEmailStatus_push() {
+ let fxa = new MockFxAccounts();
+ let test_user = getTestUser("carol");
+
+ fxa.setSignedInUser(test_user).then(() => {
+ fxa._internal.getUserAccountData().then(user => {
+ const mock = sinon.mock(fxa._internal);
+ mock.expects("_scheduleNextPollEmailStatus").never();
+ fxa._internal
+ .startPollEmailStatus(
+ fxa._internal.currentAccountState,
+ user.sessionToken,
+ "push"
+ )
+ .then(() => {
+ mock.verify();
+ mock.restore();
+ run_next_test();
+ });
+ });
+ });
+});
+
+add_test(function test_getKeyForScope() {
+ 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._internal.getUserAccountData().then(user2 => {
+ // Before getKeyForScope, we have no keys
+ Assert.equal(!!user2.scopedKeys, false);
+ // And we still have a key-fetch token and unwrapBKey to use
+ Assert.equal(!!user2.keyFetchToken, true);
+ Assert.equal(!!user2.unwrapBKey, true);
+
+ fxa.keys.getKeyForScope(SCOPE_OLD_SYNC).then(() => {
+ fxa._internal.getUserAccountData().then(user3 => {
+ // Now we should have keys
+ Assert.equal(fxa._internal.isUserEmailVerified(user3), true);
+ Assert.equal(!!user3.verified, true);
+ Assert.notEqual(null, user3.scopedKeys);
+ Assert.equal(user3.keyFetchToken, undefined);
+ Assert.equal(user3.unwrapBKey, undefined);
+ run_next_test();
+ });
+ });
+ });
+ });
+});
+
+add_task(
+ async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_high_level_keys() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+
+ user.verified = true;
+
+ // An account state with the deprecated kinto extension sync keys...
+ user.kExtSync =
+ "f5ccd9cfdefd9b1ac4d02c56964f59239d8dfa1ca326e63696982765c1352cdc" +
+ "5d78a5a9c633a6d25edfea0a6c221a3480332a49fd866f311c2e3508ddd07395";
+ user.kExtKbHash =
+ "6192f1cc7dce95334455ba135fa1d8fca8f70e8f594ae318528de06f24ed0273";
+ user.scopedKeys = {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ };
+
+ await fxa.setSignedInUser(user);
+ // getKeyForScope will run the migration
+ await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ let newUser = await fxa._internal.getUserAccountData();
+ // Then, the deprecated keys will be removed
+ Assert.strictEqual(newUser.kExtSync, undefined);
+ Assert.strictEqual(newUser.kExtKbHash, undefined);
+ }
+);
+
+add_task(
+ async function test_getKeyForScope_scopedKeys_migration_removes_deprecated_scoped_keys() {
+ let fxa = new MockFxAccounts();
+ let user = getTestUser("eusebius");
+ const DEPRECATED_SCOPE_WEBEXT_SYNC = "sync:addon_storage";
+ const EXTRA_SCOPE = "an unknown, but non-deprecated scope";
+ user.verified = true;
+ user.ecosystemUserId = "ecoUserId";
+ user.ecosystemAnonId = "ecoAnonId";
+ user.scopedKeys = {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ [DEPRECATED_SCOPE_ECOSYSTEM_TELEMETRY]:
+ MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ [DEPRECATED_SCOPE_WEBEXT_SYNC]:
+ MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ };
+
+ await fxa.setSignedInUser(user);
+ await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ let newUser = await fxa._internal.getUserAccountData();
+ // It should have removed the deprecated ecosystem_telemetry key,
+ // and the old kinto extension sync key
+ // but left the other keys intact.
+ const expectedScopedKeys = {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ [EXTRA_SCOPE]: MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ };
+ Assert.deepEqual(newUser.scopedKeys, expectedScopedKeys);
+ Assert.equal(newUser.ecosystemUserId, null);
+ Assert.equal(newUser.ecosystemAnonId, null);
+ }
+);
+
+add_task(async function test_getKeyForScope_nonexistent_account() {
+ let fxa = new MockFxAccounts();
+ let bismarck = getTestUser("bismarck");
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(false);
+ client.sessionStatus = () => Promise.resolve(false);
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ await fxa.setSignedInUser(bismarck);
+
+ let promiseLogout = new Promise(resolve => {
+ makeObserver(ONLOGOUT_NOTIFICATION, function () {
+ log.debug("test_getKeyForScope_nonexistent_account observed logout");
+ resolve();
+ });
+ });
+
+ // XXX - the exception message here isn't ideal, but doesn't really matter...
+ await Assert.rejects(
+ fxa.keys.getKeyForScope(SCOPE_OLD_SYNC),
+ /A different user signed in/
+ );
+
+ await promiseLogout;
+
+ let user = await fxa._internal.getUserAccountData();
+ Assert.equal(user, null);
+});
+
+// getKeyForScope with invalid keyFetchToken should delete keyFetchToken from storage
+add_task(async function test_getKeyForScope_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let yusuf = getTestUser("yusuf");
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accountStatus = () => Promise.resolve(true); // account exists.
+ client.sessionStatus = () => Promise.resolve(false); // session is invalid.
+ client.accountKeys = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+
+ await fxa.setSignedInUser(yusuf);
+ let user = await fxa._internal.getUserAccountData();
+ Assert.notEqual(user.encryptedSendTabKeys, null);
+
+ try {
+ await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ Assert.ok(false);
+ } catch (err) {
+ Assert.equal(err.code, 401);
+ Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, yusuf.email);
+ Assert.equal(user.keyFetchToken, null);
+ // We verify that encryptedSendTabKeys are also wiped
+ // when a user's credentials are wiped
+ Assert.equal(user.encryptedSendTabKeys, null);
+ await fxa._internal.abortExistingFlow();
+});
+
+// Test vectors from
+// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#Test_Vectors
+add_task(async function test_getKeyForScope_oldsync() {
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+ client.getScopedKeyData = () =>
+ Promise.resolve({
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 1510726317123,
+ },
+ });
+
+ // We mock the server returning the wrapKB from our test vectors
+ client.accountKeys = async () => {
+ return {
+ wrapKB: CommonUtils.hexToBytes(
+ "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f"
+ ),
+ };
+ };
+
+ // We set the user to have the keyFetchToken and unwrapBKey from our test vectors
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ keyFetchToken:
+ "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f",
+ unwrapBKey:
+ "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a",
+ sessionToken: "mock session token, used in metadata request",
+ verified: true,
+ };
+ await fxa.setSignedInUser(user);
+
+ // We derive, persist and return the sync key
+ const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+
+ // We verify the key returned matches what we would expect from the test vectors
+ // kb = 2ee722fdd8ccaa721bdeb2d1b76560efef705b04349d9357c3e592cf4906e075 (from test vectors)
+ //
+ // kid can be verified by "${keyRotationTimestamp}-${sha256(kb)[0:16]}"
+ //
+ // k can be verified by HKDF(kb, undefined, "identity.mozilla.com/picl/v1/oldsync", 64)
+ Assert.deepEqual(key, {
+ scope: SCOPE_OLD_SYNC,
+ kid: "1510726317123-BAik7hEOdpGnPZnPBSdaTg",
+ k: "fwM5VZu0Spf5XcFRZYX2zk6MrqZP7zvovCBcvuKwgYMif3hz98FHmIVa3qjKjrW0J244Zj-P5oWaOcQbvypmpw",
+ kty: "oct",
+ });
+});
+
+add_task(async function test_getScopedKeys_cached_key() {
+ let fxa = new MockFxAccounts();
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+
+ await fxa.setSignedInUser(user);
+ let key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ Assert.deepEqual(key, {
+ scope: SCOPE_OLD_SYNC,
+ ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ });
+});
+
+add_task(async function test_getScopedKeys_unavailable_scope() {
+ let fxa = new MockFxAccounts();
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ await fxa.setSignedInUser(user);
+ await Assert.rejects(
+ fxa.keys.getKeyForScope("otherkeybearingscope"),
+ /Key not available for scope/
+ );
+});
+
+add_task(async function test_getScopedKeys_misconfigured_fxa_server() {
+ let fxa = new MockFxAccounts();
+ let client = fxa._internal.fxAccountsClient;
+ client.getScopedKeyData = () =>
+ Promise.resolve({
+ wrongscope: {
+ identifier: "wrongscope",
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ keyRotationTimestamp: 1510726331712,
+ },
+ });
+ let user = {
+ ...getTestUser("eusebius"),
+ uid: "aeaa1725c7a24ff983c6295725d5fc9b",
+ verified: true,
+ keyFetchToken:
+ "808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f",
+ unwrapBKey:
+ "6ea660be9c89ec355397f89afb282ea0bf21095760c8c5009bbcc894155bbe2a",
+ sessionToken: "mock session token, used in metadata request",
+ };
+ await fxa.setSignedInUser(user);
+ await Assert.rejects(
+ fxa.keys.getKeyForScope(SCOPE_OLD_SYNC),
+ /The FxA server did not grant Firefox the `oldsync` scope/
+ );
+});
+
+add_task(async function test_setScopedKeys() {
+ const fxa = new MockFxAccounts();
+ const user = {
+ ...getTestUser("foo"),
+ verified: true,
+ };
+ await fxa.setSignedInUser(user);
+ await fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys);
+ const key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC);
+ Assert.deepEqual(key, {
+ scope: SCOPE_OLD_SYNC,
+ ...MOCK_ACCOUNT_KEYS.scopedKeys[SCOPE_OLD_SYNC],
+ });
+});
+
+add_task(async function test_setScopedKeys_user_not_signed_in() {
+ const fxa = new MockFxAccounts();
+ await Assert.rejects(
+ fxa.keys.setScopedKeys(MOCK_ACCOUNT_KEYS.scopedKeys),
+ /Cannot persist keys, no user signed in/
+ );
+});
+
+// _fetchAndUnwrapAndDeriveKeys with no keyFetchToken should trigger signOut
+// XXX - actually, it probably shouldn't - bug 1572313.
+add_test(function test_fetchAndUnwrapAndDeriveKeys_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(user2 => {
+ fxa._internal.abortExistingFlow().then(run_next_test);
+ });
+ });
+
+ fxa
+ .setSignedInUser(user)
+ .then(user2 => {
+ return fxa.keys._fetchAndUnwrapAndDeriveKeys();
+ })
+ .catch(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 => {
+ Assert.equal(user.email, bob.email);
+ Assert.equal(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 => {
+ Assert.equal(user.email, alice.email);
+ Assert.equal(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(async function test_resend_email_not_signed_in() {
+ let fxa = new MockFxAccounts();
+
+ try {
+ await fxa.resendVerificationEmail();
+ } catch (err) {
+ Assert.equal(err.message, ERROR_NO_ACCOUNT);
+ return;
+ }
+ do_throw("Should not be able to resend email when nobody is signed in");
+});
+
+add_task(async function test_accountStatus() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+
+ // If we have no user, we have no account server-side
+ let result = await fxa.checkAccountStatus();
+ Assert.ok(!result);
+ // Set a user - the fxAccountsClient mock will say "ok".
+ await fxa.setSignedInUser(alice);
+ result = await fxa.checkAccountStatus();
+ Assert.ok(result);
+ // flag the item as deleted on the server.
+ fxa._internal.fxAccountsClient._deletedOnServer = true;
+ result = await fxa.checkAccountStatus();
+ Assert.ok(!result);
+ fxa._internal.fxAccountsClient._deletedOnServer = false;
+ await fxa.signOut();
+});
+
+add_task(async function test_resend_email_invalid_token() {
+ let fxa = new MockFxAccounts();
+ let sophia = getTestUser("sophia");
+ Assert.notEqual(sophia.sessionToken, null);
+
+ let client = fxa._internal.fxAccountsClient;
+ client.resendVerificationEmail = () => {
+ return Promise.reject({
+ code: 401,
+ errno: ERRNO_INVALID_AUTH_TOKEN,
+ });
+ };
+ // This test wants the account to exist but the local session invalid.
+ client.accountStatus = uid => {
+ Assert.ok(uid, "got a uid to check");
+ return Promise.resolve(true);
+ };
+ client.sessionStatus = token => {
+ Assert.ok(token, "got a token to check");
+ return Promise.resolve(false);
+ };
+
+ await fxa.setSignedInUser(sophia);
+ let user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, sophia.email);
+ Assert.equal(user.verified, false);
+ log.debug("Sophia wants verification email resent");
+
+ try {
+ await fxa.resendVerificationEmail();
+ Assert.ok(
+ false,
+ "resendVerificationEmail should reject invalid session token"
+ );
+ } catch (err) {
+ Assert.equal(err.code, 401);
+ Assert.equal(err.errno, ERRNO_INVALID_AUTH_TOKEN);
+ }
+
+ user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, sophia.email);
+ Assert.equal(user.sessionToken, null);
+ await fxa._internal.abortExistingFlow();
+});
+
+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
+ Assert.ok(fxa._internal.currentAccountState !== initialState);
+ let aliceState = fxa._internal.currentAccountState;
+
+ // The polling timer is ticking
+ Assert.ok(fxa._internal.currentTimer > 0);
+
+ fxa._internal.getUserAccountData().then(user => {
+ Assert.equal(user.email, alice.email);
+ Assert.equal(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
+ Assert.equal(result, "alice's session token");
+
+ // Timer was not restarted
+ Assert.ok(fxa._internal.currentAccountState === aliceState);
+
+ // Timer is still ticking
+ Assert.ok(fxa._internal.currentTimer > 0);
+
+ // Ok abort polling before we go on to the next test
+ fxa._internal.abortExistingFlow();
+ run_next_test();
+ });
+ });
+ });
+});
+
+Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.oauth.uri",
+ "https://example.com/v1"
+);
+
+add_test(async function test_getOAuthTokenWithSessionToken() {
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ true
+ );
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let oauthTokenCalled = false;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async (
+ sessionTokenHex,
+ clientId,
+ scope,
+ ttl
+ ) => {
+ oauthTokenCalled = true;
+ Assert.equal(sessionTokenHex, "alice's session token");
+ Assert.equal(clientId, "5882386c6d801776");
+ Assert.equal(scope, "profile");
+ Assert.equal(ttl, undefined);
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ await fxa.setSignedInUser(alice);
+ const result = await fxa.getOAuthToken({ scope: "profile" });
+ Assert.ok(oauthTokenCalled);
+ Assert.equal(result, MOCK_TOKEN_RESPONSE.access_token);
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ false
+ );
+ run_next_test();
+});
+
+add_task(async function test_getOAuthTokenCachedWithSessionToken() {
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ true
+ );
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numOauthTokenCalls = 0;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async () => {
+ numOauthTokenCalls++;
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ await fxa.setSignedInUser(alice);
+ let result = await fxa.getOAuthToken({
+ scope: "profile",
+ service: "test-service",
+ });
+ Assert.equal(numOauthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+
+ // requesting it again should not re-fetch the token.
+ result = await fxa.getOAuthToken({
+ scope: "profile",
+ service: "test-service",
+ });
+ Assert.equal(numOauthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ // But requesting the same service and a different scope *will* get a new one.
+ result = await fxa.getOAuthToken({
+ scope: "something-else",
+ service: "test-service",
+ });
+ Assert.equal(numOauthTokenCalls, 2);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ Services.prefs.setBoolPref(
+ "identity.fxaccounts.useSessionTokensForOAuth",
+ false
+ );
+});
+
+add_test(function test_getOAuthTokenScopedWithSessionToken() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numOauthTokenCalls = 0;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async (
+ _sessionTokenHex,
+ _clientId,
+ scopeString
+ ) => {
+ equal(scopeString, "bar foo"); // scopes are sorted locally before request.
+ numOauthTokenCalls++;
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: ["foo", "bar"] }).then(result => {
+ Assert.equal(numOauthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ run_next_test();
+ });
+ });
+});
+
+add_task(async function test_getOAuthTokenCachedScopeNormalization() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+ let numOAuthTokenCalls = 0;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = async (
+ _sessionTokenHex,
+ _clientId,
+ scopeString
+ ) => {
+ numOAuthTokenCalls++;
+ return MOCK_TOKEN_RESPONSE;
+ };
+
+ await fxa.setSignedInUser(alice);
+ let result = await fxa.getOAuthToken({
+ scope: ["foo", "bar"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+
+ // requesting it again with the scope array in a different order should not re-fetch the token.
+ result = await fxa.getOAuthToken({
+ scope: ["bar", "foo"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ // requesting it again with the scope array in different case should not re-fetch the token.
+ result = await fxa.getOAuthToken({
+ scope: ["Bar", "Foo"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 1);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+ // But requesting with a new entry in the array does fetch one.
+ result = await fxa.getOAuthToken({
+ scope: ["foo", "bar", "etc"],
+ service: "test-service",
+ });
+ Assert.equal(numOAuthTokenCalls, 2);
+ Assert.equal(
+ result,
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69"
+ );
+});
+
+add_test(function test_getOAuthToken_invalid_param() {
+ let fxa = new MockFxAccounts();
+
+ fxa.getOAuthToken().catch(err => {
+ Assert.equal(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: [] }).catch(err => {
+ Assert.equal(err.message, "INVALID_PARAMETER");
+ fxa.signOut().then(run_next_test);
+ });
+});
+
+add_test(function test_getOAuthToken_misconfigure_oauth_uri() {
+ let fxa = new MockFxAccounts();
+
+ const prevServerURL = Services.prefs.getStringPref(
+ "identity.fxaccounts.remote.oauth.uri"
+ );
+ Services.prefs.deleteBranch("identity.fxaccounts.remote.oauth.uri");
+
+ fxa.getOAuthToken().catch(err => {
+ Assert.equal(err.message, "INVALID_PARAMETER");
+ // revert the pref
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.oauth.uri",
+ prevServerURL
+ );
+ 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" }).catch(err => {
+ Assert.equal(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" }).catch(err => {
+ Assert.equal(err.message, "UNVERIFIED_ACCOUNT");
+ fxa.signOut().then(run_next_test);
+ });
+ });
+});
+
+add_test(function test_getOAuthToken_error() {
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.accessTokenWithSessionToken = () => {
+ return Promise.reject("boom");
+ };
+
+ fxa.setSignedInUser(alice).then(() => {
+ fxa.getOAuthToken({ scope: "profile" }).catch(err => {
+ equal(err.details, "boom");
+ run_next_test();
+ });
+ });
+});
+
+add_task(async function test_listAttachedOAuthClients() {
+ const ONE_HOUR = 60 * 60 * 1000;
+ const ONE_DAY = 24 * ONE_HOUR;
+
+ const timestamp = Date.now();
+
+ let fxa = new MockFxAccounts();
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let client = fxa._internal.fxAccountsClient;
+ client.attachedClients = async () => {
+ return {
+ body: [
+ // This entry was previously filtered but no longer is!
+ {
+ clientId: "a2270f727f45f648",
+ deviceId: "deadbeef",
+ sessionTokenId: null,
+ name: "Firefox Preview (no session token)",
+ scope: ["profile", "https://identity.mozilla.com/apps/oldsync"],
+ lastAccessTime: Date.now(),
+ },
+ {
+ clientId: "802d56ef2a9af9fa",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Firefox Monitor",
+ scope: ["profile"],
+ lastAccessTime: Date.now() - ONE_DAY - ONE_HOUR,
+ },
+ {
+ clientId: "1f30e32975ae5112",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Firefox Send",
+ scope: ["profile", "https://identity.mozilla.com/apps/send"],
+ lastAccessTime: Date.now() - ONE_DAY * 2 - ONE_HOUR,
+ },
+ // One with a future date should be impossible, but having a negative
+ // result here would almost certainly confuse something!
+ {
+ clientId: "future-date",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Whatever",
+ lastAccessTime: Date.now() + ONE_DAY,
+ },
+ // A missing/null lastAccessTime should end up with a missing lastAccessedDaysAgo
+ {
+ clientId: "missing-date",
+ deviceId: null,
+ sessionTokenId: null,
+ name: "Whatever",
+ },
+ ],
+ headers: { "x-timestamp": timestamp.toString() },
+ };
+ };
+
+ await fxa.setSignedInUser(alice);
+ const clients = await fxa.listAttachedOAuthClients();
+ Assert.deepEqual(clients, [
+ {
+ id: "a2270f727f45f648",
+ lastAccessedDaysAgo: 0,
+ },
+ {
+ id: "802d56ef2a9af9fa",
+ lastAccessedDaysAgo: 1,
+ },
+ {
+ id: "1f30e32975ae5112",
+ lastAccessedDaysAgo: 2,
+ },
+ {
+ id: "future-date",
+ lastAccessedDaysAgo: 0,
+ },
+ {
+ id: "missing-date",
+ lastAccessedDaysAgo: null,
+ },
+ ]);
+});
+
+add_task(async function test_getSignedInUserProfile() {
+ let alice = getTestUser("alice");
+ alice.verified = true;
+
+ let mockProfile = {
+ getProfile() {
+ return Promise.resolve({ avatar: "image" });
+ },
+ tearDown() {},
+ };
+ let fxa = new FxAccounts({
+ _signOutServer() {
+ return Promise.resolve();
+ },
+ device: {
+ _registerOrUpdateDevice() {
+ return Promise.resolve();
+ },
+ },
+ });
+
+ await fxa._internal.setSignedInUser(alice);
+ fxa._internal._profile = mockProfile;
+ let result = await fxa.getSignedInUser();
+ Assert.ok(!!result);
+ Assert.equal(result.avatar, "image");
+});
+
+add_task(async 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" });
+ };
+ fxa._internal._profile = {
+ getProfile() {
+ return Promise.reject("boom");
+ },
+ tearDown() {
+ teardownCalled = true;
+ },
+ };
+
+ let teardownCalled = false;
+ await fxa.setSignedInUser(alice);
+ let result = await fxa.getSignedInUser();
+ Assert.deepEqual(result.avatar, null);
+ await fxa.signOut();
+ Assert.ok(teardownCalled);
+});
+
+add_task(async 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);
+ client.sessionStatus = () => Promise.resolve(false);
+
+ await fxa.setSignedInUser(alice);
+ let user = await fxa._internal.getUserAccountData();
+ Assert.notEqual(alice.sessionToken, null);
+ Assert.equal(user.email, alice.email);
+ Assert.equal(user.verified, true);
+
+ await fxa._internal.checkVerificationStatus();
+
+ user = await fxa._internal.getUserAccountData();
+ Assert.equal(user.email, alice.email);
+ Assert.equal(user.sessionToken, null);
+});
+
+add_task(async function test_flushLogFile() {
+ _("Tests flushLogFile");
+ let account = await MakeFxAccounts();
+ let promiseObserved = new Promise(res => {
+ log.info("Adding flush-log-file observer.");
+ Services.obs.addObserver(function onFlushLogFile() {
+ Services.obs.removeObserver(
+ onFlushLogFile,
+ "service:log-manager:flush-log-file"
+ );
+ res();
+ }, "service:log-manager:flush-log-file");
+ });
+ account.flushLogFile();
+ await promiseObserved;
+});
+
+/*
+ * 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: "1ad7f5024cc74ec1a209071fd2fae348",
+ sessionToken: name + "'s session token",
+ keyFetchToken: name + "'s keyfetch token",
+ unwrapBKey: expandHex("44"),
+ verified: false,
+ encryptedSendTabKeys: name + "'s encrypted Send tab keys",
+ };
+}
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let observer = {
+ // nsISupports provides type management in C++
+ // nsIObserver is to be an observer
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(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);
+ return removeMe;
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_accounts_config.js b/services/fxaccounts/tests/xpcshell/test_accounts_config.js
new file mode 100644
index 0000000000..33ace13c47
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_config.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+add_task(
+ async function test_non_https_remote_server_uri_with_requireHttps_false() {
+ Services.prefs.setBoolPref("identity.fxaccounts.allowHttp", true);
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.root",
+ "http://example.com/"
+ );
+ Assert.equal(
+ await FxAccounts.config.promiseConnectAccountURI("test"),
+ "http://example.com/?context=fx_desktop_v3&entrypoint=test&action=email&service=sync"
+ );
+
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
+ Services.prefs.clearUserPref("identity.fxaccounts.allowHttp");
+ }
+);
+
+add_task(async function test_non_https_remote_server_uri() {
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.remote.root",
+ "http://example.com/"
+ );
+ await Assert.rejects(
+ FxAccounts.config.promiseConnectAccountURI(),
+ /Firefox Accounts server must use HTTPS/
+ );
+ Services.prefs.clearUserPref("identity.fxaccounts.remote.root");
+});
+
+add_task(async function test_is_production_config() {
+ // should start with no auto-config URL.
+ Assert.ok(!FxAccounts.config.getAutoConfigURL());
+ // which means we are using prod.
+ Assert.ok(FxAccounts.config.isProductionConfig());
+
+ // Set an auto-config URL.
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.autoconfig.uri",
+ "http://x"
+ );
+ Assert.equal(FxAccounts.config.getAutoConfigURL(), "http://x");
+ Assert.ok(!FxAccounts.config.isProductionConfig());
+
+ // Clear the auto-config URL, but set one of the other config params.
+ Services.prefs.clearUserPref("identity.fxaccounts.autoconfig.uri");
+ Services.prefs.setStringPref("identity.sync.tokenserver.uri", "http://t");
+ Assert.ok(!FxAccounts.config.isProductionConfig());
+ Services.prefs.clearUserPref("identity.sync.tokenserver.uri");
+});
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 0000000000..68337eb69e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_accounts_device_registration.js
@@ -0,0 +1,1204 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+const { FxAccountsDevice } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsDevice.sys.mjs"
+);
+const {
+ ERRNO_DEVICE_SESSION_CONFLICT,
+ ERRNO_TOO_MANY_CLIENT_REQUESTS,
+ ERRNO_UNKNOWN_DEVICE,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ ON_DEVICELIST_UPDATED,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+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.setStringPref("identity.fxaccounts.loglevel", "Trace");
+
+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";
+ // Be careful relying on `this._verified` as it doesn't change if the user's
+ // state does via setting the `verified` flag in the user data.
+ 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.accountKeys = function (keyFetchToken) {
+ Assert.ok(keyFetchToken, "must be called with a key-fetch-token");
+ // ideally we'd check the verification status here to more closely simulate
+ // the server, but `this._verified` is a test-only construct and doesn't
+ // update when the user changes verification status.
+ Assert.ok(!this._deletedOnServer, "this test thinks the acct is deleted!");
+ return {
+ kA: "test-ka",
+ wrapKB: "X".repeat(32),
+ };
+ };
+
+ this.accountStatus = function (uid) {
+ return Promise.resolve(!!uid && !this._deletedOnServer);
+ };
+
+ this.registerDevice = (st, name, type) =>
+ Promise.resolve({ id: device.id, name });
+ this.updateDevice = (st, id, name) => Promise.resolve({ id, name });
+ this.signOut = () => Promise.resolve({});
+ this.getDeviceList = st =>
+ Promise.resolve([
+ {
+ id: device.id,
+ name: device.name,
+ type: device.type,
+ pushCallback: device.pushCallback,
+ pushEndpointExpired: device.pushEndpointExpired,
+ isCurrentDevice: st === device.sessionToken,
+ },
+ ]);
+
+ FxAccountsClient.apply(this);
+}
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+async function MockFxAccounts(credentials, device = {}) {
+ let fxa = new FxAccounts({
+ newAccountState(creds) {
+ // we use a real accountState but mocked storage.
+ let storage = new MockStorageManager();
+ storage.initialize(creds);
+ return new AccountState(storage);
+ },
+ fxAccountsClient: new MockFxAccountsClient(device, credentials),
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" }
+ );
+ },
+ });
+ });
+ },
+ unsubscribe() {
+ return Promise.resolve();
+ },
+ },
+ commands: {
+ async availableCommands() {
+ return {};
+ },
+ },
+ device: {
+ DEVICE_REGISTRATION_VERSION,
+ _checkRemoteCommandsUpdateNeeded: async () => false,
+ },
+ VERIFICATION_POLL_TIMEOUT_INITIAL: 1,
+ });
+ fxa._internal.device._fxai = fxa._internal;
+ await fxa._internal.setSignedInUser(credentials);
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.account.device.name",
+ device.name || "mock device name"
+ );
+ return fxa;
+}
+
+function updateUserAccountData(fxa, data) {
+ return fxa._internal.updateUserAccountData(data);
+}
+
+add_task(async function test_updateDeviceRegistration_with_new_device() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ // Remove the current device registration (setSignedInUser does one!).
+ await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
+
+ 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([]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.updateDevice.count, 0);
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.registerDevice.count, 1);
+ Assert.equal(spy.registerDevice.args[0].length, 4);
+ Assert.equal(spy.registerDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.registerDevice.args[0][1], deviceName);
+ Assert.equal(spy.registerDevice.args[0][2], "desktop");
+ Assert.equal(
+ spy.registerDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.registerDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.registerDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(data.device.id, "newly-generated device id");
+ Assert.equal(data.device.registrationVersion, DEVICE_REGISTRATION_VERSION);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_updateDeviceRegistration_with_existing_device() {
+ const deviceId = "my device id";
+ const deviceName = "phil's device";
+
+ const credentials = getTestUser("pb");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: deviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ 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: deviceId,
+ name: deviceName,
+ });
+ };
+ client.getDeviceList = function () {
+ spy.getDeviceList.count += 1;
+ spy.getDeviceList.args.push(arguments);
+ return Promise.resolve([]);
+ };
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.registerDevice.count, 0);
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.equal(spy.updateDevice.args[0].length, 4);
+ Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.updateDevice.args[0][1], deviceId);
+ Assert.equal(spy.updateDevice.args[0][2], deviceName);
+ Assert.equal(
+ spy.updateDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(data.device.id, deviceId);
+ Assert.equal(data.device.registrationVersion, DEVICE_REGISTRATION_VERSION);
+ await fxa.signOut(true);
+});
+
+add_task(
+ async function test_updateDeviceRegistration_with_unknown_device_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+ const currentDeviceId = "my device id";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: currentDeviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ 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([]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.registerDevice.count, 0);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.equal(spy.updateDevice.args[0].length, 4);
+ Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.updateDevice.args[0][1], currentDeviceId);
+ Assert.equal(spy.updateDevice.args[0][2], deviceName);
+ Assert.equal(
+ spy.updateDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(null, data.device);
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_updateDeviceRegistration_with_device_session_conflict_error() {
+ const deviceName = "foo";
+ const deviceType = "bar";
+ const currentDeviceId = "my device id";
+ const conflictingDeviceId = "conflicting device id";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: currentDeviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ 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: conflictingDeviceId,
+ 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: conflictingDeviceId,
+ name: deviceName,
+ type: deviceType,
+ isCurrentDevice: true,
+ },
+ ]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.registerDevice.count, 0);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.equal(spy.updateDevice.args[0].length, 4);
+ Assert.equal(spy.updateDevice.args[0][0], credentials.sessionToken);
+ Assert.equal(spy.updateDevice.args[0][1], currentDeviceId);
+ Assert.equal(spy.updateDevice.args[0][2], deviceName);
+ Assert.equal(
+ spy.updateDevice.args[0][3].pushCallback,
+ "http://mochi.test:8888"
+ );
+ Assert.equal(spy.updateDevice.args[0][3].pushPublicKey, BOGUS_PUBLICKEY);
+ Assert.equal(spy.updateDevice.args[0][3].pushAuthKey, BOGUS_AUTHKEY);
+ Assert.equal(spy.getDeviceList.count, 1);
+ Assert.equal(spy.getDeviceList.args[0].length, 1);
+ Assert.equal(spy.getDeviceList.args[0][0], credentials.sessionToken);
+ Assert.ok(spy.getDeviceList.time >= spy.updateDevice.time);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(data.device.id, conflictingDeviceId);
+ Assert.equal(data.device.registrationVersion, null);
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_updateDeviceRegistration_with_unrecoverable_error() {
+ const deviceName = "foo";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, { name: deviceName });
+ await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
+
+ 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([]);
+ };
+
+ await fxa.updateDeviceRegistration();
+
+ Assert.equal(spy.getDeviceList.count, 0);
+ Assert.equal(spy.updateDevice.count, 0);
+ Assert.equal(spy.registerDevice.count, 1);
+ Assert.equal(spy.registerDevice.args[0].length, 4);
+
+ const state = fxa._internal.currentAccountState;
+ const data = await state.getUserAccountData();
+
+ Assert.equal(null, data.device);
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_no_device_id_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ await updateUserAccountData(fxa, { uid: credentials.uid, device: null });
+
+ const spy = { count: 0, args: [] };
+ fxa._internal.currentAccountState.getUserAccountData = () =>
+ Promise.resolve({
+ email: credentials.email,
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("bar");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 1);
+ Assert.equal(spy.args[0].length, 2);
+ Assert.equal(spy.args[0][1].email, credentials.email);
+ Assert.equal(null, spy.args[0][1].device);
+ Assert.equal(result, "bar");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_registration_version_outdated_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa._internal.currentAccountState.getUserAccountData = () =>
+ Promise.resolve({
+ device: {
+ id: "my id",
+ registrationVersion: 0,
+ registeredCommandsKeys: [],
+ },
+ });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 1);
+ Assert.equal(spy.args[0].length, 2);
+ Assert.equal(spy.args[0][1].device.id, "my id");
+ Assert.equal(result, "wibble");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_device_id_and_uptodate_registration_version_doesnt_invoke_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+
+ const spy = { count: 0 };
+ fxa._internal.currentAccountState.getUserAccountData = async () => ({
+ device: {
+ id: "foo's device id",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ registeredCommandsKeys: [],
+ },
+ });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ return Promise.resolve("bar");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 0);
+ Assert.equal(result, "foo's device id");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(
+ async function test_getDeviceId_with_device_id_and_with_no_registration_version_invokes_device_registration() {
+ const credentials = getTestUser("foo");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+
+ const spy = { count: 0, args: [] };
+ fxa._internal.currentAccountState.getUserAccountData = () =>
+ Promise.resolve({ device: { id: "wibble" } });
+ fxa._internal.device._registerOrUpdateDevice = function () {
+ spy.count += 1;
+ spy.args.push(arguments);
+ return Promise.resolve("wibble");
+ };
+
+ const result = await fxa.device.getLocalId();
+
+ Assert.equal(spy.count, 1);
+ Assert.equal(spy.args[0].length, 2);
+ Assert.equal(spy.args[0][1].device.id, "wibble");
+ Assert.equal(result, "wibble");
+ await fxa.signOut(true);
+ }
+);
+
+add_task(async function test_verification_updates_registration() {
+ const deviceName = "foo";
+
+ const credentials = getTestUser("baz");
+ const fxa = await MockFxAccounts(credentials, {
+ id: "device-id",
+ name: deviceName,
+ });
+
+ // We should already have a device registration, but without send-tab due to
+ // our inability to fetch keys for an unverified users.
+ const state = fxa._internal.currentAccountState;
+ const { device } = await state.getUserAccountData();
+ Assert.equal(device.registeredCommandsKeys.length, 0);
+
+ let updatePromise = new Promise(resolve => {
+ const old_registerOrUpdateDevice = fxa.device._registerOrUpdateDevice.bind(
+ fxa.device
+ );
+ fxa.device._registerOrUpdateDevice = async function (
+ currentState,
+ signedInUser
+ ) {
+ await old_registerOrUpdateDevice(currentState, signedInUser);
+ fxa.device._registerOrUpdateDevice = old_registerOrUpdateDevice;
+ resolve();
+ };
+ });
+
+ fxa._internal.checkEmailStatus = async function (sessionToken) {
+ credentials.verified = true;
+ return credentials;
+ };
+
+ await updatePromise;
+
+ const { device: newDevice, encryptedSendTabKeys } =
+ await state.getUserAccountData();
+ Assert.equal(newDevice.registeredCommandsKeys.length, 1);
+ Assert.notEqual(encryptedSendTabKeys, null);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_devicelist_pushendpointexpired() {
+ const deviceId = "mydeviceid";
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: deviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1, // < 42
+ },
+ });
+
+ const spy = {
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ 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([
+ {
+ id: "mydeviceid",
+ name: "foo",
+ type: "desktop",
+ isCurrentDevice: true,
+ pushEndpointExpired: true,
+ pushCallback: "https://example.com",
+ },
+ ]);
+ };
+ let polledForMissedCommands = false;
+ fxa._internal.commands.pollDeviceCommands = () => {
+ polledForMissedCommands = true;
+ };
+
+ await fxa.device.refreshDeviceList();
+
+ Assert.equal(spy.getDeviceList.count, 1);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.ok(polledForMissedCommands);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_devicelist_nopushcallback() {
+ const deviceId = "mydeviceid";
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ await updateUserAccountData(fxa, {
+ uid: credentials.uid,
+ device: {
+ id: deviceId,
+ registeredCommandsKeys: [],
+ registrationVersion: 1,
+ },
+ });
+
+ const spy = {
+ updateDevice: { count: 0, args: [] },
+ getDeviceList: { count: 0, args: [] },
+ };
+ const client = fxa._internal.fxAccountsClient;
+ 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([
+ {
+ id: "mydeviceid",
+ name: "foo",
+ type: "desktop",
+ isCurrentDevice: true,
+ pushEndpointExpired: false,
+ pushCallback: null,
+ },
+ ]);
+ };
+
+ let polledForMissedCommands = false;
+ fxa._internal.commands.pollDeviceCommands = () => {
+ polledForMissedCommands = true;
+ };
+
+ await fxa.device.refreshDeviceList();
+
+ Assert.equal(spy.getDeviceList.count, 1);
+ Assert.equal(spy.updateDevice.count, 1);
+ Assert.ok(polledForMissedCommands);
+ await fxa.signOut(true);
+});
+
+add_task(async function test_refreshDeviceList() {
+ let credentials = getTestUser("baz");
+
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ let state = new AccountState(storage);
+
+ let fxAccountsClient = new MockFxAccountsClient({
+ id: "deviceAAAAAA",
+ name: "iPhone",
+ type: "phone",
+ pushCallback: "http://mochi.test:8888",
+ pushEndpointExpired: false,
+ sessionToken: credentials.sessionToken,
+ });
+ let spy = {
+ getDeviceList: { count: 0 },
+ };
+ const deviceListUpdateObserver = {
+ count: 0,
+ observe(subject, topic, data) {
+ this.count++;
+ },
+ };
+ Services.obs.addObserver(deviceListUpdateObserver, ON_DEVICELIST_UPDATED);
+
+ fxAccountsClient.getDeviceList = (function (old) {
+ return function getDeviceList() {
+ spy.getDeviceList.count += 1;
+ return old.apply(this, arguments);
+ };
+ })(fxAccountsClient.getDeviceList);
+ let fxai = {
+ _now: Date.now(),
+ _generation: 0,
+ fxAccountsClient,
+ now() {
+ return this._now;
+ },
+ withVerifiedAccountState(func) {
+ // Ensure `func` is called asynchronously, and simulate the possibility
+ // of a different user signng in while the promise is in-flight.
+ const currentGeneration = this._generation;
+ return Promise.resolve()
+ .then(_ => func(state))
+ .then(result => {
+ if (currentGeneration < this._generation) {
+ throw new Error("Another user has signed in");
+ }
+ return result;
+ });
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" }
+ );
+ },
+ });
+ });
+ },
+ unsubscribe() {
+ return Promise.resolve();
+ },
+ getSubscription() {
+ return Promise.resolve({
+ isExpired: () => {
+ return false;
+ },
+ endpoint: "http://mochi.test:8888",
+ });
+ },
+ },
+ async _handleTokenError(e) {
+ _(`Test failure: ${e} - ${e.stack}`);
+ throw e;
+ },
+ };
+ let device = new FxAccountsDevice(fxai);
+ device._checkRemoteCommandsUpdateNeeded = async () => false;
+
+ Assert.equal(
+ device.recentDeviceList,
+ null,
+ "Should not have device list initially"
+ );
+ Assert.ok(await device.refreshDeviceList(), "Should refresh list");
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 1,
+ `${ON_DEVICELIST_UPDATED} was notified`
+ );
+ Assert.deepEqual(
+ device.recentDeviceList,
+ [
+ {
+ id: "deviceAAAAAA",
+ name: "iPhone",
+ type: "phone",
+ pushCallback: "http://mochi.test:8888",
+ pushEndpointExpired: false,
+ isCurrentDevice: true,
+ },
+ ],
+ "Should fetch device list"
+ );
+ Assert.equal(
+ spy.getDeviceList.count,
+ 1,
+ "Should make request to refresh list"
+ );
+ Assert.ok(
+ !(await device.refreshDeviceList()),
+ "Should not refresh device list if fresh"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 1,
+ `${ON_DEVICELIST_UPDATED} was not notified`
+ );
+
+ fxai._now += device.TIME_BETWEEN_FXA_DEVICES_FETCH_MS;
+
+ let refreshPromise = device.refreshDeviceList();
+ let secondRefreshPromise = device.refreshDeviceList();
+ Assert.ok(
+ await Promise.all([refreshPromise, secondRefreshPromise]),
+ "Should refresh list if stale"
+ );
+ Assert.equal(
+ spy.getDeviceList.count,
+ 2,
+ "Should only make one request if called with pending request"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 2,
+ `${ON_DEVICELIST_UPDATED} only notified once`
+ );
+
+ device.observe(null, ON_DEVICE_CONNECTED_NOTIFICATION);
+ await device.refreshDeviceList();
+ Assert.equal(
+ spy.getDeviceList.count,
+ 3,
+ "Should refresh device list after connecting new device"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 3,
+ `${ON_DEVICELIST_UPDATED} notified when new device connects`
+ );
+ device.observe(
+ null,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ JSON.stringify({ isLocalDevice: false })
+ );
+ await device.refreshDeviceList();
+ Assert.equal(
+ spy.getDeviceList.count,
+ 4,
+ "Should refresh device list after disconnecting device"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 4,
+ `${ON_DEVICELIST_UPDATED} notified when device disconnects`
+ );
+ device.observe(
+ null,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ JSON.stringify({ isLocalDevice: true })
+ );
+ await device.refreshDeviceList();
+ Assert.equal(
+ spy.getDeviceList.count,
+ 4,
+ "Should not refresh device list after disconnecting this device"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 4,
+ `${ON_DEVICELIST_UPDATED} not notified again`
+ );
+
+ let refreshBeforeResetPromise = device.refreshDeviceList({
+ ignoreCached: true,
+ });
+ fxai._generation++;
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 4,
+ `${ON_DEVICELIST_UPDATED} not notified`
+ );
+ await Assert.rejects(refreshBeforeResetPromise, /Another user has signed in/);
+
+ device.reset();
+ Assert.equal(
+ device.recentDeviceList,
+ null,
+ "Should clear device list after resetting"
+ );
+ Assert.ok(
+ await device.refreshDeviceList(),
+ "Should fetch new list after resetting"
+ );
+ Assert.equal(
+ deviceListUpdateObserver.count,
+ 5,
+ `${ON_DEVICELIST_UPDATED} notified after reset`
+ );
+ Services.obs.removeObserver(deviceListUpdateObserver, ON_DEVICELIST_UPDATED);
+});
+
+add_task(async function test_push_resubscribe() {
+ let credentials = getTestUser("baz");
+
+ let storage = new MockStorageManager();
+ storage.initialize(credentials);
+ let state = new AccountState(storage);
+
+ let mockDevice = {
+ id: "deviceAAAAAA",
+ name: "iPhone",
+ type: "phone",
+ pushCallback: "http://mochi.test:8888",
+ pushEndpointExpired: false,
+ sessionToken: credentials.sessionToken,
+ };
+
+ var mockSubscription = {
+ isExpired: () => {
+ return false;
+ },
+ endpoint: "http://mochi.test:8888",
+ };
+
+ let fxAccountsClient = new MockFxAccountsClient(mockDevice);
+
+ const spy = {
+ _registerOrUpdateDevice: { count: 0 },
+ };
+
+ let fxai = {
+ _now: Date.now(),
+ _generation: 0,
+ fxAccountsClient,
+ now() {
+ return this._now;
+ },
+ withVerifiedAccountState(func) {
+ // Ensure `func` is called asynchronously, and simulate the possibility
+ // of a different user signng in while the promise is in-flight.
+ const currentGeneration = this._generation;
+ return Promise.resolve()
+ .then(_ => func(state))
+ .then(result => {
+ if (currentGeneration < this._generation) {
+ throw new Error("Another user has signed in");
+ }
+ return result;
+ });
+ },
+ fxaPushService: {
+ registerPushEndpoint() {
+ return new Promise(resolve => {
+ resolve({
+ endpoint: "http://mochi.test:8888",
+ getKey(type) {
+ return ChromeUtils.base64URLDecode(
+ type === "auth" ? BOGUS_AUTHKEY : BOGUS_PUBLICKEY,
+ { padding: "ignore" }
+ );
+ },
+ });
+ });
+ },
+ unsubscribe() {
+ return Promise.resolve();
+ },
+ getSubscription() {
+ return Promise.resolve(mockSubscription);
+ },
+ },
+ commands: {
+ async pollDeviceCommands() {},
+ },
+ async _handleTokenError(e) {
+ _(`Test failure: ${e} - ${e.stack}`);
+ throw e;
+ },
+ };
+ let device = new FxAccountsDevice(fxai);
+ device._checkRemoteCommandsUpdateNeeded = async () => false;
+ device._registerOrUpdateDevice = async () => {
+ spy._registerOrUpdateDevice.count += 1;
+ };
+
+ Assert.ok(await device.refreshDeviceList(), "Should refresh list");
+ Assert.equal(spy._registerOrUpdateDevice.count, 0, "not expecting a refresh");
+
+ mockDevice.pushEndpointExpired = true;
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 1,
+ "end-point expired means should resubscribe"
+ );
+
+ mockDevice.pushEndpointExpired = false;
+ mockSubscription.isExpired = () => true;
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 2,
+ "push service saying expired should resubscribe"
+ );
+
+ mockSubscription.isExpired = () => false;
+ mockSubscription.endpoint = "something-else";
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 3,
+ "push service endpoint diff should resubscribe"
+ );
+
+ mockSubscription = null;
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 4,
+ "push service saying no sub should resubscribe"
+ );
+
+ // reset everything to make sure we didn't leave something behind causing the above to
+ // not check what we thought it was.
+ mockSubscription = {
+ isExpired: () => {
+ return false;
+ },
+ endpoint: "http://mochi.test:8888",
+ };
+ Assert.ok(
+ await device.refreshDeviceList({ ignoreCached: true }),
+ "Should refresh list"
+ );
+ Assert.equal(
+ spy._registerOrUpdateDevice.count,
+ 4,
+ "resetting to good data should not resubscribe"
+ );
+});
+
+add_task(async function test_checking_remote_availableCommands_mismatch() {
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ fxa.device._checkRemoteCommandsUpdateNeeded =
+ FxAccountsDevice.prototype._checkRemoteCommandsUpdateNeeded;
+ fxa.commands.availableCommands = async () => {
+ return {
+ "https://identity.mozilla.com/cmd/open-uri": "local-keys",
+ };
+ };
+
+ const ourDevice = {
+ isCurrentDevice: true,
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "remote-keys",
+ },
+ };
+ Assert.ok(
+ await fxa.device._checkRemoteCommandsUpdateNeeded(
+ ourDevice.availableCommands
+ )
+ );
+});
+
+add_task(async function test_checking_remote_availableCommands_match() {
+ const credentials = getTestUser("baz");
+ credentials.verified = true;
+ const fxa = await MockFxAccounts(credentials);
+ fxa.device._checkRemoteCommandsUpdateNeeded =
+ FxAccountsDevice.prototype._checkRemoteCommandsUpdateNeeded;
+ fxa.commands.availableCommands = async () => {
+ return {
+ "https://identity.mozilla.com/cmd/open-uri": "local-keys",
+ };
+ };
+
+ const ourDevice = {
+ isCurrentDevice: true,
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "local-keys",
+ },
+ };
+ Assert.ok(
+ !(await fxa.device._checkRemoteCommandsUpdateNeeded(
+ ourDevice.availableCommands
+ ))
+ );
+});
+
+function getTestUser(name) {
+ return {
+ email: name + "@example.com",
+ uid: "1ad7f502-4cc7-4ec1-a209-071fd2fae348",
+ sessionToken: name + "'s session token",
+ verified: false,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_client.js b/services/fxaccounts/tests/xpcshell/test_client.js
new file mode 100644
index 0000000000..f3cc48a70e
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_client.js
@@ -0,0 +1,966 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+const FAKE_SESSION_TOKEN =
+ "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf";
+
+// https://wiki.mozilla.org/Identity/AttachedServices/KeyServerProtocol#.2Faccount.2Fkeys
+var ACCOUNT_KEYS = {
+ keyFetch: h(
+ // eslint-disable-next-line no-useless-concat
+ "8081828384858687 88898a8b8c8d8e8f" + "9091929394959697 98999a9b9c9d9e9f"
+ ),
+
+ response: h(
+ "ee5c58845c7c9412 b11bbd20920c2fdd" +
+ "d83c33c9cd2c2de2 d66b222613364636" +
+ "c2c0f8cfbb7c6304 72c0bd88451342c6" +
+ "c05b14ce342c5ad4 6ad89e84464c993c" +
+ "3927d30230157d08 17a077eef4b20d97" +
+ "6f7a97363faf3f06 4c003ada7d01aa70"
+ ),
+
+ kA: h(
+ // eslint-disable-next-line no-useless-concat
+ "2021222324252627 28292a2b2c2d2e2f" + "3031323334353637 38393a3b3c3d3e3f"
+ ),
+
+ wrapKB: h(
+ // eslint-disable-next-line no-useless-concat
+ "4041424344454647 48494a4b4c4d4e4f" + "5051525354555657 58595a5b5c5d5e5f"
+ ),
+};
+
+add_task(async 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) {
+ Assert.ok(request.hasHeader("Authorization"));
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(message, message.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+
+ let result = await client._request("/foo", method, credentials);
+ Assert.equal("Great Success!", result.msg);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_authenticated_post_request() {
+ let credentials = {
+ id: "eyJleHBpcmVzIjogMTM2NTAxMDg5OC4x",
+ key: "qTZf4ZFpAMpMoeSsX3zVRjiqmNs=",
+ algorithm: "sha256",
+ };
+ let method = "POST";
+
+ let server = httpd_setup({
+ "/foo": function (request, response) {
+ Assert.ok(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 = await client._request("/foo", method, credentials, {
+ foo: "bar",
+ });
+ Assert.equal("bar", result.foo);
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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 {
+ await client._request("/foo", method);
+ do_throw("Expected to catch an exception");
+ } catch (e) {
+ Assert.equal(500, e.code);
+ Assert.equal("Internal Server Error", e.message);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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
+ Assert.equal(client.backoffError, null);
+ try {
+ await client._request("/retryDelay", method);
+ } catch (e) {
+ Assert.equal(429, e.code);
+ Assert.equal(30, e.retryAfter);
+ Assert.notEqual(typeof client.fxaBackoffTimer, "undefined");
+ Assert.notEqual(client.backoffError, null);
+ }
+ // While delay is in effect, client short-circuits any requests
+ // and re-rejects with previous error.
+ try {
+ await client._request("/duringDelayIShouldNotBeCalled", method);
+ throw new Error("I should not be reached");
+ } catch (e) {
+ Assert.equal(e.retryAfter, 30);
+ Assert.equal(e.message, "Client has sent too many requests");
+ Assert.notEqual(client.backoffError, null);
+ }
+ // Once timer fires, client nulls error out and HTTP calls work again.
+ client._clearBackoff();
+ let result = await client._request("/duringDelayIShouldNotBeCalled", method);
+ Assert.equal(client.backoffError, null);
+ Assert.equal(result.working, "yes");
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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) {
+ Assert.equal("", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "247b675ffb4c46310bc87e26d712153abe5e1c90ef00a4784594f97ef54f2375"
+ );
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ creationMessage_noKey,
+ creationMessage_noKey.length
+ );
+ return;
+ }
+
+ if (jsonBody.email == "you@example.org") {
+ Assert.equal("keys=true", request._queryString);
+ Assert.equal(
+ 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.
+ Assert.ok(false, "unexpected email: " + jsonBody.email);
+ },
+ });
+
+ // Try to create an account without retrieving optional keys.
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.signUp(unicodeUsername, unicodePassword);
+ Assert.equal("uid", result.uid);
+ Assert.equal("sessionToken", result.sessionToken);
+ Assert.equal(undefined, result.keyFetchToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "de6a2648b78284fcb9ffa81ba95803309cfba7af583c01a8a1a63e567234dd28"
+ );
+
+ // Try to create an account retrieving optional keys.
+ result = await client.signUp("you@example.org", "pässwörd", true);
+ Assert.equal("uid", result.uid);
+ Assert.equal("sessionToken", result.sessionToken);
+ Assert.equal("keyFetchToken", result.keyFetchToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "f589225b609e56075d76eb74f771ff9ab18a4dc0e901e131ba8f984c7fb0ca8c"
+ );
+
+ // Try to create an existing account. Triggers error path.
+ try {
+ result = await client.signUp(unicodeUsername, unicodePassword);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(101, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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) {
+ Assert.equal("", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "08b9d111196b8408e8ed92439da49206c8ecfbf343df0ae1ecefcd1e0174a8b6"
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ sessionMessage_noKey,
+ sessionMessage_noKey.length
+ );
+ } else if (jsonBody.email == "you@example.com") {
+ Assert.equal("keys=true", request._queryString);
+ Assert.equal(
+ jsonBody.authPW,
+ "93d20ec50304d496d0707ec20d7e8c89459b6396ec5dd5b9e92809c5e42856c7"
+ );
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(
+ sessionMessage_withKey,
+ sessionMessage_withKey.length
+ );
+ } 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
+ );
+ } else {
+ // Error trying to sign in to nonexistent account
+ response.setStatusLine(request.httpVersion, 400, "Bad request");
+ response.bodyOutputStream.write(
+ errorMessage_notExistent,
+ errorMessage_notExistent.length
+ );
+ }
+ },
+ });
+
+ // Login without retrieving optional keys
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.signIn(unicodeUsername, "bigsecret");
+ Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "c076ec3f4af123a615157154c6e1d0d6293e514fd7b0221e32d50517ecf002b8"
+ );
+ Assert.equal(undefined, result.keyFetchToken);
+
+ // Login with retrieving optional keys
+ result = await client.signIn("you@example.com", "bigsecret", true);
+ Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624"
+ );
+ Assert.equal("keyFetchToken", result.keyFetchToken);
+
+ // Retry due to wrong email capitalization
+ result = await client.signIn("You@example.com", "bigsecret", true);
+ Assert.equal(FAKE_SESSION_TOKEN, result.sessionToken);
+ Assert.equal(
+ result.unwrapBKey,
+ "65970516211062112e955d6420bebe020269d6b6a91ebd288319fc8d0cb49624"
+ );
+ Assert.equal("keyFetchToken", result.keyFetchToken);
+
+ // Trigger error path
+ try {
+ result = await client.signIn("yøü@bad.example.org", "nofear");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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;
+ Assert.ok(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);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.signOut("FakeSession");
+ Assert.equal(typeof result, "object");
+
+ // Trigger error path
+ try {
+ result = await client.signOut("FakeSession");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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) {
+ Assert.ok(request.hasHeader("Authorization"));
+ Assert.equal("", 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);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.recoveryEmailStatus(FAKE_SESSION_TOKEN);
+ Assert.equal(result.verified, true);
+
+ // Trigger error path
+ try {
+ result = await client.recoveryEmailStatus("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_recoveryEmailStatusWithReason() {
+ let emailStatus = JSON.stringify({ verified: true });
+ let server = httpd_setup({
+ "/recovery_email/status": function (request, response) {
+ Assert.ok(request.hasHeader("Authorization"));
+ // if there is a query string then it will have a reason
+ Assert.equal("reason=push", request._queryString);
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(emailStatus, emailStatus.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.recoveryEmailStatus(FAKE_SESSION_TOKEN, {
+ reason: "push",
+ });
+ Assert.equal(result.verified, true);
+ await promiseStopServer(server);
+});
+
+add_task(async 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) {
+ Assert.ok(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);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result = await client.resendVerificationEmail(FAKE_SESSION_TOKEN);
+ Assert.equal(JSON.stringify(result), emptyMessage);
+
+ // Trigger error path
+ try {
+ result = await client.resendVerificationEmail("some bogus session");
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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) {
+ Assert.ok(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 = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ Assert.equal(CommonUtils.hexToBytes(ACCOUNT_KEYS.kA), result.kA);
+ Assert.equal(CommonUtils.hexToBytes(ACCOUNT_KEYS.wrapKB), result.wrapKB);
+
+ // Second try, empty bundle should trigger error
+ try {
+ result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(expectedError.message, "failed to retrieve keys");
+ }
+
+ // Third try, bad bundle results in MAC error
+ try {
+ result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(expectedError.message, "error unbundling encryption keys");
+ }
+
+ // Fourth try, pretend account doesn't exist
+ try {
+ result = await client.accountKeys(ACCOUNT_KEYS.keyFetch);
+ do_throw("Expected to catch an exception");
+ } catch (expectedError) {
+ Assert.equal(102, expectedError.errno);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_accessTokenWithSessionToken() {
+ let server = httpd_setup({
+ "/oauth/token": function (request, response) {
+ const responseMessage = JSON.stringify({
+ access_token:
+ "43793fdfffec22eb39fc3c44ed09193a6fde4c24e5d6a73f73178597b268af69",
+ token_type: "bearer",
+ scope: "https://identity.mozilla.com/apps/oldsync",
+ expires_in: 21600,
+ auth_at: 1589579900,
+ });
+
+ response.setStatusLine(request.httpVersion, 200, "OK");
+ response.bodyOutputStream.write(responseMessage, responseMessage.length);
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let sessionTokenHex =
+ "0599c36ebb5cad6feb9285b9547b65342b5434d55c07b33bffd4307ab8f82dc4";
+ let clientId = "5882386c6d801776";
+ let scope = "https://identity.mozilla.com/apps/oldsync";
+ let ttl = 100;
+ let result = await client.accessTokenWithSessionToken(
+ sessionTokenHex,
+ clientId,
+ scope,
+ ttl
+ );
+ Assert.ok(result);
+
+ await promiseStopServer(server);
+});
+
+add_task(async function test_accountExists() {
+ 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);
+ }
+ },
+ });
+
+ let client = new FxAccountsClient(server.baseURI);
+ let result;
+
+ result = await client.accountExists("i.exist@example.com");
+ Assert.ok(result);
+
+ result = await client.accountExists("i.also.exist@example.com");
+ Assert.ok(result);
+
+ result = await client.accountExists("i.dont.exist@example.com");
+ Assert.ok(!result);
+
+ try {
+ result = await client.accountExists("i.break.things@example.com");
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ if (body.name === ERROR_NAME) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ 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 = await client.registerDevice(
+ FAKE_SESSION_TOKEN,
+ DEVICE_NAME,
+ DEVICE_TYPE
+ );
+
+ Assert.ok(result);
+ Assert.equal(Object.keys(result).length, 4);
+ Assert.equal(result.id, DEVICE_ID);
+ Assert.equal(typeof result.createdAt, "number");
+ Assert.ok(result.createdAt > 0);
+ Assert.equal(result.name, DEVICE_NAME);
+ Assert.equal(result.type, DEVICE_TYPE);
+
+ try {
+ await client.registerDevice(FAKE_SESSION_TOKEN, ERROR_NAME, DEVICE_TYPE);
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ if (body.id === ERROR_ID) {
+ response.setStatusLine(request.httpVersion, 500, "Alas");
+ response.bodyOutputStream.write("{}", 2);
+ return;
+ }
+
+ 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 = await client.updateDevice(
+ FAKE_SESSION_TOKEN,
+ DEVICE_ID,
+ DEVICE_NAME
+ );
+
+ Assert.ok(result);
+ Assert.equal(Object.keys(result).length, 2);
+ Assert.equal(result.id, DEVICE_ID);
+ Assert.equal(result.name, DEVICE_NAME);
+
+ try {
+ await client.updateDevice(FAKE_SESSION_TOKEN, ERROR_ID, DEVICE_NAME);
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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 = await client.getDeviceList(FAKE_SESSION_TOKEN);
+ Assert.ok(Array.isArray(result));
+ Assert.equal(result.length, 0);
+
+ try {
+ canReturnDevices = false;
+ await client.getDeviceList(FAKE_SESSION_TOKEN);
+ do_throw("Expected to catch an exception");
+ } catch (unexpectedError) {
+ Assert.equal(unexpectedError.code, 500);
+ }
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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);
+
+ await Assert.rejects(
+ client.signOut(FAKE_SESSION_TOKEN, {
+ service: "sync",
+ }),
+ function (err) {
+ return err.errno == 111;
+ }
+ );
+
+ await promiseStopServer(server);
+});
+
+add_task(async 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 = await client.signIn(clientEmail, "123456");
+ Assert.equal(result.areWeHappy, "yes");
+ Assert.equal(attempts, 2);
+
+ await promiseStopServer(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_commands.js b/services/fxaccounts/tests/xpcshell/test_commands.js
new file mode 100644
index 0000000000..3fa42da439
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_commands.js
@@ -0,0 +1,708 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsCommands, SendTab } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommands.sys.mjs"
+);
+
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+const { COMMAND_SENDTAB, COMMAND_SENDTAB_TAIL } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+class TelemetryMock {
+ constructor() {
+ this._events = [];
+ this._uuid_counter = 0;
+ }
+
+ recordEvent(object, method, value, extra = undefined) {
+ this._events.push({ object, method, value, extra });
+ }
+
+ generateFlowID() {
+ this._uuid_counter += 1;
+ return this._uuid_counter.toString();
+ }
+
+ sanitizeDeviceId(id) {
+ return id + "-san";
+ }
+}
+
+function FxaInternalMock() {
+ return {
+ telemetry: new TelemetryMock(),
+ };
+}
+
+function MockFxAccountsClient() {
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+add_task(async function test_sendtab_isDeviceCompatible() {
+ const sendTab = new SendTab(null, null);
+ let device = { name: "My device" };
+ Assert.ok(!sendTab.isDeviceCompatible(device));
+ device = { name: "My device", availableCommands: {} };
+ Assert.ok(!sendTab.isDeviceCompatible(device));
+ device = {
+ name: "My device",
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "payload",
+ },
+ };
+ Assert.ok(sendTab.isDeviceCompatible(device));
+});
+
+add_task(async function test_sendtab_send() {
+ const commands = {
+ invoke: sinon.spy((cmd, device, payload) => {
+ if (device.name == "Device 1") {
+ throw new Error("Invoke error!");
+ }
+ Assert.equal(payload.encrypted, "encryptedpayload");
+ }),
+ };
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(commands, fxai);
+ sendTab._encrypt = (bytes, device) => {
+ if (device.name == "Device 2") {
+ throw new Error("Encrypt error!");
+ }
+ return "encryptedpayload";
+ };
+ const to = [
+ { name: "Device 1" },
+ { name: "Device 2" },
+ { id: "dev3", name: "Device 3" },
+ ];
+ // although we are sending to 3 devices, only 1 is successful - so there's
+ // only 1 streamID we care about. However, we've created IDs even for the
+ // failing items - so it's "4"
+ const expectedTelemetryStreamID = "4";
+ const tab = { title: "Foo", url: "https://foo.bar/" };
+ const report = await sendTab.send(to, tab);
+ Assert.equal(report.succeeded.length, 1);
+ Assert.equal(report.failed.length, 2);
+ Assert.equal(report.succeeded[0].name, "Device 3");
+ Assert.equal(report.failed[0].device.name, "Device 1");
+ Assert.equal(report.failed[0].error.message, "Invoke error!");
+ Assert.equal(report.failed[1].device.name, "Device 2");
+ Assert.equal(report.failed[1].error.message, "Encrypt error!");
+ Assert.ok(commands.invoke.calledTwice);
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "dev3-san",
+ extra: { flowID: "1", streamID: expectedTelemetryStreamID },
+ },
+ ]);
+});
+
+add_task(async function test_sendtab_send_rate_limit() {
+ const rateLimitReject = {
+ code: 429,
+ retryAfter: 5,
+ retryAfterLocalized: "retry after 5 seconds",
+ };
+ const fxAccounts = {
+ fxAccountsClient: new MockFxAccountsClient(),
+ getUserAccountData() {
+ return {};
+ },
+ telemetry: new TelemetryMock(),
+ };
+ let rejected = false;
+ let invoked = 0;
+ fxAccounts.fxAccountsClient.invokeCommand = async function invokeCommand() {
+ invoked++;
+ Assert.ok(invoked <= 2, "only called twice and not more");
+ if (rejected) {
+ return {};
+ }
+ rejected = true;
+ return Promise.reject(rateLimitReject);
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const sendTab = new SendTab(commands, fxAccounts);
+ sendTab._encrypt = () => "encryptedpayload";
+
+ const tab = { title: "Foo", url: "https://foo.bar/" };
+ let report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 0);
+ Assert.equal(report.failed.length, 1);
+ Assert.equal(report.failed[0].error, rateLimitReject);
+
+ report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 0);
+ Assert.equal(report.failed.length, 1);
+ Assert.ok(
+ report.failed[0].error.message.includes(
+ "Invoke for " +
+ "https://identity.mozilla.com/cmd/open-uri is rate-limited"
+ )
+ );
+
+ commands._invokeRateLimitExpiry = Date.now() - 1000;
+ report = await sendTab.send([{ name: "Device 1" }], tab);
+ Assert.equal(report.succeeded.length, 1);
+ Assert.equal(report.failed.length, 0);
+});
+
+add_task(async function test_sendtab_receive() {
+ // We are testing 'receive' here, but might as well go through 'send'
+ // to package the data and for additional testing...
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(commands, fxai);
+ sendTab._encrypt = (bytes, device) => {
+ return bytes;
+ };
+ sendTab._decrypt = bytes => {
+ return bytes;
+ };
+ const tab = { title: "tab title", url: "http://example.com" };
+ const to = [{ id: "devid", name: "The Device" }];
+ const reason = "push";
+
+ await sendTab.send(to, tab);
+ Assert.equal(commands._invokes.length, 1);
+
+ for (let { cmd, device, payload } of commands._invokes) {
+ Assert.equal(cmd, COMMAND_SENDTAB);
+ // Older Firefoxes would send a plaintext flowID in the top-level payload.
+ // Test that we sensibly ignore it.
+ Assert.ok(!payload.hasOwnProperty("flowID"));
+ // change it - ensure we still get what we expect in telemetry later.
+ payload.flowID = "ignore-me";
+ Assert.deepEqual(await sendTab.handle(device.id, payload, reason), {
+ title: "tab title",
+ uri: "http://example.com",
+ });
+ }
+
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-sent",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "devid-san",
+ extra: { flowID: "1", streamID: "2" },
+ },
+ {
+ object: "command-received",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "devid-san",
+ extra: { flowID: "1", streamID: "2", reason },
+ },
+ ]);
+});
+
+// Test that a client which only sends the flowID in the envelope and not in the
+// encrypted body gets recorded without the flowID.
+add_task(async function test_sendtab_receive_old_client() {
+ const fxai = FxaInternalMock();
+ const sendTab = new SendTab(null, fxai);
+ sendTab._decrypt = bytes => {
+ return bytes;
+ };
+ const data = { entries: [{ title: "title", url: "url" }] };
+ // No 'flowID' in the encrypted payload, no 'streamID' anywhere.
+ const payload = {
+ flowID: "flow-id",
+ encrypted: new TextEncoder().encode(JSON.stringify(data)),
+ };
+ const reason = "push";
+ await sendTab.handle("sender-id", payload, reason);
+ Assert.deepEqual(fxai.telemetry._events, [
+ {
+ object: "command-received",
+ method: COMMAND_SENDTAB_TAIL,
+ value: "sender-id-san",
+ // deepEqual doesn't ignore undefined, but our telemetry code and
+ // JSON.stringify() do...
+ extra: { flowID: undefined, streamID: undefined, reason },
+ },
+ ]);
+});
+
+add_task(function test_commands_getReason() {
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const testCases = [
+ {
+ receivedIndex: 0,
+ currentIndex: 0,
+ expectedReason: "poll",
+ message: "should return reason 'poll'",
+ },
+ {
+ receivedIndex: 7,
+ currentIndex: 3,
+ expectedReason: "push-missed",
+ message: "should return reason 'push-missed'",
+ },
+ {
+ receivedIndex: 2,
+ currentIndex: 8,
+ expectedReason: "push",
+ message: "should return reason 'push'",
+ },
+ ];
+ for (const tc of testCases) {
+ const reason = commands._getReason(tc.receivedIndex, tc.currentIndex);
+ Assert.equal(reason, tc.expectedReason, tc.message);
+ }
+});
+
+add_task(async function test_commands_pollDeviceCommands_push() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+
+ // Local state.
+ const pushIndexReceived = 11;
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 10,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_push_already_fetched() {
+ // Local state.
+ const pushIndexReceived = 12;
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 12,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").never();
+ mockCommands.expects("_handleCommands").never();
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_commands_handleCommands() {
+ // This test ensures that `_getReason` is being called by
+ // `_handleCommands` with the expected parameters.
+ const pushIndexReceived = 12;
+ const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485";
+ const remoteMessageIndex = 8;
+ const remoteMessages = [
+ {
+ index: remoteMessageIndex,
+ data: {
+ command: COMMAND_SENDTAB,
+ payload: {
+ encrypted: {},
+ },
+ sender: senderID,
+ },
+ },
+ ];
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ commands.sendTab.handle = (sender, data, reason) => {
+ return {
+ title: "testTitle",
+ uri: "https://testURI",
+ };
+ };
+ commands._fxai.device = {
+ refreshDeviceList: () => {},
+ recentDeviceList: [
+ {
+ id: senderID,
+ },
+ ],
+ };
+ const mockCommands = sinon.mock(commands);
+ mockCommands
+ .expects("_getReason")
+ .once()
+ .withExactArgs(pushIndexReceived, remoteMessageIndex);
+ mockCommands.expects("_notifyFxATabsReceived").once();
+ await commands._handleCommands(remoteMessages, pushIndexReceived);
+ mockCommands.verify();
+});
+
+add_task(async function test_commands_handleCommands_invalid_tab() {
+ // This test ensures that `_getReason` is being called by
+ // `_handleCommands` with the expected parameters.
+ const pushIndexReceived = 12;
+ const senderID = "6d09f6c4-89b2-41b3-a0ac-e4c2502b5485";
+ const remoteMessageIndex = 8;
+ const remoteMessages = [
+ {
+ index: remoteMessageIndex,
+ data: {
+ command: COMMAND_SENDTAB,
+ payload: {
+ encrypted: {},
+ },
+ sender: senderID,
+ },
+ },
+ ];
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb({});
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ commands.sendTab.handle = (sender, data, reason) => {
+ return {
+ title: "badUriTab",
+ uri: "file://path/to/pdf",
+ };
+ };
+ commands._fxai.device = {
+ refreshDeviceList: () => {},
+ recentDeviceList: [
+ {
+ id: senderID,
+ },
+ ],
+ };
+ const mockCommands = sinon.mock(commands);
+ mockCommands
+ .expects("_getReason")
+ .once()
+ .withExactArgs(pushIndexReceived, remoteMessageIndex);
+ // We shouldn't have tried to open a tab with an invalid uri
+ mockCommands.expects("_notifyFxATabsReceived").never();
+
+ await commands._handleCommands(remoteMessages, pushIndexReceived);
+ mockCommands.verify();
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_push_local_state_empty() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+
+ // Local state.
+ const pushIndexReceived = 11;
+ const accountState = {
+ data: {
+ device: {},
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands(pushIndexReceived);
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_commands_pollDeviceCommands_scheduled_local() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+ const pushIndexReceived = 0;
+ // Local state.
+ const accountState = {
+ data: {
+ device: {
+ lastCommandIndex: 10,
+ },
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(11).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands();
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+});
+
+add_task(
+ async function test_commands_pollDeviceCommands_scheduled_local_state_empty() {
+ // Server state.
+ const remoteMessages = [
+ {
+ index: 11,
+ data: {},
+ },
+ {
+ index: 12,
+ data: {},
+ },
+ ];
+ const remoteIndex = 12;
+ const pushIndexReceived = 0;
+ // Local state.
+ const accountState = {
+ data: {
+ device: {},
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ };
+ const commands = new FxAccountsCommands(fxAccounts);
+ const mockCommands = sinon.mock(commands);
+ mockCommands.expects("_fetchDeviceCommands").once().withArgs(0).returns({
+ index: remoteIndex,
+ messages: remoteMessages,
+ });
+ mockCommands
+ .expects("_handleCommands")
+ .once()
+ .withArgs(remoteMessages, pushIndexReceived);
+ await commands.pollDeviceCommands();
+
+ mockCommands.verify();
+ Assert.equal(accountState.data.device.lastCommandIndex, 12);
+ }
+);
+
+add_task(async function test_send_tab_keys_regenerated_if_lost() {
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ // Local state.
+ const accountState = {
+ data: {
+ // Since the device object has no
+ // sendTabKeys, it will recover
+ // when we attempt to get the
+ // encryptedSendTabKeys
+ device: {
+ lastCommandIndex: 10,
+ },
+ encryptedSendTabKeys: "keys",
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ async getUserAccountData(data) {
+ return accountState.getUserAccountData(data);
+ },
+ telemetry: new TelemetryMock(),
+ };
+ const sendTab = new SendTab(commands, fxAccounts);
+ let generateEncryptedKeysCalled = false;
+ sendTab._generateAndPersistEncryptedSendTabKey = async () => {
+ generateEncryptedKeysCalled = true;
+ };
+ await sendTab.getEncryptedSendTabKeys();
+ Assert.ok(generateEncryptedKeysCalled);
+});
+
+add_task(async function test_send_tab_keys_are_not_regenerated_if_not_lost() {
+ const commands = {
+ _invokes: [],
+ invoke(cmd, device, payload) {
+ this._invokes.push({ cmd, device, payload });
+ },
+ };
+
+ // Local state.
+ const accountState = {
+ data: {
+ // Since the device object has
+ // sendTabKeys, it will not try
+ // to regenerate them
+ // when we attempt to get the
+ // encryptedSendTabKeys
+ device: {
+ lastCommandIndex: 10,
+ sendTabKeys: "keys",
+ },
+ encryptedSendTabKeys: "encrypted-keys",
+ },
+ getUserAccountData() {
+ return this.data;
+ },
+ updateUserAccountData(data) {
+ this.data = data;
+ },
+ };
+
+ const fxAccounts = {
+ async withCurrentAccountState(cb) {
+ await cb(accountState);
+ },
+ async getUserAccountData(data) {
+ return accountState.getUserAccountData(data);
+ },
+ telemetry: new TelemetryMock(),
+ };
+ const sendTab = new SendTab(commands, fxAccounts);
+ let generateEncryptedKeysCalled = false;
+ sendTab._generateAndPersistEncryptedSendTabKey = async () => {
+ generateEncryptedKeysCalled = true;
+ };
+ await sendTab.getEncryptedSendTabKeys();
+ Assert.ok(!generateEncryptedKeysCalled);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_credentials.js b/services/fxaccounts/tests/xpcshell/test_credentials.js
new file mode 100644
index 0000000000..c3656f219d
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_credentials.js
@@ -0,0 +1,130 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Credentials } = ChromeUtils.importESModule(
+ "resource://gre/modules/Credentials.sys.mjs"
+);
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+
+var { hexToBytes: h2b, hexAsString: h2s, 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(async function test_onepw_setup_credentials() {
+ let email = "francine@example.org";
+ let password = CommonUtils.encodeUTF8("i like pie");
+
+ let pbkdf2 = CryptoUtils.pbkdf2Generate;
+ let hkdf = CryptoUtils.hkdfLegacy;
+
+ // quickStretch the email
+ let saltyEmail = Credentials.keyWordExtended("quickStretch", email);
+
+ Assert.equal(
+ b2h(saltyEmail),
+ "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a6672616e63696e65406578616d706c652e6f7267"
+ );
+
+ let pbkdf2Rounds = 1000;
+ let pbkdf2Len = 32;
+
+ let quickStretchedPW = await pbkdf2(
+ password,
+ saltyEmail,
+ pbkdf2Rounds,
+ pbkdf2Len
+ );
+ let quickStretchedActual =
+ "6b88094c1c73bbf133223f300d101ed70837af48d9d2c1b6e7d38804b20cdde4";
+ Assert.equal(b2h(quickStretchedPW), quickStretchedActual);
+
+ // obtain hkdf info
+ let authKeyInfo = Credentials.keyWord("authPW");
+ Assert.equal(
+ b2h(authKeyInfo),
+ "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f617574685057"
+ );
+
+ // derive auth password
+ let hkdfSalt = h2b("00");
+ let hkdfLen = 32;
+ let authPW = await hkdf(quickStretchedPW, hkdfSalt, authKeyInfo, hkdfLen);
+
+ Assert.equal(
+ b2h(authPW),
+ "4b8dec7f48e7852658163601ff766124c312f9392af6c3d4e1a247eb439be342"
+ );
+
+ // derive unwrap key
+ let unwrapKeyInfo = Credentials.keyWord("unwrapBkey");
+ let unwrapKey = await hkdf(
+ quickStretchedPW,
+ hkdfSalt,
+ unwrapKeyInfo,
+ hkdfLen
+ );
+
+ Assert.equal(
+ b2h(unwrapKey),
+ "8ff58975be391338e4ec5d7138b5ed7b65c7d1bfd1f3a4f93e05aa47d5b72be9"
+ );
+});
+
+add_task(async function test_client_stretch_kdf() {
+ 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);
+ Assert.equal(
+ b2h(salt),
+ "6964656e746974792e6d6f7a696c6c612e636f6d2f7069636c2f76312f717569636b537472657463683a616e6472c3a9406578616d706c652e6f7267"
+ );
+
+ let options = {
+ stretchedPassLength: 32,
+ pbkdf2Rounds: 1000,
+ hkdfSalt: h2b("00"),
+ hkdfLength: 32,
+ };
+
+ let results = await Credentials.setup(email, password, options);
+
+ Assert.equal(
+ expected.quickStretchedPW,
+ b2h(results.quickStretchedPW),
+ "quickStretchedPW is wrong"
+ );
+
+ Assert.equal(expected.authPW, b2h(results.authPW), "authPW is wrong");
+});
+
+// End of tests
+// Utility functions follow
+
+// turn formatted test vectors into normal hex strings
+function h(hexStr) {
+ return hexStr.replace(/\s+/g, "");
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_device.js b/services/fxaccounts/tests/xpcshell/test_device.js
new file mode 100644
index 0000000000..037db2b101
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_device.js
@@ -0,0 +1,127 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const fxAccounts = getFxAccountsSingleton();
+
+const { ON_NEW_DEVICE_ID, PREF_ACCOUNT_ROOT } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+function promiseObserved(topic) {
+ return new Promise(res => {
+ Services.obs.addObserver(res, topic);
+ });
+}
+
+_("Misc tests for FxAccounts.device");
+
+fxAccounts.device._checkRemoteCommandsUpdateNeeded = async () => false;
+
+add_test(function test_default_device_name() {
+ // Note that head_helpers overrides getDefaultLocalName - this test is
+ // really just to ensure the actual implementation is sane - we can't
+ // really check the value it uses is correct.
+ // We are just hoping to avoid a repeat of bug 1369285.
+ let def = fxAccounts.device.getDefaultLocalName(); // make sure it doesn't throw.
+ _("default value is " + def);
+ ok(!!def.length);
+
+ // This is obviously tied to the implementation, but we want early warning
+ // if any of these things fail.
+ // We really want one of these 2 to provide a value.
+ let hostname = Services.sysinfo.get("device") || Services.dns.myHostName;
+ _("hostname is " + hostname);
+ ok(!!hostname.length);
+ // the hostname should be in the default.
+ ok(def.includes(hostname));
+ // We expect the following to work as a fallback to the above.
+ let fallback = Cc["@mozilla.org/network/protocol;1?name=http"].getService(
+ Ci.nsIHttpProtocolHandler
+ ).oscpu;
+ _("UA fallback is " + fallback);
+ ok(!!fallback.length);
+ // the fallback should not be in the default
+ ok(!def.includes(fallback));
+
+ run_next_test();
+});
+
+add_test(function test_migration() {
+ Services.prefs.clearUserPref("identity.fxaccounts.account.device.name");
+ Services.prefs.setStringPref("services.sync.client.name", "my client name");
+ // calling getLocalName() should move the name to the new pref and reset the old.
+ equal(fxAccounts.device.getLocalName(), "my client name");
+ equal(
+ Services.prefs.getStringPref("identity.fxaccounts.account.device.name"),
+ "my client name"
+ );
+ ok(!Services.prefs.prefHasUserValue("services.sync.client.name"));
+ run_next_test();
+});
+
+add_test(function test_migration_set_before_get() {
+ Services.prefs.setStringPref("services.sync.client.name", "old client name");
+ fxAccounts.device.setLocalName("new client name");
+ equal(fxAccounts.device.getLocalName(), "new client name");
+ run_next_test();
+});
+
+add_task(async function test_reset() {
+ // We don't test the client name specifically here because the client name
+ // is set as part of signing the user in via the attempt to register the
+ // device.
+ const testPref = PREF_ACCOUNT_ROOT + "test-pref";
+ Services.prefs.setStringPref(testPref, "whatever");
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ verified: true,
+ ...MOCK_ACCOUNT_KEYS,
+ };
+ // FxA will try to register its device record in the background after signin.
+ const registerDevice = sinon
+ .stub(fxAccounts._internal.fxAccountsClient, "registerDevice")
+ .callsFake(async () => {
+ return { id: "foo" };
+ });
+ await fxAccounts._internal.setSignedInUser(credentials);
+ // wait for device registration to complete.
+ await promiseObserved(ON_NEW_DEVICE_ID);
+ ok(!Services.prefs.prefHasUserValue(testPref));
+ // signing the user out should reset the name pref.
+ const namePref = PREF_ACCOUNT_ROOT + "device.name";
+ ok(Services.prefs.prefHasUserValue(namePref));
+ await fxAccounts.signOut(/* localOnly = */ true);
+ ok(!Services.prefs.prefHasUserValue(namePref));
+ registerDevice.restore();
+});
+
+add_task(async function test_name_sanitization() {
+ fxAccounts.device.setLocalName("emoji is valid \u2665");
+ Assert.equal(fxAccounts.device.getLocalName(), "emoji is valid \u2665");
+
+ let invalid = "x\uFFFD\n\r\t" + "x".repeat(255);
+ let sanitized = "x\uFFFD\uFFFD\uFFFD\uFFFD" + "x".repeat(250); // 255 total.
+
+ // If the pref already has the invalid value we still get the valid one back.
+ Services.prefs.setStringPref(
+ "identity.fxaccounts.account.device.name",
+ invalid
+ );
+ Assert.equal(fxAccounts.device.getLocalName(), sanitized);
+
+ // But if we explicitly set it to an invalid name, the sanitized value ends
+ // up in the pref.
+ fxAccounts.device.setLocalName(invalid);
+ Assert.equal(fxAccounts.device.getLocalName(), sanitized);
+ Assert.equal(
+ Services.prefs.getStringPref("identity.fxaccounts.account.device.name"),
+ sanitized
+ );
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_keys.js b/services/fxaccounts/tests/xpcshell/test_keys.js
new file mode 100644
index 0000000000..6e650a1609
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_keys.js
@@ -0,0 +1,182 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsKeys } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsKeys.sys.mjs"
+);
+
+// Ref https://github.com/mozilla/fxa-crypto-relier/ for the details
+// of these test vectors.
+
+add_task(async function test_derive_scoped_key_test_vector() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45";
+ const scopedKeyMetadata = {
+ identifier: "app_key:https%3A//example.com",
+ keyRotationTimestamp: 1510726317000,
+ keyRotationSecret:
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ };
+
+ const scopedKey = await keys._deriveScopedKey(
+ uid,
+ CommonUtils.hexToBytes(kB),
+ "app_key",
+ scopedKeyMetadata
+ );
+
+ Assert.deepEqual(scopedKey, {
+ kty: "oct",
+ kid: "1510726317-Voc-Eb9IpoTINuo9ll7bjA",
+ k: "Kkbk1_Q0oCcTmggeDH6880bQrxin2RLu5D00NcJazdQ",
+ });
+});
+
+add_task(async function test_derive_legacy_sync_key_test_vector() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9";
+ const scopedKeyMetadata = {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationTimestamp: 1510726317123,
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ };
+
+ const scopedKey = await keys._deriveLegacyScopedKey(
+ uid,
+ CommonUtils.hexToBytes(kB),
+ "https://identity.mozilla.com/apps/oldsync",
+ scopedKeyMetadata
+ );
+
+ Assert.deepEqual(scopedKey, {
+ kty: "oct",
+ kid: "1510726317123-IqQv4onc7VcVE1kTQkyyOw",
+ k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
+ });
+});
+
+add_task(async function test_derive_multiple_keys_at_once() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "eaf9570b7219a4187d3d6bf3cec2770c2e0719b7cc0dfbb38243d6f1881675e9";
+ const scopedKeysMetadata = {
+ app_key: {
+ identifier: "app_key:https%3A//example.com",
+ keyRotationTimestamp: 1510726317000,
+ keyRotationSecret:
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ },
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationTimestamp: 1510726318123,
+ keyRotationSecret:
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ },
+ };
+
+ const scopedKeys = await keys._deriveScopedKeys(
+ uid,
+ CommonUtils.hexToBytes(kB),
+ scopedKeysMetadata
+ );
+
+ Assert.deepEqual(scopedKeys, {
+ app_key: {
+ kty: "oct",
+ kid: "1510726317-tUkxiR1lTlFrTgkF0tJidA",
+ k: "TYK6Hmj86PfKiqsk9DZmX61nxk9VsExGrwo94HP-0wU",
+ },
+ "https://identity.mozilla.com/apps/oldsync": {
+ kty: "oct",
+ kid: "1510726318123-IqQv4onc7VcVE1kTQkyyOw",
+ k: "DW_ll5GwX6SJ5GPqJVAuMUP2t6kDqhUulc2cbt26xbTcaKGQl-9l29FHAQ7kUiJETma4s9fIpEHrt909zgFang",
+ },
+ });
+});
+
+add_task(async function test_rejects_bad_scoped_key_data() {
+ const keys = new FxAccountsKeys(null);
+ const uid = "aeaa1725c7a24ff983c6295725d5fc9b";
+ const kB = "8b2e1303e21eee06a945683b8d495b9bf079ca30baa37eb8392d9ffa4767be45";
+ const scopedKeyMetadata = {
+ identifier: "app_key:https%3A//example.com",
+ keyRotationTimestamp: 1510726317000,
+ keyRotationSecret:
+ "517d478cb4f994aa69930416648a416fdaa1762c5abf401a2acf11a0f185e98d",
+ };
+
+ await Assert.rejects(
+ keys._deriveScopedKey(
+ uid.slice(0, -1),
+ CommonUtils.hexToBytes(kB),
+ "app_key",
+ scopedKeyMetadata
+ ),
+ /uid must be a 32-character hex string/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(
+ uid.slice(0, -1) + "Q",
+ CommonUtils.hexToBytes(kB),
+ "app_key",
+ scopedKeyMetadata
+ ),
+ /uid must be a 32-character hex string/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(
+ uid,
+ CommonUtils.hexToBytes(kB).slice(0, -1),
+ "app_key",
+ scopedKeyMetadata
+ ),
+ /kBbytes must be exactly 32 bytes/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ identifier: "foo",
+ }),
+ /identifier must be a string of length >= 10/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ identifier: {},
+ }),
+ /identifier must be a string of length >= 10/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationTimestamp: "xyz",
+ }),
+ /keyRotationTimestamp must be a number/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationTimestamp: 12345,
+ }),
+ /keyRotationTimestamp must round to a 10-digit number/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationSecret: scopedKeyMetadata.keyRotationSecret.slice(0, -1),
+ }),
+ /keyRotationSecret must be a 64-character hex string/
+ );
+ await Assert.rejects(
+ keys._deriveScopedKey(uid, CommonUtils.hexToBytes(kB), "app_key", {
+ ...scopedKeyMetadata,
+ keyRotationSecret: scopedKeyMetadata.keyRotationSecret.slice(0, -1) + "z",
+ }),
+ /keyRotationSecret must be a 64-character hex string/
+ );
+});
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 0000000000..5b80035418
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_loginmgr_storage.js
@@ -0,0 +1,307 @@
+/* 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.
+
+// See verbose logging from FxAccounts.jsm
+Services.prefs.setStringPref("identity.fxaccounts.loglevel", "Trace");
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FXA_PWDMGR_HOST, FXA_PWDMGR_REALM } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+// Use a backstage pass to get at our LoginManagerStorage object, so we can
+// mock the prototype.
+var { LoginManagerStorage } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsStorage.sys.mjs"
+);
+var isLoggedIn = true;
+LoginManagerStorage.prototype.__defineGetter__("_isLoggedIn", () => isLoggedIn);
+
+function setLoginMgrLoggedInState(loggedIn) {
+ isLoggedIn = loggedIn;
+}
+
+initTestLogging("Trace");
+
+async function getLoginMgrData() {
+ let logins = await Services.logins.searchLoginsAsync({
+ origin: FXA_PWDMGR_HOST,
+ httpRealm: FXA_PWDMGR_REALM,
+ });
+ if (!logins.length) {
+ return null;
+ }
+ Assert.equal(logins.length, 1, "only 1 login available");
+ return logins[0];
+}
+
+function createFxAccounts() {
+ return new FxAccounts({
+ _fxAccountsClient: {
+ async registerDevice() {
+ return { id: "deviceAAAAAA" };
+ },
+ async recoveryEmailStatus() {
+ return { verified: true };
+ },
+ async signOut() {},
+ },
+ updateDeviceRegistration() {},
+ _getDeviceName() {
+ return "mock device name";
+ },
+ observerPreloads: [],
+ fxaPushService: {
+ async registerPushEndpoint() {
+ return {
+ endpoint: "http://mochi.test:8888",
+ getKey() {
+ return null;
+ },
+ };
+ },
+ async unsubscribe() {
+ return true;
+ },
+ },
+ });
+}
+
+add_task(async function test_simple() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ scopedKeys: {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ },
+ verified: true,
+ };
+ await fxa._internal.setSignedInUser(creds);
+
+ // This should have stored stuff in both the .json file in the profile
+ // dir, and the login dir.
+ let path = PathUtils.join(PathUtils.profileDir, "signedInUser.json");
+ let data = await IOUtils.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(
+ !("scopedKeys" in data.accountData),
+ "scopedKeys not stored in clear text"
+ );
+
+ let login = await 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.deepEqual(
+ loginData.accountData.scopedKeys,
+ creds.scopedKeys,
+ "correct scoped keys 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"
+ );
+
+ await fxa.signOut(/* localOnly = */ true);
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "login mgr data deleted on logout"
+ );
+});
+
+add_task(async function test_MPLocked() {
+ let fxa = createFxAccounts();
+
+ let creds = {
+ uid: "abcd",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ scopedKeys: {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ },
+ verified: true,
+ };
+
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "no login mgr at the start"
+ );
+ // tell the storage that the MP is locked.
+ setLoginMgrLoggedInState(false);
+ await fxa._internal.setSignedInUser(creds);
+
+ // This should have stored stuff in the .json, and the login manager stuff
+ // will not exist.
+ let path = PathUtils.join(PathUtils.profileDir, "signedInUser.json");
+ let data = await IOUtils.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(
+ !("scopedKeys" in data.accountData),
+ "scopedKeys not stored in clear text"
+ );
+
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "login mgr data doesn't exist"
+ );
+ await fxa.signOut(/* localOnly = */ true);
+});
+
+add_task(async function test_consistentWithMPEdgeCases() {
+ setLoginMgrLoggedInState(true);
+
+ let fxa = createFxAccounts();
+
+ let creds1 = {
+ uid: "uid1",
+ email: "test@example.com",
+ sessionToken: "sessionToken",
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id 1",
+ k: "key material 1",
+ kty: "oct",
+ },
+ },
+ verified: true,
+ };
+
+ let creds2 = {
+ uid: "uid2",
+ email: "test2@example.com",
+ sessionToken: "sessionToken2",
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id 2",
+ k: "key material 2",
+ kty: "oct",
+ },
+ verified: false,
+ };
+
+ // Log a user in while MP is unlocked.
+ await fxa._internal.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.
+ await fxa._internal.setSignedInUser(creds2);
+
+ // We should still have creds1 data in the login manager.
+ let login = await getLoginMgrData();
+ Assert.strictEqual(login.username, creds1.uid);
+ // and that we do have the first scopedKeys in the login manager.
+ Assert.deepEqual(
+ JSON.parse(login.password).accountData.scopedKeys,
+ creds1.scopedKeys,
+ "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 = await fxa.getSignedInUser();
+ Assert.strictEqual(accountData.email, creds2.email);
+ // we should have no scopedKeys at all.
+ Assert.strictEqual(
+ accountData.scopedKeys,
+ undefined,
+ "stale scopedKey wasn't used"
+ );
+ await 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(async function test_uidMigration() {
+ setLoginMgrLoggedInState(true);
+ Assert.strictEqual(
+ await getLoginMgrData(),
+ null,
+ "expect no logins at the start"
+ );
+
+ // create the login entry using email as a key.
+ let contents = {
+ scopedKeys: {
+ ...MOCK_ACCOUNT_KEYS.scopedKeys,
+ },
+ };
+
+ let loginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+ );
+ let login = new loginInfo(
+ FXA_PWDMGR_HOST,
+ null, // aFormActionOrigin,
+ FXA_PWDMGR_REALM, // aHttpRealm,
+ "foo@bar.com", // aUsername
+ JSON.stringify(contents), // aPassword
+ "", // aUsernameField
+ ""
+ ); // aPasswordField
+ await Services.logins.addLoginAsync(login);
+
+ // ensure we read it.
+ let storage = new LoginManagerStorage();
+ let got = await storage.get("uid", "foo@bar.com");
+ Assert.deepEqual(got, contents);
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_oauth_flow.js b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
new file mode 100644
index 0000000000..ef5102ae17
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_flow.js
@@ -0,0 +1,274 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global crypto */
+
+"use strict";
+
+const {
+ FxAccountsOAuth,
+ ERROR_INVALID_SCOPES,
+ ERROR_INVALID_STATE,
+ ERROR_SYNC_SCOPE_NOT_GRANTED,
+ ERROR_NO_KEYS_JWE,
+ ERROR_OAUTH_FLOW_ABANDONED,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsOAuth.sys.mjs"
+);
+
+const { SCOPE_PROFILE, FX_OAUTH_CLIENT_ID } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
+});
+
+initTestLogging("Trace");
+
+add_task(function test_begin_oauth_flow() {
+ const oauth = new FxAccountsOAuth();
+ add_task(async function test_begin_oauth_flow_invalid_scopes() {
+ try {
+ await oauth.beginOAuthFlow("foo,fi,fum", "foo");
+ Assert.fail("Should have thrown error, scopes must be an array");
+ } catch (e) {
+ Assert.equal(e.message, ERROR_INVALID_SCOPES);
+ }
+ try {
+ await oauth.beginOAuthFlow(["not-a-real-scope", SCOPE_PROFILE]);
+ Assert.fail("Should have thrown an error, must use a valid scope");
+ } catch (e) {
+ Assert.equal(e.message, ERROR_INVALID_SCOPES);
+ }
+ });
+ add_task(async function test_begin_oauth_flow_ok() {
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+
+ // First verify default query parameters
+ Assert.equal(queryParams.client_id, FX_OAUTH_CLIENT_ID);
+ Assert.equal(queryParams.action, "email");
+ Assert.equal(queryParams.response_type, "code");
+ Assert.equal(queryParams.access_type, "offline");
+ Assert.equal(queryParams.scope, [SCOPE_PROFILE, SCOPE_OLD_SYNC].join(" "));
+
+ // Then, we verify that the state is a valid Base64 value
+ const state = queryParams.state;
+ ChromeUtils.base64URLDecode(state, { padding: "reject" });
+
+ // Then, we verify that the codeVerifier, can be used to verify the code_challenge
+ const code_challenge = queryParams.code_challenge;
+ Assert.equal(queryParams.code_challenge_method, "S256");
+ const oauthFlow = oauth.getFlow(state);
+ const codeVerifierB64 = oauthFlow.verifier;
+ const expectedChallenge = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(codeVerifierB64)
+ );
+ const expectedChallengeB64 = ChromeUtils.base64URLEncode(
+ expectedChallenge,
+ { pad: false }
+ );
+ Assert.equal(expectedChallengeB64, code_challenge);
+
+ // Then, we verify that something encrypted with the `keys_jwk`, can be decrypted using the private key
+ const keysJwk = queryParams.keys_jwk;
+ const decodedKeysJwk = JSON.parse(
+ new TextDecoder().decode(
+ ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
+ )
+ );
+ const plaintext = "text to be encrypted and decrypted!";
+ delete decodedKeysJwk.key_ops;
+ const jwe = await jwcrypto.generateJWE(
+ decodedKeysJwk,
+ new TextEncoder().encode(plaintext)
+ );
+ const privateKey = oauthFlow.key;
+ const decrypted = await jwcrypto.decryptJWE(jwe, privateKey);
+ Assert.equal(new TextDecoder().decode(decrypted), plaintext);
+
+ // Finally, we verify that we stored the requested scopes
+ Assert.deepEqual(oauthFlow.requestedScopes, scopes.join(" "));
+ });
+});
+
+add_task(function test_complete_oauth_flow() {
+ add_task(async function test_invalid_state() {
+ const oauth = new FxAccountsOAuth();
+ const code = "foo";
+ const state = "bar";
+ const sessionToken = "01abcef12";
+ try {
+ await oauth.completeOAuthFlow(sessionToken, code, state);
+ Assert.fail("Should have thrown an error");
+ } catch (err) {
+ Assert.equal(err.message, ERROR_INVALID_STATE);
+ }
+ });
+ add_task(async function test_sync_scope_not_authorized() {
+ const fxaClient = {
+ oauthToken: () =>
+ Promise.resolve({
+ access_token: "access_token",
+ refresh_token: "refresh_token",
+ // Note that the scope does not include the sync scope
+ scope: SCOPE_PROFILE,
+ }),
+ };
+ const oauth = new FxAccountsOAuth(fxaClient);
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const sessionToken = "01abcef12";
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+ try {
+ await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
+ Assert.fail(
+ "Should have thrown an error because the sync scope was not authorized"
+ );
+ } catch (err) {
+ Assert.equal(err.message, ERROR_SYNC_SCOPE_NOT_GRANTED);
+ }
+ });
+ add_task(async function test_jwe_not_returned() {
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const fxaClient = {
+ oauthToken: () =>
+ Promise.resolve({
+ access_token: "access_token",
+ refresh_token: "refresh_token",
+ scope: scopes.join(" "),
+ }),
+ };
+ const oauth = new FxAccountsOAuth(fxaClient);
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+ const sessionToken = "01abcef12";
+ try {
+ await oauth.completeOAuthFlow(sessionToken, "foo", queryParams.state);
+ Assert.fail(
+ "Should have thrown an error because we didn't get back a keys_nwe"
+ );
+ } catch (err) {
+ Assert.equal(err.message, ERROR_NO_KEYS_JWE);
+ }
+ });
+ add_task(async function test_complete_oauth_ok() {
+ // First, we initialize some fake values we would typically get
+ // from outside our system
+ const scopes = [SCOPE_PROFILE, SCOPE_OLD_SYNC];
+ const oauthCode = "fake oauth code";
+ const sessionToken = "01abcef12";
+ const plainTextScopedKeys = {
+ kid: "fake key id",
+ k: "fake key",
+ kty: "oct",
+ };
+ const fakeAccessToken = "fake access token";
+ const fakeRefreshToken = "fake refresh token";
+ // Then, we initialize a fake http client, we'll add our fake oauthToken call
+ // once we have started the oauth flow (so we have the public keys!)
+ const fxaClient = {};
+ // Then, we initialize our oauth object with the given client and begin a new flow
+ const oauth = new FxAccountsOAuth(fxaClient);
+ const queryParams = await oauth.beginOAuthFlow(scopes);
+ // Now that we have the public keys in `keys_jwk`, we use it to generate a JWE
+ // representing our scoped keys
+ const keysJwk = queryParams.keys_jwk;
+ const decodedKeysJwk = JSON.parse(
+ new TextDecoder().decode(
+ ChromeUtils.base64URLDecode(keysJwk, { padding: "reject" })
+ )
+ );
+ delete decodedKeysJwk.key_ops;
+ const jwe = await jwcrypto.generateJWE(
+ decodedKeysJwk,
+ new TextEncoder().encode(JSON.stringify(plainTextScopedKeys))
+ );
+ // We also grab the stored PKCE verifier that the oauth object stored internally
+ // to verify that we correctly send it as a part of our HTTP request
+ const storedVerifier = oauth.getFlow(queryParams.state).verifier;
+
+ // To test what happens when more than one flow is completed simulatniously
+ // We mimic a slow network call on the first oauthToken call and let the second
+ // one win
+ let callCount = 0;
+ let slowResolve;
+ const resolveFn = (payload, resolve) => {
+ if (callCount === 1) {
+ // This is the second call
+ // lets resolve it so the second call wins
+ resolve(payload);
+ } else {
+ callCount += 1;
+ // This is the first call, let store our resolve function for later
+ // it will be resolved once the fast flow is fully completed
+ slowResolve = () => resolve(payload);
+ }
+ };
+
+ // Now we initialize our mock of the HTTP request, it verifies we passed in all the correct
+ // parameters and returns what we'd expect a healthy HTTP Response would look like
+ fxaClient.oauthToken = (sessionTokenHex, code, verifier, clientId) => {
+ Assert.equal(sessionTokenHex, sessionToken);
+ Assert.equal(code, oauthCode);
+ Assert.equal(verifier, storedVerifier);
+ Assert.equal(clientId, queryParams.client_id);
+ const response = {
+ access_token: fakeAccessToken,
+ refresh_token: fakeRefreshToken,
+ scope: scopes.join(" "),
+ keys_jwe: jwe,
+ };
+ return new Promise(resolve => {
+ resolveFn(response, resolve);
+ });
+ };
+
+ // Then, we call the completeOAuthFlow function, and get back our access token,
+ // refresh token and scopedKeys
+
+ // To test what happens when multiple flows race, we create two flows,
+ // A slow one that will start first, but finish last
+ // And a fast one that will beat the slow one
+ const firstCompleteOAuthFlow = oauth
+ .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
+ .then(res => {
+ // To mimic the slow network connection on the slowCompleteOAuthFlow
+ // We resume the slow completeOAuthFlow once this one is complete
+ slowResolve();
+ return res;
+ });
+ const secondCompleteOAuthFlow = oauth
+ .completeOAuthFlow(sessionToken, oauthCode, queryParams.state)
+ .then(res => {
+ // since we can't fully gaurentee which oauth flow finishes first, we also resolve here
+ slowResolve();
+ return res;
+ });
+
+ const { accessToken, refreshToken, scopedKeys } = await Promise.allSettled([
+ firstCompleteOAuthFlow,
+ secondCompleteOAuthFlow,
+ ]).then(results => {
+ let fast;
+ let slow;
+ for (const result of results) {
+ if (result.status === "fulfilled") {
+ fast = result.value;
+ } else {
+ slow = result.reason;
+ }
+ }
+ // We make sure that we indeed have one slow flow that lost
+ Assert.equal(slow.message, ERROR_OAUTH_FLOW_ABANDONED);
+ return fast;
+ });
+
+ Assert.equal(accessToken, fakeAccessToken);
+ Assert.equal(refreshToken, fakeRefreshToken);
+ Assert.deepEqual(scopedKeys, plainTextScopedKeys);
+
+ // Finally, we verify that all stored flows were cleared
+ Assert.equal(oauth.numOfFlows(), 0);
+ });
+});
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 0000000000..798c439212
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_token_storage.js
@@ -0,0 +1,180 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+
+// We grab some additional stuff via backstage passes.
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ };
+ Services.obs.addObserver(observe, topic);
+ });
+}
+
+// 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) {
+ return Promise.resolve(!!uid && !this._deletedOnServer);
+ };
+
+ 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 = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ 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",
+ });
+ });
+ },
+ },
+ });
+}
+
+async function createMockFxA() {
+ let fxa = new MockFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id for sync key",
+ k: "key material for sync key",
+ kty: "oct",
+ },
+ },
+ verified: true,
+ };
+ await fxa._internal.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+
+add_task(async function testCacheStorage() {
+ let fxa = await 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");
+ });
+ };
+
+ 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.
+ await 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");
+ await cas.removeCachedToken("token1");
+ deepEqual(cas.oauthTokens, {});
+ await 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.
+ await 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 0000000000..82f174edd1
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_oauth_tokens.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const { FxAccountsClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsClient.sys.mjs"
+);
+var { AccountState } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+
+function promiseNotification(topic) {
+ return new Promise(resolve => {
+ let observe = () => {
+ Services.obs.removeObserver(observe, topic);
+ resolve();
+ };
+ Services.obs.addObserver(observe, topic);
+ });
+}
+
+// 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(activeTokens) {
+ this._email = "nobody@example.com";
+ this._verified = false;
+
+ this.accountStatus = function (uid) {
+ return Promise.resolve(!!uid && !this._deletedOnServer);
+ };
+
+ 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();
+ };
+ this.accessTokenWithSessionToken = function (
+ sessionTokenHex,
+ clientId,
+ scope,
+ ttl
+ ) {
+ let token = `token${this.numTokenFetches}`;
+ if (ttl) {
+ token += `-ttl-${ttl}`;
+ }
+ this.numTokenFetches += 1;
+ this.activeTokens.add(token);
+ print("accessTokenWithSessionToken returning token", token);
+ return Promise.resolve({ access_token: token, ttl });
+ };
+ this.oauthDestroy = sinon.stub().callsFake((_clientId, token) => {
+ this.activeTokens.delete(token);
+ return Promise.resolve();
+ });
+
+ // Test only stuff.
+ this.activeTokens = activeTokens;
+ this.numTokenFetches = 0;
+
+ FxAccountsClient.apply(this);
+}
+
+MockFxAccountsClient.prototype = {};
+Object.setPrototypeOf(
+ MockFxAccountsClient.prototype,
+ FxAccountsClient.prototype
+);
+
+function MockFxAccounts() {
+ // The FxA "auth" and "oauth" servers both share the same db of tokens,
+ // so we need to simulate the same here in the tests.
+ const activeTokens = new Set();
+ return new FxAccounts({
+ fxAccountsClient: new MockFxAccountsClient(activeTokens),
+ 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",
+ });
+ });
+ },
+ },
+ });
+}
+
+async function createMockFxA() {
+ let fxa = new MockFxAccounts();
+ let credentials = {
+ email: "foo@example.com",
+ uid: "1234@lcip.org",
+ sessionToken: "dead",
+ scopedKeys: {
+ [SCOPE_OLD_SYNC]: {
+ kid: "key id for sync key",
+ k: "key material for sync key",
+ kty: "oct",
+ },
+ },
+ verified: true,
+ };
+
+ await fxa._internal.setSignedInUser(credentials);
+ return fxa;
+}
+
+// The tests.
+
+add_task(async function testRevoke() {
+ let tokenOptions = { scope: "test-scope" };
+ let fxa = await createMockFxA();
+ let client = fxa._internal.fxAccountsClient;
+
+ // get our first token and check we hit the mock.
+ let token1 = await 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.
+ await fxa.removeCachedOAuthToken({ token: token1 });
+ ok(client.oauthDestroy.calledOnce);
+
+ // the revoke should have been successful.
+ equal(client.activeTokens.size, 0);
+ // fetching it again hits the server.
+ let token2 = await 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(async function testSignOutDestroysTokens() {
+ let fxa = await createMockFxA();
+ let client = fxa._internal.fxAccountsClient;
+
+ // get our first token and check we hit the mock.
+ let token1 = await fxa.getOAuthToken({ scope: "test-scope" });
+ equal(client.numTokenFetches, 1);
+ equal(client.activeTokens.size, 1);
+ ok(token1, "got a token");
+
+ // get another
+ let token2 = await fxa.getOAuthToken({ scope: "test-scope-2" });
+ equal(client.numTokenFetches, 2);
+ equal(client.activeTokens.size, 2);
+ ok(token2, "got a token");
+ notEqual(token1, token2, "got a different token");
+
+ // FxA fires an observer when the "background" signout is complete.
+ let signoutComplete = promiseNotification("testhelper-fxa-signout-complete");
+ // now sign out - they should be removed.
+ await fxa.signOut();
+ await signoutComplete;
+ ok(client.oauthDestroy.calledTwice);
+ // No active tokens left.
+ equal(client.activeTokens.size, 0);
+});
+
+add_task(async 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 use
+ // a map of in-flight token fetches, so we should still only perform 2
+ // fetches, but each of the 4 calls should resolve with the correct values.
+ let fxa = await createMockFxA();
+ let client = fxa._internal.fxAccountsClient;
+
+ let results = await Promise.all([
+ fxa.getOAuthToken({ scope: "test-scope" }),
+ fxa.getOAuthToken({ scope: "test-scope" }),
+ fxa.getOAuthToken({ scope: "test-scope-2" }),
+ fxa.getOAuthToken({ scope: "test-scope-2" }),
+ ]);
+
+ equal(client.numTokenFetches, 2, "should have fetched 2 tokens.");
+
+ // 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);
+ await fxa.removeCachedOAuthToken({ token: results[0] });
+ equal(client.activeTokens.size, 1);
+ await fxa.removeCachedOAuthToken({ token: results[2] });
+ equal(client.activeTokens.size, 0);
+ ok(client.oauthDestroy.calledTwice);
+});
+
+add_task(async function testTokenTTL() {
+ // This tests the TTL option passed into the method
+ let fxa = await createMockFxA();
+ let token = await fxa.getOAuthToken({ scope: "test-ttl", ttl: 1000 });
+ equal(token, "token0-ttl-1000");
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_pairing.js b/services/fxaccounts/tests/xpcshell/test_pairing.js
new file mode 100644
index 0000000000..eac3112242
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_pairing.js
@@ -0,0 +1,384 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { FxAccountsPairingFlow } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsPairing.sys.mjs"
+);
+const { EventEmitter } = ChromeUtils.importESModule(
+ "resource://gre/modules/EventEmitter.sys.mjs"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ jwcrypto: "resource://services-crypto/jwcrypto.sys.mjs",
+});
+
+const CHANNEL_ID = "sW-UA97Q6Dljqen7XRlYPw";
+const CHANNEL_KEY = crypto.getRandomValues(new Uint8Array(32));
+
+const SENDER_SUPP = {
+ ua: "Firefox Supp",
+ city: "Nice",
+ region: "PACA",
+ country: "France",
+ remote: "127.0.0.1",
+};
+const UID = "abcd";
+const EMAIL = "foo@bar.com";
+const AVATAR = "https://foo.bar/avatar";
+const DISPLAY_NAME = "Foo bar";
+const DEVICE_NAME = "Foo's computer";
+
+const PAIR_URI = "https://foo.bar/pair";
+const OAUTH_URI = "https://foo.bar/oauth";
+const KSYNC = "myksync";
+const SESSION = "mysession";
+const fxaConfig = {
+ promisePairingURI() {
+ return PAIR_URI;
+ },
+ promiseOAuthURI() {
+ return OAUTH_URI;
+ },
+};
+const fxAccounts = {
+ getSignedInUser() {
+ return {
+ uid: UID,
+ email: EMAIL,
+ avatar: AVATAR,
+ displayName: DISPLAY_NAME,
+ };
+ },
+ async _withVerifiedAccountState(cb) {
+ return cb({
+ async getUserAccountData() {
+ return {
+ sessionToken: SESSION,
+ };
+ },
+ });
+ },
+ _internal: {
+ keys: {
+ getKeyForScope(scope) {
+ return {
+ kid: "123456",
+ k: KSYNC,
+ kty: "oct",
+ };
+ },
+ },
+ fxAccountsClient: {
+ async getScopedKeyData() {
+ return {
+ "https://identity.mozilla.com/apps/oldsync": {
+ identifier: "https://identity.mozilla.com/apps/oldsync",
+ keyRotationTimestamp: 12345678,
+ },
+ };
+ },
+ async oauthAuthorize() {
+ return { code: "mycode", state: "mystate" };
+ },
+ },
+ },
+};
+const weave = {
+ Service: { clientsEngine: { localName: DEVICE_NAME } },
+};
+
+class MockPairingChannel extends EventTarget {
+ get channelId() {
+ return CHANNEL_ID;
+ }
+
+ get channelKey() {
+ return CHANNEL_KEY;
+ }
+
+ send(data) {
+ this.dispatchEvent(
+ new CustomEvent("send", {
+ detail: { data },
+ })
+ );
+ }
+
+ simulateIncoming(data) {
+ this.dispatchEvent(
+ new CustomEvent("message", {
+ detail: { data, sender: SENDER_SUPP },
+ })
+ );
+ }
+
+ close() {
+ this.closed = true;
+ }
+}
+
+add_task(async function testFullFlow() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ const pairingUri = await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ Assert.equal(
+ pairingUri,
+ `${PAIR_URI}#channel_id=${CHANNEL_ID}&channel_key=${ChromeUtils.base64URLEncode(
+ CHANNEL_KEY,
+ { pad: false }
+ )}`
+ );
+
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+
+ const promiseSwitchToWebContent = emitter.once("view:SwitchToWebContent");
+ const promiseMetadataSent = promiseOutgoingMessage(pairingChannel);
+ const epk = await generateEphemeralKeypair();
+
+ pairingChannel.simulateIncoming({
+ message: "pair:supp:request",
+ data: {
+ client_id: "client_id_1",
+ state: "mystate",
+ keys_jwk: ChromeUtils.base64URLEncode(
+ new TextEncoder().encode(JSON.stringify(epk.publicJWK)),
+ { pad: false }
+ ),
+ scope: "profile https://identity.mozilla.com/apps/oldsync",
+ code_challenge: "chal",
+ code_challenge_method: "S256",
+ },
+ });
+ const sentAuthMetadata = await promiseMetadataSent;
+ Assert.deepEqual(sentAuthMetadata, {
+ message: "pair:auth:metadata",
+ data: {
+ email: EMAIL,
+ avatar: AVATAR,
+ displayName: DISPLAY_NAME,
+ deviceName: DEVICE_NAME,
+ },
+ });
+ const oauthUrl = await promiseSwitchToWebContent;
+ Assert.equal(
+ oauthUrl,
+ `${OAUTH_URI}?client_id=client_id_1&scope=profile+https%3A%2F%2Fidentity.mozilla.com%2Fapps%2Foldsync&email=foo%40bar.com&uid=abcd&channel_id=${CHANNEL_ID}&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob%3Apair-auth-webchannel`
+ );
+
+ let pairSuppMetadata = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_supplicant_metadata"
+ );
+ Assert.deepEqual(
+ {
+ ua: "Firefox Supp",
+ city: "Nice",
+ region: "PACA",
+ country: "France",
+ ipAddress: "127.0.0.1",
+ },
+ pairSuppMetadata
+ );
+
+ const generateJWE = sinon.spy(jwcrypto, "generateJWE");
+ const oauthAuthorize = sinon.spy(
+ fxAccounts._internal.fxAccountsClient,
+ "oauthAuthorize"
+ );
+ const promiseOAuthParamsMsg = promiseOutgoingMessage(pairingChannel);
+ await simulateIncomingWebChannel(flow, "fxaccounts:pair_authorize");
+ // We should have generated the expected JWE.
+ Assert.ok(generateJWE.calledOnce);
+ const generateArgs = generateJWE.firstCall.args;
+ Assert.deepEqual(generateArgs[0], epk.publicJWK);
+ Assert.deepEqual(JSON.parse(new TextDecoder().decode(generateArgs[1])), {
+ "https://identity.mozilla.com/apps/oldsync": {
+ kid: "123456",
+ k: KSYNC,
+ kty: "oct",
+ },
+ });
+ // We should have authorized an oauth code with expected parameters.
+ Assert.ok(oauthAuthorize.calledOnce);
+ const oauthCodeArgs = oauthAuthorize.firstCall.args[1];
+ console.log(oauthCodeArgs);
+ Assert.ok(!oauthCodeArgs.keys_jwk);
+ Assert.deepEqual(
+ oauthCodeArgs.keys_jwe,
+ await generateJWE.firstCall.returnValue
+ );
+ Assert.equal(oauthCodeArgs.client_id, "client_id_1");
+ Assert.equal(oauthCodeArgs.access_type, "offline");
+ Assert.equal(oauthCodeArgs.state, "mystate");
+ Assert.equal(
+ oauthCodeArgs.scope,
+ "profile https://identity.mozilla.com/apps/oldsync"
+ );
+ Assert.equal(oauthCodeArgs.code_challenge, "chal");
+ Assert.equal(oauthCodeArgs.code_challenge_method, "S256");
+
+ const oAuthParams = await promiseOAuthParamsMsg;
+ Assert.deepEqual(oAuthParams, {
+ message: "pair:auth:authorize",
+ data: { code: "mycode", state: "mystate" },
+ });
+
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(!heartbeat.suppAuthorized);
+
+ await pairingChannel.simulateIncoming({
+ message: "pair:supp:authorize",
+ });
+
+ heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.suppAuthorized);
+
+ await simulateIncomingWebChannel(flow, "fxaccounts:pair_complete");
+ // The flow should have been destroyed!
+ Assert.ok(!FxAccountsPairingFlow.get(CHANNEL_ID));
+ Assert.ok(pairingChannel.closed);
+ generateJWE.restore();
+ oauthAuthorize.restore();
+});
+
+add_task(async function testUnknownPairingMessage() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ const viewErrorObserved = emitter.once("view:Error");
+ pairingChannel.simulateIncoming({
+ message: "pair:boom",
+ });
+ await viewErrorObserved;
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err);
+});
+
+add_task(async function testUnknownWebChannelCommand() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ const viewErrorObserved = emitter.once("view:Error");
+ await simulateIncomingWebChannel(flow, "fxaccounts:boom");
+ await viewErrorObserved;
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err);
+});
+
+add_task(async function testPairingChannelFailure() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ const viewErrorObserved = emitter.once("view:Error");
+ sinon.stub(pairingChannel, "send").callsFake(() => {
+ throw new Error("Boom!");
+ });
+ pairingChannel.simulateIncoming({
+ message: "pair:supp:request",
+ data: {
+ client_id: "client_id_1",
+ state: "mystate",
+ scope: "profile https://identity.mozilla.com/apps/oldsync",
+ code_challenge: "chal",
+ code_challenge_method: "S256",
+ },
+ });
+ await viewErrorObserved;
+
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err);
+});
+
+add_task(async function testFlowTimeout() {
+ const emitter = new EventEmitter();
+ const pairingChannel = new MockPairingChannel();
+ const viewErrorObserved = emitter.once("view:Error");
+ await FxAccountsPairingFlow.start({
+ emitter,
+ pairingChannel,
+ fxAccounts,
+ fxaConfig,
+ weave,
+ flowTimeout: 1,
+ });
+ const flow = FxAccountsPairingFlow.get(CHANNEL_ID);
+ await viewErrorObserved;
+
+ let heartbeat = await simulateIncomingWebChannel(
+ flow,
+ "fxaccounts:pair_heartbeat"
+ );
+ Assert.ok(heartbeat.err.match(/Timeout/));
+});
+
+async function simulateIncomingWebChannel(flow, command) {
+ return flow.onWebChannelMessage(command);
+}
+
+async function promiseOutgoingMessage(pairingChannel) {
+ return new Promise(res => {
+ const onMessage = event => {
+ pairingChannel.removeEventListener("send", onMessage);
+ res(event.detail.data);
+ };
+ pairingChannel.addEventListener("send", onMessage);
+ });
+}
+
+async function generateEphemeralKeypair() {
+ const keypair = await crypto.subtle.generateKey(
+ { name: "ECDH", namedCurve: "P-256" },
+ true,
+ ["deriveKey"]
+ );
+ const publicJWK = await crypto.subtle.exportKey("jwk", keypair.publicKey);
+ const privateJWK = await crypto.subtle.exportKey("jwk", keypair.privateKey);
+ delete publicJWK.key_ops;
+ return {
+ publicJWK,
+ privateJWK,
+ };
+}
diff --git a/services/fxaccounts/tests/xpcshell/test_profile.js b/services/fxaccounts/tests/xpcshell/test_profile.js
new file mode 100644
index 0000000000..f8137b5691
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile.js
@@ -0,0 +1,677 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ON_PROFILE_CHANGE_NOTIFICATION, log } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+const { FxAccountsProfileClient } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsProfileClient.sys.mjs"
+);
+const { FxAccountsProfile } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsProfile.sys.mjs"
+);
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+let mockClient = function (fxa) {
+ let options = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa,
+ };
+ return new FxAccountsProfileClient(options);
+};
+
+const ACCOUNT_UID = "abc123";
+const ACCOUNT_EMAIL = "foo@bar.com";
+const ACCOUNT_DATA = {
+ uid: ACCOUNT_UID,
+ email: ACCOUNT_EMAIL,
+};
+
+let mockFxa = function () {
+ let fxa = {
+ // helpers to make the tests using this mock less verbose...
+ set _testProfileCache(profileCache) {
+ this._internal.currentAccountState._data.profileCache = profileCache;
+ },
+ get _testProfileCache() {
+ return this._internal.currentAccountState._data.profileCache;
+ },
+ };
+ fxa._internal = Object.assign(
+ {},
+ {
+ currentAccountState: Object.assign(
+ {},
+ {
+ _data: Object.assign({}, ACCOUNT_DATA),
+
+ get isCurrent() {
+ return true;
+ },
+
+ async getUserAccountData() {
+ return this._data;
+ },
+
+ async updateUserAccountData(data) {
+ this._data = Object.assign(this._data, data);
+ },
+ }
+ ),
+
+ withCurrentAccountState(cb) {
+ return cb(this.currentAccountState);
+ },
+
+ async _handleTokenError(err) {
+ // handleTokenError always rethrows.
+ throw err;
+ },
+ }
+ );
+ return fxa;
+};
+
+function CreateFxAccountsProfile(fxa = null, client = null) {
+ if (!fxa) {
+ fxa = mockFxa();
+ }
+ let options = {
+ fxai: fxa._internal,
+ profileServerUrl: "http://127.0.0.1:1111/v1",
+ };
+ if (client) {
+ options.profileClient = client;
+ }
+ return new FxAccountsProfile(options);
+}
+
+add_test(function cacheProfile_change() {
+ let setProfileCacheCalled = false;
+ let fxa = mockFxa();
+ fxa._internal.currentAccountState.updateUserAccountData = data => {
+ setProfileCacheCalled = true;
+ Assert.equal(data.profileCache.profile.avatar, "myurl");
+ Assert.equal(data.profileCache.etag, "bogusetag");
+ return Promise.resolve();
+ };
+ let profile = CreateFxAccountsProfile(fxa);
+
+ makeObserver(ON_PROFILE_CHANGE_NOTIFICATION, function (subject, topic, data) {
+ Assert.equal(data, ACCOUNT_DATA.uid);
+ Assert.ok(setProfileCacheCalled);
+ run_next_test();
+ });
+
+ return profile._cacheProfile({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myurl" },
+ etag: "bogusetag",
+ });
+});
+
+add_test(function fetchAndCacheProfile_ok() {
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ return Promise.resolve({ body: { uid: ACCOUNT_UID, avatar: "myimg" } });
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile._cachedAt = 12345;
+
+ profile._cacheProfile = function (toCache) {
+ Assert.equal(toCache.body.avatar, "myimg");
+ return Promise.resolve(toCache.body);
+ };
+
+ return profile._fetchAndCacheProfile().then(result => {
+ Assert.equal(result.avatar, "myimg");
+ Assert.notEqual(profile._cachedAt, 12345, "cachedAt has been bumped");
+ run_next_test();
+ });
+});
+
+add_test(function fetchAndCacheProfile_always_bumps_cachedAt() {
+ let client = mockClient(mockFxa());
+ client.fetchProfile = function () {
+ return Promise.reject(new Error("oops"));
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile._cachedAt = 12345;
+
+ return profile._fetchAndCacheProfile().then(
+ result => {
+ do_throw("Should not succeed");
+ },
+ err => {
+ Assert.notEqual(profile._cachedAt, 12345, "cachedAt has been bumped");
+ run_next_test();
+ }
+ );
+});
+
+add_test(function fetchAndCacheProfile_sendsETag() {
+ let fxa = mockFxa();
+ fxa._testProfileCache = { profile: {}, etag: "bogusETag" };
+ let client = mockClient(fxa);
+ client.fetchProfile = function (etag) {
+ Assert.equal(etag, "bogusETag");
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ });
+ };
+ let profile = CreateFxAccountsProfile(fxa, client);
+
+ return profile._fetchAndCacheProfile().then(result => {
+ run_next_test();
+ });
+});
+
+// Check that a second profile request when one is already in-flight reuses
+// the in-flight one.
+add_task(async 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 fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ profile._fetchAndCacheProfile();
+ await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise)
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ Assert.equal(numFetches, 1);
+
+ // resolve the promise.
+ resolveProfile({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ });
+
+ // both requests should complete with the same data.
+ let got1 = await request1;
+ Assert.equal(got1.avatar, "myimg");
+ let got2 = await request1;
+ Assert.equal(got2.avatar, "myimg");
+
+ // and still only 1 request was made.
+ Assert.equal(numFetches, 1);
+});
+
+// Check that sharing a single fetch promise works correctly when the promise
+// is rejected.
+add_task(async 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 fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa, client);
+
+ let request1 = profile._fetchAndCacheProfile();
+ let request2 = profile._fetchAndCacheProfile();
+ await new Promise(res => setTimeout(res, 0)); // Yield so fetchProfile() is called (promise)
+
+ // should be one request made to fetch the profile (but the promise returned
+ // by it remains unresolved)
+ Assert.equal(numFetches, 1);
+
+ // reject the promise.
+ rejectProfile("oh noes");
+
+ // both requests should reject.
+ try {
+ await request1;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+ try {
+ await request2;
+ throw new Error("should have rejected");
+ } catch (ex) {
+ if (ex != "oh noes") {
+ throw ex;
+ }
+ }
+
+ // but a new request should works.
+ client.fetchProfile = function () {
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ });
+ };
+
+ let got = await profile._fetchAndCacheProfile();
+ Assert.equal(got.avatar, "myimg");
+});
+
+add_test(function fetchAndCacheProfile_alreadyCached() {
+ let cachedUrl = "cachedurl";
+ let fxa = mockFxa();
+ fxa._testProfileCache = {
+ profile: { uid: ACCOUNT_UID, avatar: cachedUrl },
+ etag: "bogusETag",
+ };
+ let client = mockClient(fxa);
+ client.fetchProfile = function (etag) {
+ Assert.equal(etag, "bogusETag");
+ return Promise.resolve(null);
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ profile._cacheProfile = function (toCache) {
+ do_throw("This method should not be called.");
+ };
+
+ return profile._fetchAndCacheProfile().then(result => {
+ Assert.equal(result, null);
+ Assert.equal(fxa._testProfileCache.profile.avatar, cachedUrl);
+ run_next_test();
+ });
+});
+
+// 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(async function fetchAndCacheProfileAfterThreshold() {
+ /*
+ * This test was observed to cause a timeout for... any timer precision reduction.
+ * Even 1 us. Exact reason is still undiagnosed.
+ */
+ Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false);
+
+ registerCleanupFunction(async () => {
+ Services.prefs.clearUserPref("privacy.reduceTimerPrecision");
+ });
+
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = async function () {
+ numFetches += 1;
+ return {
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ };
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ // first fetch should return null as we don't have data.
+ let p = await profile.getProfile();
+ Assert.equal(p, null);
+ // ensure we kicked off a fetch.
+ Assert.notEqual(profile._currentFetchPromise, null);
+ // wait for that fetch to finish
+ await profile._currentFetchPromise;
+ Assert.equal(numFetches, 1);
+ Assert.equal(profile._currentFetchPromise, null);
+
+ await profile.getProfile();
+ Assert.equal(numFetches, 1);
+ Assert.equal(profile._currentFetchPromise, null);
+
+ await new Promise(resolve => {
+ do_timeout(1000, resolve);
+ });
+
+ let origFetchAndCatch = profile._fetchAndCacheProfile;
+ let backgroundFetchDone = Promise.withResolvers();
+ profile._fetchAndCacheProfile = async () => {
+ await origFetchAndCatch.call(profile);
+ backgroundFetchDone.resolve();
+ };
+ await profile.getProfile();
+ await backgroundFetchDone.promise;
+ Assert.equal(numFetches, 2);
+});
+
+add_task(async function test_ensureProfile() {
+ let client = new FxAccountsProfileClient({
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxa: mockFxa(),
+ });
+ let profile = CreateFxAccountsProfile(null, client);
+
+ const testCases = [
+ // profile retrieval when there is no cached profile info
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: null,
+ fetchedProfile: {
+ uid: ACCOUNT_UID,
+ email: ACCOUNT_EMAIL,
+ avatar: "myimg",
+ },
+ },
+ // profile retrieval when the cached profile is recent
+ {
+ // Note: The threshold for this test case is being set to an arbitrary value that will
+ // be greater than Date.now() so the retrieved cached profile will be deemed recent.
+ threshold: Date.now() + 5000,
+ expectsCachedProfileReturned: true,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}2`,
+ email: `${ACCOUNT_EMAIL}2`,
+ avatar: "myimg2",
+ },
+ },
+ // profile retrieval when the cached profile is old and a new profile is fetched
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}3`,
+ email: `${ACCOUNT_EMAIL}3`,
+ avatar: "myimg3",
+ },
+ fetchAndCacheProfileResolves: true,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}4`,
+ email: `${ACCOUNT_EMAIL}4`,
+ avatar: "myimg4",
+ },
+ },
+ // profile retrieval when the cached profile is old and a null profile is fetched
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}5`,
+ email: `${ACCOUNT_EMAIL}5`,
+ avatar: "myimg5",
+ },
+ fetchAndCacheProfileResolves: true,
+ fetchedProfile: null,
+ },
+ // profile retrieval when the cached profile is old and fetching a new profile errors
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}6`,
+ email: `${ACCOUNT_EMAIL}6`,
+ avatar: "myimg6",
+ },
+ fetchAndCacheProfileResolves: false,
+ },
+ // profile retrieval when we've cached a failure to fetch profile data
+ {
+ // Note: The threshold for this test case is being set to an arbitrary value that will
+ // be greater than Date.now() so the retrieved cached profile will be deemed recent.
+ threshold: Date.now() + 5000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: null,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}7`,
+ email: `${ACCOUNT_EMAIL}7`,
+ avatar: "myimg7",
+ },
+ fetchAndCacheProfileResolves: true,
+ },
+ // profile retrieval when the cached profile is old but staleOk is true.
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: true,
+ cachedProfile: {
+ uid: `${ACCOUNT_UID}8`,
+ email: `${ACCOUNT_EMAIL}8`,
+ avatar: "myimg8",
+ },
+ fetchAndCacheProfileResolves: false,
+ options: { staleOk: true },
+ },
+ // staleOk but no cached profile
+ {
+ threshold: 1000,
+ expectsCachedProfileReturned: false,
+ cachedProfile: null,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}9`,
+ email: `${ACCOUNT_EMAIL}9`,
+ avatar: "myimg9",
+ },
+ options: { staleOk: true },
+ },
+ // fresh profile but forceFresh = true
+ {
+ // Note: The threshold for this test case is being set to an arbitrary value that will
+ // be greater than Date.now() so the retrieved cached profile will be deemed recent.
+ threshold: Date.now() + 5000,
+ expectsCachedProfileReturned: false,
+ fetchedProfile: {
+ uid: `${ACCOUNT_UID}10`,
+ email: `${ACCOUNT_EMAIL}10`,
+ avatar: "myimg10",
+ },
+ options: { forceFresh: true },
+ },
+ ];
+
+ for (const tc of testCases) {
+ print(`test case: ${JSON.stringify(tc)}`);
+ let mockProfile = sinon.mock(profile);
+ mockProfile
+ .expects("_getProfileCache")
+ .once()
+ .returns(
+ tc.cachedProfile
+ ? {
+ profile: tc.cachedProfile,
+ }
+ : null
+ );
+ profile.PROFILE_FRESHNESS_THRESHOLD = tc.threshold;
+
+ let options = tc.options || {};
+ if (tc.expectsCachedProfileReturned) {
+ mockProfile.expects("_fetchAndCacheProfile").never();
+ let actualProfile = await profile.ensureProfile(options);
+ mockProfile.verify();
+ Assert.equal(actualProfile, tc.cachedProfile);
+ } else if (tc.fetchAndCacheProfileResolves) {
+ mockProfile
+ .expects("_fetchAndCacheProfile")
+ .once()
+ .resolves(tc.fetchedProfile);
+
+ let actualProfile = await profile.ensureProfile(options);
+ let expectedProfile = tc.fetchedProfile
+ ? tc.fetchedProfile
+ : tc.cachedProfile;
+ mockProfile.verify();
+ Assert.equal(actualProfile, expectedProfile);
+ } else {
+ mockProfile.expects("_fetchAndCacheProfile").once().rejects();
+
+ let actualProfile = await profile.ensureProfile(options);
+ mockProfile.verify();
+ Assert.equal(actualProfile, tc.cachedProfile);
+ }
+ }
+});
+
+// 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(async function fetchAndCacheProfileBeforeThresholdOnNotification() {
+ let numFetches = 0;
+ let client = mockClient(mockFxa());
+ client.fetchProfile = async function () {
+ numFetches += 1;
+ return {
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ };
+ };
+ let profile = CreateFxAccountsProfile(null, client);
+ profile.PROFILE_FRESHNESS_THRESHOLD = 1000;
+
+ // first fetch should return null as we don't have data.
+ let p = await profile.getProfile();
+ Assert.equal(p, null);
+ // ensure we kicked off a fetch.
+ Assert.notEqual(profile._currentFetchPromise, null);
+ // wait for that fetch to finish
+ await profile._currentFetchPromise;
+ Assert.equal(numFetches, 1);
+ Assert.equal(profile._currentFetchPromise, null);
+
+ Services.obs.notifyObservers(null, ON_PROFILE_CHANGE_NOTIFICATION);
+
+ let origFetchAndCatch = profile._fetchAndCacheProfile;
+ let backgroundFetchDone = Promise.withResolvers();
+ profile._fetchAndCacheProfile = async () => {
+ await origFetchAndCatch.call(profile);
+ backgroundFetchDone.resolve();
+ };
+ await profile.getProfile();
+ await backgroundFetchDone.promise;
+ Assert.equal(numFetches, 2);
+});
+
+add_test(function tearDown_ok() {
+ let profile = CreateFxAccountsProfile();
+
+ Assert.ok(!!profile.client);
+ Assert.ok(!!profile.fxai);
+
+ profile.tearDown();
+ Assert.equal(null, profile.fxai);
+ Assert.equal(null, profile.client);
+
+ run_next_test();
+});
+
+add_task(async function getProfile_ok() {
+ let cachedUrl = "myurl";
+ let didFetch = false;
+
+ let fxa = mockFxa();
+ fxa._testProfileCache = { profile: { uid: ACCOUNT_UID, avatar: cachedUrl } };
+ let profile = CreateFxAccountsProfile(fxa);
+
+ profile._fetchAndCacheProfile = function () {
+ didFetch = true;
+ return Promise.resolve();
+ };
+
+ let result = await profile.getProfile();
+
+ Assert.equal(result.avatar, cachedUrl);
+ Assert.ok(didFetch);
+});
+
+add_task(async function getProfile_no_cache() {
+ let fetchedUrl = "newUrl";
+ let fxa = mockFxa();
+ let profile = CreateFxAccountsProfile(fxa);
+
+ profile._fetchAndCacheProfileInternal = function () {
+ return Promise.resolve({ uid: ACCOUNT_UID, avatar: fetchedUrl });
+ };
+
+ await profile.getProfile(); // returns null.
+ let result = await profile._currentFetchPromise;
+ Assert.equal(result.avatar, fetchedUrl);
+});
+
+add_test(function getProfile_has_cached_fetch_deleted() {
+ let cachedUrl = "myurl";
+
+ let fxa = mockFxa();
+ let client = mockClient(fxa);
+ client.fetchProfile = function () {
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: null },
+ });
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ fxa._testProfileCache = {
+ profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, 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 => {
+ Assert.equal(null, profileData.avatar);
+ run_next_test();
+ });
+ });
+
+ return profile.getProfile().then(result => {
+ Assert.equal(result.avatar, "myurl");
+ });
+});
+
+add_test(function getProfile_fetchAndCacheProfile_throws() {
+ let fxa = mockFxa();
+ fxa._testProfileCache = {
+ profile: { uid: ACCOUNT_UID, email: ACCOUNT_EMAIL, avatar: "myimg" },
+ };
+ let profile = CreateFxAccountsProfile(fxa);
+
+ profile._fetchAndCacheProfile = () => Promise.reject(new Error());
+
+ return profile.getProfile().then(result => {
+ Assert.equal(result.avatar, "myimg");
+ run_next_test();
+ });
+});
+
+add_test(function getProfile_email_changed() {
+ let fxa = mockFxa();
+ let client = mockClient(fxa);
+ client.fetchProfile = function () {
+ return Promise.resolve({
+ body: { uid: ACCOUNT_UID, email: "newemail@bar.com" },
+ });
+ };
+ fxa._internal._handleEmailUpdated = email => {
+ Assert.equal(email, "newemail@bar.com");
+ run_next_test();
+ };
+
+ let profile = CreateFxAccountsProfile(fxa, client);
+ return profile._fetchAndCacheProfile();
+});
+
+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);
+ 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 0000000000..22fcc293f8
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_profile_client.js
@@ -0,0 +1,422 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const {
+ ERRNO_NETWORK,
+ ERRNO_PARSE,
+ ERRNO_UNKNOWN_ERROR,
+ ERROR_CODE_METHOD_NOT_ALLOWED,
+ ERROR_MSG_METHOD_NOT_ALLOWED,
+ ERROR_NETWORK,
+ ERROR_PARSE,
+ ERROR_UNKNOWN,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+const { FxAccountsProfileClient, FxAccountsProfileClientError } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsProfileClient.sys.mjs"
+ );
+
+const STATUS_SUCCESS = 200;
+
+/**
+ * Mock request responder
+ * @param {String} response
+ * Mocked raw response from the server
+ * @returns {Function}
+ */
+let mockResponse = function (response) {
+ let Request = function (requestUri) {
+ // Store the request uri so tests can inspect it
+ Request._requestUri = requestUri;
+ Request.ifNoneMatchSet = false;
+ return {
+ setHeader(header, value) {
+ if (header == "If-None-Match" && value == "bogusETag") {
+ Request.ifNoneMatchSet = true;
+ }
+ },
+ async dispatch(method, payload) {
+ this.response = response;
+ return this.response;
+ },
+ };
+ };
+
+ 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.
+let mockFxaInternal = {
+ getOAuthToken(options) {
+ Assert.equal(options.scope, "profile");
+ return "token";
+ },
+};
+
+const PROFILE_OPTIONS = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxai: mockFxaInternal,
+};
+
+/**
+ * Mock request error responder
+ * @param {Error} error
+ * Error object
+ * @returns {Function}
+ */
+let mockResponseError = function (error) {
+ return function () {
+ return {
+ setHeader() {},
+ async dispatch(method, payload) {
+ throw error;
+ },
+ };
+ };
+};
+
+add_test(function successfulResponse() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ headers: { etag: "bogusETag" },
+ body: '{"email":"someone@restmail.net","uid":"0d5c1a89b8c54580b8e3e8adadae864a"}',
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile().then(function (result) {
+ Assert.equal(
+ client._Request._requestUri,
+ "http://127.0.0.1:1111/v1/profile"
+ );
+ Assert.equal(result.body.email, "someone@restmail.net");
+ Assert.equal(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+ Assert.equal(result.etag, "bogusETag");
+ run_next_test();
+ });
+});
+
+add_test(function setsIfNoneMatchETagHeader() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ status: STATUS_SUCCESS,
+ headers: {},
+ body: '{"email":"someone@restmail.net","uid":"0d5c1a89b8c54580b8e3e8adadae864a"}',
+ };
+
+ let req = new mockResponse(response);
+ client._Request = req;
+ client.fetchProfile("bogusETag").then(function (result) {
+ Assert.equal(
+ client._Request._requestUri,
+ "http://127.0.0.1:1111/v1/profile"
+ );
+ Assert.equal(result.body.email, "someone@restmail.net");
+ Assert.equal(result.body.uid, "0d5c1a89b8c54580b8e3e8adadae864a");
+ Assert.ok(req.ifNoneMatchSet);
+ run_next_test();
+ });
+});
+
+add_test(function successful304Response() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+ let response = {
+ success: true,
+ headers: { etag: "bogusETag" },
+ status: 304,
+ };
+
+ client._Request = new mockResponse(response);
+ client.fetchProfile().then(function (result) {
+ Assert.equal(result, null);
+ 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().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, STATUS_SUCCESS);
+ Assert.equal(e.errno, ERRNO_PARSE);
+ Assert.equal(e.error, ERROR_PARSE);
+ Assert.equal(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().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, 500);
+ Assert.equal(e.errno, 100);
+ Assert.equal(e.error, "Bad Request");
+ Assert.equal(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 mockFxaWithRemove = {
+ getOAuthToken(options) {
+ Assert.equal(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.
+ Assert.equal(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ },
+ };
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxai: mockFxaWithRemove,
+ };
+ 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,
+ headers: {},
+ 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(name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ Assert.equal(value, "Bearer " + lastToken);
+ }
+ },
+ async dispatch(method, payload) {
+ this.response = responses[numRequests];
+ ++numRequests;
+ return this.response;
+ },
+ };
+ };
+
+ client.fetchProfile().then(result => {
+ Assert.equal(result.body.avatar, "http://example.com/image.jpg");
+ Assert.equal(result.body.id, "0d5c1a89b8c54580b8e3e8adadae864a");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ Assert.equal(numRequests, 2);
+ Assert.equal(numAuthHeaders, 2);
+ // and we should have seen one token revoked.
+ Assert.equal(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 mockFxaWithRemove = {
+ getOAuthToken(options) {
+ Assert.equal(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.
+ Assert.equal(parseInt(options.token), lastToken);
+ ++numTokensRemoved;
+ },
+ };
+ let profileOptions = {
+ serverURL: "http://127.0.0.1:1111/v1",
+ fxai: mockFxaWithRemove,
+ };
+ 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(name, value) {
+ if (name == "Authorization") {
+ numAuthHeaders++;
+ Assert.equal(value, "Bearer " + lastToken);
+ }
+ },
+ async dispatch(method, payload) {
+ this.response = response;
+ ++numRequests;
+ return this.response;
+ },
+ };
+ };
+
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, 401);
+ Assert.equal(e.errno, 100);
+ Assert.equal(e.error, "It's not your token, it's you!");
+ // should have been exactly 2 requests and exactly 2 auth headers.
+ Assert.equal(numRequests, 2);
+ Assert.equal(numAuthHeaders, 2);
+ // and we should have seen both tokens revoked.
+ Assert.equal(numTokensRemoved, 2);
+ run_next_test();
+ });
+});
+
+add_test(function networkErrorResponse() {
+ let client = new FxAccountsProfileClient({
+ serverURL: "http://domain.dummy",
+ fxai: mockFxaInternal,
+ });
+ client.fetchProfile().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, null);
+ Assert.equal(e.errno, ERRNO_NETWORK);
+ Assert.equal(e.error, ERROR_NETWORK);
+ run_next_test();
+ });
+});
+
+add_test(function unsupportedMethod() {
+ let client = new FxAccountsProfileClient(PROFILE_OPTIONS);
+
+ return client._createRequest("/profile", "PUT").catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, ERROR_CODE_METHOD_NOT_ALLOWED);
+ Assert.equal(e.errno, ERRNO_NETWORK);
+ Assert.equal(e.error, ERROR_NETWORK);
+ Assert.equal(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().catch(function (e) {
+ Assert.equal(e.name, "FxAccountsProfileClientError");
+ Assert.equal(e.code, null);
+ Assert.equal(e.errno, ERRNO_NETWORK);
+ Assert.equal(e.error, ERROR_NETWORK);
+ Assert.equal(e.message, "Error: onComplete error");
+ 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();
+ Assert.equal(error1.name, "FxAccountsProfileClientError");
+ Assert.equal(error1.code, null);
+ Assert.equal(error1.errno, ERRNO_UNKNOWN_ERROR);
+ Assert.equal(error1.error, ERROR_UNKNOWN);
+ Assert.equal(error1.message, null);
+
+ let error2 = new FxAccountsProfileClientError({
+ code: STATUS_SUCCESS,
+ errno: 1,
+ error: "Error",
+ message: "Something",
+ });
+ let fields2 = error2._toStringFields();
+ let statusCode = 1;
+
+ Assert.equal(error2.name, "FxAccountsProfileClientError");
+ Assert.equal(error2.code, STATUS_SUCCESS);
+ Assert.equal(error2.errno, statusCode);
+ Assert.equal(error2.error, "Error");
+ Assert.equal(error2.message, "Something");
+
+ Assert.equal(fields2.name, "FxAccountsProfileClientError");
+ Assert.equal(fields2.code, STATUS_SUCCESS);
+ Assert.equal(fields2.errno, statusCode);
+ Assert.equal(fields2.error, "Error");
+ Assert.equal(fields2.message, "Something");
+
+ Assert.ok(error2.toString().includes("Something"));
+ 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 fxai to options - that missing isn't what we are testing here.
+ if (options) {
+ options.fxai = mockFxaInternal;
+ }
+ try {
+ new FxAccountsProfileClient(options);
+ } catch (e) {
+ return Assert.equal(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 0000000000..0441888847
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_push_service.js
@@ -0,0 +1,522 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA push service.
+
+/* eslint-disable mozilla/use-chromeutils-generateqi */
+
+const {
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE,
+ ONLOGOUT_NOTIFICATION,
+ ON_ACCOUNT_DESTROYED_NOTIFICATION,
+ ON_DEVICE_CONNECTED_NOTIFICATION,
+ ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ ON_PASSWORD_CHANGED_NOTIFICATION,
+ ON_PASSWORD_RESET_NOTIFICATION,
+ ON_PROFILE_CHANGE_NOTIFICATION,
+ ON_PROFILE_UPDATED_NOTIFICATION,
+ ON_VERIFY_LOGIN_NOTIFICATION,
+ log,
+} = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+const { FxAccountsPushService } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsPush.sys.mjs"
+);
+
+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: PushService.pushTopic,
+ subscriptionChangeTopic: PushService.subscriptionChangeTopic,
+ subscribe(scope, principal, cb) {
+ cb(Cr.NS_OK, {
+ endpoint: MOCK_ENDPOINT,
+ });
+ },
+ unsubscribe(scope, principal, cb) {
+ cb(Cr.NS_OK, true);
+ },
+};
+
+let mockFxAccounts = {
+ checkVerificationStatus() {},
+ updateDeviceRegistration() {},
+};
+
+let mockLog = {
+ trace() {},
+ debug() {},
+ warn() {},
+ error() {},
+};
+
+add_task(async function initialize() {
+ let pushService = new FxAccountsPushService();
+ equal(pushService.initialize(), false);
+});
+
+add_task(async function registerPushEndpointSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let subscription = await pushService.registerPushEndpoint();
+ equal(subscription.endpoint, MOCK_ENDPOINT);
+});
+
+add_task(async function registerPushEndpointFailure() {
+ let failPushService = Object.assign(mockPushService, {
+ subscribe(scope, principal, cb) {
+ cb(Cr.NS_ERROR_ABORT);
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: failPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let subscription = await pushService.registerPushEndpoint();
+ equal(subscription, null);
+});
+
+add_task(async function unsubscribeSuccess() {
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let result = await pushService.unsubscribe();
+ equal(result, true);
+});
+
+add_task(async function unsubscribeFailure() {
+ let failPushService = Object.assign(mockPushService, {
+ unsubscribe(scope, principal, cb) {
+ cb(Cr.NS_ERROR_ABORT);
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: failPushService,
+ fxai: mockFxAccounts,
+ });
+
+ let result = await pushService.unsubscribe();
+ equal(result, null);
+});
+
+add_test(function observeLogout() {
+ let customLog = Object.assign(mockLog, {
+ trace(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() {
+ return this;
+ },
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ checkVerificationStatus() {
+ // checking verification status on push messages without data
+ run_next_test();
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: customAccounts,
+ });
+
+ pushService.observe(
+ emptyMsg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicDeviceConnected() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_CONNECTED_NOTIFICATION,
+ data: {
+ deviceName: "My phone",
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let obs = (subject, topic, data) => {
+ Services.obs.removeObserver(obs, topic);
+ run_next_test();
+ };
+ Services.obs.addObserver(obs, ON_DEVICE_CONNECTED_NOTIFICATION);
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_task(async function observePushTopicDeviceDisconnected_current_device() {
+ const deviceId = "bogusid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ data: {
+ id: deviceId,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let signoutCalled = false;
+ let { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ );
+ const fxAccountsMock = new FxAccounts({
+ newAccountState() {
+ return {
+ async getUserAccountData() {
+ return { device: { id: deviceId } };
+ },
+ };
+ },
+ signOut() {
+ signoutCalled = true;
+ },
+ })._internal;
+
+ const deviceDisconnectedNotificationObserved = new Promise(resolve => {
+ Services.obs.addObserver(function obs(subject, topic, data) {
+ Services.obs.removeObserver(obs, topic);
+ equal(data, JSON.stringify({ isLocalDevice: true }));
+ resolve();
+ }, ON_DEVICE_DISCONNECTED_NOTIFICATION);
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: fxAccountsMock,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+
+ await deviceDisconnectedNotificationObserved;
+ ok(signoutCalled);
+});
+
+add_task(async function observePushTopicDeviceDisconnected_another_device() {
+ const deviceId = "bogusid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_DEVICE_DISCONNECTED_NOTIFICATION,
+ data: {
+ id: deviceId,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let signoutCalled = false;
+ let { FxAccounts } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ );
+ const fxAccountsMock = new FxAccounts({
+ newAccountState() {
+ return {
+ async getUserAccountData() {
+ return { device: { id: "thelocaldevice" } };
+ },
+ };
+ },
+ signOut() {
+ signoutCalled = true;
+ },
+ })._internal;
+
+ const deviceDisconnectedNotificationObserved = new Promise(resolve => {
+ Services.obs.addObserver(function obs(subject, topic, data) {
+ Services.obs.removeObserver(obs, topic);
+ equal(data, JSON.stringify({ isLocalDevice: false }));
+ resolve();
+ }, ON_DEVICE_DISCONNECTED_NOTIFICATION);
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: fxAccountsMock,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+
+ await deviceDisconnectedNotificationObserved;
+ ok(!signoutCalled);
+});
+
+add_test(function observePushTopicAccountDestroyed() {
+ const uid = "bogusuid";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_ACCOUNT_DESTROYED_NOTIFICATION,
+ data: {
+ uid,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let customAccounts = Object.assign(mockFxAccounts, {
+ _handleAccountDestroyed() {
+ // checking verification status on push messages without data
+ run_next_test();
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: customAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicVerifyLogin() {
+ let url = "http://localhost/newLogin";
+ let title = "bogustitle";
+ let body = "bogusbody";
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_VERIFY_LOGIN_NOTIFICATION,
+ data: {
+ body,
+ title,
+ url,
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let obs = (subject, topic, data) => {
+ Services.obs.removeObserver(obs, topic);
+ Assert.equal(data, JSON.stringify(msg.data.json().data));
+ run_next_test();
+ };
+ Services.obs.addObserver(obs, ON_VERIFY_LOGIN_NOTIFICATION);
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicProfileUpdated() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PROFILE_UPDATED_NOTIFICATION,
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+ let obs = (subject, topic, data) => {
+ Services.obs.removeObserver(obs, topic);
+ run_next_test();
+ };
+ Services.obs.addObserver(obs, ON_PROFILE_CHANGE_NOTIFICATION);
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: mockFxAccounts,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+});
+
+add_test(function observePushTopicPasswordChanged() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: ON_PASSWORD_CHANGED_NOTIFICATION,
+ }),
+ },
+ QueryInterface() {
+ 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() {
+ 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_task(async function commandReceived() {
+ let msg = {
+ data: {
+ json: () => ({
+ command: "fxaccounts:command_received",
+ data: {
+ url: "https://api.accounts.firefox.com/auth/v1/account/device/commands?index=42&limit=1",
+ },
+ }),
+ },
+ QueryInterface() {
+ return this;
+ },
+ };
+
+ let fxAccountsMock = {};
+ const promiseConsumeRemoteMessagesCalled = new Promise(res => {
+ fxAccountsMock.commands = {
+ pollDeviceCommands() {
+ res();
+ },
+ };
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: fxAccountsMock,
+ });
+
+ pushService.observe(
+ msg,
+ mockPushService.pushTopic,
+ FXA_PUSH_SCOPE_ACCOUNT_UPDATE
+ );
+ await promiseConsumeRemoteMessagesCalled;
+});
+
+add_test(function observeSubscriptionChangeTopic() {
+ let customAccounts = Object.assign(mockFxAccounts, {
+ updateDeviceRegistration() {
+ // subscription change means updating the device registration
+ run_next_test();
+ },
+ });
+
+ let pushService = new FxAccountsPushService({
+ pushService: mockPushService,
+ fxai: 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 0000000000..05c565d2f4
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_storage_manager.js
@@ -0,0 +1,586 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Tests for the FxA storage manager.
+
+const { FxAccountsStorageManager } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsStorage.sys.mjs"
+);
+const { DATA_FORMAT_VERSION, log } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsCommon.sys.mjs"
+);
+
+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,
+ };
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+MockedPlainStorage.prototype = {
+ async get() {
+ this.numReads++;
+ Assert.equal(this.numReads, 1, "should only ever be 1 read of acct data");
+ return this.data;
+ },
+
+ async set(data) {
+ this.data = data;
+ },
+};
+
+function MockedSecureStorage(accountData) {
+ let data = null;
+ if (accountData) {
+ data = {
+ version: DATA_FORMAT_VERSION,
+ accountData,
+ };
+ }
+ this.data = data;
+ this.numReads = 0;
+}
+
+MockedSecureStorage.prototype = {
+ fetchCount: 0,
+ locked: false,
+ /* eslint-disable object-shorthand */
+ // This constructor must be declared without
+ // object shorthand or we get an exception of
+ // "TypeError: this.STORAGE_LOCKED is not a constructor"
+ STORAGE_LOCKED: function () {},
+ /* eslint-enable object-shorthand */
+ async get(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;
+ },
+
+ async set(uid, contents) {
+ this.data = contents;
+ },
+};
+
+function add_storage_task(testFunction) {
+ add_task(async function () {
+ print("Starting test with secure storage manager");
+ await testFunction(new FxAccountsStorageManager());
+ });
+ add_task(async function () {
+ print("Starting test with simple storage manager");
+ await testFunction(new FxAccountsStorageManager({ useSecure: false }));
+ });
+}
+
+// initialized without account data and there's nothing to read. Not logged in.
+add_storage_task(async function checkInitializedEmpty(sm) {
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize();
+ Assert.strictEqual(await sm.getAccountData(), null);
+ await Assert.rejects(
+ sm.updateAccountData({ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys } }),
+ /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(async function checkNewUser(sm) {
+ let initialAccountData = {
+ uid: "uid",
+ email: "someone@somewhere.com",
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ device: {
+ id: "device id",
+ },
+ };
+ sm.plainStorage = new MockedPlainStorage();
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize(initialAccountData);
+ let accountData = await sm.getAccountData();
+ Assert.equal(accountData.uid, initialAccountData.uid);
+ Assert.equal(accountData.email, initialAccountData.email);
+ Assert.deepEqual(accountData.scopedKeys, initialAccountData.scopedKeys);
+ Assert.deepEqual(accountData.device, initialAccountData.device);
+
+ // 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.deepEqual(
+ sm.plainStorage.data.accountData.device,
+ initialAccountData.device
+ );
+ // check secure
+ if (sm.secureStorage) {
+ Assert.deepEqual(
+ sm.secureStorage.data.accountData.scopedKeys,
+ initialAccountData.scopedKeys
+ );
+ } else {
+ Assert.deepEqual(
+ sm.plainStorage.data.accountData.scopedKeys,
+ initialAccountData.scopedKeys
+ );
+ }
+});
+
+// Initialized without account data but storage has it available.
+add_storage_task(async function checkEverythingRead(sm) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ device: {
+ id: "wibble",
+ registrationVersion: null,
+ },
+ });
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize();
+ let accountData = await sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.deepEqual(accountData.device, {
+ id: "wibble",
+ registrationVersion: null,
+ });
+ // Update the data - we should be able to fetch it back and it should appear
+ // in our storage.
+ await sm.updateAccountData({
+ verified: true,
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ device: {
+ id: "wibble",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ },
+ });
+ accountData = await sm.getAccountData();
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+ Assert.deepEqual(accountData.device, {
+ id: "wibble",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ });
+ // Check the new value was written to storage.
+ await sm._promiseStorageComplete; // storage is written in the background.
+ Assert.equal(sm.plainStorage.data.accountData.verified, true);
+ Assert.deepEqual(sm.plainStorage.data.accountData.device, {
+ id: "wibble",
+ registrationVersion: DEVICE_REGISTRATION_VERSION,
+ });
+ // derive keys are secure
+ if (sm.secureStorage) {
+ Assert.deepEqual(
+ sm.secureStorage.data.accountData.scopedKeys,
+ MOCK_ACCOUNT_KEYS.scopedKeys
+ );
+ } else {
+ Assert.deepEqual(
+ sm.plainStorage.data.accountData.scopedKeys,
+ MOCK_ACCOUNT_KEYS.scopedKeys
+ );
+ }
+});
+
+add_storage_task(async function checkInvalidUpdates(sm) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ if (sm.secureStorage) {
+ sm.secureStorage = new MockedSecureStorage(null);
+ }
+ await sm.initialize();
+
+ await Assert.rejects(
+ sm.updateAccountData({ uid: "another" }),
+ /Can't change uid/
+ );
+});
+
+add_storage_task(async function checkNullUpdatesRemovedUnlocked(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey",
+ });
+ } else {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey",
+ });
+ }
+ await sm.initialize();
+
+ await sm.updateAccountData({ unwrapBKey: null });
+ let accountData = await sm.getAccountData();
+ Assert.ok(!accountData.unwrapBKey);
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+});
+
+add_storage_task(async function checkNullRemovesUnlistedFields(sm) {
+ // kA and kB are not listed in FXA_PWDMGR_*_FIELDS, but we still want to
+ // be able to delete them (migration case).
+ 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",
+ });
+ }
+ await sm.initialize();
+
+ await sm.updateAccountData({ kA: null, kB: null });
+ let accountData = await sm.getAccountData();
+ Assert.ok(!accountData.kA);
+ Assert.ok(!accountData.kB);
+});
+
+add_storage_task(async function checkDelete(sm) {
+ if (sm.secureStorage) {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ } else {
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ }
+ await sm.initialize();
+
+ await 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(await sm.getAccountData(), null);
+});
+
+// Some tests only for the secure storage manager.
+add_task(async function checkNullUpdatesRemovedLocked() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey is another secure value",
+ });
+ sm.secureStorage.locked = true;
+ await sm.initialize();
+
+ await sm.updateAccountData({ scopedKeys: null });
+ let accountData = await sm.getAccountData();
+ // No scopedKeys because it was removed.
+ Assert.ok(!accountData.scopedKeys);
+ // No unwrapBKey because we are locked
+ Assert.ok(!accountData.unwrapBKey);
+
+ // now unlock - should still be no scopedKeys but unwrapBKey should appear.
+ sm.secureStorage.locked = false;
+ accountData = await sm.getAccountData();
+ Assert.ok(!accountData.scopedKeys);
+ Assert.equal(accountData.unwrapBKey, "unwrapBKey is another secure value");
+ // And secure storage should have been written with our previously-cached
+ // data.
+ Assert.strictEqual(sm.secureStorage.data.accountData.scopedKeys, undefined);
+ Assert.strictEqual(
+ sm.secureStorage.data.accountData.unwrapBKey,
+ "unwrapBKey is another secure value"
+ );
+});
+
+add_task(async function checkEverythingReadSecure() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ await sm.initialize();
+
+ let accountData = await sm.getAccountData();
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.email, "someone@somewhere.com");
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+});
+
+add_task(async function checkExplicitGet() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ await sm.initialize();
+
+ let accountData = await sm.getAccountData(["uid", "scopedKeys"]);
+ Assert.ok(accountData, "read account data");
+ Assert.equal(accountData.uid, "uid");
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+ // We didn't ask for email so shouldn't have got it.
+ Assert.strictEqual(accountData.email, undefined);
+});
+
+add_task(async function checkExplicitGetNoSecureRead() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ await sm.initialize();
+
+ Assert.equal(sm.secureStorage.fetchCount, 0);
+ // request 2 fields in secure storage - it should have caused a single fetch.
+ let accountData = await 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.scopedKeys, undefined);
+ Assert.equal(sm.secureStorage.fetchCount, 1);
+});
+
+add_task(async function checkLockedUpdates() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ unwrapBKey: "unwrapBKey",
+ });
+ sm.secureStorage.locked = true;
+ await sm.initialize();
+
+ let accountData = await sm.getAccountData();
+ // requesting scopedKeys will fail as storage is locked.
+ Assert.ok(!accountData.scopedKeys);
+ // While locked we can still update it and see the updated value.
+ sm.updateAccountData({ unwrapBKey: "new-unwrapBKey" });
+ accountData = await sm.getAccountData();
+ Assert.equal(accountData.unwrapBKey, "new-unwrapBKey");
+ // unlock.
+ sm.secureStorage.locked = false;
+ accountData = await sm.getAccountData();
+ // should reflect the value we updated and the one we didn't.
+ Assert.equal(accountData.unwrapBKey, "new-unwrapBKey");
+ Assert.deepEqual(accountData.scopedKeys, MOCK_ACCOUNT_KEYS.scopedKeys);
+ // And storage should also reflect it.
+ Assert.deepEqual(
+ sm.secureStorage.data.accountData.scopedKeys,
+ MOCK_ACCOUNT_KEYS.scopedKeys
+ );
+ Assert.strictEqual(
+ sm.secureStorage.data.accountData.unwrapBKey,
+ "new-unwrapBKey"
+ );
+});
+
+// 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.
+async function setupStorageManagerForQueueTest() {
+ let sm = new FxAccountsStorageManager();
+ sm.plainStorage = new MockedPlainStorage({
+ uid: "uid",
+ email: "someone@somewhere.com",
+ });
+ sm.secureStorage = new MockedSecureStorage({
+ scopedKeys: { ...MOCK_ACCOUNT_KEYS.scopedKeys },
+ });
+ sm.secureStorage.locked = true;
+ await 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(async function checkQueueSemantics() {
+ let { sm, resolveBlocked } = await 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.
+ await subsequentPromise;
+ Assert.ok(subsequentCalled);
+ await sm.finalize();
+});
+
+// Check that a queued promise being rejected works correctly.
+add_task(async function checkQueueSemanticsOnError() {
+ let { sm, blockedPromise, rejectBlocked } =
+ await 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.
+ await subsequentPromise;
+ Assert.ok(subsequentCalled);
+
+ // But the first promise should reflect the rejection.
+ try {
+ await blockedPromise;
+ Assert.ok(false, "expected this promise to reject");
+ } catch (ex) {
+ Assert.equal(ex, "oh no");
+ }
+ await sm.finalize();
+});
+
+// And some tests for the specific operations that are queued.
+add_task(async function checkQueuedReadAndUpdate() {
+ let { sm, resolveBlocked } = await 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();
+ await resultPromise;
+ Assert.ok(_doReadCalled);
+ await sm.finalize();
+});
+
+add_task(async function checkQueuedWrite() {
+ let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let __writeCalled = false;
+ sm.__write = () => {
+ __writeCalled = true;
+ return Promise.resolve();
+ };
+
+ let writePromise = sm._write();
+ Assert.ok(!__writeCalled);
+
+ resolveBlocked();
+ await writePromise;
+ Assert.ok(__writeCalled);
+ await sm.finalize();
+});
+
+add_task(async function checkQueuedDelete() {
+ let { sm, resolveBlocked } = await setupStorageManagerForQueueTest();
+ // Mock the underlying operations
+ let _deleteCalled = false;
+ sm._deleteAccountData = () => {
+ _deleteCalled = true;
+ return Promise.resolve();
+ };
+
+ let resultPromise = sm.deleteAccountData();
+ Assert.ok(!_deleteCalled);
+
+ resolveBlocked();
+ await resultPromise;
+ Assert.ok(_deleteCalled);
+ await sm.finalize();
+});
diff --git a/services/fxaccounts/tests/xpcshell/test_telemetry.js b/services/fxaccounts/tests/xpcshell/test_telemetry.js
new file mode 100644
index 0000000000..3b9d318404
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_telemetry.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { getFxAccountsSingleton } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+);
+const fxAccounts = getFxAccountsSingleton();
+
+_("Misc tests for FxAccounts.telemetry");
+
+const MOCK_HASHED_UID = "00112233445566778899aabbccddeeff";
+const MOCK_DEVICE_ID = "ffeeddccbbaa99887766554433221100";
+
+add_task(function test_sanitized_uid() {
+ Services.prefs.deleteBranch(
+ "identity.fxaccounts.account.telemetry.sanitized_uid"
+ );
+
+ // Returns `null` by default.
+ Assert.equal(fxAccounts.telemetry.getSanitizedUID(), null);
+
+ // Returns provided value if set.
+ fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID);
+ Assert.equal(fxAccounts.telemetry.getSanitizedUID(), MOCK_HASHED_UID);
+
+ // Reverts to unset for falsey values.
+ fxAccounts.telemetry._setHashedUID("");
+ Assert.equal(fxAccounts.telemetry.getSanitizedUID(), null);
+});
+
+add_task(function test_sanitize_device_id() {
+ Services.prefs.deleteBranch(
+ "identity.fxaccounts.account.telemetry.sanitized_uid"
+ );
+
+ // Returns `null` by default.
+ Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null);
+
+ // Hashes with the sanitized UID if set.
+ // (test value here is SHA256(MOCK_DEVICE_ID + MOCK_HASHED_UID))
+ fxAccounts.telemetry._setHashedUID(MOCK_HASHED_UID);
+ Assert.equal(
+ fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID),
+ "dd7c845006df9baa1c6d756926519c8ce12f91230e11b6057bf8ec65f9b55c1a"
+ );
+
+ // Reverts to unset for falsey values.
+ fxAccounts.telemetry._setHashedUID("");
+ Assert.equal(fxAccounts.telemetry.sanitizeDeviceId(MOCK_DEVICE_ID), null);
+});
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 0000000000..48f043d0b9
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/test_web_channel.js
@@ -0,0 +1,1380 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ON_PROFILE_CHANGE_NOTIFICATION, WEBCHANNEL_ID, log } =
+ ChromeUtils.importESModule("resource://gre/modules/FxAccountsCommon.sys.mjs");
+const { CryptoUtils } = ChromeUtils.importESModule(
+ "resource://services-crypto/utils.sys.mjs"
+);
+const { FxAccountsWebChannel, FxAccountsWebChannelHelpers } =
+ ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs"
+ );
+
+const URL_STRING = "https://example.com";
+
+const mockSendingContext = {
+ browsingContext: { top: { embedderElement: {} } },
+ 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(async function test_rejection_reporting() {
+ Services.prefs.setBoolPref(
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ false
+ );
+
+ 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 } = await 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_error_message_remove_profile_path() {
+ const errors = {
+ windows: {
+ err: new Error(
+ "Win error 183 during operation rename on file C:\\Users\\Some Computer\\AppData\\Roaming\\" +
+ "Mozilla\\Firefox\\Profiles\\dbzjmzxa.default\\signedInUser.json (Cannot create a file)"
+ ),
+ expected:
+ "Error: Win error 183 during operation rename on file C:[REDACTED]signedInUser.json (Cannot create a file)",
+ },
+ unix: {
+ err: new Error(
+ "Unix error 28 during operation write on file /Users/someuser/Library/Application Support/" +
+ "Firefox/Profiles/dbzjmzxa.default-release-7/signedInUser.json (No space left on device)"
+ ),
+ expected:
+ "Error: Unix error 28 during operation write on file [REDACTED]signedInUser.json (No space left on device)",
+ },
+ netpath: {
+ err: new Error(
+ "Win error 32 during operation rename on file \\\\SVC.LOC\\HOMEDIRS$\\USERNAME\\Mozilla\\" +
+ "Firefox\\Profiles\\dbzjmzxa.default-release-7\\signedInUser.json (No space left on device)"
+ ),
+ expected:
+ "Error: Win error 32 during operation rename on file [REDACTED]signedInUser.json (No space left on device)",
+ },
+ mount: {
+ err: new Error(
+ "Win error 649 during operation rename on file C:\\SnapVolumes\\MountPoints\\" +
+ "{9e399ec5-0000-0000-0000-100000000000}\\SVROOT\\Users\\username\\AppData\\Roaming\\Mozilla\\Firefox\\" +
+ "Profiles\\dbzjmzxa.default-release\\signedInUser.json (The create operation failed)"
+ ),
+ expected:
+ "Error: Win error 649 during operation rename on file C:[REDACTED]signedInUser.json " +
+ "(The create operation failed)",
+ },
+ };
+ const mockMessage = {
+ command: "fxaccounts:sync_preferences",
+ messageId: "1234",
+ };
+ const channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ });
+
+ let testNum = 0;
+ const toTest = Object.keys(errors).length;
+ for (const key in errors) {
+ let error = errors[key];
+ channel._channel.send = (message, context) => {
+ equal(
+ message.data.error.message,
+ error.expected,
+ "Should remove the profile path from the error message"
+ );
+ testNum++;
+ if (testNum === toTest) {
+ run_next_test();
+ }
+ };
+ channel._sendError(error.err, 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) {
+ Assert.equal(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(accountData) {
+ Assert.equal(accountData.email, "testuser@testuser.com");
+ run_next_test();
+ return Promise.resolve();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_oauth_login() {
+ const mockData = {
+ code: "oauth code",
+ state: "state parameter",
+ declinedSyncEngines: ["tabs", "creditcards"],
+ offeredSyncEngines: ["tabs", "creditcards", "history"],
+ };
+ const mockMessage = {
+ command: "fxaccounts:oauth_login",
+ data: mockData,
+ };
+ const channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ oauthLogin(data) {
+ Assert.deepEqual(data, mockData);
+ 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(uid) {
+ Assert.equal(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(uid) {
+ Assert.equal(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(email) {
+ Assert.equal(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(browser, entryPoint) {
+ Assert.equal(entryPoint, "fxa:verification_complete");
+ Assert.equal(
+ browser,
+ mockSendingContext.browsingContext.top.embedderElement
+ );
+ run_next_test();
+ },
+ },
+ });
+
+ channel._channelCallback(WEBCHANNEL_ID, mockMessage, mockSendingContext);
+});
+
+add_test(function test_fxa_status_message() {
+ let mockMessage = {
+ command: "fxaccounts:fxa_status",
+ messageId: 123,
+ data: {
+ service: "sync",
+ context: "fx_desktop_v3",
+ },
+ };
+
+ let channel = new FxAccountsWebChannel({
+ channel_id: WEBCHANNEL_ID,
+ content_uri: URL_STRING,
+ helpers: {
+ async getFxaStatus(service, sendingContext, isPairing, context) {
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+ Assert.ok(!isPairing);
+ Assert.equal(context, "fx_desktop_v3");
+ return {
+ signedInUser: {
+ email: "testuser@testuser.com",
+ sessionToken: "session-token",
+ uid: "uid",
+ verified: true,
+ },
+ capabilities: {
+ engines: ["creditcards", "addresses"],
+ },
+ };
+ },
+ },
+ });
+
+ channel._channel = {
+ send(response, sendingContext) {
+ Assert.equal(response.command, "fxaccounts:fxa_status");
+ Assert.equal(response.messageId, 123);
+
+ let signedInUser = response.data.signedInUser;
+ Assert.ok(!!signedInUser);
+ Assert.equal(signedInUser.email, "testuser@testuser.com");
+ Assert.equal(signedInUser.sessionToken, "session-token");
+ Assert.equal(signedInUser.uid, "uid");
+ Assert.equal(signedInUser.verified, true);
+
+ deepEqual(response.data.capabilities.engines, [
+ "creditcards",
+ "addresses",
+ ]);
+
+ 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");
+ Assert.ok(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";
+ };
+
+ Assert.ok(helpers.shouldAllowRelink("allowed_to_relink@testuser.com"));
+ Assert.ok(!helpers.shouldAllowRelink("not_allowed_to_relink@testuser.com"));
+
+ run_next_test();
+});
+
+add_task(async function test_helpers_login_without_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ Assert.equal(accountData.email, "testuser@testuser.com");
+
+ // verifiedCanLinkAccount should be stripped in the data.
+ Assert.equal(false, "verifiedCanLinkAccount" in accountData);
+
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection: sinon.spy(),
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref("lastuser@testuser.com");
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: false,
+ });
+ Assert.ok(
+ helpers._fxAccounts.telemetry.recordConnection.calledWith([], "webchannel")
+ );
+});
+
+add_task(async function test_helpers_login_set_previous_account_name_hash() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // previously signed in user preference is updated.
+ Assert.equal(
+ helpers.getPreviousAccountNameHashPref(),
+ CryptoUtils.sha256Base64("newuser@testuser.com")
+ );
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref("lastuser@testuser.com");
+
+ await helpers.login({
+ email: "newuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: false,
+ verified: true,
+ });
+});
+
+add_task(
+ async function test_helpers_login_dont_set_previous_account_name_hash_for_unverified_emails() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // previously signed in user preference should not be updated.
+ Assert.equal(
+ helpers.getPreviousAccountNameHashPref(),
+ CryptoUtils.sha256Base64("lastuser@testuser.com")
+ );
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ // ensure the previous account pref is overwritten.
+ helpers.setPreviousAccountNameHashPref("lastuser@testuser.com");
+
+ await helpers.login({
+ email: "newuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: false,
+ });
+ }
+);
+
+add_task(async function test_helpers_login_with_customize_sync() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ Assert.equal(accountData.email, "testuser@testuser.com");
+
+ // customizeSync should be stripped in the data.
+ Assert.equal(false, "customizeSync" in accountData);
+
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection: sinon.spy(),
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {},
+ },
+ },
+ },
+ });
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ });
+ Assert.ok(
+ helpers._fxAccounts.telemetry.recordConnection.calledWith([], "webchannel")
+ );
+});
+
+add_task(
+ async function test_helpers_login_with_customize_sync_and_declined_engines() {
+ let configured = false;
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ setSignedInUser(accountData) {
+ return new Promise(resolve => {
+ // ensure fxAccounts is informed of the new user being signed in.
+ Assert.equal(accountData.email, "testuser@testuser.com");
+
+ // customizeSync should be stripped in the data.
+ Assert.equal(false, "customizeSync" in accountData);
+ Assert.equal(false, "services" in accountData);
+ resolve();
+ });
+ },
+ },
+ telemetry: {
+ recordConnection: sinon.spy(),
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {
+ configured = true;
+ },
+ },
+ },
+ },
+ });
+
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.addons"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.bookmarks"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.history"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.passwords"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.prefs"),
+ true
+ );
+ Assert.equal(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ services: {
+ sync: {
+ offeredEngines: [
+ "addons",
+ "bookmarks",
+ "history",
+ "passwords",
+ "prefs",
+ ],
+ declinedEngines: ["addons", "prefs"],
+ },
+ },
+ });
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.addons"),
+ false
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.bookmarks"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.history"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.passwords"),
+ true
+ );
+ Assert.equal(
+ Services.prefs.getBoolPref("services.sync.engine.prefs"),
+ false
+ );
+ Assert.equal(Services.prefs.getBoolPref("services.sync.engine.tabs"), true);
+ Assert.ok(configured, "sync was configured");
+ Assert.ok(
+ helpers._fxAccounts.telemetry.recordConnection.calledWith(
+ ["sync"],
+ "webchannel"
+ )
+ );
+ }
+);
+
+add_task(async function test_helpers_login_with_offered_sync_engines() {
+ let helpers;
+ let configured = false;
+ const setSignedInUserCalled = new Promise(resolve => {
+ helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ async setSignedInUser(accountData) {
+ resolve(accountData);
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {
+ configured = true;
+ },
+ },
+ },
+ },
+ });
+ });
+
+ Services.prefs.setBoolPref("services.sync.engine.creditcards", false);
+ Services.prefs.setBoolPref("services.sync.engine.addresses", false);
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ customizeSync: true,
+ services: {
+ sync: {
+ declinedEngines: ["addresses"],
+ offeredEngines: ["creditcards", "addresses"],
+ },
+ },
+ });
+
+ const accountData = await setSignedInUserCalled;
+
+ // ensure fxAccounts is informed of the new user being signed in.
+ equal(accountData.email, "testuser@testuser.com");
+
+ // services should be stripped in the data.
+ ok(!("services" in accountData));
+ // credit cards was offered but not declined.
+ equal(Services.prefs.getBoolPref("services.sync.engine.creditcards"), true);
+ // addresses was offered and explicitely declined.
+ equal(Services.prefs.getBoolPref("services.sync.engine.addresses"), false);
+ ok(configured);
+});
+
+add_task(async function test_helpers_login_nothing_offered() {
+ let helpers;
+ let configured = false;
+ const setSignedInUserCalled = new Promise(resolve => {
+ helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ async setSignedInUser(accountData) {
+ resolve(accountData);
+ },
+ },
+ telemetry: {
+ recordConnection() {},
+ },
+ },
+ weaveXPCOM: {
+ whenLoaded() {},
+ Weave: {
+ Service: {
+ configure() {
+ configured = true;
+ },
+ },
+ },
+ },
+ });
+ });
+
+ // doesn't really matter if it's *all* engines...
+ const allEngines = [
+ "addons",
+ "addresses",
+ "bookmarks",
+ "creditcards",
+ "history",
+ "passwords",
+ "prefs",
+ ];
+ for (let name of allEngines) {
+ Services.prefs.clearUserPref("services.sync.engine." + name);
+ }
+
+ await helpers.login({
+ email: "testuser@testuser.com",
+ verifiedCanLinkAccount: true,
+ services: {
+ sync: {},
+ },
+ });
+
+ const accountData = await setSignedInUserCalled;
+ // ensure fxAccounts is informed of the new user being signed in.
+ equal(accountData.email, "testuser@testuser.com");
+
+ for (let name of allEngines) {
+ Assert.ok(!Services.prefs.prefHasUserValue("services.sync.engine." + name));
+ }
+ Assert.ok(configured);
+});
+
+add_test(function test_helpers_open_sync_preferences() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {},
+ });
+
+ let mockBrowser = {
+ loadURI(uri) {
+ Assert.equal(
+ uri.spec,
+ "about:preferences?entrypoint=fxa%3Averification_complete#sync"
+ );
+ run_next_test();
+ },
+ };
+
+ helpers.openSyncPreferences(mockBrowser, "fxa:verification_complete");
+});
+
+add_task(async function test_helpers_getFxAStatus_extra_engines() {
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ return Promise.resolve({
+ email: "testuser@testuser.com",
+ sessionToken: "sessionToken",
+ uid: "uid",
+ verified: true,
+ });
+ },
+ },
+ },
+ privateBrowsingUtils: {
+ isBrowserPrivate: () => true,
+ },
+ });
+
+ Services.prefs.setBoolPref(
+ "services.sync.engine.creditcards.available",
+ true
+ );
+ // Not defining "services.sync.engine.addresses.available" on purpose.
+
+ let fxaStatus = await helpers.getFxaStatus("sync", mockSendingContext);
+ ok(!!fxaStatus);
+ ok(!!fxaStatus.signedInUser);
+ deepEqual(fxaStatus.capabilities.engines, ["creditcards"]);
+});
+
+add_task(async function test_helpers_getFxaStatus_allowed_signedInUser() {
+ let wasCalled = {
+ getUserAccountData: false,
+ shouldAllowFxaStatus: false,
+ };
+
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ wasCalled.getUserAccountData = true;
+ return Promise.resolve({
+ email: "testuser@testuser.com",
+ sessionToken: "sessionToken",
+ uid: "uid",
+ verified: true,
+ });
+ },
+ },
+ },
+ });
+
+ helpers.shouldAllowFxaStatus = (service, sendingContext) => {
+ wasCalled.shouldAllowFxaStatus = true;
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+
+ return true;
+ };
+
+ return helpers.getFxaStatus("sync", mockSendingContext).then(fxaStatus => {
+ Assert.ok(!!fxaStatus);
+ Assert.ok(wasCalled.getUserAccountData);
+ Assert.ok(wasCalled.shouldAllowFxaStatus);
+
+ Assert.ok(!!fxaStatus.signedInUser);
+ let { signedInUser } = fxaStatus;
+
+ Assert.equal(signedInUser.email, "testuser@testuser.com");
+ Assert.equal(signedInUser.sessionToken, "sessionToken");
+ Assert.equal(signedInUser.uid, "uid");
+ Assert.ok(signedInUser.verified);
+
+ // These properties are filtered and should not
+ // be returned to the requester.
+ Assert.equal(false, "scopedKeys" in signedInUser);
+ });
+});
+
+add_task(async function test_helpers_getFxaStatus_allowed_no_signedInUser() {
+ let wasCalled = {
+ getUserAccountData: false,
+ shouldAllowFxaStatus: false,
+ };
+
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ wasCalled.getUserAccountData = true;
+ return Promise.resolve(null);
+ },
+ },
+ },
+ });
+
+ helpers.shouldAllowFxaStatus = (service, sendingContext) => {
+ wasCalled.shouldAllowFxaStatus = true;
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+
+ return true;
+ };
+
+ return helpers.getFxaStatus("sync", mockSendingContext).then(fxaStatus => {
+ Assert.ok(!!fxaStatus);
+ Assert.ok(wasCalled.getUserAccountData);
+ Assert.ok(wasCalled.shouldAllowFxaStatus);
+
+ Assert.equal(null, fxaStatus.signedInUser);
+ });
+});
+
+add_task(async function test_helpers_getFxaStatus_not_allowed() {
+ let wasCalled = {
+ getUserAccountData: false,
+ shouldAllowFxaStatus: false,
+ };
+
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ getUserAccountData() {
+ wasCalled.getUserAccountData = true;
+ return Promise.resolve(null);
+ },
+ },
+ },
+ });
+
+ helpers.shouldAllowFxaStatus = (
+ service,
+ sendingContext,
+ isPairing,
+ context
+ ) => {
+ wasCalled.shouldAllowFxaStatus = true;
+ Assert.equal(service, "sync");
+ Assert.equal(sendingContext, mockSendingContext);
+ Assert.ok(!isPairing);
+ Assert.equal(context, "fx_desktop_v3");
+
+ return false;
+ };
+
+ return helpers
+ .getFxaStatus("sync", mockSendingContext, false, "fx_desktop_v3")
+ .then(fxaStatus => {
+ Assert.ok(!!fxaStatus);
+ Assert.ok(!wasCalled.getUserAccountData);
+ Assert.ok(wasCalled.shouldAllowFxaStatus);
+
+ Assert.equal(null, fxaStatus.signedInUser);
+ });
+});
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_sync_service_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "sync",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_desktop_context_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false,
+ "fx_desktop_v3"
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_oauth_service_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "dcdb5ae7add825d2",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_no_service_not_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return false;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_sync_service_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "sync",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_desktop_context_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false,
+ "fx_desktop_v3"
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_oauth_service_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "dcdb5ae7add825d2",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(!shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_oauth_service_pairing_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "dcdb5ae7add825d2",
+ mockSendingContext,
+ true
+ );
+ Assert.ok(shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(
+ async function test_helpers_shouldAllowFxaStatus_no_service_private_browsing() {
+ let wasCalled = {
+ isPrivateBrowsingMode: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({});
+
+ helpers.isPrivateBrowsingMode = sendingContext => {
+ wasCalled.isPrivateBrowsingMode = true;
+ Assert.equal(sendingContext, mockSendingContext);
+ return true;
+ };
+
+ let shouldAllowFxaStatus = helpers.shouldAllowFxaStatus(
+ "",
+ mockSendingContext,
+ false
+ );
+ Assert.ok(!shouldAllowFxaStatus);
+ Assert.ok(wasCalled.isPrivateBrowsingMode);
+ }
+);
+
+add_task(async function test_helpers_isPrivateBrowsingMode_private_browsing() {
+ let wasCalled = {
+ isBrowserPrivate: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ privateBrowsingUtils: {
+ isBrowserPrivate(browser) {
+ wasCalled.isBrowserPrivate = true;
+ Assert.equal(
+ browser,
+ mockSendingContext.browsingContext.top.embedderElement
+ );
+ return true;
+ },
+ },
+ });
+
+ let isPrivateBrowsingMode = helpers.isPrivateBrowsingMode(mockSendingContext);
+ Assert.ok(isPrivateBrowsingMode);
+ Assert.ok(wasCalled.isBrowserPrivate);
+});
+
+add_task(async function test_helpers_isPrivateBrowsingMode_private_browsing() {
+ let wasCalled = {
+ isBrowserPrivate: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ privateBrowsingUtils: {
+ isBrowserPrivate(browser) {
+ wasCalled.isBrowserPrivate = true;
+ Assert.equal(
+ browser,
+ mockSendingContext.browsingContext.top.embedderElement
+ );
+ return false;
+ },
+ },
+ });
+
+ let isPrivateBrowsingMode = helpers.isPrivateBrowsingMode(mockSendingContext);
+ Assert.ok(!isPrivateBrowsingMode);
+ Assert.ok(wasCalled.isBrowserPrivate);
+});
+
+add_task(async function test_helpers_change_password() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ updateUserAccountData(credentials) {
+ return new Promise(resolve => {
+ Assert.ok(credentials.hasOwnProperty("email"));
+ Assert.ok(credentials.hasOwnProperty("uid"));
+ Assert.ok(credentials.hasOwnProperty("unwrapBKey"));
+ Assert.ok(credentials.hasOwnProperty("device"));
+ Assert.equal(null, credentials.device);
+ Assert.equal(null, credentials.encryptedSendTabKeys);
+ // "foo" isn't a field known by storage, so should be dropped.
+ Assert.ok(!credentials.hasOwnProperty("foo"));
+ wasCalled.updateUserAccountData = true;
+
+ resolve();
+ });
+ },
+
+ updateDeviceRegistration() {
+ Assert.equal(arguments.length, 0);
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve();
+ },
+ },
+ },
+ });
+ await helpers.changePassword({
+ email: "email",
+ uid: "uid",
+ unwrapBKey: "unwrapBKey",
+ foo: "foo",
+ });
+ Assert.ok(wasCalled.updateUserAccountData);
+ Assert.ok(wasCalled.updateDeviceRegistration);
+});
+
+add_task(async function test_helpers_change_password_with_error() {
+ let wasCalled = {
+ updateUserAccountData: false,
+ updateDeviceRegistration: false,
+ };
+ let helpers = new FxAccountsWebChannelHelpers({
+ fxAccounts: {
+ _internal: {
+ updateUserAccountData() {
+ wasCalled.updateUserAccountData = true;
+ return Promise.reject();
+ },
+
+ updateDeviceRegistration() {
+ wasCalled.updateDeviceRegistration = true;
+ return Promise.resolve();
+ },
+ },
+ },
+ });
+ try {
+ await helpers.changePassword({});
+ Assert.equal(false, "changePassword should have rejected");
+ } catch (_) {
+ Assert.ok(wasCalled.updateUserAccountData);
+ Assert.ok(!wasCalled.updateDeviceRegistration);
+ }
+});
+
+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);
+ return removeMe;
+}
+
+function validationHelper(params, expected) {
+ try {
+ new FxAccountsWebChannel(params);
+ } catch (e) {
+ if (typeof expected === "string") {
+ return Assert.equal(e.toString(), expected);
+ }
+ return Assert.ok(e.toString().match(expected));
+ }
+ throw new Error("Validation helper error");
+}
diff --git a/services/fxaccounts/tests/xpcshell/xpcshell.toml b/services/fxaccounts/tests/xpcshell/xpcshell.toml
new file mode 100644
index 0000000000..7fc9c60006
--- /dev/null
+++ b/services/fxaccounts/tests/xpcshell/xpcshell.toml
@@ -0,0 +1,49 @@
+[DEFAULT]
+head = "head.js ../../../common/tests/unit/head_helpers.js ../../../common/tests/unit/head_http.js"
+firefox-appdir = "browser"
+skip-if = [
+ "os == 'android'",
+ "appname == 'thunderbird'",
+]
+support-files = [
+ "!/services/common/tests/unit/head_helpers.js",
+ "!/services/common/tests/unit/head_http.js",
+]
+
+["test_accounts.js"]
+
+["test_accounts_config.js"]
+
+["test_accounts_device_registration.js"]
+
+["test_client.js"]
+
+["test_commands.js"]
+
+["test_credentials.js"]
+
+["test_device.js"]
+
+["test_keys.js"]
+
+["test_loginmgr_storage.js"]
+
+["test_oauth_flow.js"]
+
+["test_oauth_token_storage.js"]
+
+["test_oauth_tokens.js"]
+
+["test_pairing.js"]
+
+["test_profile.js"]
+
+["test_profile_client.js"]
+
+["test_push_service.js"]
+
+["test_storage_manager.js"]
+
+["test_telemetry.js"]
+
+["test_web_channel.js"]