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