diff options
Diffstat (limited to 'services/fxaccounts/tests')
28 files changed, 10521 insertions, 0 deletions
diff --git a/services/fxaccounts/tests/browser/browser.toml b/services/fxaccounts/tests/browser/browser.toml new file mode 100644 index 0000000000..7cb752b59a --- /dev/null +++ b/services/fxaccounts/tests/browser/browser.toml @@ -0,0 +1,6 @@ +[DEFAULT] +support-files = ["head.js"] + +["browser_device_connected.js"] + +["browser_verify_login.js"] diff --git a/services/fxaccounts/tests/browser/browser_device_connected.js b/services/fxaccounts/tests/browser/browser_device_connected.js new file mode 100644 index 0000000000..8e567ddf35 --- /dev/null +++ b/services/fxaccounts/tests/browser/browser_device_connected.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { FxAccounts } = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); + +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver +); +const DEVICES_URL = "https://example.com/devices"; + +add_setup(async function () { + const origManageDevicesURI = FxAccounts.config.promiseManageDevicesURI; + FxAccounts.config.promiseManageDevicesURI = () => + Promise.resolve(DEVICES_URL); + setupMockAlertsService(); + + registerCleanupFunction(function () { + FxAccounts.config.promiseManageDevicesURI = origManageDevicesURI; + delete window.FxAccounts; + }); +}); + +async function testDeviceConnected(deviceName) { + info("testDeviceConnected with deviceName=" + deviceName); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:mozilla" + ); + await waitForDocLoadComplete(); + + let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + Services.obs.notifyObservers(null, "fxaccounts:device_connected", deviceName); + + let tab = await waitForTabPromise; + Assert.ok("Tab successfully opened"); + + Assert.equal(tab.linkedBrowser.currentURI.spec, DEVICES_URL); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function () { + await testDeviceConnected("My phone"); +}); + +add_task(async function () { + await testDeviceConnected(null); +}); diff --git a/services/fxaccounts/tests/browser/browser_verify_login.js b/services/fxaccounts/tests/browser/browser_verify_login.js new file mode 100644 index 0000000000..fa9d952a52 --- /dev/null +++ b/services/fxaccounts/tests/browser/browser_verify_login.js @@ -0,0 +1,33 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_task(async function () { + let payload = { + data: { + deviceName: "Laptop", + url: "https://example.com/newLogin", + title: "Sign-in Request", + body: "New sign-in request from vershwal's Nighty on Intel Mac OS X 10.12", + }, + }; + info("testVerifyNewSignin"); + setupMockAlertsService(); + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "about:mozilla" + ); + await waitForDocLoadComplete(); + + let waitForTabPromise = BrowserTestUtils.waitForNewTab(gBrowser); + + Services.obs.notifyObservers( + null, + "fxaccounts:verify_login", + JSON.stringify(payload.data) + ); + + let tab = await waitForTabPromise; + Assert.ok("Tab successfully opened"); + Assert.equal(tab.linkedBrowser.currentURI.spec, payload.data.url); + BrowserTestUtils.removeTab(tab); +}); diff --git a/services/fxaccounts/tests/browser/head.js b/services/fxaccounts/tests/browser/head.js new file mode 100644 index 0000000000..e9fb8ad0dc --- /dev/null +++ b/services/fxaccounts/tests/browser/head.js @@ -0,0 +1,73 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Waits for the next load to complete in any browser or the given browser. + * If a <tabbrowser> is given it waits for a load in any of its browsers. + * + * @return promise + */ +function waitForDocLoadComplete(aBrowser = gBrowser) { + return new Promise(resolve => { + let listener = { + onStateChange(webProgress, req, flags, status) { + let docStop = + Ci.nsIWebProgressListener.STATE_IS_NETWORK | + Ci.nsIWebProgressListener.STATE_STOP; + info( + "Saw state " + + flags.toString(16) + + " and status " + + status.toString(16) + ); + + // When a load needs to be retargetted to a new process it is cancelled + // with NS_BINDING_ABORTED so ignore that case + if ((flags & docStop) == docStop && status != Cr.NS_BINDING_ABORTED) { + aBrowser.removeProgressListener(this); + waitForDocLoadComplete.listeners.delete(this); + + let chan = req.QueryInterface(Ci.nsIChannel); + info("Browser loaded " + chan.originalURI.spec); + resolve(); + } + }, + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + ]), + }; + aBrowser.addProgressListener(listener); + waitForDocLoadComplete.listeners.add(listener); + info("Waiting for browser load"); + }); +} + +function setupMockAlertsService() { + const alertsService = { + showAlertNotification: ( + image, + title, + text, + clickable, + cookie, + clickCallback + ) => { + // We are invoking the event handler ourselves directly. + clickCallback(null, "alertclickcallback", null); + }, + }; + const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + gBrowserGlue.observe( + { wrappedJSObject: alertsService }, + "browser-glue-test", + "mock-alerts-service" + ); +} + +// Keep a set of progress listeners for waitForDocLoadComplete() to make sure +// they're not GC'ed before we saw the page load. +waitForDocLoadComplete.listeners = new Set(); +registerCleanupFunction(() => waitForDocLoadComplete.listeners.clear()); diff --git a/services/fxaccounts/tests/mochitest/chrome.toml b/services/fxaccounts/tests/mochitest/chrome.toml new file mode 100644 index 0000000000..5e88133317 --- /dev/null +++ b/services/fxaccounts/tests/mochitest/chrome.toml @@ -0,0 +1,5 @@ +[DEFAULT] +skip-if = ["os == 'android'"] +support-files = ["file_invalidEmailCase.sjs"] + +["test_invalidEmailCase.html"] diff --git a/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs new file mode 100644 index 0000000000..971cf52bba --- /dev/null +++ b/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This server simulates the behavior of /account/login on the Firefox Accounts + * auth server in the case where the user is trying to sign in with an email + * with the wrong capitalization. + * + * https://github.com/mozilla/fxa-auth-server/blob/master/docs/api.md#post-v1accountlogin + * + * The expected behavior is that on the first attempt, with the wrong email, + * the server will respond with a 400 and the canonical email capitalization + * that the client should use. The client then has one chance to sign in with + * this different capitalization. + * + * In this test, the user with the account id "Greta.Garbo@gmail.COM" initially + * tries to sign in as "greta.garbo@gmail.com". + * + * On success, the client is responsible for updating its sign-in user state + * and recording the proper email capitalization. + */ + +const CC = Components.Constructor; +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const goodEmail = "Greta.Garbo@gmail.COM"; +const badEmail = "greta.garbo@gmail.com"; + +function handleRequest(request, response) { + let body = new BinaryInputStream(request.bodyInputStream); + let bytes = []; + let available; + while ((available = body.available()) > 0) { + Array.prototype.push.apply(bytes, body.readByteArray(available)); + } + + let data = JSON.parse(String.fromCharCode.apply(null, bytes)); + let message; + + switch (data.email) { + case badEmail: + // Almost - try again with fixed email case + message = { + code: 400, + errno: 120, + error: "Incorrect email case", + email: goodEmail, + }; + response.setStatusLine(request.httpVersion, 400, "Almost"); + break; + + case goodEmail: + // Successful login. + message = { + uid: "your-uid", + sessionToken: "your-sessionToken", + keyFetchToken: "your-keyFetchToken", + verified: true, + authAt: 1392144866, + }; + response.setStatusLine(request.httpVersion, 200, "Yay"); + break; + + default: + // Anything else happening in this test is a failure. + message = { + code: 400, + errno: 999, + error: "What happened!?", + }; + response.setStatusLine(request.httpVersion, 400, "Ouch"); + break; + } + + let messageStr = JSON.stringify(message); + response.bodyOutputStream.write(messageStr, messageStr.length); +} diff --git a/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html new file mode 100644 index 0000000000..4b5e943591 --- /dev/null +++ b/services/fxaccounts/tests/mochitest/test_invalidEmailCase.html @@ -0,0 +1,129 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> +<!DOCTYPE HTML> +<html> +<!-- +Tests for Firefox Accounts signin with invalid email case +https://bugzilla.mozilla.org/show_bug.cgi?id=963835 +--> +<head> + <title>Test for Firefox Accounts (Bug 963835)</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css" /> +</head> +<body> + +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=963835">Mozilla Bug 963835</a> +<p id="display"></p> +<div id="content" style="display: none"> + Test for correction of invalid email case in Fx Accounts signIn +</div> +<pre id="test"> +<script class="testbody" type="text/javascript"> + +SimpleTest.waitForExplicitFinish(); + +const {FxAccounts} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" +); +const {FxAccountsClient} = ChromeUtils.importESModule( + "resource://gre/modules/FxAccountsClient.sys.mjs" +); + +const TEST_SERVER = + "http://mochi.test:8888/chrome/services/fxaccounts/tests/mochitest/file_invalidEmailCase.sjs?path="; + +let MockStorage = function() { + this.data = null; +}; +MockStorage.prototype = Object.freeze({ + set(contents) { + this.data = contents; + return Promise.resolve(null); + }, + get() { + return Promise.resolve(this.data); + }, + getOAuthTokens() { + return Promise.resolve(null); + }, + setOAuthTokens(contents) { + return Promise.resolve(); + }, +}); + +function MockFxAccounts() { + return new FxAccounts({ + _now_is: new Date(), + + now() { + return this._now_is; + }, + + signedInUserStorage: new MockStorage(), + + fxAccountsClient: new FxAccountsClient(TEST_SERVER), + }); +} + +let wrongEmail = "greta.garbo@gmail.com"; +let rightEmail = "Greta.Garbo@gmail.COM"; +let password = "123456"; + +function runTest() { + is(Services.prefs.getStringPref("identity.fxaccounts.auth.uri"), TEST_SERVER, + "Pref for auth.uri should be set to test server"); + + let fxa = new MockFxAccounts(); + let client = fxa._internal.fxAccountsClient; + + is(true, !!fxa, "Couldn't mock fxa"); + is(true, !!client, "Couldn't mock fxa client"); + is(client.host, TEST_SERVER, "Should be using the test auth server uri"); + + // First try to sign in using the email with the wrong capitalization. The + // FxAccountsClient will receive a 400 from the server with the corrected email. + // It will automatically try to sign in again. We expect this to succeed. + client.signIn(wrongEmail, password).then( + user => { + // Now store the signed-in user state. This will include the correct + // email capitalization. + fxa._internal.setSignedInUser(user).then( + () => { + // Confirm that the correct email got stored. + fxa.getSignedInUser().then( + data => { + is(data.email, rightEmail); + SimpleTest.finish(); + }, + getUserError => { + ok(false, JSON.stringify(getUserError)); + } + ); + }, + setSignedInUserError => { + ok(false, JSON.stringify(setSignedInUserError)); + } + ); + }, + signInError => { + ok(false, JSON.stringify(signInError)); + } + ); +} + +SpecialPowers.pushPrefEnv({"set": [ + ["identity.fxaccounts.enabled", true], // fx accounts + ["identity.fxaccounts.auth.uri", TEST_SERVER], // our sjs server + ["browser.dom.window.dump.enabled", true], + ["devtools.console.stdout.chrome", true], + ]}, + function() { runTest(); } +); + +</script> +</pre> +</body> +</html> 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"] |