diff --git a/config/default.js b/config/default.js index 2b6c4945..236307c3 100644 --- a/config/default.js +++ b/config/default.js @@ -21,4 +21,6 @@ module.exports = { test: { port: 7778, }, + + showClientConsole: false }; diff --git a/package.json b/package.json index 260e3205..543c948a 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "js-beautify": "^1.4.2", "mocha-phantomjs": "~3.3.2", "nunjucks": "~1.0.1", - "underscore": "~1.6.0" + "underscore": "^1.6.0" }, "scripts": { "test": "node -e \"require('grunt').cli()\" null test", diff --git a/public/js/main.js b/public/js/main.js index 7c67c561..34278c74 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -7,18 +7,26 @@ require(['config'], function(config) { // Main Entry point of the app. require([ 'backbone', - 'id', 'i18n', + 'id', + 'jquery', 'log', 'models/user', 'router', 'utils', 'views/throbber', - ], function(Backbone, id, i18n, log, UserModel, router, utils, throbber){ + ], function(Backbone, i18n, id, $, log, UserModel, router, utils, throbber){ window.app = {}; var console = log('app'); + // Common ajax settings. + $.ajaxSetup({ + headers: { + "X-CSRFToken": $('meta[name=csrf]').attr('content') + } + }); + function initialize() { console.log('I AM SPARTACUS!'); // Always show throbber. @@ -28,6 +36,8 @@ require(['config'], function(config) { app.user = new UserModel(); app.router = new router.AppRouter(); Backbone.history.start({pushState: true, root: app.router.root}); + // Start identity watch. + app.user.watchIdentity(); } // Require locale then run init. diff --git a/public/js/models/user.js b/public/js/models/user.js index d4d2a55b..e6813529 100644 --- a/public/js/models/user.js +++ b/public/js/models/user.js @@ -15,54 +15,94 @@ define([ var UserModel = BaseModel.extend({ + // Initialization of the model. Event listeners and setup should happen here. initialize: function(){ - _.bindAll(this, 'checkAuth', 'handleLoginStatechange', 'loginHandler', 'logoutHandler'); - this.on('change:logged_in', this.handleLoginStatechange); + _.bindAll(this, 'handleLoginStateChange', 'loginHandler', 'logoutHandler', 'watchIdentity'); + this.on('change:logged_in', this.handleLoginStateChange); }, defaults: { logged_in: null, + // Whether the user has a pin. + pin: false, + // Date object for when the pin was locked. + pin_locked_out: null, + // If the user has a locked pin + pin_is_locked_out: null, + // If the user was previously locked out. + pin_was_locked_out: null }, - handleLoginStatechange: function() { - if (this.get('logged_in') === false) { + baseURL: utils.bodyData.baseApiURL || '', + + url: '/mozpay/v1/api/pin/', + + // Takes the data retrieved from the API and works out how to + // dispatch the user based on the reponse. + handleUserState: function(data) { + if (data.pin_is_locked_out === true) { + console.log('User is locked out. Navigating to /locked'); + return app.router.navigate('/locked', {trigger: true}); + } else if (data.pin_was_locked_out === true) { + console.log('User was locked out. Navigating to /was-locked'); + return app.router.navigate('/was-locked', {trigger: true}); + } else if (data.pin === true) { + console.log('User has a pin so navigate to /enter-pin'); + return app.router.navigate('/enter-pin', {trigger: true}); + } else { + console.log('User has no pin so navigate to /create-pin'); + return app.router.navigate('/create-pin', {trigger: true}); + } + }, + + // Runs fetch to get the current model state. + getUserState: function() { + // Check the user's state + console.log('Fetching model state'); + this.fetch() + .done(this.handleUserState) + .fail(function(){ + console.log('fail'); + }); + }, + + // When the `logged_in` attr changes this function decides + // if login is required or if the user is logged in it hands off to + // checking the User's state e.g. if the user has a PIN or if the user + // is currently locked out. + handleLoginStateChange: function() { + var logged_in = this.get('logged_in'); + console.log('logged_in state changed to ' + logged_in); + if (logged_in === false) { console.log('navigating to /login'); app.router.navigate('/login', {trigger: true}); } else { - // TODO: More advanced logic here to check state. - if (Backbone.history.fragment === 'enter-pin') { - // If we're already trying to get to 'enter-pin' navigating to it - // won't cause it to be re-rendered so force it to be rendered by - // calling the view direct. - console.log('Already on Enter Pin. Re-rendering'); - app.router.showEnterPin(); - } else { - console.log('navigating to /enter-pin'); - app.router.navigate('/enter-pin', {trigger: true}); - } + this.getUserState(); } }, - checkAuth: function() { + // Runs navigator.id.watch via Persona. + watchIdentity: function() { id.watch({ onlogin: this.loginHandler, onlogout: this.logoutHandler, }); }, + // Runs the logout for the user. logoutHandler: function() { - var self = this; - self.set({'logged_in': false}); - self.resetUser(); + this.set({'logged_in': false}); + this.resetUser(); }, + // Carries out resetting the user. + // TODO: Needs timers. resetUser: function _resetUser() { var console = log('UserModel', 'resetUser'); console.log('Begin webpay user reset'); var request = { 'type': 'POST', - url: utils.bodyData.resetUserUrl, - headers: {'X-CSRFToken': $('meta[name=csrf]').attr('content')} + url: utils.bodyData.resetUserUrl }; var result = $.ajax(request) .done(function _resetSuccess() { @@ -79,6 +119,8 @@ define([ return result; }, + // Handle login from id.watch. Here is where verification occurs. + // TODO: needs timers. loginHandler: function(assertion) { if (loginTimer) { @@ -87,6 +129,7 @@ define([ } throbber.show(this.gettext('Connecting to Persona')); + console.log('Verifying assertion'); $.ajax({ type: 'POST', @@ -102,7 +145,7 @@ define([ // callback(data); //}); }, this), - error: _.bind(function(xhr, textStatus ) { + error: _.bind(function(xhr, textStatus) { if (textStatus === 'timeout') { console.log('login timed out'); utils.trackEvent({'action': 'persona login', diff --git a/public/js/router.js b/public/js/router.js index bb8edf33..ba2b5d7c 100644 --- a/public/js/router.js +++ b/public/js/router.js @@ -53,11 +53,18 @@ define([ }, before: function() { - // Always run id.watch. - app.user.checkAuth(); - // Check login state and prevent routing if unknown. - if (app.user.get('logged_in') !== true && Backbone.history.fragment !== 'login') { - console.log('Preventing navigation as logged_in state is unknown and not login view.'); + // If logged_in state hasn't yet been set we need to prevent + // routing until it is. + if (app.user.get('logged_in') === null) { + console.log('Preventing navigation as logged_in state is unknown.'); + this.navigate('', {replace: true}); + return false; + } + // If logged_in state is false then we need to always show the login page. + // assuming that's not where we already are. + if (app.user.get('logged_in') === false && Backbone.history.fragment !== 'login') { + console.log('Not login page and logged_out so navigating to /login'); + this.navigate('/login', {trigger: true}); return false; } }, diff --git a/public/js/views/base.js b/public/js/views/base.js index a8222b08..87e6af69 100644 --- a/public/js/views/base.js +++ b/public/js/views/base.js @@ -35,6 +35,12 @@ define([ this.$el.html(this.template(template, data)); console.log('Replacing $el with rendered content'); return this; + }, + clear: function clear() { + // Remote the content in the view. + this.$el.empty(); + // Disconnect the view's event handlers. + this.unbind(); } }); return BaseView; diff --git a/public/js/views/create-pin.js b/public/js/views/create-pin.js index 2b97d2b0..f62248ae 100644 --- a/public/js/views/create-pin.js +++ b/public/js/views/create-pin.js @@ -1,4 +1,9 @@ -define(['views/base', 'log', 'lib/pin'], function(BaseView, log, pin){ +define([ + 'lib/pin', + 'log', + 'views/base', + 'views/throbber' +], function(pin, log, BaseView, throbber){ var console = log('view', 'create-pin'); var CreatePinView = BaseView.extend({ render: function(){ @@ -6,6 +11,7 @@ define(['views/base', 'log', 'lib/pin'], function(BaseView, log, pin){ this.setTitle(this.gettext('Create Pin')); this.renderTemplate('create-pin.html'); pin.init(); + throbber.hide(); return this; } }); diff --git a/public/js/views/enter-pin.js b/public/js/views/enter-pin.js index 6b44fdbc..5f7f3ccb 100644 --- a/public/js/views/enter-pin.js +++ b/public/js/views/enter-pin.js @@ -1,4 +1,9 @@ -define(['views/base', 'log', 'lib/pin', 'views/throbber'], function(BaseView, log, pin, throbber){ +define([ + 'lib/pin', + 'log', + 'views/base', + 'views/throbber' +], function(pin, log, BaseView, throbber){ var console = log('view', 'enter-pin'); var EnterPinView = BaseView.extend({ render: function(){ diff --git a/public/js/views/throbber.js b/public/js/views/throbber.js index 1ebf7159..d6236115 100644 --- a/public/js/views/throbber.js +++ b/public/js/views/throbber.js @@ -2,13 +2,14 @@ define(['jquery', 'views/base', 'log'], function($, BaseView, log){ var console = log('view', 'throbber'); var ThrobberView = BaseView.extend({ - el: $('#progress'), + el: '#progress', + $el: $('#progress'), render: function(msg){ - console.log('rendering view'); + console.log('rendering throbber'); this.setTitle(msg || this.gettext('Loading...')); this.renderTemplate('throbber.html', {msg: msg || this.gettext('Loading...')}); return this; - } + }, }); var throbberView = new ThrobberView(); @@ -20,7 +21,7 @@ define(['jquery', 'views/base', 'log'], function($, BaseView, log){ }, hide: function _hide() { console.log('Hiding progress'); - throbberView.remove(); + throbberView.clear(); }, }; }); diff --git a/public/stylus/spartacus.styl b/public/stylus/spartacus.styl index aa16991e..6613d93f 100644 --- a/public/stylus/spartacus.styl +++ b/public/stylus/spartacus.styl @@ -19,6 +19,22 @@ body, html { width: 100%; } +// Add stripes when running the dev server just to make it easier to know if you're looking +// at a webpay hosted version or the devlopment version. +.dev-server:before { + background-color: yellow; + background-image: repeating-linear-gradient(45deg, transparent, transparent 10px, black 10px, black 20px); + bottom: 0; + content: ''; + display: block; + opacity: 0.5; + position: absolute; + right: 0; + top: 0; + width: 10px; + z-index: 20; +} + .app { background: $bg-color; grain(); diff --git a/server/index.js b/server/index.js index 72b54f43..577fa21c 100644 --- a/server/index.js +++ b/server/index.js @@ -39,6 +39,17 @@ app.get('/mozpay', function (req, res) { app.get(/\/testlib\/?.*/, express.static(__dirname + '/../tests/static')); app.get(/\/unit\/?.*/, express.static(__dirname + '/../tests/')); +// Fake API response. +app.get('/mozpay/v1/api/pin/', function(req, res) { + var result = { + pin: false, + pin_is_locked_out: false, + pin_was_locked_out: false, + pin_locked_out: null + }; + res.send(result); +}); + // Fake verification. app.post('/fake-verify', function (req, res) { var assertion = req.query.assertion ? req.query.assertion : ''; diff --git a/server/templates/index.html b/server/templates/index.html index b4cb7cde..d828ebf0 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -8,11 +8,13 @@
diff --git a/tests/helpers.js b/tests/helpers.js index cc762928..1836b016 100644 --- a/tests/helpers.js +++ b/tests/helpers.js @@ -1,3 +1,5 @@ +var _ = require('underscore'); + var config = require('../config'); var _currTestId; @@ -6,8 +8,12 @@ var _testInited = {}; var baseTestUrl = 'http://localhost:' + config.test.port; -// Add poly-fill for Function.prototype.bind. -casper.options.clientScripts = ["tests/static/testlib/bind-poly.js"]; +casper.options.clientScripts = [ + // Add poly-fill for Function.prototype.bind. + 'tests/static/testlib/bind-poly.js', + // Add sinon for server response faking. + 'public/lib/js/sinon/index.js' +]; function makeToken() { @@ -99,8 +105,58 @@ exports.logInAsNewUser = function() { }; -exports.startCasper = function startCasper(path) { +exports.startCasper = function startCasper(path, cb) { var url = baseTestUrl + path; casper.echo('Starting with url: ' + url); - casper.start(url); + if (cb) { + casper.start(url, cb); + } else { + casper.start(url); + } +}; + +exports.injectSinon = function() { + casper.evaluate(function() { + window.server = sinon.fakeServer.create(); + window.server.autoRespond = true; + }); + // Setup the teardown when injecting. + casper.test.tearDown(function() { + casper.echo('Tearing down Sinon', 'INFO'); + casper.evaluate(function() { + window.server.restore(); + }); + }); +}; + +exports.fakeVerificationSuccess = function() { + casper.echo('Faking verification success with Sinon', 'INFO'); + casper.evaluate(function() { + window.server.respondWith('POST', '/fake-verify', + [200, {'Content-Type': 'application/json'}, JSON.stringify({ + 'status': 'okay', + 'audience': 'http://localhost', + 'expires': Date.now(), + 'issuer': 'fake-persona' + })]); + }); +}; + +var pinDefaults = { + pin: false, + pin_is_locked_out: false, + pin_was_locked_out: false, + pin_locked_out: null +}; + +exports.fakePinData = function(overrides) { + var pinData = _.clone(pinDefaults); + _.extend(pinData, overrides || {}); + pinData = JSON.stringify(pinData); + casper.echo('Setting up fakePinData with Sinon', 'INFO'); + casper.echo(pinData, 'COMMENT'); + casper.evaluate(function(pinData) { + window.server.respondWith('GET', '/mozpay/v1/api/pin/', + [200, {'Content-Type': 'application/json'}, pinData]); + }, pinData); }; diff --git a/tests/ui/test-login-locked.js b/tests/ui/test-login-locked.js new file mode 100644 index 00000000..06f9ab66 --- /dev/null +++ b/tests/ui/test-login-locked.js @@ -0,0 +1,27 @@ +var helpers = require('../helpers'); + +helpers.startCasper('/mozpay', function(){ + helpers.injectSinon(); + helpers.fakeVerificationSuccess(); + helpers.fakePinData({pin: true, pin_is_locked_out: true}); +}); + +casper.test.begin('Login then locked', { + test: function(test) { + + casper.waitForUrl('/mozpay/login', function() { + helpers.logInAsNewUser(); + }); + + casper.waitForUrl('/mozpay/locked', function() { + test.assertSelectorHasText('h1', 'Error'); + test.assertVisible('.full-error'); + test.assertSelectorHasText('.msg', 'You entered the wrong pin too many times. Your account is locked. Please try your purchase again in 5 minutes.'); + }); + + casper.run(function() { + test.done(); + }); + + }, +}); diff --git a/tests/ui/test-login-no-pin.js b/tests/ui/test-login-no-pin.js new file mode 100644 index 00000000..84591e50 --- /dev/null +++ b/tests/ui/test-login-no-pin.js @@ -0,0 +1,26 @@ +var helpers = require('../helpers'); + +helpers.startCasper('/mozpay', function(){ + helpers.injectSinon(); + helpers.fakeVerificationSuccess(); + helpers.fakePinData({pin: false}); +}); + + +casper.test.begin('Login test no pin', { + + test: function(test) { + + casper.waitForUrl('/mozpay/login', function() { + helpers.logInAsNewUser(); + }); + + casper.waitForUrl('/mozpay/create-pin', function() { + test.assertVisible('.pinbox', 'Pin entry widget should be displayed'); + }); + + casper.run(function() { + test.done(); + }); + }, +}); diff --git a/tests/ui/test-login-was-locked.js b/tests/ui/test-login-was-locked.js new file mode 100644 index 00000000..11263772 --- /dev/null +++ b/tests/ui/test-login-was-locked.js @@ -0,0 +1,25 @@ +var helpers = require('../helpers'); + +helpers.startCasper('/mozpay', function(){ + helpers.injectSinon(); + helpers.fakeVerificationSuccess(); + helpers.fakePinData({pin: true, pin_was_locked_out: true}); +}); + +casper.test.begin('Login then was-locked', { + test: function(test) { + + casper.waitForUrl('/mozpay/login', function() { + helpers.logInAsNewUser(); + }); + + casper.waitForUrl('/mozpay/was-locked', function() { + test.assertSelectorHasText('h1', 'Your Pin was locked'); + }); + + casper.run(function() { + test.done(); + }); + + }, +}); diff --git a/tests/ui/test-login.js b/tests/ui/test-login-with-pin.js similarity index 66% rename from tests/ui/test-login.js rename to tests/ui/test-login-with-pin.js index bdfd4df3..f85d88da 100644 --- a/tests/ui/test-login.js +++ b/tests/ui/test-login-with-pin.js @@ -1,9 +1,13 @@ var helpers = require('../helpers'); -helpers.startCasper('/mozpay'); -casper.test.begin('Login test', { +helpers.startCasper('/mozpay', function(){ + helpers.injectSinon(); + helpers.fakeVerificationSuccess(); + helpers.fakePinData({pin: true}); +}); +casper.test.begin('Login test has pin', { test: function(test) { casper.waitForUrl('/mozpay/login', function() {