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();
}