diff --git a/app/scripts/lib/app-start.js b/app/scripts/lib/app-start.js index a4dbb09c6a..5b84ea458d 100644 --- a/app/scripts/lib/app-start.js +++ b/app/scripts/lib/app-start.js @@ -35,6 +35,7 @@ define([ 'lib/constants', 'lib/oauth-client', 'lib/auth-errors', + 'lib/channels/inter-tab', 'models/reliers/relier', 'models/reliers/oauth', 'models/reliers/fx-desktop' @@ -57,6 +58,7 @@ function ( Constants, OAuthClient, AuthErrors, + InterTabChannel, Relier, OAuthRelier, FxDesktopRelier @@ -91,7 +93,8 @@ function ( // fetch both config and translations in parallel to speed up load. return p.all([ this.initializeConfig(), - this.initializeL10n() + this.initializeL10n(), + this.initializeInterTabChannel() ]) .then(_.bind(this.allResourcesReady, this)) .then(null, function (err) { @@ -111,6 +114,10 @@ function ( }); }, + initializeInterTabChannel: function () { + this._interTabChannel = new InterTabChannel(); + }, + initializeConfig: function () { return this._configLoader.fetch() .then(_.bind(this.useConfig, this)) @@ -119,7 +126,8 @@ function ( .then(_.bind(this.initializeRelier, this)) // channels relies on the relier .then(_.bind(this.initializeChannels, this)) - // fxaClient depends on the relier. + // fxaClient depends on the relier and + // inter tab communication. .then(_.bind(this.initializeFxaClient, this)) // profileClient dependsd on fxaClient. .then(_.bind(this.initializeProfileClient, this)) @@ -197,7 +205,8 @@ function ( initializeFxaClient: function () { if (! this._fxaClient) { this._fxaClient = new FxaClient({ - relier: this._relier + relier: this._relier, + interTabChannel: this._interTabChannel }); } }, @@ -218,7 +227,8 @@ function ( language: this._config.language, relier: this._relier, fxaClient: this._fxaClient, - profileClient: this._profileClient + profileClient: this._profileClient, + interTabChannel: this._interTabChannel }); } this._window.router = this._router; diff --git a/app/scripts/lib/channels/inter-tab.js b/app/scripts/lib/channels/inter-tab.js new file mode 100644 index 0000000000..bcf98d0328 --- /dev/null +++ b/app/scripts/lib/channels/inter-tab.js @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This is a special channel that communicates between two + * tabs of the same browser. It uses localStorage to communicate. + */ + +'use strict'; + +define([ + 'crosstab' +], function (crosstab) { + + function InterTabChannel(options) { + options = options || {}; + this._crosstab = options.crosstab || crosstab; + } + + InterTabChannel.prototype = { + emit: function (name, data) { + // Sensitive data is sent across the channel and should only + // be in localStorage if absolutely necessary. Only send + // data if another tab is listening. + if (this._crosstab.util.tabCount() > 1) { + try { + this._crosstab.broadcast(name, data, null); + } catch (e) { + // this can blow up if the browser does not support localStorage + // or if on a mobile device. Ignore the error. + } + } + }, + + on: function (name, callback) { + this._crosstab.util.events.on(name, callback); + }, + + off: function (name, callback) { + this._crosstab.util.events.off(name, callback); + }, + + clearMessages: function () { + this._crosstab.util.clearMessages(); + } + }; + + return InterTabChannel; +}); diff --git a/app/scripts/lib/fxa-client.js b/app/scripts/lib/fxa-client.js index a8d0bffd3b..e88028a9c1 100644 --- a/app/scripts/lib/fxa-client.js +++ b/app/scripts/lib/fxa-client.js @@ -32,6 +32,8 @@ function (_, FxaClient, $, p, Session, AuthErrors, Constants, Channels, BaseReli this._signUpResendCount = 0; this._passwordResetResendCount = 0; this._channel = options.channel; + this._interTabChannel = options.interTabChannel; + // BaseRelier is used as a NullRelier for testing. this._relier = options.relier || new BaseRelier(); } @@ -140,7 +142,11 @@ function (_, FxaClient, $, p, Session, AuthErrors, Constants, Channels, BaseReli sessionTokenContext: self._relier.get('context') }; - if (self._relier.isFxDesktop()) { + // isSync is added in case the user verifies in a second tab + // on the first browser, the context will not be available. We + // need to ship the keyFetchToken and unwrapBKey to the first tab, + // so generate these any time we are using sync as well. + if (self._relier.isFxDesktop() || self._relier.isSync()) { updatedSessionData.unwrapBKey = accountData.unwrapBKey; updatedSessionData.keyFetchToken = accountData.keyFetchToken; updatedSessionData.customizeSync = options.customizeSync; @@ -156,25 +162,38 @@ function (_, FxaClient, $, p, Session, AuthErrors, Constants, Channels, BaseReli } Session.set(updatedSessionData); - // Skipping the relink warning is only relevant to the channel - // communication with the Desktop browser. It may have been - // done during a sign up flow. - updatedSessionData.verifiedCanLinkAccount = true; - - return Channels.sendExpectResponse('login', updatedSessionData, { - channel: self._channel - }).then(function () { - return accountData; - }, function (err) { - // We ignore this error unless the service is set to Sync. - // This allows us to tests flows with the desktop context - // without things blowing up. In production/reality, - // the context is set to desktop iff service is Sync. - if (self._relier.isSync()) { - throw err; - } - return accountData; - }); + if (self._interTabChannel) { + self._interTabChannel.emit('login', updatedSessionData); + } + + if (options.notifyChannel !== false) { + return self.notifyChannelOfLogin(updatedSessionData) + .then(function () { + return accountData; + }); + } + }); + }, + + // TODO - this should go on the broker when that + // functionality is available. + notifyChannelOfLogin: function (dataToSend) { + // Skipping the relink warning is only relevant to the channel + // communication with the Desktop browser. It may have been + // done during a sign up flow. + dataToSend.verifiedCanLinkAccount = true; + + var self = this; + return Channels.sendExpectResponse('login', dataToSend, { + channel: self._channel + }).then(null, function (err) { + // We ignore this error unless the service is set to Sync. + // This allows us to tests flows with the desktop context + // without things blowing up. In production/reality, + // the context is set to desktop iff service is Sync. + if (self._relier.isSync()) { + throw err; + } }); }, @@ -326,9 +345,12 @@ function (_, FxaClient, $, p, Session, AuthErrors, Constants, Channels, BaseReli }); }, - completePasswordReset: function (originalEmail, newPassword, token, code) { + completePasswordReset: function (originalEmail, newPassword, token, code, + options) { + options = options || {}; var email = trim(originalEmail); var client; + var self = this; return this._getClientAsync() .then(function (_client) { client = _client; @@ -338,6 +360,17 @@ function (_, FxaClient, $, p, Session, AuthErrors, Constants, Channels, BaseReli return client.accountReset(email, newPassword, result.accountResetToken); + }) + .then(function () { + if (options.shouldSignIn) { + // Sync should not notify the channel on password reset + // because nobody will be listening for the message and + // a channel timeout will occur if the message is sent. + // If the original tab is still open, it'll handle things. + return self.signIn(email, newPassword, { + notifyChannel: ! self._relier.isSync() + }); + } }); }, diff --git a/app/scripts/lib/url.js b/app/scripts/lib/url.js index 48d436c7f4..adb5f542ec 100644 --- a/app/scripts/lib/url.js +++ b/app/scripts/lib/url.js @@ -38,16 +38,6 @@ function (_) { return allowedTerms; } - /** - * Returns true if given "uri" has HTTP or HTTPS scheme - * - * @param {String} uri - * @returns {boolean} - */ - function isHTTP (uri) { - return /^(http|https):\/\//.test(uri); - } - return { searchParams: searchParams, searchParam: function (name, str) { @@ -63,8 +53,7 @@ function (_) { .replace(/\/$/, '') // search params can contain sensitive info .replace(/\?.*/, ''); - }, - isHTTP: isHTTP + } }; }); diff --git a/app/scripts/require_config.js b/app/scripts/require_config.js index 924a39a32f..a82473f003 100644 --- a/app/scripts/require_config.js +++ b/app/scripts/require_config.js @@ -18,7 +18,8 @@ require.config({ speedTrap: '../bower_components/speed-trap/dist/speed-trap', md5: '../bower_components/JavaScript-MD5/js/md5', canvasToBlob: '../bower_components/blueimp-canvas-to-blob/js/canvas-to-blob', - moment: '../bower_components/moment/moment' + moment: '../bower_components/moment/moment', + crosstab: 'vendor/crosstab' }, config: { moment: { @@ -48,6 +49,9 @@ require.config({ }, sinon: { exports: 'sinon' + }, + crosstab: { + exports: 'crosstab' } }, stache: { diff --git a/app/scripts/router.js b/app/scripts/router.js index d695ec412e..7215bcd324 100644 --- a/app/scripts/router.js +++ b/app/scripts/router.js @@ -80,7 +80,8 @@ function ( language: this.language, relier: this.relier, fxaClient: this.fxaClient, - profileClient: this.profileClient + profileClient: this.profileClient, + interTabChannel: this.interTabChannel }, options || {}); this.showView(new View(options)); @@ -129,6 +130,7 @@ function ( this.relier = options.relier; this.fxaClient = options.fxaClient; this.profileClient = options.profileClient; + this.interTabChannel = options.interTabChannel; this.$stage = $('#stage'); diff --git a/app/scripts/vendor/crosstab.js b/app/scripts/vendor/crosstab.js new file mode 100644 index 0000000000..77f2f0be42 --- /dev/null +++ b/app/scripts/vendor/crosstab.js @@ -0,0 +1,575 @@ +/** + * crosstab - A utility library from cross-tab communication using + * localStorage. + * + * Written by Tom Jacques + * + * Source: https://github.com/tejacques/crosstab + * + * Apache 2.0 License + * - https://github.com/tejacques/crosstab/blob/master/LICENSE + * + * Our local modifications remove the frozen tab check. + */ +'use strict'; + +define([], function () { + + // --- Handle Support --- + // See: http://detectmobilebrowsers.com/about + var useragent = navigator.userAgent || navigator.vendor || window.opera; + window.isMobile = (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(useragent) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(useragent.substr(0, 4))); + + var localStorage = window.localStorage; + + function notSupported() { + var errorMsg = 'crosstab not supported'; + var reasons = []; + if (!localStorage) { + reasons.push('localStorage not availabe'); + } + if (!window.addEventListener) { + reasons.push('addEventListener not available'); + } + if (window.isMobile) { + reasons.push('mobile browser'); + } + + if(reasons.length > 0) { + errorMsg += ': ' + reasons.join(', '); + } + + throw new Error(errorMsg); + } + + // --- Utility --- + var util = { + keys: { + MESSAGE_KEY: 'crosstab.MESSAGE_KEY', + TABS_KEY: 'crosstab.TABS_KEY', + MASTER_TAB: 'MASTER_TAB', + SUPPORTED_KEY: 'crosstab.SUPPORTED' + } + }; + + util.forEachObj = function (thing, fn) { + for (var key in thing) { + if (thing.hasOwnProperty(key)) { + fn.call(thing, thing[key], key); + } + } + }; + + util.forEachArr = function (thing, fn) { + for (var i = 0; i < thing.length; i++) { + fn.call(thing, thing[i], i); + } + }; + + util.forEach = function (thing, fn) { + if (Object.prototype.toString.call(thing) === '[object Array]') { + util.forEachArr(thing, fn); + } else { + util.forEachObj(thing, fn); + } + }; + + util.map = function (thing, fn) { + var res = []; + util.forEach(thing, function (item) { + res.push(fn(item)); + }); + + return res; + }; + + util.filter = function (thing, fn) { + var isArr = Object.prototype.toString.call(thing) === '[object Array]'; + var res = isArr ? [] : {}; + + if (isArr) { + util.forEachArr(thing, function (value, key) { + if (fn(value, key)) { + res.push(value); + } + }); + } else { + util.forEachObj(thing, function (value, key) { + if (fn(value, key)) { + res[key] = value; + } + }); + } + + return res; + }; + + util.now = function () { + return (new Date()).getTime(); + }; + + util.tabs = getStoredTabs(); + util.tabCount = function () { + return Object.keys(getStoredTabs()).length; + }; + + util.clearMessages = function () { + broadcast('overwrite_all'); + }; + + util.eventTypes = { + becomeMaster: 'becomeMaster', + tabUpdated: 'tabUpdated', + tabClosed: 'tabClosed', + tabPromoted: 'tabPromoted' + }; + + // --- Events --- + // node.js style events, with the main difference being object based + // rather than array based, as well as being able to add/remove + // events by key. + util.createEventHandler = function () { + var events = {}; + + var addListener = function (event, listener, key) { + key = key || listener; + var handlers = listeners(event); + handlers[key] = listener; + + return key; + }; + + var removeListener = function (event, key) { + if (events[event] && events[event][key]) { + delete events[event][key]; + return true; + } + return false; + }; + + var removeAllListeners = function (event) { + if (event) { + if (events[event]) { + delete events[event]; + } + } else { + events = {}; + } + }; + + var emit = function (event) { + var args = Array.prototype.slice.call(arguments, 1); + var handlers = listeners(event); + + util.forEach(handlers, function (listener) { + if (typeof (listener) === 'function') { + listener.apply(this, args); + } + }); + }; + + var once = function (event, listener, key) { + // Generate a unique id for this listener + var handlers = listeners(event); + while (!key || handlers[key]) { + key = util.generateId(); + } + + addListener(event, function () { + removeListener(event, key); + var args = Array.prototype.slice.call(arguments); + listener.apply(this, args); + }, key); + + return key; + }; + + var listeners = function (event) { + var handlers = events[event] = events[event] || {}; + return handlers; + }; + + return { + addListener: addListener, + on: addListener, + off: removeListener, + once: once, + emit: emit, + listeners: listeners, + removeListener: removeListener, + removeAllListeners: removeAllListeners + }; + }; + + // --- Setup Events --- + var eventHandler = util.createEventHandler(); + + // wrap eventHandler so that setting it will not blow up + // any of the internal workings + util.events = { + addListener: eventHandler.addListener, + on: eventHandler.on, + off: eventHandler.off, + once: eventHandler.once, + emit: eventHandler.emit, + listeners: eventHandler.listeners, + removeListener: eventHandler.removeListener, + removeAllListeners: eventHandler.removeAllListeners + }; + + function onStorageEvent(event) { + var eventValue; + try { + eventValue = event.newValue ? JSON.parse(event.newValue) : {}; + } catch (e) { + eventValue = {}; + } + if (!eventValue.id || eventValue.id === crosstab.id) { + // This is to force IE to behave properly + return; + } + if (event.key === util.keys.MESSAGE_KEY) { + var message = eventValue.data; + // only handle if this message was meant for this tab. + if (!message.destination || message.destination === crosstab.id) { + eventHandler.emit(message.event, message); + } + } + } + + function setLocalStorageItem(key, data) { + var storageItem = { + id: crosstab.id, + data: data, + timestamp: util.now() + }; + + localStorage.setItem(key, JSON.stringify(storageItem)); + } + + function getLocalStorageItem(key) { + var item = getLocalStorageRaw(key); + return item.data; + } + + function getLocalStorageRaw(key) { + var json = localStorage.getItem(key); + var item = json ? JSON.parse(json) : {}; + return item; + } + + function beforeUnload() { + var numTabs = 0; + util.forEach(util.tabs, function (tab, key) { + if (key !== util.keys.MASTER_TAB) { + numTabs++; + } + }); + + if (numTabs === 1) { + util.tabs = {}; + setStoredTabs(); + } else { + broadcast(util.eventTypes.tabClosed, crosstab.id); + } + } + + function getMaster() { + return util.tabs[util.keys.MASTER_TAB]; + } + + function setMaster(newMaster) { + util.tabs[util.keys.MASTER_TAB] = newMaster; + } + + function deleteMaster() { + delete util.tabs[util.keys.MASTER_TAB]; + } + + function isMaster() { + return getMaster().id === crosstab.id; + } + + function masterTabElection() { + var maxId = null; + util.forEach(util.tabs, function (tab) { + if (!maxId || tab.id < maxId) { + maxId = tab.id; + } + }); + + // only broadcast the promotion if I am the new master + if (maxId === crosstab.id) { + broadcast(util.eventTypes.tabPromoted, crosstab.id); + } else { + // this is done so that in the case where multiple tabs are being + // started at the same time, and there is no current saved tab + // information, we will still have a value set for the master tab + setMaster({ + id: maxId, + lastUpdated: util.now() + }); + } + } + + // Handle other tabs closing by updating internal tab model, and promoting + // self if we are the lowest tab id + eventHandler.addListener(util.eventTypes.tabClosed, function (message) { + var id = message.data; + if (util.tabs[id]) { + delete util.tabs[id]; + } + + if (!getMaster() || getMaster().id === id) { + // If the master was the closed tab, delete it and the highest + // tab ID becomes the new master, which will save the tabs + if (getMaster()) { + deleteMaster(); + } + masterTabElection(); + } else if (getMaster().id === crosstab.id) { + // If I am master, save the new tabs out + setStoredTabs(); + } + }); + + eventHandler.addListener(util.eventTypes.tabUpdated, function (message) { + var tab = message.data; + util.tabs[tab.id] = tab; + + // If there is no master, hold an election + if (!getMaster()) { + masterTabElection(); + } + + if (getMaster().id === tab.id) { + setMaster(tab); + } + if (getMaster().id === crosstab.id) { + // If I am master, save the new tabs out + setStoredTabs(); + } + }); + + eventHandler.addListener(util.eventTypes.tabPromoted, function (message) { + var id = message.data; + var lastUpdated = message.timestamp; + setMaster({ + id: id, + lastUpdated: lastUpdated + }); + + if (crosstab.id === id) { + // set the tabs in localStorage + setStoredTabs(); + + // emit the become master event so we can handle it accordingly + util.events.emit(util.eventTypes.becomeMaster); + } + }); + + function pad(num, width, padChar) { + padChar = padChar || '0'; + var numStr = (num.toString()); + + if (numStr.length >= width) { + return numStr; + } + + return new Array(width - numStr.length + 1).join(padChar) + numStr; + } + + util.generateId = function () { + /*jshint bitwise: false*/ + return util.now().toString() + pad((Math.random() * 0x7FFFFFFF) | 0, 10); + }; + + // --- Setup message sending and handling --- + function broadcast(event, data, destination) { + var message = { + event: event, + data: data, + destination: destination, + origin: crosstab.id, + timestamp: util.now() + }; + + // If the destination differs from the origin send it out, otherwise + // handle it locally + if (message.destination !== message.origin) { + setLocalStorageItem(util.keys.MESSAGE_KEY, message); + } + + if (!message.destination || message.destination === message.origin) { + eventHandler.emit(event, message); + } + } + + function broadcastMaster(event, data) { + broadcast(event, data, getMaster().id); + } + + function clear() { + for (var key in util.keys) { + localStorage.removeItem(util.keys[key]); + } + } + + // ---- Return ---- + var setupComplete = false; + util.events.once('setupComplete', function () { + setupComplete = true; + }); + + var crosstab = function (fn) { + if (setupComplete) { + fn(); + } else { + util.events.once('setupComplete', fn); + } + }; + + crosstab.id = util.generateId(); + crosstab.supported = !!localStorage && window.addEventListener && !window.isMobile; + crosstab.util = util; + crosstab.broadcast = broadcast; + crosstab.broadcastMaster = broadcastMaster; + crosstab.clear = clear; + + // --- Crosstab supported --- + // Check to see if the global supported key has been set. + if (!setupComplete) { + var supportedRaw = getLocalStorageRaw(util.keys.SUPPORTED_KEY); + var supported = supportedRaw.data; + if (supported === false || supported === true) { + // As long as it is explicitely set, use the value + crosstab.supported = supported; + util.events.emit('setupComplete'); + } + } + + // --- Tab Setup --- + // 3 second keepalive + var TAB_KEEPALIVE = 3 * 1000; + // 5 second timeout + var TAB_TIMEOUT = 5 * 1000; + // 100 ms ping timeout + var PING_TIMEOUT = 100; + + function getStoredTabs() { + var storedTabs = getLocalStorageItem(util.keys.TABS_KEY); + util.tabs = storedTabs || util.tabs || {}; + return util.tabs; + } + + function setStoredTabs() { + setLocalStorageItem(util.keys.TABS_KEY, util.tabs); + } + + function keepalive() { + var now = util.now(); + + var myTab = { + id: crosstab.id, + lastUpdated: now + }; + + // broadcast tabUpdated event + broadcast(util.eventTypes.tabUpdated, myTab); + + // broadcast tabClosed event for each tab that timed out + function stillAlive(tab) { + return now - tab.lastUpdated < TAB_TIMEOUT; + } + + function notAlive(tab, key) { + return key !== util.keys.MASTER_TAB && !stillAlive(tab); + } + + var deadTabs = util.filter(util.tabs, notAlive); + util.forEach(deadTabs, function (tab) { + broadcast(util.eventTypes.tabClosed, tab.id); + }); + + // check to see if setup is complete + if (!setupComplete) { + var masterTab = crosstab.util.tabs[crosstab.util.keys.MASTER_TAB]; + // ping master + if (masterTab && masterTab.id !== myTab.id) { + var timeout; + var start; + + crosstab.util.events.once('PONG', function () { + if (!setupComplete) { + clearTimeout(timeout); + // set supported to true / frozen to false + setLocalStorageItem( + util.keys.SUPPORTED_KEY, + true); + util.events.emit('setupComplete'); + } + }); + + start = util.now(); + + // There is a nested timeout here. We'll give it 100ms + // timeout, with iters "yields" to the event loop. So at least + // iters number of blocks of javascript will be able to run + // covering at least 100ms + var recursiveTimeout = function (iters) { + var diff = util.now() - start; + + if (!setupComplete) { + if (iters <= 0 && diff > PING_TIMEOUT) { + util.events.emit('setupComplete'); + } else { + timeout = setTimeout(function () { + recursiveTimeout(iters - 1); + }, 5); + } + } + }; + + var iterations = 5; + timeout = setTimeout(function () { + recursiveTimeout(5); + }, PING_TIMEOUT - 5 * iterations); + crosstab.broadcastMaster('PING'); + } else if (masterTab && masterTab.id === myTab.id) { + util.events.emit('setupComplete'); + } + } + } + + function keepaliveLoop() { + if (!crosstab.stopKeepalive) { + keepalive(); + window.setTimeout(keepaliveLoop, TAB_KEEPALIVE); + } + } + + // --- Check if crosstab is supported --- + if (!crosstab.supported) { + crosstab.broadcast = notSupported; + } else { + // ---- Setup Storage Listener + window.addEventListener('storage', onStorageEvent, false); + window.addEventListener('beforeunload', beforeUnload, false); + + util.events.on('PING', function (message) { + // only handle direct messages + if (!message.destination || message.destination !== crosstab.id) { + return; + } + + if (util.now() - message.timestamp < PING_TIMEOUT) { + crosstab.broadcast('PONG', null, message.origin); + } + }); + + keepaliveLoop(); + } + + return crosstab; +}); + diff --git a/app/scripts/views/complete_reset_password.js b/app/scripts/views/complete_reset_password.js index f1026ba9b6..020b8700e2 100644 --- a/app/scripts/views/complete_reset_password.js +++ b/app/scripts/views/complete_reset_password.js @@ -97,23 +97,19 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, FloatingPlace }, submit: function () { - var password = this._getPassword(); - var self = this; - return this.fxaClient.completePasswordReset(this.email, password, this.token, this.code) - .then(function () { - // Get a new sessionToken if we're in an OAuth flow - // so that we can generate FxA assertions - if (self.isOAuthSameBrowser()) { - // cache oauth params because signIn will clear them - var params = Session.oauth; - return self.fxaClient.signIn(self.email, password) - .then(function () { - // restore oauth params - Session.set('oauth', params); - }); - } - }) + var password = self._getPassword(); + + // If the user verifies in the same browser and the original tab + // is still open, we want the original tab to redirect back to + // the RP. The only way to do that is for this tab to sign in and + // get a sessionToken. When the reset password complete poll + // completes in the original tab, it will fetch the sessionToken + // from localStorage and go to town. + return self.fxaClient.completePasswordReset( + self.email, password, self.token, self.code, { + shouldSignIn: self._shouldSignIn() + }) .then(function () { self.navigate('reset_password_complete'); }, function (err) { @@ -129,6 +125,10 @@ function (_, BaseView, FormView, Template, Session, PasswordMixin, FloatingPlace }); }, + _shouldSignIn: function () { + return this.isOAuthSameBrowser() || this.relier.isSync(); + }, + _getPassword: function () { return this.$('#password').val(); }, diff --git a/app/scripts/views/confirm.js b/app/scripts/views/confirm.js index 30a9bb3792..db39f14029 100644 --- a/app/scripts/views/confirm.js +++ b/app/scripts/views/confirm.js @@ -10,10 +10,13 @@ define([ 'views/base', 'stache!templates/confirm', 'lib/session', + 'lib/promise', 'lib/auth-errors', - 'views/mixins/resend-mixin' + 'views/mixins/resend-mixin', + 'views/mixins/service-mixin' ], -function (_, FormView, BaseView, Template, Session, AuthErrors, ResendMixin) { +function (_, FormView, BaseView, Template, Session, p, AuthErrors, ResendMixin, + ServiceMixin) { var VERIFICATION_POLL_IN_MS = 4000; // 4 seconds var View = FormView.extend({ @@ -39,6 +42,8 @@ function (_, FormView, BaseView, Template, Session, AuthErrors, ResendMixin) { if (! Session.sessionToken) { this.navigate('signup'); return false; + } else if (this.relier.isOAuth()) { + this.setupOAuth(); } }, @@ -46,23 +51,40 @@ function (_, FormView, BaseView, Template, Session, AuthErrors, ResendMixin) { var graphic = this.$el.find('.graphic'); graphic.addClass('pulse'); - // If we're in an OAuth flow, start polling the user's verification - // status and transistion to the signup complete screen to complete the flow - if (Session.oauth) { - var self = this; - var pollFn = function () { - self.fxaClient.recoveryEmailStatus(Session.sessionToken) - .then(function (result) { - if (result.verified) { - self.navigate('signup_complete'); - } else { - self.setTimeout(pollFn, self.VERIFICATION_POLL_IN_MS); - } + var self = this; + self._waitForVerification() + .then(function () { + // The original window should always finish the OAuth flow. + if (self.relier.isOAuth()) { + self.finishOAuthFlow({ + source: 'signup' }); - }; + } else { + self.navigate('signup_complete'); + } + }, function (err) { + self.displayError(err); + }); + }, - this.setTimeout(pollFn, self.VERIFICATION_POLL_IN_MS); - } + _waitForVerification: function () { + var self = this; + return self.fxaClient.recoveryEmailStatus(Session.sessionToken) + .then(function (result) { + if (result.verified) { + return true; + } + + var deferred = p.defer(); + + // _waitForVerification will return a promise and the + // promise chain remains unbroken. + self.setTimeout(function () { + deferred.resolve(self._waitForVerification()); + }, self.VERIFICATION_POLL_IN_MS); + + return deferred.promise; + }); }, submit: function () { @@ -85,7 +107,7 @@ function (_, FormView, BaseView, Template, Session, AuthErrors, ResendMixin) { } }); - _.extend(View.prototype, ResendMixin); + _.extend(View.prototype, ResendMixin, ServiceMixin); return View; }); diff --git a/app/scripts/views/confirm_reset_password.js b/app/scripts/views/confirm_reset_password.js index 758bbd42f2..945d60aeb4 100644 --- a/app/scripts/views/confirm_reset_password.js +++ b/app/scripts/views/confirm_reset_password.js @@ -6,21 +6,33 @@ define([ 'underscore', + 'jquery', 'views/confirm', 'views/base', 'stache!templates/confirm_reset_password', + 'lib/promise', 'lib/session', 'lib/constants', 'lib/auth-errors', 'views/mixins/service-mixin' ], -function (_, ConfirmView, BaseView, Template, Session, Constants, AuthErrors, ServiceMixin) { +function (_, $, ConfirmView, BaseView, Template, p, Session, Constants, + AuthErrors, ServiceMixin) { var t = BaseView.t; + var SESSION_UPDATE_TIMEOUT_MS = 10000; + var View = ConfirmView.extend({ template: Template, className: 'confirm-reset-password', + initialize: function (options) { + options = options || {}; + this._interTabChannel = options.interTabChannel; + this._sessionUpdateTimeoutMS = options.sessionUpdateTimeoutMS || + SESSION_UPDATE_TIMEOUT_MS; + }, + events: { 'click #resend': BaseView.preventDefaultThen('validateAndSubmit'), 'click a[href="/signin"]': 'savePrefillEmailForSignin', @@ -43,43 +55,188 @@ function (_, ConfirmView, BaseView, Template, Session, Constants, AuthErrors, Se } }, + _getSignInRoute: function () { + if (this.relier.isOAuth()) { + return 'oauth/signin'; + } + return 'signin'; + }, + afterRender: function () { var bounceGraphic = this.$el.find('.graphic'); bounceGraphic.addClass('pulse'); - var signInRoute = 'signin'; var self = this; - if (this.isOAuthSameBrowser()) { - signInRoute = 'oauth/signin'; + if (self.relier.isOAuth()) { + this.setupOAuth(); this.setupOAuthLinks(); } + // this sequence is a bit tricky and needs to be explained. + // + // For OAuth and Sync, we are trying to make it so users who complete + // the password reset flow in another tab of the same browser are + // able to finish signing in if the original tab is still open. + // After requesting the password reset, the original tab sits and polls + // the server querying whether the password reset is complete. + // + // This crypto stuff needs to occur in the original tab because OAuth + // reliers and sync may only have the appropriate state in the original + // tab. This means, for password reset, we have to ship information like + // the unwrapBKey and keyFetchToken from tab 2 to tab 1. We have a plan, + // albeit a complex one. + // + // In tab 2, two auth server calls are made after the user + // fills out the new passwords and submits the form: + // + // 1. /account/reset + // 2. /account/login + // + // The first call resets the password, the second signs the user in + // so that Sync/OAuth key/code generation can occur. + // + // tab 1 will be notified that the password reset is complete + // after step 1. The problem is, tab 1 can only do its crypto + // business after step 2 and after the information has been shipped from + // tab 2 to tab 1. + // + // To communicate between tabs, a special channel is set up that makes + // use of localStorage as the comms medium. When tab 1 starts its poll, + // it also starts listening for messages on localStorage. This is in + // case the tab 2 finishes both #1 and #2 before the poll completes. + // If a message is received by time the poll completes, take the + // information from the message and sign the user in. + // + // If a message has not been received by time the poll completes, + // assume we are either in a second browser or in between steps #1 and + // #2. Start a timeout in case the user verifies in a second browser + // and the message is never received. If the timeout is reached, + // force a manual signin of the user. + // + // If a message is received before the timeout hits, HOORAY! + self._waitForVerification() + .then(function (sessionInfo) { + // The original window should finish the flow if the user + // completes verification in the same browser and has sessionInfo + // passed over from tab 2. + if (sessionInfo) { + return self._finishPasswordResetSameBrowser(sessionInfo); + } + + return self._finishPasswordResetDifferentBrowser(); + }) + .then(null, function (err) { + self.displayError(err); + }); + }, + + _waitForVerification: function () { + var self = this; + return p.all([ + self._waitForSessionUpdate(), + self._waitForServerVerificationNotice() + .then(function () { + if (! self._isWaitForSessionUpdateComplete) { + self._startSessionUpdateWaitTimeout(); + } + }) + ]).spread(function (sessionInfo) { + return sessionInfo; + }); + }, + + _finishPasswordResetSameBrowser: function (sessionInfo) { + var self = this; + + // The OAuth flow needs the sessionToken to finish the flow. + Session.set(sessionInfo); + + // TODO - This should move to the broker when that is ready. + if (self.relier.isOAuth()) { + return self.finishOAuthFlow({ + source: 'reset_password' + }); + } else if (self.relier.isSync()) { + // This should only happen for Sync if the user complete the + // password reset flow in the same browser. The tab the user + // completes the flow in should show the user a complete screen. + + // show a little placeholder message to facilitate manual testing. + // In real life, FxDesktop should display the "manage" screen. + self.displaySuccess(t('Password reset')); + return self.fxaClient.notifyChannelOfLogin(sessionInfo); + } + + self.navigate('reset_password_complete'); + }, + + _finishPasswordResetDifferentBrowser: function () { + var self = this; + // user verified in a different browser, make them sign in. OAuth + // users will be redirected back to the RP, Sync users will be + // taken to the Sync controlled completion page. + var email = Session.email; + Session.clear(); + Session.set('prefillEmail', email); + self.navigate(self._getSignInRoute(), { + success: t('Password reset. Sign in to continue.') + }); + }, + + _waitForServerVerificationNotice: function () { + var self = this; return self.fxaClient.isPasswordResetComplete(Session.passwordForgotToken) .then(function (isComplete) { if (isComplete) { - var email = Session.email; - Session.load(); - if (self.isOAuthSameBrowser() && Session.sessionToken) { - self.navigate('reset_password_complete'); - } else { - Session.clear(); - Session.set('prefillEmail', email); - self.navigate(signInRoute, { - success: t('Password reset. Sign in to continue.') - }); - } - } else { - var retryCB = _.bind(self.afterRender, self); - self.setTimeout(retryCB, Constants.RESET_PASSWORD_POLL_INTERVAL); + return true; } - return isComplete; - }, function (err) { - // an unexpected error occurred - console.error(err); + var deferred = p.defer(); + + self.setTimeout(function () { + // _waitForServerVerificationNotice will return a promise and the + // promise chain remains unbroken. + deferred.resolve(self._waitForServerVerificationNotice()); + }, self.VERIFICATION_POLL_IN_MS); + + return deferred.promise; }); }, + _waitForSessionUpdate: function () { + var deferred = p.defer(); + var self = this; + self._deferred = deferred; + + self._sessionUpdateNotificationHandler = _.bind(self._sessionUpdateWaitComplete, self); + self._interTabChannel.on('login', self._sessionUpdateNotificationHandler); + + return deferred.promise; + }, + + _startSessionUpdateWaitTimeout: function () { + var self = this; + self._sessionUpdateWaitTimeoutHandler = + _.bind(self._sessionUpdateWaitComplete, self, null); + self._sessionUpdateWaitTimeout = + self.setTimeout(self._sessionUpdateWaitTimeoutHandler, + self._sessionUpdateTimeoutMS); + }, + + _sessionUpdateWaitComplete: function (event) { + var self = this; + var data = event && event.data; + + self._isWaitForSessionUpdateComplete = true; + + self.clearTimeout(self._sessionUpdateWaitTimeout); + self._interTabChannel.off('login', self._sessionUpdateNotificationHandler); + // Sensitive data is passed between tabs using localStorage. + // Delete the data from storage as soon as possible. + self._interTabChannel.clearMessages(); + self._deferred.resolve(data); + }, + submit: function () { var self = this; diff --git a/app/scripts/views/mixins/service-mixin.js b/app/scripts/views/mixins/service-mixin.js index 18c0afe030..46564c76d0 100644 --- a/app/scripts/views/mixins/service-mixin.js +++ b/app/scripts/views/mixins/service-mixin.js @@ -23,11 +23,6 @@ define([ OAuthErrors, ConfigLoader, Session, ServiceName, Channels) { /* jshint camelcase: false */ - // If the user completes an OAuth flow using a different browser than - // they started with, we redirect them back to the RP with this code - // in the `error_code` query param. - var RP_DIFFERENT_BROWSER_ERROR_CODE = 3005; - var EXPECT_CHANNEL_RESPONSE_TIMEOUT = 5000; function shouldSetupOAuthLinksOnError () { @@ -122,13 +117,6 @@ define([ }); }, - finishOAuthFlowDifferentBrowser: function () { - return _notifyChannel.call(this, 'oauth_complete', { - redirect: this.relier.get('redirectUri'), - error: RP_DIFFERENT_BROWSER_ERROR_CODE - }); - }, - finishOAuthFlow: progressIndicator(function (viewOptions) { var self = this; return this._configLoader.fetch().then(function (config) { diff --git a/app/scripts/views/ready.js b/app/scripts/views/ready.js index bd3e79f9c5..f3029b8b82 100644 --- a/app/scripts/views/ready.js +++ b/app/scripts/views/ready.js @@ -13,17 +13,17 @@ define([ 'underscore', 'views/base', - 'views/form', 'stache!templates/ready', 'lib/session', 'lib/xss', - 'lib/url', 'lib/strings', 'lib/auth-errors', + 'lib/promise', 'views/mixins/service-mixin', 'views/marketing_snippet' ], -function (_, BaseView, FormView, Template, Session, Xss, Url, Strings, AuthErrors, ServiceMixin, MarketingSnippet) { +function (_, BaseView, Template, Session, Xss, Strings, + AuthErrors, p, ServiceMixin, MarketingSnippet) { var View = BaseView.extend({ template: Template, @@ -44,18 +44,6 @@ function (_, BaseView, FormView, Template, Session, Xss, Url, Strings, AuthError context: function () { var serviceName = this.relier.get('serviceName'); - var redirectUri = this.relier.get('redirectUri'); - - // if the given redirect uri is an URN based uri, such as - // "urn:ietf:wg:oauth:2.0:fx:webchannel" then we don't show - // clickable service links. The flow should be completed - // automatically depending on the flow it is using - // (such as iFrame or WebChannel). - if (redirectUri && Url.isHTTP(redirectUri)) { - serviceName = Strings.interpolate('%s', [ - Xss.href(redirectUri), serviceName - ]); - } return { service: this.relier.get('service'), @@ -94,16 +82,21 @@ function (_, BaseView, FormView, Template, Session, Xss, Url, Strings, AuthError }, submit: function () { - if (this.isOAuthSameBrowser()) { - return this.finishOAuthFlow({ - source: this.type - }); - } else if (this.isOAuthDifferentBrowser()) { - return this.finishOAuthFlowDifferentBrowser(); - } else { - // We aren't expecting this case to happen. - this.displayError(AuthErrors.toError('UNEXPECTED_ERROR')); - } + var self = this; + return p().then(function () { + if (self.isOAuthSameBrowser()) { + return self.finishOAuthFlow({ + source: self.type + }) + .then(function () { + // clear any stale OAuth information + Session.clear('oauth'); + }); + } else { + // We aren't expecting this case to happen. + self.displayError(AuthErrors.toError('UNEXPECTED_ERROR')); + } + }); }, is: function (type) { diff --git a/app/tests/mocks/crosstab.js b/app/tests/mocks/crosstab.js new file mode 100644 index 0000000000..7cb87752ea --- /dev/null +++ b/app/tests/mocks/crosstab.js @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +define([ + 'crosstab' +], function (crosstab) { + + function deepStub(target, source) { + for (var key in source) { + if (typeof source[key] === 'function') { + target[key] = function () {}; //jshint ignore:line + } + + if (typeof source[key] === 'object') { + target[key] = {}; + deepStub(target[key], source[key]); + } + } + + return target; + } + + + return function () { + // create a complete new copy of the tree + // every time a mock is created so that we + // can stub functions out and forget about + // restoring them between tests. + var CrossTabMock = {}; + deepStub(CrossTabMock, crosstab); + + return CrossTabMock; + }; +}); + + + diff --git a/app/tests/spec/lib/channels/inter-tab.js b/app/tests/spec/lib/channels/inter-tab.js new file mode 100644 index 0000000000..db146be792 --- /dev/null +++ b/app/tests/spec/lib/channels/inter-tab.js @@ -0,0 +1,95 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +'use strict'; + +define([ + 'chai', + 'sinon', + '../../../mocks/crosstab', + 'lib/channels/inter-tab' +], function (chai, sinon, CrossTabMock, InterTabChannel) { + describe('lib/channels/inter-tab', function () { + var assert = chai.assert; + + var interTabChannel; + var crossTabMock; + + beforeEach(function () { + crossTabMock = new CrossTabMock(); + + interTabChannel = new InterTabChannel({ + crosstab: crossTabMock + }); + }); + + afterEach(function () { + }); + + describe('emit', function () { + it('does not emit a message if no other tab is ready', function () { + sinon.stub(crossTabMock.util, 'tabCount', function () { + return 1; + }); + + sinon.spy(crossTabMock, 'broadcast'); + + interTabChannel.emit('message'); + assert.isFalse(crossTabMock.broadcast.called); + }); + + it('emits a message if another tab is ready', function () { + sinon.stub(crossTabMock.util, 'tabCount', function () { + return 2; + }); + + sinon.spy(crossTabMock, 'broadcast'); + + interTabChannel.emit('message'); + assert.isTrue(crossTabMock.broadcast.called); + }); + + it('does not blow up if the browser is not supported', function () { + sinon.stub(crossTabMock.util, 'tabCount', function () { + return 2; + }); + + sinon.stub(crossTabMock, 'broadcast', function () { + throw new Error('unsupported browser'); + }); + + interTabChannel.emit('message'); + }); + }); + + describe('on', function () { + it('register a callback to be called when a message is emitted', function () { + sinon.spy(crossTabMock.util.events, 'on'); + interTabChannel.on('message', function () {}); + + assert.isTrue(crossTabMock.util.events.on.called); + }); + }); + + describe('off', function () { + it('unregister a callback to be called when a message is emitted', function () { + sinon.spy(crossTabMock.util.events, 'off'); + interTabChannel.off('message', function () {}); + + assert.isTrue(crossTabMock.util.events.off.called); + }); + }); + + describe('clearMessages', function () { + it('clears all stored messages', function () { + sinon.spy(crossTabMock.util, 'clearMessages'); + interTabChannel.clearMessages(); + + assert.isTrue(crossTabMock.util.clearMessages.called); + }); + }); + }); +}); + + diff --git a/app/tests/spec/lib/fxa-client.js b/app/tests/spec/lib/fxa-client.js index 264368ba88..6b0a3e24f4 100644 --- a/app/tests/spec/lib/fxa-client.js +++ b/app/tests/spec/lib/fxa-client.js @@ -104,9 +104,9 @@ function (chai, $, sinon, p, ChannelMock, testHelpers, Session, }); it('informs browser of customizeSync option', function () { - relier.isFxDesktop = function () { + sinon.stub(relier, 'isSync', function () { return true; - }; + }); return client.signUp(email, password, { customizeSync: true }) .then(function () { @@ -256,9 +256,9 @@ function (chai, $, sinon, p, ChannelMock, testHelpers, Session, }); it('informs browser of customizeSync option', function () { - relier.isFxDesktop = function () { + sinon.stub(relier, 'isSync', function () { return true; - }; + }); return client.signUp(email, password) .then(function () { @@ -378,6 +378,36 @@ function (chai, $, sinon, p, ChannelMock, testHelpers, Session, }); describe('completePasswordReset', function () { + it('completes the password reset, signs the user in', function () { + var email = 'testuser@testuser.com'; + var password = 'password'; + var token = 'token'; + var code = 'code'; + + realClient.passwordForgotVerifyCode.restore(); + sinon.stub(realClient, 'passwordForgotVerifyCode', function () { + return p({ + accountResetToken: 'reset_token' + }); + }); + + realClient.accountReset.restore(); + sinon.stub(realClient, 'accountReset', function () { + return p(true); + }); + + sinon.stub(client, 'signIn', function () { + return p(true); + }); + + return client.completePasswordReset(email, password, token, code, { + shouldSignIn: true + }).then(function () { + assert.isTrue(realClient.passwordForgotVerifyCode.called); + assert.isTrue(realClient.accountReset.called); + assert.isTrue(client.signIn.called); + }); + }); }); describe('signOut', function () { diff --git a/app/tests/spec/lib/url.js b/app/tests/spec/lib/url.js index 74f07a35eb..8c0ffa1aa4 100644 --- a/app/tests/spec/lib/url.js +++ b/app/tests/spec/lib/url.js @@ -66,16 +66,6 @@ function (chai, _, Url) { assert.equal(Url.pathToScreenName('complete_sign_up?email=testuser@testuser.com'), 'complete_sign_up'); }); }); - - describe('isHTTP', function () { - it('detects HTTP and HTTPS properly', function () { - assert.isFalse(Url.isHTTP('urn:ietf:wg:oauth:2.0:fx:webchannel')); - assert.isTrue(Url.isHTTP('https://find.firefox.com')); - assert.isTrue(Url.isHTTP('http://find.firefox.com')); - assert.isFalse(Url.isHTTP('')); - assert.isFalse(Url.isHTTP()); - }); - }); }); }); diff --git a/app/tests/spec/views/complete_reset_password.js b/app/tests/spec/views/complete_reset_password.js index cbb0faf061..3a6ebcf38c 100644 --- a/app/tests/spec/views/complete_reset_password.js +++ b/app/tests/spec/views/complete_reset_password.js @@ -7,6 +7,7 @@ define([ 'chai', + 'sinon', 'lib/promise', 'lib/auth-errors', 'lib/metrics', @@ -17,7 +18,8 @@ define([ '../../mocks/window', '../../lib/helpers' ], -function (chai, p, AuthErrors, Metrics, FxaClient, View, Relier, RouterMock, WindowMock, TestHelpers) { +function (chai, sinon, p, AuthErrors, Metrics, FxaClient, View, Relier, + RouterMock, WindowMock, TestHelpers) { var assert = chai.assert; var wrapAssertion = TestHelpers.wrapAssertion; @@ -88,7 +90,27 @@ function (chai, p, AuthErrors, Metrics, FxaClient, View, Relier, RouterMock, Win }); it('shows malformed screen if the token is missing', function () { - windowMock.location.search = '?code=faea&email=testuser@testuser.com'; + windowMock.location.search = TestHelpers.toSearchString({ + code: 'faea', + email: 'testuser@testuser.com' + }); + + return view.render() + .then(function () { + testEventLogged('complete_reset_password.link_damaged'); + }) + .then(function () { + assert.ok(view.$('#fxa-reset-link-damaged-header').length); + }); + }); + + it('shows malformed screen if the token is invalid', function () { + windowMock.location.search = TestHelpers.toSearchString({ + token: 'invalid_token', // not a hex string + code: 'dea0fae1abc2fab3bed4dec5eec6ace7', + email: 'testuser@testuser.com' + }); + return view.render() .then(function () { testEventLogged('complete_reset_password.link_damaged'); @@ -99,7 +121,27 @@ function (chai, p, AuthErrors, Metrics, FxaClient, View, Relier, RouterMock, Win }); it('shows malformed screen if the code is missing', function () { - windowMock.location.search = '?token=feed&email=testuser@testuser.com'; + windowMock.location.search = TestHelpers.toSearchString({ + token: 'feed', + email: 'testuser@testuser.com' + }); + + return view.render() + .then(function () { + testEventLogged('complete_reset_password.link_damaged'); + }) + .then(function () { + assert.ok(view.$('#fxa-reset-link-damaged-header').length); + }); + }); + + it('shows malformed screen if the code is invalid', function () { + windowMock.location.search = TestHelpers.toSearchString({ + token: 'feed', + code: 'invalid_code', // not a hex string + email: 'testuser@testuser.com' + }); + return view.render() .then(function () { testEventLogged('complete_reset_password.link_damaged'); @@ -120,6 +162,22 @@ function (chai, p, AuthErrors, Metrics, FxaClient, View, Relier, RouterMock, Win }); }); + it('shows malformed screen if the email is invalid', function () { + windowMock.location.search = TestHelpers.toSearchString({ + token: 'feed', + code: 'dea0fae1abc2fab3bed4dec5eec6ace7', + email: 'does_not_validate' + }); + + return view.render() + .then(function () { + testEventLogged('complete_reset_password.link_damaged'); + }) + .then(function () { + assert.ok(view.$('#fxa-reset-link-damaged-header').length); + }); + }); + it('shows the expired screen if the token has already been used', function () { isPasswordResetComplete = true; return view.render() @@ -268,6 +326,51 @@ function (chai, p, AuthErrors, Metrics, FxaClient, View, Relier, RouterMock, Win assert.ok(view.$('.error').text().length); }); }); + + it('signs the user in if completing the OAuth flow', function () { + view.$('[type=password]').val('password'); + + sinon.stub(view, 'isOAuthSameBrowser', function () { + return true; + }); + + sinon.stub(fxaClient, 'signIn', function () { + return p(true); + }); + + sinon.stub(fxaClient, 'completePasswordReset', function ( + email, password, token, code, options) { + + assert.isTrue(options.shouldSignIn); + return p(true); + }); + + return view.validateAndSubmit() + .then(function () { + assert.isTrue(view.isOAuthSameBrowser.called); + }); + }); + + it('signs the user in if completing the Sync flow', function () { + view.$('[type=password]').val('password'); + + sinon.stub(relier, 'isSync', function () { + return true; + }); + + sinon.stub(fxaClient, 'signIn', function () { + return p(true); + }); + + sinon.stub(fxaClient, 'completePasswordReset', function ( + email, password, token, code, options) { + + assert.isTrue(options.shouldSignIn); + return p(true); + }); + + return view.validateAndSubmit(); + }); }); describe('resendResetEmail', function () { diff --git a/app/tests/spec/views/confirm.js b/app/tests/spec/views/confirm.js index 1ab8d2a33f..607417b51e 100644 --- a/app/tests/spec/views/confirm.js +++ b/app/tests/spec/views/confirm.js @@ -4,6 +4,7 @@ define([ 'chai', + 'sinon', 'lib/promise', 'lib/session', 'lib/auth-errors', @@ -14,8 +15,8 @@ define([ '../../mocks/router', '../../lib/helpers' ], -function (chai, p, Session, AuthErrors, Metrics, FxaClient, View, Relier, - RouterMock, TestHelpers) { +function (chai, sinon, p, Session, AuthErrors, Metrics, FxaClient, View, + Relier, RouterMock, TestHelpers) { 'use strict'; var assert = chai.assert; @@ -71,6 +72,20 @@ function (chai, p, Session, AuthErrors, Metrics, FxaClient, View, Relier, assert.equal(routerMock.page, 'signup'); }); }); + + it('redirects the normal flow to /signup_complete when verification completes', function (done) { + sinon.stub(view, 'navigate', function (page) { + TestHelpers.wrapAssertion(function () { + assert.equal(page, 'signup_complete'); + }, done); + }); + + sinon.stub(view.fxaClient, 'recoveryEmailStatus', function () { + return p({ verified: true }); + }); + + return view.render(); + }); }); describe('submit', function () { @@ -174,35 +189,25 @@ function (chai, p, Session, AuthErrors, Metrics, FxaClient, View, Relier, }); describe('oauth', function () { - it('redirects to signup_complete after account is verified', function () { + it('completes the oauth flow after the account is verified', function (done) { /* jshint camelcase: false */ - var email = TestHelpers.createEmail(); - - relier.set('service', 'sync'); + sinon.stub(relier, 'isOAuth', function () { + return true; + }); - view.VERIFICATION_POLL_IN_MS = 100; + sinon.stub(view, 'finishOAuthFlow', function () { + done(); + }); - return view.fxaClient.signUp(email, 'password', { preVerified: true }) - .then(function () { - Session.set('oauth', { - client_id: 'sync' - }); - return view.render(); - }) - .then(function () { - var defer = p.defer(); - setTimeout(function () { - try { - assert.equal(routerMock.page, 'signup_complete'); - defer.resolve(); - } catch (e) { - defer.reject(e); - } - }, view.VERIFICATION_POLL_IN_MS + 1000); - - return defer.promise; - }); + var count = 0; + sinon.stub(view.fxaClient, 'recoveryEmailStatus', function () { + // force at least one cycle through the poll + count++; + return p({ verified: count === 2 }); + }); + view.VERIFICATION_POLL_IN_MS = 100; + return view.render(); }); }); diff --git a/app/tests/spec/views/confirm_reset_password.js b/app/tests/spec/views/confirm_reset_password.js index ad7f151a00..738a815365 100644 --- a/app/tests/spec/views/confirm_reset_password.js +++ b/app/tests/spec/views/confirm_reset_password.js @@ -4,25 +4,26 @@ define([ 'chai', + 'sinon', 'lib/promise', 'lib/auth-errors', 'views/confirm_reset_password', 'lib/session', 'lib/metrics', - 'lib/fxa-client', + 'lib/channels/inter-tab', + '../../mocks/fxa-client', 'models/reliers/relier', '../../mocks/router', '../../mocks/window', '../../lib/helpers' ], -function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, - RouterMock, WindowMock, TestHelpers) { +function (chai, sinon, p, AuthErrors, View, Session, Metrics, + InterTabChannel, FxaClient, Relier, RouterMock, + WindowMock, TestHelpers) { 'use strict'; var assert = chai.assert; - var CLIENT_ID = 'dcdb5ae7add825d2'; - describe('views/confirm_reset_password', function () { var view; var routerMock; @@ -30,26 +31,33 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, var metrics; var fxaClient; var relier; + var interTabChannel; beforeEach(function () { routerMock = new RouterMock(); windowMock = new WindowMock(); metrics = new Metrics(); relier = new Relier(); - fxaClient = new FxaClient({ - relier: relier - }); + fxaClient = new FxaClient(); + interTabChannel = new InterTabChannel(); Session.set('passwordForgotToken', 'fake password reset token'); Session.set('email', 'testuser@testuser.com'); + sinon.stub(fxaClient, 'isPasswordResetComplete', function () { + return p(true); + }); + view = new View({ router: routerMock, window: windowMock, metrics: metrics, fxaClient: fxaClient, - relier: relier + relier: relier, + interTabChannel: interTabChannel, + sessionUpdateTimeoutMS: 100 }); + return view.render() .then(function () { $('#container').html(view.el); @@ -68,7 +76,7 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, describe('constructor', function () { it('redirects to /reset_password if no passwordForgotToken', function () { Session.clear('passwordForgotToken'); - view.render() + return view.render() .then(function () { assert.equal(routerMock.page, 'reset_password'); }); @@ -83,7 +91,7 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, it('`sign in` link goes to /force_auth in force auth flow', function () { Session.set('forceAuth', true); - view.render() + return view.render() .then(function () { // Check to make sure the signin link goes "back" assert.equal(view.$('a[href="/signin"]').length, 0); @@ -92,10 +100,11 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, }); it('`sign in` link goes to /oauth/signin in oauth flow', function () { - /* jshint camelcase: false */ - Session.set('service', 'sync'); - Session.set('oauth', { client_id: 'sync' }); - view.render() + sinon.stub(relier, 'isOAuth', function () { + return true; + }); + + return view.render() .then(function () { // Check to make sure the signin link goes "back" assert.equal(view.$('a[href="/oauth/signin"]').length, 1); @@ -105,89 +114,174 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, }); }); - describe('afterRender', function () { + describe('_waitForVerification', function () { it('polls to check if user has verified, if not, retry', function () { - view.fxaClient.isPasswordResetComplete = function () { - return p().then(function () { - return false; + var count = 0; + fxaClient.isPasswordResetComplete.restore(); + sinon.stub(view.fxaClient, 'isPasswordResetComplete', function () { + // force at least one cycle through the poll + count++; + return p(count === 2); + }); + + sinon.stub(view, 'setTimeout', function (callback) { + callback(); + }); + + return view._waitForVerification() + .then(function () { + assert.equal(view.fxaClient.isPasswordResetComplete.callCount, 2); + }); + }); + }); + + describe('render', function () { + it('finishes non-OAuth flow at /reset_password_complete if user has verified in the same browser', function (done) { + fxaClient.isPasswordResetComplete.restore(); + sinon.stub(fxaClient, 'isPasswordResetComplete', function () { + // simulate the login occurring in another tab. + interTabChannel.emit('login', { + sessionToken: 'sessiontoken' }); - }; + return p(true); + }); - return view.afterRender() - .then(function (isComplete) { - assert.isFalse(isComplete); - assert.isTrue(windowMock.isTimeoutSet()); - }); + sinon.stub(relier, 'isOAuth', function () { + return false; + }); + + sinon.stub(view, 'navigate', function (page) { + TestHelpers.wrapAssertion(function () { + assert.equal(page, 'reset_password_complete'); + }, done); + }); + + view.render(); }); - it('redirects to /signin if user has verified', function () { - view.fxaClient.isPasswordResetComplete = function () { - return p().then(function () { - return true; + it('finishes the OAuth flow if user has verified in the same browser', function (done) { + fxaClient.isPasswordResetComplete.restore(); + sinon.stub(fxaClient, 'isPasswordResetComplete', function () { + // simulate the sessionToken being set in another tab. + // simulate the login occurring in another tab. + interTabChannel.emit('login', { + sessionToken: 'sessiontoken' }); - }; + return p(true); + }); - // email is cleared in initial render in beforeEach, reset it to - // see if it makes it through to the redirect. - Session.set('email', 'testuser@testuser.com'); - return view.afterRender() - .then(function (isComplete) { - assert.isTrue(isComplete); - assert.equal(routerMock.page, 'signin'); - // session.email is used to pre-fill the email on - // the signin page. - assert.equal(Session.prefillEmail, 'testuser@testuser.com'); - }); - }); + sinon.stub(relier, 'isOAuth', function () { + return true; + }); - it('redirects to /oauth/signin if user has verified in oauth flow', function () { - /* jshint camelcase: false */ + sinon.stub(view, 'finishOAuthFlow', function () { + done(); + }); - relier.set('clientId', CLIENT_ID); - Session.set('oauth', { client_id: CLIENT_ID }); + view.render(); + }); - view.fxaClient.isPasswordResetComplete = function () { - return p().then(function () { - return true; + it('finishes the Sync flow if user has verified in the same browser', function (done) { + fxaClient.isPasswordResetComplete.restore(); + sinon.stub(fxaClient, 'isPasswordResetComplete', function () { + // simulate the sessionToken being set in another tab. + // simulate the login occurring in another tab. + interTabChannel.emit('login', { + sessionToken: 'sessiontoken' }); - }; + return p(true); + }); + + sinon.stub(relier, 'isSync', function () { + return true; + }); + + sinon.stub(fxaClient, 'notifyChannelOfLogin', function () { + done(); + }); + + view.render(); + }); + + it('normal flow redirects to /signin if user verifies in a second browser', function (done) { + sinon.stub(relier, 'isOAuth', function () { + return false; + }); + + testSecondBrowserVerifyForcesSignIn('signin', done); + }); + + it('oauth flow redirects to /oauth/signin if user verifies in a second browser', function (done) { + sinon.stub(relier, 'isOAuth', function () { + return true; + }); + + testSecondBrowserVerifyForcesSignIn('oauth/signin', done); + }); + + function testSecondBrowserVerifyForcesSignIn(expectedPage, done) { + fxaClient.isPasswordResetComplete.restore(); + sinon.stub(fxaClient, 'isPasswordResetComplete', function () { + return p(true); + }); + + sinon.stub(view, 'setTimeout', function (callback) { + callback(); + }); + + sinon.stub(view, 'navigate', function (page) { + TestHelpers.wrapAssertion(function () { + assert.equal(page, expectedPage); + // session.email is used to pre-fill the email on + // the signin page. + assert.equal(Session.prefillEmail, 'testuser@testuser.com'); + }, done); + }); // email is cleared in initial render in beforeEach, reset it to // see if it makes it through to the redirect. Session.set('email', 'testuser@testuser.com'); - return view.afterRender() - .then(function (isComplete) { - assert.isTrue(isComplete); - assert.equal(routerMock.page, 'oauth/signin'); - // session.email is used to pre-fill the email on - // the signin page. - assert.equal(Session.prefillEmail, 'testuser@testuser.com'); - }); + view.render(); + } + + it('displays an error if isPasswordResetComplete blows up', function (done) { + fxaClient.isPasswordResetComplete.restore(); + + sinon.stub(fxaClient, 'isPasswordResetComplete', function () { + return p().then(function () { + throw AuthErrors.toError('UNEXPECTED_ERROR'); + }); + }); + + sinon.stub(view, 'displayError', function () { + // if isPasswordResetComplete blows up, it will be after + // view.render()'s promise has already resolved. Wait for the + // error to be displayed. + done(); + }); + + view.render(); }); }); describe('submit', function () { it('resends the confirmation email, shows success message', function () { - var email = TestHelpers.createEmail(); + sinon.stub(fxaClient, 'passwordResetResend', function () { + return p(true); + }); - return view.fxaClient.signUp(email, 'password') - .then(function () { - return view.fxaClient.passwordReset(email); - }) - .then(function () { - return view.submit(); - }) - .then(function () { - assert.isTrue(view.$('.success').is(':visible')); - }); + return view.submit() + .then(function () { + assert.isTrue(view.$('.success').is(':visible')); + }); }); it('redirects to `/reset_password` if the resend token is invalid', function () { - view.fxaClient.passwordResetResend = function () { + sinon.stub(fxaClient, 'passwordResetResend', function () { return p().then(function () { throw AuthErrors.toError('INVALID_TOKEN', 'Invalid token'); }); - }; + }); return view.submit() .then(function () { @@ -199,11 +293,11 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, }); it('displays other error messages if there is a problem', function () { - view.fxaClient.passwordResetResend = function () { + sinon.stub(fxaClient, 'passwordResetResend', function () { return p().then(function () { throw new Error('synthesized error from auth server'); }); - }; + }); return view.submit() .then(function () { @@ -216,21 +310,16 @@ function (chai, p, AuthErrors, View, Session, Metrics, FxaClient, Relier, describe('validateAndSubmit', function () { it('only called after click on #resend', function () { - var email = TestHelpers.createEmail(); - - return view.fxaClient.signUp(email, 'password') - .then(function () { - var count = 0; - view.validateAndSubmit = function () { - count++; - }; + var count = 0; + view.validateAndSubmit = function () { + count++; + }; - view.$('section').click(); - assert.equal(count, 0); + view.$('section').click(); + assert.equal(count, 0); - view.$('#resend').click(); - assert.equal(count, 1); - }); + view.$('#resend').click(); + assert.equal(count, 1); }); }); diff --git a/app/tests/spec/views/ready.js b/app/tests/spec/views/ready.js index 16ab25fd12..da2295e6a7 100644 --- a/app/tests/spec/views/ready.js +++ b/app/tests/spec/views/ready.js @@ -7,15 +7,17 @@ define([ 'chai', + 'sinon', 'views/ready', 'lib/session', 'lib/fxa-client', + 'lib/promise', 'models/reliers/fx-desktop', '../../mocks/window' ], -function (chai, View, Session, FxaClient, FxDesktopRelier, WindowMock) { +function (chai, sinon, View, Session, FxaClient, p, FxDesktopRelier, + WindowMock) { var assert = chai.assert; - //var redirectUri = 'https://sync.firefox.com'; describe('views/ready', function () { var view; @@ -39,6 +41,10 @@ function (chai, View, Session, FxaClient, FxDesktopRelier, WindowMock) { }); } + beforeEach(function () { + createView(); + }); + afterEach(function () { view.remove(); view.destroy(); @@ -46,10 +52,6 @@ function (chai, View, Session, FxaClient, FxDesktopRelier, WindowMock) { }); describe('render', function () { - beforeEach(function () { - createView(); - }); - it('renders with correct header for reset_password type', function () { view.type = 'reset_password'; @@ -91,54 +93,6 @@ function (chai, View, Session, FxaClient, FxDesktopRelier, WindowMock) { }); }); - // TODO Renable these (issue #1141) - //it('shows redirectTo link and service name if available', function () { - //// This would be fetched from the OAuth server, but set it - //// explicitly for tests that use the mock `sync` service ID. - //view.serviceRedirectURI = redirectUri; - //relier.set('service', 'sync'); - - //return view.render() - //.then(function () { - //assert.equal(view.$('#redirectTo').length, 1); - //var html = view.$('section').text(); - //assert.include(html, 'Firefox Sync'); - //assert.ok(view.hasService()); - //assert.notOk(view.isOAuthSameBrowser()); - //}); - //}); - - //it('shows redirectTo link and service name if continuing OAuth flow', function () { - //[> jshint camelcase: false <] - //relier.set('service', 'sync'); - - //// oauth is set if using the same browser - //Session.set('oauth', { - //client_id: 'sync' - //}); - - //// This would be fetched from the OAuth server, but set it - //// explicitly for tests that use the mock `sync` service ID. - //view.serviceRedirectURI = redirectUri; - - //return view.render() - //.then(function () { - //assert.ok(view.hasService()); - //assert.ok(view.isOAuthSameBrowser()); - - //assert.equal(view.$('#redirectTo').length, 1); - //var html = view.$('section').text(); - //assert.include(html, 'Firefox Sync'); - //}); - //}); - - it('does not show redirectTo link if unavailable', function () { - return view.render() - .then(function () { - assert.equal(view.$('#redirectTo').length, 0); - }); - }); - it('shows some form of marketing for english speakers', function () { view.type = 'sign_up'; Session.set('language', 'en'); @@ -150,13 +104,44 @@ function (chai, View, Session, FxaClient, FxDesktopRelier, WindowMock) { }); }); - it('formats the service name correctly depending on the redirect uris', function () { - relier.set('redirectUri', 'https://find.firefox.com'); - relier.set('serviceName', 'Find My Device'); - assert.equal(view.context().serviceName, 'Find My Device'); + it('auto-completes the OAuth flow if using the WebChannel on the same browser', function () { + relier.set('webChannelId', 'channel_id'); + relier.set('clientId', 'fmd'); + //jshint camelcase: false + Session.set('oauth', { client_id: 'fmd' }); + + sinon.stub(view, 'finishOAuthFlow', function () { + return p(true); + }); + + return view.render() + .then(function () { + assert.isTrue(view.finishOAuthFlow.called); + }); + }); + }); + + describe('submit', function () { + it('completes the oauth flow if completing in the same browser', function () { + relier.set('clientId', 'fmd'); + //jshint camelcase: false + Session.set('oauth', { client_id: 'fmd' }); + + sinon.stub(view, 'finishOAuthFlow', function () { + return p(true); + }); - relier.set('redirectUri', 'urn:ietf:wg:oauth:2.0:fx:webchannel'); - assert.equal(view.context().serviceName, 'Find My Device'); + return view.submit() + .then(function () { + assert.isTrue(view.finishOAuthFlow.called); + }); + }); + + it('shows an error if submitting and not an oauth flow on the same browser', function () { + return view.submit() + .then(function () { + assert.isTrue(view.isErrorVisible()); + }); }); }); }); diff --git a/app/tests/test_start.js b/app/tests/test_start.js index 772836799c..c93f80d809 100644 --- a/app/tests/test_start.js +++ b/app/tests/test_start.js @@ -14,6 +14,7 @@ function (Translator, Session) { '../tests/spec/lib/channels/fx-desktop', '../tests/spec/lib/channels/redirect', '../tests/spec/lib/channels/web', + '../tests/spec/lib/channels/inter-tab', '../tests/spec/lib/xss', '../tests/spec/lib/url', '../tests/spec/lib/session', diff --git a/tests/functional/lib/helpers.js b/tests/functional/lib/helpers.js index 69566f0412..90178a783f 100644 --- a/tests/functional/lib/helpers.js +++ b/tests/functional/lib/helpers.js @@ -78,9 +78,16 @@ define([ } function getVerificationLink(user, index) { + return getVerificationHeaders(user, index) + .then(function (headers) { + return require.toUrl(headers['x-link']); + }); + } + + function getVerificationHeaders(user, index) { return restmail(EMAIL_SERVER_ROOT + '/mail/' + user) .then(function (emails) { - return require.toUrl(emails[index].headers['x-link']); + return emails[index].headers; }); } @@ -89,6 +96,7 @@ define([ clearSessionStorage: clearSessionStorage, visibleByQSA: visibleByQSA, pollUntil: pollUntil, - getVerificationLink: getVerificationLink + getVerificationLink: getVerificationLink, + getVerificationHeaders: getVerificationHeaders }; }); diff --git a/tests/functional/oauth_reset_password.js b/tests/functional/oauth_reset_password.js index 708e783630..505e40213a 100644 --- a/tests/functional/oauth_reset_password.js +++ b/tests/functional/oauth_reset_password.js @@ -134,42 +134,12 @@ define([ .findById('fxa-reset-password-complete-header') .end() - .findByCssSelector('#redirectTo') - .click() - .end() - - // let items load - .findByCssSelector('#todolist li') - .end() - - .findByCssSelector('#loggedin') - .getCurrentUrl() - .then(function (url) { - // redirected back to the App - assert.ok(url.indexOf(OAUTH_APP) > -1); - }) - .end() - - .findByCssSelector('#loggedin span') + .findByCssSelector('.account-ready-service') .getVisibleText() .then(function (text) { - // confirm logged in email - assert.ok(text.indexOf(email) > -1); - }) - .end() - - .findByCssSelector('#logout') - .click() - .end() - - .findByCssSelector('.signup') - .end() - - .findByCssSelector('#loggedin') - .getVisibleText() - .then(function (text) { - // confirm logged out - assert.ok(text.length === 0); + // user sees the name of the rp, + // but cannot redirect + assert.isTrue(/123done/i.test(text)); }) .end(); }); @@ -255,15 +225,12 @@ define([ .findById('fxa-reset-password-complete-header') .end() - .findByCssSelector('#redirectTo') - .click() - .end() - - // user is redirect to 123done, but not signed in. - .findByCssSelector('button.sign-in-button') - .isDisplayed() - .then(function (isSignInButtonDisplayed) { - assert.isTrue(isSignInButtonDisplayed); + .findByCssSelector('.account-ready-service') + .getVisibleText() + .then(function (text) { + // user sees the name of the rp, + // but cannot redirect + assert.isTrue(/123done/i.test(text)); }) .end(); }); diff --git a/tests/functional/oauth_sign_in.js b/tests/functional/oauth_sign_in.js index 52a1d0923e..4b37be1a83 100644 --- a/tests/functional/oauth_sign_in.js +++ b/tests/functional/oauth_sign_in.js @@ -9,16 +9,14 @@ define([ 'require', 'intern/node_modules/dojo/node!xmlhttprequest', 'app/bower_components/fxa-js-client/fxa-client', - 'tests/lib/restmail', 'tests/lib/helpers', 'tests/functional/lib/helpers' -], function (intern, registerSuite, assert, require, nodeXMLHttpRequest, FxaClient, restmail, TestHelpers, FunctionalHelpers) { +], function (intern, registerSuite, assert, require, nodeXMLHttpRequest, FxaClient, TestHelpers, FunctionalHelpers) { 'use strict'; var config = intern.config; var CONTENT_SERVER = config.fxaContentRoot; var OAUTH_APP = config.fxaOauthApp; - var EMAIL_SERVER_ROOT = config.fxaEmailRoot; var PASSWORD = 'password'; var TOO_YOUNG_YEAR = new Date().getFullYear() - 13; @@ -81,11 +79,12 @@ define([ 'verified': function () { var self = this; // verify account - return restmail(EMAIL_SERVER_ROOT + '/mail/' + user) - .then(function (emails) { + return FunctionalHelpers.getVerificationLink(user, 0) + .then(function (verificationUrl) { return self.get('remote') - .get(require.toUrl(emails[0].headers['x-link'])) + .setFindTimeout(intern.config.pageLoadTimeout) + .get(verificationUrl) // wait for confirmation .findById('fxa-sign-up-complete-header') @@ -164,11 +163,12 @@ define([ 'verified using a cached login': function () { var self = this; // verify account - return restmail(EMAIL_SERVER_ROOT + '/mail/' + user) - .then(function (emails) { + return FunctionalHelpers.getVerificationLink(user, 0) + .then(function (verificationUrl) { return self.get('remote') - .get(require.toUrl(emails[0].headers['x-link'])) + .setFindTimeout(intern.config.pageLoadTimeout) + .get(verificationUrl) // wait for confirmation .findById('fxa-sign-up-complete-header') @@ -230,6 +230,7 @@ define([ return this.get('remote') // Step 2: Try to sign in, unverified + .setFindTimeout(intern.config.pageLoadTimeout) .get(require.toUrl(OAUTH_APP)) .findByCssSelector('#splash .signin') .click() @@ -268,44 +269,16 @@ define([ .findByCssSelector('#fxa-confirm-header') .then(function () { - return restmail(EMAIL_SERVER_ROOT + '/mail/' + user) - .then(function (emails) { - var verifyUrl = emails[0].headers['x-link']; + return FunctionalHelpers.getVerificationLink(user, 0) + .then(function (verifyUrl) { return self.get('remote') .get(require.toUrl(verifyUrl)) - .findByCssSelector('#redirectTo') - .click() - .end() - - .findByCssSelector('#loggedin') - .getCurrentUrl() - .then(function (url) { - // redirected back to the App - assert.ok(url.indexOf(OAUTH_APP) > -1); - }) - .end() - - // let items load - .findByCssSelector('#todolist li') - .end() - - .findByCssSelector('#loggedin') - .getVisibleText() - .then(function (text) { - // confirm logged in email - assert.ok(text.indexOf(email) > -1); - }) - .end() - - .findByCssSelector('#logout') - .click() - .end() - - .findByCssSelector('#loggedin') + .findByCssSelector('.account-ready-service') .getVisibleText() .then(function (text) { - // confirm logged out - assert.ok(text.length === 0); + // user sees the name of the rp, + // but cannot redirect + assert.isTrue(/123done/i.test(text)); }) .end(); }); @@ -314,6 +287,7 @@ define([ }, 'unverified with a cached login': function () { return this.get('remote') + .setFindTimeout(intern.config.pageLoadTimeout) .get(require.toUrl(OAUTH_APP)) .findByCssSelector('#splash .signin') .click() diff --git a/tests/functional/oauth_sign_up.js b/tests/functional/oauth_sign_up.js index a9d76e3e26..99f145526b 100644 --- a/tests/functional/oauth_sign_up.js +++ b/tests/functional/oauth_sign_up.js @@ -17,12 +17,13 @@ define([ var config = intern.config; var OAUTH_APP = config.fxaOauthApp; - var EMAIL_SERVER_ROOT = config.fxaEmailRoot; var TOO_YOUNG_YEAR = new Date().getFullYear() - 13; + var AUTH_SERVER_ROOT = config.fxaAuthRoot; var PASSWORD = 'password'; var user; var email; + var fxaClient; registerSuite({ name: 'oauth sign up', @@ -30,6 +31,9 @@ define([ beforeEach: function () { email = TestHelpers.createEmail(); user = TestHelpers.emailToUser(email); + fxaClient = new FxaClient(AUTH_SERVER_ROOT, { + xhr: nodeXMLHttpRequest.XMLHttpRequest + }); // clear localStorage to avoid polluting other tests. // Without the clear, /signup tests fail because of the info stored @@ -37,9 +41,7 @@ define([ return FunctionalHelpers.clearBrowserState(this); }, - 'basic sign up': function () { - var self = this; - + 'basic signup, from first tab\'s perspective - tab should redirect to RP automatically': function () { return this.get('remote') .get(require.toUrl(OAUTH_APP)) .setFindTimeout(intern.config.pageLoadTimeout) @@ -82,28 +84,31 @@ define([ .end() .findByCssSelector('#fxa-confirm-header') - .getCurrentUrl() - .then(function (url) { - assert.ok(url.indexOf('client_id=') > -1); - assert.ok(url.indexOf('redirect_uri=') > -1); - assert.ok(url.indexOf('state=') > -1); + .end() - return restmail(EMAIL_SERVER_ROOT + '/mail/' + user) - .then(function (emails) { - return self.get('remote') - .get(require.toUrl(emails[0].headers['x-link'])); + .then(function () { + // simulate the verification in a second browser, + // though the effect should be the same if we open + // in the same browser. I'm just not sure how to get + // selenium to open a second tab. + return FunctionalHelpers.getVerificationHeaders(user, 0) + .then(function (headers) { + var uid = headers['x-uid']; + var code = headers['x-verify-code']; + return fxaClient.verifyCode(uid, code); }); }) .end() - .findByCssSelector('#redirectTo') - .click() - .end() + // user auto-redirects to 123done // let items load .findByCssSelector('#todolist li') .end() + .findByCssSelector('#loggedin') + .end() + .findByCssSelector('#logout') .click() .end() @@ -118,7 +123,7 @@ define([ }, - 'verify in a second browser': function () { + 'verify in the same browser - from second tab\'s perspective - link to redirect to RP': function () { var self = this; return this.get('remote') @@ -166,29 +171,97 @@ define([ .getCurrentUrl() .then(function (url) { assert.ok(url.indexOf('client_id=') > -1); + assert.ok(url.indexOf('redirect_uri=') > -1); + assert.ok(url.indexOf('state=') > -1); + + return FunctionalHelpers.getVerificationLink(user, 0) + .then(function (verificationLink) { + // overwrite the url in the same browser simulates + // the second tab's perspective + return self.get('remote').get(verificationLink); + }); + }) + .end() + + .findByCssSelector('.account-ready-service') + .getVisibleText() + .then(function (text) { + // user sees the name of the RP, + // but cannot redirect + assert.ok(/123done/i.test(text)); + }) + .end(); + }, + + 'verify in a second browser - from the second browser\'s perspective - no option to redirect to RP': function () { + var self = this; + + return this.get('remote') + .get(require.toUrl(OAUTH_APP)) + .setFindTimeout(intern.config.pageLoadTimeout) + .findByCssSelector('.signup') + .click() + .end() + + .findByCssSelector('#fxa-signup-header .service') + .end() + .getCurrentUrl() + .then(function (url) { + assert.ok(url.indexOf('client_id=') > -1); + assert.ok(url.indexOf('redirect_uri=') > -1); + assert.ok(url.indexOf('state=') > -1); + }) + .end() + + .findByCssSelector('form input.email') + .click() + .type(email) + .end() + + .findByCssSelector('form input.password') + .click() + .type(PASSWORD) + .end() + + .findByCssSelector('#fxa-age-year') + .click() + .end() - // clear all browser state, simulate opening in a new - // browser + .findById('fxa-' + (TOO_YOUNG_YEAR - 1)) + .pressMouseButton() + .releaseMouseButton() + .click() + .end() + + .findByCssSelector('button[type="submit"]') + .click() + .end() + + .findByCssSelector('#fxa-confirm-header') + .end() + + .then(function () { + // clear browser state to simulate opening link in a new browser return FunctionalHelpers.clearBrowserState(self); }) + .then(function () { - return restmail(EMAIL_SERVER_ROOT + '/mail/' + user) - .then(function (emails) { - return self.get('remote') - .get(require.toUrl(emails[0].headers['x-link'])); + return FunctionalHelpers.getVerificationLink(user, 0) + .then(function (verificationLink) { + return self.get('remote').get(verificationLink); }); }) - .end() - .findByCssSelector('#redirectTo') - .click() + // user is shown the ready page, without an option to redirect + .findById('fxa-sign-up-complete-header') .end() - // user is redirect to 123done, but not signed in. - .findByCssSelector('button.sign-in-button') - .isDisplayed() - .then(function (isSignInButtonDisplayed) { - assert.isTrue(isSignInButtonDisplayed); + .findByCssSelector('.account-ready-service') + .getVisibleText() + .then(function (text) { + // user sees the name of the rp, + // but cannot redirect + assert.isTrue(/123done/i.test(text)); }) .end(); }