How to create an awesome hybrid mobile app - Part IV
Table of Contents
- 1. Introduction
- 2. Part IV - Build the login mechanism
- 3. Create the session object
- 4. Create the Credential model
- 5. Create localStorage based keychain object
- 6. Create the registration view
- 7. Create the login view
- 8. Create forgot password, change password and change security info views
- 9. Create settings and info views
- 10. Implement logout functionality
- 11. Override the route method to prevent unauthorized access
- 12. Turning-off encryption
- 13. Unit testing with Jasmine and Karma
- 14. What's Next?
1. Introduction
Here comes the fourth and final part of the hybrid mobile series in the hot summer. At the time of writing this from Bangalore (was called as Green City not any more I think) it's quite hot like 40° C. Time to take care of our nature! Anyway, we've come through a long way... let's finish this final part. Our hybrid mobile app "Safe" has grown into a pretty good shape now. So far, the work we've done allows an user to select a photo and persist in a secured way by AES 256 encryption. We also completed the work to edit an already persisted photo or wipe it out permanently.
Encrypting and persisting the photos is not enough to keep them safe! We've to protect the access to the app through a login mechanism and that's what we are gonna build in this part. We'll also see how to build the settings and info pages and last but not least I'll introduce you to Jasmine and show how she'll help us to write some good unit tests. So folks, ARE YOU READY?
2. Part IV - Build the login mechanism
To complete the login feature and the related functionalities we've to build the following pages.
- Registration page
- Login page
- Change password page
- Forgot password page
- Change security info page
You can see the mockup of those pages and the navigation flow between them from the below diagrams.
Taking account of the info page, settings page and the unit testing, following are the list of tasks we've to do to complete this part.
- Create the session object
- Create the Credential model
- Create the registration view
- Create the login view
- Create forgot password, change password and change security info views
- Create settings and info views
- Implement logout functionality
- Override the route method to prevent unauthorized access
- Turning-off encryption
- Unit testing with Jasmine and Karma
Hmm.. that's quite a lot of tasks, isn't it? Let's jump to the first task.
3. Create the session object
We need to keep track of the current status of the user, whether he/she logged-in or out or not even registered to the app. We'll be storing the credential in localStorage and so we could actually get the status of the user by hitting localStorage every-time but it would be great if we could use something like session to store the state as we do in server apps. Luckily, HTML5 provides something called sessionStorage which is supported by Cordova. HTML5 sessionStorage is a twin brother to localStorage but the duration of persistence lasts for a short time. In iPhone, I noticed that at-once the app is removed from the background process then session is gone! But what I expect is to kill the session at-once the app is moved to background. Luckily Cordova provides an event to help us and we'll see about that in a second.
Create a new file with name "session.js" under "js" folder and drop the below code.
// Session module based on HTML5 sessionStorage. define(function () { 'use strict'; return { retrieve: function (key) { return window.sessionStorage.getItem(key); }, store: function (key, value) { window.sessionStorage.setItem(key, value); }, remove: function (key) { window.sessionStorage.removeItem(key); }, clear: function () { window.sessionStorage.clear(); } }; });
Listing 1. Session object
In our app, we are going to use this simple session object to store the current status of the user, the encryption/decryption key etc. As I told a little bit earlier, we need to clean-up the session at-once the user moves away from the app. Cordova provides an event called pause and by listening to it we can achieve it.
Quickly open the "app.js" file and add the below piece of code inside the "deviceready" event-handler. Don't forget to add the dependency to session in the define statement.
// Clear the session when the app moves to background. document.addEventListener('pause', function() { session.clear(); }, false);
Listing 2. Clearing session in "pause" event
4. Create the Credential model
We need a model to store the password and other security information. Let's call the model as Credential. The important things we are gonna store in this model are: password, encryption/decryption key, security question and answer. All these properties are mandatory!
Create a new file with name "credential.js" under "models" folder and drop the below code.
define(['jquery', 'underscore', 'backbone'], function ($, _, Backbone) { 'use strict'; // Model that represents credential. var Credential = Backbone.Model.extend({ defaults: { password: null, key: null, // encryption-decryption key for photos securityQuestion: null, securityAnswer: null }, // Validations. All properties are required! validation: { password: { required: true }, key: { required: true }, securityQuestion: { required: true }, securityAnswer: { required: true } } }); return Credential; });
Listing 3. The Credential model
Nothing complex in the model class yet, just the defaults and the validation properties. The important question is where we are gonna store this credential? iOS devices comes with a mechanism called "keychain" to store sensitive information like passwords and other stuff. When I last checked Android website they didn't have this keychain concept and I'm not sure whether they do have now. If they do, please let me know. To keep things simple, let's store the credential in the localStorage and for that we are going to override the sync method of the credential like we did in File model. If you remember we have already overridden the Backbone's default persistent strategy to store photos in localStorage and why we can't use it here? The answer is due to couple of reasons.
The first reason is, the global mechanism needs the encryption/decryption key of the credential object to encrypt/decrypt photos. To encrypt the credential object we are going to use a separate static key from the settings file (the settings file don't have such a key yet and we are gonna add that soon). Since both the photos and credential needs different keys it's little tricky to leverage the global strategy to achieve the persistence. Being said that, by injecting the key from outside we could still re-use it but I would like to keep things simple!
The second important reason is, in future, if there is a chance I could use the keychain concept for both the platforms (iOS and Android) doing the persistence mechanism in the Credential model will help me to switch to keychain easily!
Keeping all these things in mind, let's create a dummy keychain object first which internally uses localStorage for now and use that dummy keychain object in Credential to achieve the persistence.
5. Create localStorage based keychain object
The main purpose of the keychain object is to store sensitive information. We use the crypto object to do the encryption and decryption. In real keychain object we don't have to worry about this, the encryption/decryption stuff will be taken care by itself!
Create a new file with name "keychain.js" under "js" folder. Below is the code that you should be dropping inside it.
// A simple 'localstorage' based keychain. define(['jquery', 'adapters/crypto', 'settings'], function ($, crypto, settings) { 'use strict'; return { containsKey: function (key) { return window.localStorage.getItem(key) ? true : false; }, getForKey: function (key) { var result = window.localStorage.getItem(key); return crypto.decrypt(settings.encDecKey, result); }, setForKey: function (key, value) { var d = $.Deferred(); crypto.encrypt(settings.encDecKey, value) .done(function (encValue) { window.localStorage.setItem(key, encValue); d.resolve(); }).fail(d.reject); return d.promise(); }, removeForKey: function (key) { window.localStorage.removeItem(key); } }; });
Listing 4. The keychain object
The keychain object contains four basic methods to store (getForKey), retrieve (setForKey) remove (removeForKey) and check (containKey) the key with the associated secret information. There is one thing I should tell you from the code. We are referring the encDecKey property from the settings object as the encryption/decryption key but so far the settings object don't have such a property yet. Let's quickly add it.
// Contains all the configuration settings of the app. define(function () { 'use strict'; return { encDecKey: 'WW91clNlY3JldElzU2FmZVdpdGhNZQ==', // Encryption key for credential. Don't reveal this to anyone! encrypt: true, // Settings this to false will not encrypt the photos, not a good idea! targetWidth: 320, targetHeight: 480 }; });
Listing 5. Adding "encDecKey" in settings.js
Please make sure you replace the encDecKey property's value with your own complex one. I also added another property called encrypt which is set to true. We'll talk about this property little later.
Alright, our keychain object is ready! Let's override the sync method of the Credential class and complete the model. Below listing shows the code and it is quite same like the File model. If you've already gone through the File model you'll easily understand what's going here. Try to spend some time and understand the code. If you got some questions, please post a comment.
// Credential model. define(['jquery', 'underscore', 'backbone', 'keychain'], function ($, _, Backbone, keychain) { 'use strict'; // Model that represents credential. var Credential = Backbone.Model.extend({ ... // Override the sync method to persist the credential in keychain. sync: function (method, model, options) { var d = $.Deferred(), resp; switch (method) { case 'read': resp = this.findModel(); break; case 'update': resp = this.createOrUpdate(); break; case 'delete': throw new Error('Not implemented'); } resp.done(function (result) { if (options && options.success) { options.success(result); } d.resolve(result); }).fail(function (error) { if (options && options.error) { options.error(error); } d.reject(error); }); if (options && options.complete) { options.complete(resp); } return d.promise(); }, // Returns the persisted model as JSON. findModel: function () { var d = $.Deferred(); keychain.getForKey(this.id) .done(_.bind(function (persistedData) { d.resolve(JSON.parse(persistedData)); }, this)) .fail(d.reject); return d.promise(); }, // Save or update the persisted model. createOrUpdate: function () { return keychain.setForKey(this.id, JSON.stringify(this.toJSON())); } }); return Credential; });
Listing 6. Overriding "sync" method in Credential model
Let's start working on the registration view.
6. Create the registration view
Time to take a relook at the mockups. From the mockup you can know that the registration page contains three fields. A textbox to type password, a dropdown to select a security question and a textbox to type the answer. The core functionality of the page is to accept these three values from the user and store in keychain.
Like the practice we are following so far in other views, we are going to inject an empty credential and the array of security questions to the view from the router. Before working on the view, let's craft the markup first.
Create a new file with name "register.handlebars" under "html" folder and throw the below html.
<header class="bar bar-nav"> <h1 class="title">SAFE</h1> </header> <nav class="bar bar-tab"> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav> <div class="content"> <p class="content-padded text-center"> Keep your photos safe! </p> <form id="register-form" class="content-padded"> <input id="password" name="password" type="password" maxlength="15" placeholder="Enter your password" autocomplete="off"> <select id="security-question" name="security-question" disabled> <option selected>Select a question</option> {{#each questions}} <option>{{this}}</option> {{/each}} </select> <input id="security-answer" name="security-answer" type="password" maxlength="15" placeholder="Enter your answer" disabled autocomplete="off"> <input id="register" type="submit" value="Register" class="btn btn-positive btn-block" disabled> </form> </div>
Listing 7. register.handlebars
There is nothing complicated in the markup other than the security questions rendered using the <select> element from the questions property using handlebars #each keyword.
Having the structure ready it's time to create the view. Create a new file with name "register.js" under "views" folder. Let's start with some basic code and the explanation follows it.
define([ 'underscore', 'backbone', 'session', 'adapters/notification', 'templates' ], function(_, Backbone, session, notification, templates) { 'use strict'; // Registration view. // Renders the registration view and handles the UI logic. var RegisterView = Backbone.View.extend({ // Set the template. template: templates.register, // Wire-up handlers for DOM events. events: { 'submit #register-form': 'register' }, // Set the bindings for input elements with model properties. bindings: { '#password': 'password', '#security-question': 'securityQuestion', '#security-answer': 'securityAnswer' }, initialize: function (options) { // If model is not passed through error. if (!this.model) { throw new Error('model is required'); } // If security questions not passed or collection empty throw error. if (!(options && options.securityQuestions && options.securityQuestions.length)) { throw new Error('security questions required'); } // Store the passed security questions in some property. this.securityQuestions = options.securityQuestions; }, // Render the view, bind the controls and cache the elements. render: function () { this.$el.html(this.template({ questions: this.securityQuestions })); this.stickit(); this.$securityQuestion = this.$('#security-question'); this.$securityAnswer = this.$('#security-answer'); this.$register = this.$('#register'); return this; } }); return RegisterView; });
Listing 8. The register view (register.js)
If you notice the events object, we are listening the "submit" event of the form. The bindings object contains the mapping between the form fields with the property names. We'll be passing both the empty credential (which will be finally stored to localStorge) and the security questions array to the view from the router. The initialize method first verifies whether the credential object and security questions are passed and if not throws error. The passed questions is stored in the securityQuestions property. The passed empty credential model is automatically set to the model property by Backbone. In the render method we are passing the security questions to the template and rendering the html.
Let's complete the register function that handles the form submit event.
register: function (e) { e.preventDefault(); // Alert the user if all the fields are not filled. if (!this.model.isValid(true)) { notification.alert('Fill all the fields.', 'Error', 'Ok'); return; } // Disable the register button to prevent multiple submits. this.$register.attr('disabled', 'disabled'); // Save the credential, security info and redirect him to '#login'. this.model.save() .done(_.bind(function () { // Update the state session variable and navigate to login page. session.store('state', 'LOGGED_OUT'); this.navigateTo('#login'); }, this)) .fail(_.bind(function () { notification.alert('Registration failed. Please try again.', 'Error', 'Ok') .done(_.bind(function () { this.$register.removeAttr('disabled'); }, this)); }, this)); }
Listing 9. The "register" submit handler
In the register function, first we are verifying the model is valid and if not displaying a message. If it's valid, then we are saving it by calling model.save(). If the model is successfully saved then we are setting user status nothing (state) in session as "LOGGED_OUT". You see the importance of this state when we work on controlling the page access in router. Finally we are navigating the user to login page. Also, don't forget the code where we disable the register button and it's because we don't want the user to register multiple times!
In the above code, the model validation will probably fail and the reason is we are not setting the key property which is mandatory. The key property is gonna store the encryption/decryption key that is used for encrypting and decrypting the photos. Let's set an unique id to the key using the underscore's guid function in the initialize method.
initialize: function() { ... // Create a new guid and set it as the encryption/decryption key for the model. this.model.set('key', _.guid()); }
Listing 10. Setting unique-id to "key" property
Our view is close to ready and I would like to do little more work to improve the user experience. We've three fields in the registration page and we don't have to enable all of them initially. The register button can be enabled only when all the fields are valid. Also, the security question dropdown can be enabled once the password field has some value and the security answer field can be enabled once a valid question is selected from the dropdown. If you see the template, the question dropdown, answer textbox and the button are already in disabled state. All we've to do is complete the pending work in the view to achieve this behavior. For that, first we've to listen the model and the properties change event.
We've to listen to whenever the password and securityQuestion properties changes and also to the model change as well. To listen to the events, add the below lines to the initialize method.
// Hook the handler to enable/disable security question whenever the password changes. this.listenTo(this.model, 'change:password', this.enableQuestion); // Hook the handler to enable/disable answer textbox when the selected security question changes. this.listenTo(this.model, 'change:securityQuestion', this.enableAnswer); // Hook the handler to enable/disable the register button whenever the model changes. this.listenTo(this.model, 'change', this.enableRegister);
Listing 11. Listening to model and properties change events
Below are the event handlers. All we do in each method is, check if the respective properties are valid and enable/disable the fields accordingly.
// Enable the security question dropdown when the password is valid. enableQuestion: function () { if (this.model.isValid('password')) { this.$securityQuestion.removeAttr('disabled'); } else { this.$securityQuestion.attr('disabled', 'disabled'); } }, // Clear the answer textbox, enable it if the selected question is valid. enableAnswer: function () { this.model.set('securityAnswer', null); if (this.model.isValid('securityQuestion') && this.model.get('securityQuestion') !== 'Select a question') { this.$securityAnswer.removeAttr('disabled'); } else { this.$securityAnswer.attr('disabled', 'disabled'); } }, // Enable the register button when the model is valid. enableRegister: function () { if (this.model.isValid(true)) { this.$register.removeAttr('disabled'); } else { this.$register.attr('disabled', 'disabled'); } }
Listing 12. Change event handlers
We've the registration view ready. Unfortunately, still we've to do couple of things before testing the page in the browser. First we've to create a separate file to store the security questions and next configure the route in the router.
Create a new file with name "data.js" under "js" folder. Copy paste the below code.
// Contains all the static data. define(function () { 'use strict'; return { securityQuestions: [ 'Which movie you love the most', 'Which country you\'ll love to go for vacation' ] }; });
Listing 13. data.js
Finally, let's create a new route to deliver the register page. Add the dependencies to keychain, data, credential and register view in the define statement and then add a new route to the routes object.
routes: { 'register': 'register' }
Listing 14. router.js
Here is the implementation of the register route handler. Note that we've set the id of the model to "Safe-Credential".
register: function () { this.renderView(new RegisterView({ model: new Credential({ id: 'Safe-Credential' }), securityQuestions: Data.securityQuestions })); }
Listing 15. router.js
That's it! We've completed the code. It's time to test! Kick the command grunt serve from your terminal and append "#register" to the URL. You should see the below registration page.
Fill all the three fields and hit the "Register" button. Now open the console window and type window.sessionStorage you should see the below.
If you could see something similar, we are good!
Building the remaining pages are quite similar like this one. I'll explain in detail how to build the login page as well but for the remaining pages my explanation will be little vague!
7. Create the login view
The login page, all it has is a password field and a link to forgot password page. Let's create the template first.
Create a new file under "html" folder with name "login.handlebars". Below is the markup that you should fill the file with.
<header class="bar bar-nav"> <h1 class="title">SAFE</h1> </header> <nav class="bar bar-tab"> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav> <div class="content"> <p class="content-padded text-center"> Keep your photos safe! </p> <form id="login-form" class="content-padded"> <input id="password" name="password" type="password" maxlength="15" placeholder="Enter your password" autocomplete="off"> <input id="login" type="submit" value="Login" disabled class="btn btn-positive btn-block"> <a href="#forgotpassword" class="forgot-password text-right">forgot the password?</a> </form> </div>
Listing 16. login.handlebars
Note that we've set the login button as disabled. When the user enters some text in the password field then we enable it similar like we did in in registration page. We are gonna follow the similar behavior throughout the app. Let's build the view.
Create a file with name "login.js" under "views" folder. Let's start with the initialize, render and other basic methods and properties.
define([ 'backbone', 'underscore', 'adapters/notification', 'session', 'templates' ], function (Backbone, _, notification, session, templates) { 'use strict'; // Login view. // Renders the login view and handles the UI logic. var LoginView = Backbone.View.extend({ // Set the template to render the markup. template: templates.login, // Wire-up handlers for DOM events. events: { 'submit #login-form': 'login' }, // Set the bindings for input elements with model properties. bindings: { '#password': 'password' }, initialize: function (options) { // if model is not passed throw error. if (!this.model) { throw new Error('model is required'); } // if the persisted credential is not passed throw error. if (!(options && options.credential)) { throw new Error('credential is required'); } // Store the actual credential in some property for easy access later. this.credential = options.credential; // Enable/disable the login button whenever the password changes. this.listenTo(this.model, 'change:password', this.enableLogin); }, render: function () { // Render the view. this.$el.html(this.template()); // Bind input elements with model properties. this.stickit(); // Cache DOM elements for easy access. this.$login = this.$('#login'); return this; }, // Enable the login button only if the 'password' property has some text. enableLogin: function () { if (this.model.isValid('password')) { this.$login.removeAttr('disabled'); } else { this.$login.attr('disabled', 'disabled'); } } }); return LoginView; });
Listing 17. Login view (login.js)
Let me explain about some important points in the code. We'll be passing two credential instances to the login view. What are the two? One instance (this.credential) is the actual object persisted during registration and the other one (this.model) is just an empty instance. The empty instance is required for model binding with the fields and finally to compare with the actual one. There is nothing much in the render method. All it does is render the template and cache the button. You are pretty much used to the events and bindings properties so I don't think I need to talk about them either. The important piece that is missing is the login handler and below is the code for it.
// Login form submit handler. login: function (e) { // Prevent the form's default action. e.preventDefault(); // Alert the user and return if the form is submitted with empty password. if (!this.model.isValid('password')) { notification.alert('Enter the password.', 'Error', 'Ok'); return; } // Disable the login button to prevent multiple submits. // We'll enable it back after authentication is over. this.$login.attr('disabled', 'disabled'); // Fetch the persisted credential and authenticate. this.credential.fetch() .done(_.bind(function () { // Authenticate the user. // On success, update the session vars and redirect him to '#photos', else show respective error message. if (this.model.get('password') === this.credential.get('password')) { // Update the 'state' to 'LOGGED_IN' and store the encryption-decryption key in session. session.store('state', 'LOGGED_IN'); session.store('key', this.credential.get('key')); this.navigateTo('#photos'); } else { // Show error and re-enable the login button. notification.alert('Invalid password.', 'Error', 'Ok') .done(_.bind(function () { this.$login.removeAttr('disabled'); }, this)); } }, this)) .fail(_.bind(function () { notification.alert('Failed to retrieve credential. Please try again.', 'Error', 'Ok') .done(_.bind(function () { this.$login.removeAttr('disabled'); }, this)); }, this)); }
Listing 18. The login function
First we are verifying the passed model is valid or not and if not displaying a message to the user. Then, we are fetching the credential. We've to fetch the credential because from the router we are gonna pass the persisted credential with only "id" not all the properties. To compare the user entered password with the actual one we've to fetch it. If both the passwords matches then we are setting the state to "LOGGED_IN", storing the key to session and finally taking the user happily to the home page. If not, we are displaying an invalid password message and enabling the login button back.
Finally, let's configure the route for the login page. Please don't forget to add the dependency to the login view in the define statement.
define([..., 'views/login'], function(..., LoginView) { ... routes: { ... 'login': 'login' } ... login: function () { this.renderView(new LoginView({ model: new Credential(), credential: new Credential({ id: 'Safe-Credential' }) })); } });
Listing 19. Configuring routes in router.js
If you run the app in browser after successful registration you'll be redirected to the login page and it should look like the below image.
Go on, enter the password and hit the login button. You should be taken to the Home page or also we call as List page.
We've completed the core pages of the login worlflow. The remaining pages follow similar strategies and I hope you can understand the functionalities by just reading the code. Anyway, I'll give some explanation here and there :)
8. Create forgot password, change password and change security info views
8.1 Forgot Password Page
Create file with name "forgotpassword.handlebars" under "html" folder. Paste the below markup.
<header class="bar bar-nav"> <a href="#login" class="btn btn-link btn-nav pull-left"> <span class="icon icon-left-nav"></span> </a> <h1 class="title">Verify yourself</h1> </header> <div class="content"> <form id="forgotpassword-form" class="content-padded"> <label id="security-question" class="field-label">{{securityQuestion}}?</label> <input id="security-answer" name="security-answer" type="password" maxlength="15" placeholder="Enter your answer" autocomplete="off"> <input id="verify" type="submit" value="Verify" disabled class="btn btn-positive btn-block"> </form> </div> <nav class="bar bar-tab"> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav>
Listing 20. forgotpassword.handlebars
The markup is fairly simple. You can notice the securityQuestion property is binded to a label and the footer menu contains only the link to the Info page.
Create a view and the name should be "forgotpassword.js". Below is the complete code. Go through the code first there is something I need to explain following it.
define([ 'backbone', 'underscore', 'adapters/notification', 'session', 'templates' ], function (Backbone, _, notification, session, templates) { 'use strict'; // Forgot password view. // Renders the view for forgot password and handles the UI actions. var ForgotPasswordView = Backbone.View.extend({ // Set the template. template: templates.forgotpassword, // Wire handlers for DOM events. events: { 'submit #forgotpassword-form': 'verifyAnswer' }, // Set the bindings. bindings: { '#security-answer': 'securityAnswer' }, initialize: function (options) { // if model is not passed throw error. if (!this.model) { throw new Error('model is required'); } // if the persisted credential is not passed throw error. if (!(options && options.credential)) { throw new Error('credential is required'); } // Set the passed credential to a property. this.credential = options.credential; // Hook the handler to enable/disable submit button when security answer changes. this.listenTo(this.model, 'change:securityAnswer', this.enableSubmit); }, // Get the security question and render the view. render: function () { this.$el.html(this.template(this.model.toJSON())); this.stickit(); this.$verify = this.$('#verify'); return this; }, // Clear the answer textbox and enable it if the selected question is valid. enableSubmit: function () { if (this.model.isValid('securityAnswer')) { this.$verify.removeAttr('disabled'); } else { this.$verify.attr('disabled', 'disabled'); } }, // Verify button event handler. verifyAnswer: function (e) { e.preventDefault(); // Alert the user if the form is submitted with empty answer. if (!this.model.isValid('securityAnswer')) { notification.alert('Enter the answer.', 'Error', 'Ok'); return; } // Disable the button to prevent multiple clicks. this.$verify.attr('disabled', 'disabled'); // Verify the answer and on success set the state variable to 'VERIFIED' and navigate to change password page. if (this.model.get('securityAnswer') === this.credential.get('securityAnswer')) { session.store('state', 'VERIFIED'); this.navigateTo('#changepassword'); } else { notification.alert('Invalid answer.', 'Error', 'Ok') .done(_.bind(function () { this.$verify.removeAttr('disabled'); }, this)); } } }); return ForgotPasswordView; });
Listing 21. Forgot password view (forgotpassword.js)
Like the login view, the forgot password view also requires two models. One model is used to bind the security answer provided by the user and another one is the actual credential (this.credential). If you go through the verifyAnswer function, we are verifying the provided security answer is same as the registered one and if it's so we are setting the state as "VERIFIED" and navigating to the change password page else displaying an error to the user.
One thing that puzzled me is, when I was writing about the forgot password page is I wondered why I was not fetching the credential in the submit action like we did in the login view? I went to the router code and surprised. Unlike in the login or other routes, in the forgot password route I've done something different. I was fetching the credential and passing the complete model to the view. I've no idea why I did like that way. The code was written little long ago and so... after some thinking... I figured out why. In forgot-password page we need to display the security question to the user right at the time the page is rendered. To retrieve the security question we've to pre-fetch the credential model in the router and that's the reason. You'll understand about this better when you see the router.
8.2 Change Password Page
Create file with name "changepassword.handlebars" and throw the below html.
<header class="bar bar-nav"> <a href="#{{backToUrl}}" class="btn btn-link btn-nav pull-left"> <span class="icon icon-left-nav"></span> </a> <h1 class="title">Change Password</h1> </header> <nav class="bar bar-tab"> {{#if isAuthenticated}} <a class="tab-item" href="#photos"> <span class="icon icon-home"></span> <span class="tab-label">Home</span> </a> <a class="tab-item" href="#settings"> <span class="icon icon-gear"></span> <span class="tab-label">Settings</span> </a> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> {{else}} <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> {{/if}} </nav> <div class="content"> <form id="changepassword-form" class="content-padded"> <input id="password" name="password" type="password" maxlength="15" placeholder="Enter your new password" autocomplete="off"> <input id="change" type="submit" value="Change" disabled class="btn btn-positive btn-block"> </form> </div>
Listing 22. changepassword.handlebars
We've used couple of properties in the template: backUrl and isAuthenticated. The backUrl property represents the page the user should be taken when hitting the back button and isAuthenticated property represents whether the user is logged-in or not and based on that we show the corresponding menus in the footer.
Create the view file with name "changepassword.js". Go through the below code and paste it. Explanation follows.
define([ 'backbone', 'underscore', 'adapters/notification', 'session', 'templates' ], function (Backbone, _, notification, session, templates) { 'use strict'; // Change password view. // Renders the view to change password. Handles all the UI logic associated with it. var ChangePasswordView = Backbone.View.extend({ // Set the template. template: templates.changepassword, // Wire-up the handlers for DOM events. events: { 'submit #changepassword-form': 'changePassword' }, // Set the bindings. bindings: { '#password': 'password' }, initialize: function (options) { // If model is not passed throw error. if (!this.model) { throw new Error('model is required'); } // If credential is not passed throw error. if (!options.credential) { throw new Error('credential is required'); } // Store the passed credential in a property. this.credential = options.credential; // Enable/disable the change button whenever password changes. this.listenTo(this.model, 'change:password', this.enableChange); }, render: function () { var state = session.retrieve('state'); this.$el.html(this.template({ isAuthenticated: state === 'LOGGED_IN', backToUrl: state === 'LOGGED_IN' ? '#settings' : '#login' })); this.stickit(); this.$change = this.$('#change'); return this; }, // Enable the change button when password text is valid. enableChange: function () { if (this.model.isValid('password')) { this.$change.removeAttr('disabled'); } else { this.$change.attr('disabled', 'disabled'); } }, // Change button event handler. changePassword: function (e) { e.preventDefault(); // Alert the user if the form is submitted with empty password. if (!this.model.isValid('password')) { notification.alert('Enter the password.', 'Error', 'Ok'); return; } // Disable the change button. this.$change.attr('disabled', 'disabled'); var error = function () { notification.alert('Failed to change password. Please try again.', 'Error', 'Ok') .done(_.bind(function () { this.$change.removeAttr('disabled'); }, this)); }; // Fetch the credential, set the new password, save and navigate to login page. this.credential.fetch() .then(_.bind(function () { this.credential.set('password', this.model.get('password')); return this.credential.save(); }, this), _.bind(error, this)) .then(_.bind(function () { session.store('state', 'LOGGED_OUT'); session.remove('key'); this.navigateTo('#login'); }, this), _.bind(error, this)); } }); return ChangePasswordView; });
Listing 23. Change password view (changepassword.js)
Like the former two pages, the change password page also takes two credential instances. One is empty and the other is the actual persisted one. The empty one is used to bind with the form. The changed password is finally copied to the actual credential which is saved at last. You may wonder why we cannot directly bind the actual credential to the form. It's because we don't want the form accidentally changing other information of the model.
If the user access the change password page before logged-in, the back button will take him to the login page or else to the settings page. We've used the session state to achieve that. Once the password is successfully changed we are forcing the user to re-login by setting the state to "LOGGED_OUT" and redirecting him to the login page.
8.3 Change Security Info Page
Create a new file with name "changesecurityinfo.handlebars" and drop the below markup.
<header class="bar bar-nav"> <a href="#settings" class="btn btn-link btn-nav pull-left"> <span class="icon icon-left-nav"></span> </a> <h1 class="title">Change Security Info</h1> </header> <nav class="bar bar-tab"> <a class="tab-item" href="#photos"> <span class="icon icon-home"></span> <span class="tab-label">Home</span> </a> <a class="tab-item" href="#settings"> <span class="icon icon-gear"></span> <span class="tab-label">Settings</span> </a> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav> <div class="content"> <form id="changesecurityinfo-form" class="content-padded"> <select id="security-question" name="security-question"> <option>Select a question</option> {{#each questions}} <option>{{this}}</option> {{/each}} </select> <input id="security-answer" name="security-answer" type="password" maxlength="15" placeholder="Enter your answer" disabled autocomplete="off"> <input id="save" type="submit" value="Save" disabled class="btn btn-positive btn-block"> </form> </div>
Listing 24. changesecurityinfo.handlebars
Create a new file "changesecurityinfo.js" under "views" folder.
define(['backbone', 'underscore', 'adapters/notification', 'templates'], function (Backbone, _, notification, templates) { 'use strict'; // Change security info view. // Renders the view to change the security question and answer. // Handles all the UI logic. var ChangeSecurityInfoView = Backbone.View.extend({ // Set the template. template: templates.changesecurityinfo, // Wire-up the handlers for DOM events. events: { 'submit #changesecurityinfo-form': 'changeSecurityInfo' }, // Set the bindings. bindings: { '#security-question': 'securityQuestion', '#security-answer': 'securityAnswer' }, initialize: function (options) { // If model is not passes throw error. if (!this.model) { throw new Error('model is required'); } // If credential is not passed throw error. if (!options.credential) { throw new Error('credential is required'); } // If not security questions passed throw error. if (!(options && options.securityQuestions && options.securityQuestions.length)) { throw new Error('security questions required'); } // Store the passed credential in a property. this.credential = options.credential; // Store the passed questions in a property. this.securityQuestions = options.securityQuestions; // Listen to model change events to enable/disable controls. this.listenTo(this.model, 'change:securityQuestion', this.enableAnswer); this.listenTo(this.model, 'change:securityAnswer', this.enableSave); }, render: function () { // We need to pass the security questions also to the view, so let's create a viewmodel. var viewModel = this.model.toJSON(); viewModel.questions = this.securityQuestions; this.$el.html(this.template(viewModel)); this.stickit(); this.$securityAnswer = this.$('#security-answer'); this.$save = this.$('#save'); return this; }, enableAnswer: function () { this.model.set('securityAnswer', null); if (this.model.get('securityQuestion') && this.model.get('securityQuestion') !== 'Select a question') { this.$securityAnswer.removeAttr('disabled'); } else { this.$securityAnswer.attr('disabled', 'disabled'); } }, enableSave: function () { if (this.model.isValid('securityQuestion') && this.model.isValid('securityAnswer')) { this.$save.removeAttr('disabled'); } else { this.$save.attr('disabled', 'disabled'); } }, // Save button event handler. changeSecurityInfo: function (e) { e.preventDefault(); // Alert the user if the form is submitted with empty question or answer. if (!(this.model.isValid('securityQuestion') && this.model.isValid('securityAnswer'))) { notification.alert('Submit security question with answer.', 'Error', 'Ok'); return; } // Disable the save button to avoid multiple submits. this.$save.attr('disabled', 'disabled'); var error = function () { notification.alert('Failed to change security info. Please try again.', 'Error', 'Ok') .done(_.bind(function () { this.$save.removeAttr('disabled'); }, this)); }; // Fetch the credential, set the security question/answer, save and navigate to settings page. this.credential.fetch() .then(_.bind(function () { this.credential.set({ securityQuestion: this.model.get('securityQuestion'), securityAnswer: this.model.get('securityAnswer') }); return this.credential.save(); }, this), _.bind(error, this)) .then(_.bind(function () { this.navigateTo('#settings'); }, this), _.bind(error, this)); } }); return ChangeSecurityInfoView; });
Listing 25. Change securityinfo view (changesecurityinfo.js)
Unlike the change password page, once the security info is successfully updated we are not forcing the user to login again just taking him back to the settings page.
Hurrah! We've completed all the pages related to the login process. To test those pages let's configure the pending routes.
After adding the dependencies to the views in the define statement, update the routes object to include the routes.
routes: { '': 'photos', 'photos': 'photos', 'login': 'login', 'logout': 'logout', 'register': 'register', 'forgotpassword': 'forgotPassword', 'changepassword': 'changePassword', 'changesecurityinfo': 'changesecurityinfo', 'photos/:id': 'photo', 'add': 'add', 'edit/:id': 'edit' }
Listing 26. router.js
Below code shows the implementation of the route handlers. See in the forgotPassword handler we've to pre-fetch the credential to retrieve the securityQuestion and assign it to the duplicate model.
forgotPassword: function () { var credential = new Credential({ id: 'Safe-Credential' }); credential.fetch() .done(_.bind(function () { this.renderView(new ForgotPasswordView({ model: new Credential({ securityQuestion: credential.get('securityQuestion') }), credential: credential })); }, this)) .fail(function () { notification.alert('Failed to retrieve security info. Please try again.', 'Error', 'Ok'); }); }, changePassword: function () { this.renderView(new ChangePasswordView({ model: new Credential(), credential: new Credential({ id: 'Safe-Credential' }) })); }, changesecurityinfo: function () { this.renderView(new ChangeSecurityInfoView({ model: new Credential(), credential: new Credential({ id: 'Safe-Credential' }), securityQuestions: Data.securityQuestions })); }
Listing 27. router.js
Make sure the grunt serve command is still running or re-shoot it again and verify how all the pages behaves in the browser. If you've done everything right you should see all the pages works like charm!
9. Create settings and info views
These are the two simple most pages in the app. From the mockups, you can see the settings page contains two buttons. One to navigate to change security info page and other to the change password page. The info page contain some text related to the app and it's usage. Quite simple!
9.1 Settings Page
9.1.1 settings.handlerbars
<header class="bar bar-nav"> <h1 class="title">Settings</h1> </header> <nav class="bar bar-tab"> <a class="tab-item" href="#photos"> <span class="icon icon-home"></span> <span class="tab-label">Home</span> </a> <a class="tab-item active" href="#settings"> <span class="icon icon-gear"></span> <span class="tab-label">Settings</span> </a> <a class="tab-item" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav> <div class="content"> <div class="content-padded"> <a href="#changepassword" class="btn btn-block">Change Password</a> <a href="#changesecurityinfo" class="btn btn-block">Change Security Info</a> </div> </div>
Listing 28. settings.handlerbars
9.1.2 settings.js
define(['backbone', 'templates'], function (Backbone, templates) { 'use strict'; // Settings view. var SettingsView = Backbone.View.extend({ template: templates.settings, render: function () { this.$el.html(this.template()); return this; } }); return SettingsView; });
Listing 29. Settings view (settings.js)
9.2 Info Page
9.2.1 info.handlebars
<header class="bar bar-nav"> {{#if backToUrl}} <a href="{{backToUrl}}" class="btn btn-link btn-nav pull-left"> <span class="icon icon-left-nav"></span> </a> {{/if}} <h1 class="title">Info</h1> </header> <nav class="bar bar-tab"> {{#if isAuthenticated}} <a class="tab-item" href="#photos"> <span class="icon icon-home"></span> <span class="tab-label">Home</span> </a> <a class="tab-item" href="#settings"> <span class="icon icon-gear"></span> <span class="tab-label">Settings</span> </a> {{/if}} <a class="tab-item active" href="#info"> <span class="icon icon-info"></span> <span class="tab-label">Info</span> </a> </nav> <div class="content"> <div class="content-padded"> <h4>Safe</h4> <p> A simple app that helps to preserve your photos safe! The persisted photos are encrypted using the powerful AES 256 encryption algorithm. </p> <p> This app is an experiment done to demonstrate how we can architect and develop a pure hybrid application that runs across mobile platforms using Cordova, Backbone and other UI technologies. </p> <p> This application is developed purely for educational purpose. You can download the complete source code from <a href="https://github.com/vjai/safe">Github</a>. You are free to use the source code as a reference material to architect your mobile apps for personal or commercial purposes.</p> <p class="bold">You are not allowed to sell this app in same or different name. You are also not allowed to build and sell apps by copying maximum portion of the source code. For more information about license please visit this <a href="https://github.com/vjai/safe/license">page</a>. </p> <p> As I said earlier, this is an experiment! If you run into any issues of using this app or source code the complete responsibility is yours! For any questions please contact me <a href="http://www.prideparrot.com/contact">here</a>. </p> </div> </div>
Listing 30. info.handlebars
9.2.2 info.js
define(['underscore', 'backbone', 'session', 'templates'], function (_, Backbone, session, templates) { 'use strict'; // Info view. var InfoView = Backbone.View.extend({ template: templates.info, render: function () { var isAuthenticated = session.retrieve('state') === 'LOGGED_IN'; this.$el.html(this.template({ isAuthenticated: isAuthenticated, backToUrl: !isAuthenticated ? '#login' : null })); return this; } }); return InfoView; });
Listing 31. Info view (info.js)
Below are the routes and their handlers. Don't forget to include the dependencies of the views in the define statement.
define([..., 'views/info', 'views/settings'], function(InfoView, SettingsView) { ... routes: { ... 'info': 'info', 'settings': 'settings' } // Renders the info view. info: function () { this.renderView(new InfoView()); }, // Renders the settings view. settings: function () { this.renderView(new SettingsView()); } });
Listing 32. router.js
You can test the settings and info pages either by clicking the menus in the footer or by replacing the hash to "#settings" or "#info".
We've completed all the pages of the app. Before giving that sigh relief we've still got some work to do! We've to implement the logout functionality, which is very simple! Following that, we've to fix a major problem! Let's implement the logout functionality first and then we talk about the next one.
10. Implement logout functionality
Implement logout functionality is straight forward. All we've to do is configure a new route and in the route handler clear the session and redirect the user to the login page. One more thing, we've already placed the logout link in the home page and so we don't have to worry about that.
Open the "router.js", add the session object in the define statement and add a new route called "logout" with the handler as shown in the below listing.
define([..., 'session'], function(..., session) { ... routes: { 'logout': 'logout' }, ... logout: function () { session.clear(); this.navigate('#login', true); } });
Listing 33. Logout route and handler in router.js
We've the logout functionality done! Let's talk about the major problem. Currently whether the user is logged-in or not they can access all the pages and it's because we are not doing any authorization check in each page. This is really bad! How we can fix this? Looks like there is a smart way we can do that without changing any of the view. Thanks to Backbone router!
11. Override the route method to prevent unauthorized access
In single page applications, router is the best place to put your logic to control unauthorized access. In Backbone, we can override the default route method to control the navigation. In our app, based on the state we stored in session we've to decide where the user is allowed to navigate to that page or some other page. To achieve that we've to create a simple dictionary that contains for each state what are the pages that are allowed or not-allowed.
Open the "router.js" file and add the below dictionary after the 'use strict' statement.
// Array that maps the user state to allowed/not-allowed routes and default one. var stateRouteMapArray = [ { state: 'NOT_REGISTERED', allowed: 'register|info', default: 'register' }, { state: 'LOGGED_OUT', allowed: 'login|forgotpassword|info', default: 'login' }, { state: 'VERIFIED', allowed: 'changepassword|info|login', default: 'changepassword' }, { state: 'LOGGED_IN', notallowed: 'register|login|forgotpassword', default: 'photos' } ];
Listing 34. State-route dictionary in router.js
If you see the dictionary, each object contains the property state which maps to state that we stored in session, the pages that are allowed or not allowed and also we've an additional property to represent the default page for that state.
Now we need to implement a private method that takes the state and route parameters as input arguments and returns the actual page that the user should be redirected based on the dictionary. Note that actually the below method is not returning the page but the name of the route handler. By calling the returned route handler the corresponding page will be displayed to the user.
Drop the below method after the dictionary.
// Returns the route that the user should be directed to. var whereToGo = function (state, route) { // Get the map object for the user state. var stateRouteMap = _.find(stateRouteMapArray, { state: state }); var routes = (stateRouteMap.allowed || stateRouteMap.notallowed).split('|'), toGo; var containsRoute = _.contains(routes, route); if (stateRouteMap.allowed) { toGo = containsRoute ? route : stateRouteMap.default; } else { toGo = containsRoute ? stateRouteMap.default : route; } return toGo; };
Listing 35. "whereToGo" function in router.js
The final thing we've to do is override the route method. Overriding the route method in Backbone is not straight-forward and it kind of little hacky! I really hate the below code but please visit the stackoverflow page to get a better understanding of it.
// Override the 'route' method to prevent unauthorized access. // before directing the routes to their corresponding methods. route: function (route, name, callback) { if (!_.isRegExp(route)) { route = this._routeToRegExp(route); } if (_.isFunction(name)) { callback = name; name = ''; } if (!callback) { callback = this[name]; } // here my custom code callback = _.wrap(callback, _.bind(function (cb, args) { var state = session.retrieve('state'), fn = function () { if (state === 'VERIFIED') { session.store('state', 'LOGGED_OUT'); } // Get the page the user should navigate to. // 'name' is the handler of the particular route. var goto = whereToGo(state, name.toLowerCase()); if (goto.toLowerCase() === name.toLowerCase()) { _.bind(cb, this)(args); } else { this.navigate(goto, true); } }; if (!state) { state = keychain.containsKey('Safe-Credential') ? 'LOGGED_OUT' : 'NOT_REGISTERED'; session.store('state', state); } fn.call(this); }, this)); var router = this; Backbone.history.route(route, function (fragment) { var args = router._extractParameters(route, fragment); if (router.execute(callback, args, name) !== false) { router.trigger.apply(router, ['route:' + name].concat(args)); router.trigger('route', name, args); Backbone.history.trigger('route', router, name, args); } }); return this; }
Listing 36. Overriding "route" method
In a higher level what we are doing in the route method is, call the whereToGo method passing the user status and the route handler's name and then redirecting him to the corresponding page (controlled by dictionary) based on the returned route handler. We also have some other logic regarding state. When the state is "VERIFIED" (which occurs when the user is successfully verified by the change password page) we are re-setting it to "LOGGED_OUT". This will make the router to take him to the login page. Next thing is, if the session don't have state then we are retrieving based on the keychain (don't forget to include this in the define statement) we are deciding the status and setting it to session.
That's all the additional functionality we needed in the router. Let's do some smoke testing by firing up the grunt. Before testing, clear all the saved data from localStorage and sessionStorage so you can test the flow from scratch. You can do that by opening the developer tools from browser and executing localStorage.clear() and sessionStorage.clear() from the console.
Refresh the browser and you'll be taken to registration page. Now try to see whether you can access the list page by manipulating the URL. Replace the hash segment "register" with "photos" and hit enter. You'll notice you'll be again redirected back to the registration page. Register new credential and hit enter you'll be taken to the login page. Enter the right credential and hit enter you'll be taken to the home page. If you change hash to "login" you'll be again redirected to the home page. Spend some more time testing the change password, forgot password and other pages and see how our routing works!
If everything works cool in browser, kick the grunt deploy command to install right to your mobile and test all the pages. If that grunt command don't works in case of mac, you can run the grunt build command and then from the XCode editor you can open the project and deploy to your preferred iOS device.
12. Turning-off encryption
When I was testing the app in couple of Nexus devices, I observed, saving the photo was taking too much time and I figured out it's due to encryption. But, when I tested in a brand new Moto E device it was blazing fast. The performance is better in iPhone devices as well. Not sure why it was slow in those Nexus devices (may be too many processes running in the background?). If you experience the same problem please share with me.
To make the app usable in slow devices I thought to provide a configuration option to turn off encrypting photos (not the credential). Remember in that case the safety of photos depends upon only the login mechanism. If you note the Listing 5, we already added a boolean property encrypt to the settings file. As default the value is set to true to force encryption and by turning this option false we can skip encryption. To control the encryption based on this flag we've to modify the serializer object. We've created this serializer object in the previous part to serialize a JSON object into string with encryption and deserialize back to object after decryption.
Open the "serializer.js" file which is located in the "js" folder and modify the code as below.
// Serializes javascript object to string and vice-versa. define([ 'jquery', 'underscore', 'settings', 'session', 'adapters/crypto' ], function ($, _, settings, session, crypto) { 'use strict'; return { serialize: function (item) { var result = JSON.stringify(item); if (settings.encrypt) { return crypto.encrypt(session.retrieve('key'), result); } return _.resolve(result); }, deserialize: function (data) { var d = $.Deferred(); if (settings.encrypt) { crypto.decrypt(session.retrieve('key'), data) .done(function (result) { d.resolve(JSON.parse(result)); }) .fail(d.reject); } else { d.resolve(JSON.parse(data)); } return d.promise(); } }; });
Listing 37. serializer.js
We modified both the serialize and deserialize methods to conditionally encrypt or decrypt based on the encrypt flag. Personally I would like to leave the flag to true but if you notice the performance is too bad then turn it to false.
Hurrah! Finally we made it! Our Safe app is ready with all the features. You learnt how to create a hybrid mobile app that runs both in iOS and Android. What's next? Before wrapping up, let me show you quickly how we can write some nice unit tests with Jasmine and run using Karma.
13. Unit testing with Jasmine and Karma
Unit testing in frontend world is becoming more and more important now. There are quite a bunch of testing frameworks available in the market. Couple of popular ones I'm aware of are: Mocha and Jasmine. Both these frameworks helps you to write BDD (Behavior Driven Development) or TDD (Test Driven Development) style tests. Initially I thought of developing the app through TDD process but after encountering lot of unknowns at early stage I couldn't use either of them. After completing the development through an experimental approach I thought it's good to write some unit tests and share with you. We are going to use Jasmine for writing the tests and Karma to run them. Karma is a test runner tool that helps you to run unit tests written in different frameworks like Jasmine, Mocha etc. Typically in Backbone based apps the views are ones that contain most of the logic. So, I did write unit tests for only the views. In this section, I'll show you how to write unit tests for only the login view. If you want to see the unit tests for the other views please see the github source-code.
13.1 Configure the grunt tasks
You cannot only write tests in Jasmine but also you can run them using it. But running the tests with Karma helps us to easily configure and run the tests either in browser or most interestingly using PhantomJS (headless browser). You can also get a lot of other benefits like coverage report, output formatting etc. etc. through plugins.
We can easily integrate Karma with our grunt build system through grunt plugins and which in-turn helps us to automate running unit tests as part of our build process. In our development, we are not going to run the unit tests every-time we modify the code and instead we run them when we build the app. Before configure grunt for testing you need to install the node modules specified in the below table. Note, PhantomJS has gone through a name change recently and it's now called as "phantomjs-rebuilt". When I was developing the app it was just called as "phantomjs" and I remember installing it globally. If you encounter any issues regarding the node packages, please install the particular version of all the node modules specified in the "package.json" file below.
Module | Purpose |
---|---|
grunt-karma | Helps to run Karma as part of Grunt |
jasmine-core | Jasmine NPM module |
karma | The awseome test runner |
karma-jasmine | Karma plugin. Adapter for Jasmine testing framework |
karma-chrome-launcher | A karma plugin. Launches the jasmine tests in Chrome or Canary |
karma-coverage | A karma test coverage plugin |
karma-jasmine-jquery | Helps to use jQuery in our jasmine tests |
karma-mocha-reporter | Karma plugin that reports tests results with mocha style logging |
phantomjs-prebuilt | An NPM installer for PhantomJS, headless webkit with JS API |
karma-phantomjs-launcher | Launches jasmine in Phantomjs |
karma-requirejs | Another plugin. Helps to write our tests as requirejs modules. |
requirejs | requirejs npm package |
All the above modules has to be installed as development dependencies. You can install them either one by one as below.
$ npm install grunt-karma --save-dev $ npm install jasmine-core --save-dev ...
Listing 38. Installing node modules one by one
or you can install all of them in a single command as below.
$ npm install grunt-karma jasmine-core karma karma-jasmine karma-chrome-launcher karma-coverage karma-jasmine-jquery karma-mocha-reporter karma-phantomjs-launcher karma-requirejs requirejs --save-dev
Listing 39. Installing node modules by a single statement
After you run the command from terminal, your "package.json" file should closely look as below and obviously the version numbers might be different unless you installed the specific version for each module.
{ "name": "Safe", "version": "0.1.0", "description": "Cordova based hybrid mobile app to keep your photos safe", "devDependencies": { "cca": "^0.8.1", "cordova": "^5.4.1", "grunt": "^0.4.5", "grunt-contrib-clean": "^0.7.0", "grunt-contrib-connect": "^0.10.1", "grunt-contrib-copy": "^0.8.2", "grunt-contrib-cssmin": "^0.14.0", "grunt-contrib-handlebars": "^0.10.2", "grunt-contrib-jshint": "^0.11.2", "grunt-contrib-uglify": "^0.11.0", "grunt-contrib-watch": "^0.6.1", "grunt-cordovacli": "^0.8.2", "grunt-karma": "^0.12.2", "grunt-processhtml": "^0.3.8", "grunt-requirejs": "^0.4.2", "jasmine-core": "^2.4.1", "jshint-stylish": "^2.0.1", "karma": "^0.13.22", "karma-chrome-launcher": "^0.2.3", "karma-coverage": "^0.5.5", "karma-jasmine": "^0.3.8", "karma-jasmine-jquery": "^0.1.1", "karma-mocha-reporter": "^2.0.2", "karma-phantomjs-launcher": "^1.0.0", "karma-requirejs": "^0.2.6", "load-grunt-tasks": "^3.2.0", "phantomjs-prebuilt": "^2.1.7", "requirejs": "^2.2.0" }
Listing 40. package.json
We've all the required packages installed. It's time to configure the tasks in grunt file. First let's create a task to kick Karma. Open our "Gruntfile.js" and add the below task definition following the "jshint" task.
karma: { unit: { basePath: '', singleRun: true, frameworks: ['jasmine-jquery', 'jasmine', 'requirejs'], files: [ { src: 'bower_components/**/*.js', included: false }, { src: '<%= config.src %>/**/*.js', included: false }, { src: '<%= config.tests %>/*spec.js', included: false }, { src: '<%= config.tests %>/main.js' } ], exclude: ['<%= config.src %>/main.js'], port: 9002, reporters: ['mocha', 'coverage'], preprocessors: { '<%= config.src %>/js/views/*.js': 'coverage' }, coverageReporter: { type: 'text' }, plugins: [ 'karma-phantomjs-launcher', 'karma-jasmine-jquery', 'karma-jasmine', 'karma-requirejs', 'karma-coverage', 'karma-mocha-reporter' ], browsers: ['PhantomJS'] } }
Listing 41. "karma" task definition
That's the biggest task definition in our grunt file, isn't it? Let me explain about some of the important options. The basePath property is used to specify the root path that will be used to resolve all relative paths defined in files and exclude properties. We've set the basePath to empty string and that represents the root folder. The singleRun property is used to specify Karma after it run the tests whether it should stay in the watch mode or not. Setting the option to true will run the tests only once and that's what we needed in our case because it simplifies to chain with other tasks. The files property is used to specify array of files that should be included or excluded in the browser. If you scan the files property other than the "main.js" file in "tests" folder (we don't have such one yet) we are excluding all the other files and the reason is we are gonna use requirejs plugin to load the files from the tests and so we don't need to pre-load all of them by Karma.
The exclude property also takes an array of files and we've specified to exclude the "main.js" file from other dependencies and you'll know the reason soon. If you see the preprocessors property we've specified to run the test coverage for only the files under "views" folder and that's because we are gonna write unit tests for only views in our app because they are ones contain most of the logic (sorry router!). To know about other options please visit this Karma configuration page.
Alright, coming back to the "main.js" file, what is it? If you think little harder you could figure that from it's name. We already created such one in the past remember? Ok, enough puzzles. The "main.js" file is nothing but the requirejs configuration to load modules in test environment. Let's create the file under "tests" folder. Except some crazy lines, the code is pretty similar like the one we built earlier.
(function () { 'use strict'; var allTestFiles = []; var TEST_REGEXP = /(spec|test)\.js$/i; var pathToModule = function (path) { return '../../' + path.replace(/^\/base\//, '').replace(/\.js$/, ''); }; Object.keys(window.__karma__.files).forEach(function (file) { if (TEST_REGEXP.test(file)) { // Normalize paths to RequireJS module names. allTestFiles.push(pathToModule(file)); } }); require.config({ baseUrl: '/base/src/js', paths: { jquery: '../../bower_components/jquery/dist/jquery', underscore: '../../bower_components/underscore/underscore', backbone: '../../bower_components/backbone/backbone', validation: '../../bower_components/backbone.validation/dist/backbone-validation-amd', stickit: '../../bower_components/backbone.stickit/backbone.stickit', touch: '../../bower_components/backbone.touch/backbone.touch', handlebars: '../../bower_components/handlebars/handlebars.runtime', almond: '../../bower_components/almond/almond' }, shim: { jquery: { exports: '$' }, underscore: { deps: ['jquery'], exports: '_' }, backbone: { deps: ['underscore', 'jquery'], exports: 'Backbone' }, handlebars: { exports: 'Handlebars' } }, // dynamically load all test files deps: allTestFiles, // we have to kickoff jasmine, as it is asynchronous callback: window.__karma__.start }); })();
Listing 42. main.js
We've the test runner task and requirejs configuration file ready. To test drive, let's create a sample test file with name "samplespec.js" under "tests" directory. Note that the test file name should end with "spec" else the runner don't pick them.
define(function() { 'use strict'; describe('Adding 5 and 3', function () { var x = 5, y = 3, sum = x + y; it('should produce 8', function(){ expect(x + y).toBe(8); }) }); });
Listing 43. samplespec.js
The "samespec.js" contains a very complicated test that verifies the sum of 5 and 3 is 8. Save the file and run the grunt karma command and you should see the below output.
100% test coverage? That's cool, because we've just one test!
Before starting to write unit tests for our login view I thought of giving a quick primer on Jasmine (for writing Behavior Driven JavaScript) but seeing the quick documentation they've in their website, I thought it's not really required! Let me stop you here and ask you a request. Please visit the Jasmine website and all you need is to spend just 10 min and come back. I'm waiting....!
Alright, hope you've a little understanding of Jasmine and that's good! Create a new file "loginspec.js" under "tests" folder and don't forget to delete the sample test file.
Since our tests files are nothing but requirejs modules, let's start with the define statement with all the required dependencies.
define([ 'jquery', 'underscore', 'extensions', 'session', 'adapters/notification', 'models/credential', 'views/login', 'templates' ], function ( $, _, extensions, session, notification, Credential, LoginView, templates ) { 'use strict'; // ...Specs on the way! });
Listing 44. loginspec.js
We can test the scenarios for Backbone views in three different stages. 1. Construction 2. Rendering 3. User interaction. In each stage we start with first negative tests and end with the positive ones.
13.2 Construction Stage
In the construction stage, we pass different arguments to the view's constructor and verify the expected behavior. The login view takes two arguments: an empty credential object and an actual persisted object with only the "id". Before jumping into writing the tests first we've to come-up with the different scenarios and the expected outcome.
Following are the scenarios and expected outcome for construction stage.
Scenario | Expectation |
---|---|
Constructing login view | |
Without model | Should throw error "model is required" |
Without credential | Should throw error "credential is required" |
With both the empty and persisted credentials | Should create the view with all the properties set properly |
Let's start with some empty describe statements. This is one of the practice I follow while I write testing. First I'll write all the describes and then the its. This will give a higher level picture how much tests I've to do.
describe('login view', function () { // 1. Construction stage describe('when constructed', function () { describe('without model', function () { it('should throw error', function () { // TODO: }); }); describe('without credential', function () { it('should throw error', function () { // TODO: }); }); describe('with required arguments', function () { it('should exist with properties initialized', function () { // TODO: }); }); }); // 2. Rendering stage describe('when rendered', function () { // TODO: }); // 3. Changing stage by user interactions describe('when changes', function () { // TODO: }); });
Listing 45. Starting with empty "describes" and "its" in loginspec.js
We've described all the scenarios for the construction stage. Let's write code to verify each test passes. Let's start with the first one.
describe('without model', function () { it('should throw error', function () { expect(function () { new LoginView(); }).toThrow(new Error('model is required')); }); });
Listing 46. loginspec.js
In the above test we are instantiating the login view without passing any arguments to it and verifying whether we are getting an error using the toThrow function.
Let's quickly run the grunt karma command and check the output.
You can see the test passes! Interestingly you can notice the other tests also passes successfully and it's because we've empty it statements that don't asserts anything! One way we can fix this is just throw an exception from the others tests that not yet completed as shown below.
describe('without credential', function () { it('should throw error', function () { throw new Exception('Pending'); }); }); describe('with required arguments', function () { it('should exist with properties initialized', function () { throw new Exception('Pending'); }); });
Listing 47. Throwing exceptions from pending tests
You can also notice the coverage report has lot of numbers in "red" and that means we are missing lot of scenarios that needs to be tested! Let's continue our testing! Below is the test for our second scenario.
describe('without credential', function () { var model = new Credential(); it('should throw error', function () { expect(function () { new LoginView({ model: model }); }).toThrow(new Error('credential is required')); }); });
Listing 48. loginspec.js
We've completed the negative tests. Let's verify the positive one. What happens if we pass all the required arguments? The view should be instantiated with the proper state right? Let's verify that.
describe('with required arguments', function () { var model = new Credential(); var persistedModel = new Credential({ id: 'Safe-Credential' }); var view = new LoginView({ model: model, credential: persistedModel }); it('should exist with properties initialized', function () { expect(view).toBeDefined(); expect(view.model).toBe(model); expect(view.credential).toBe(persistedModel); expect(view.template).toBe(templates.login); }); });
Listing 49. loginspec.js
In the above test, we've created two instances credential model, one is empty and the other with id and passing both to the login view. If you scan the it function body, we've quite a bunch of expectations there. Actually, we are verifying whether the view object is created successfully with the passed model and credential. Re-run the grunt command and verify all the tests passes successfully. By that, we've completed all the tests of the construction stage. Let's write tests for the rendering stage.
13.3 Rendering Stage
Unlike the other stages, the scenarios we've in this stage is less. All we test is after the view is rendered we verify all the fields are displayed with the expected state.
Below is the scenario-expectation table.
Scenario | Expectation |
---|---|
When login view is rendered | Should contain password textbox empty, maxlength 15 and autocomplete off |
Should contain submit button as disabled | |
Should contain the forgot password link |
Here is your code.
describe('when rendered', function () { var model = new Credential(); var persistedModel = new Credential({ id: 'Safe-Credential' }); var view = new LoginView({ model: model, credential: persistedModel }); view.render(); it('should contain password textbox empty, maxlength 15 and autocomplete off', function () { var $password = view.$el.find('#password'); expect($password).toHaveValue(''); expect($password).toHaveAttr('maxlength', '15'); expect($password).toHaveAttr('autocomplete', 'off'); }); it('should contain submit button as disabled', function () { expect(view.$el.find('#login')).toBeDisabled(); }); it('should contain the forgot password link', function () { expect(view.$el.find('.forgot-password')).toHaveAttr('href', '#forgotpassword'); }); });
Listing 50. loginspec.js
You may wonder how we could able to perform all the jQuery operations on the view without it's being rendered in browser. The answer is the magician PhantomJS. PhantomJS is a headless browser that runs in Webkit engine. You can perform pretty much most of the browser operations in PhantomJS. Run the grunt command and make sure all the tests passes.
Before moving to write tests for the final and the complicated stage, I would like to do some refactoring. The instantiation code of models and the login view is required in most of the tests. Instead of duplicating the code under each test we could do that in a centralised place. Luckily Jasmine provides a hook called beforeEach and whatever we put inside this function is guaranteed to be called every time before a test runs.
Add the below piece of code after the first describe statement.
describe('login view', function () { var view, model, persistedModel; beforeEach(function () { model = new Credential(); persistedModel = new Credential({ id: 'Safe-Credential' }); view = new LoginView({ model: model, credential: persistedModel }); }); ... });
Listing 51. loginspec.js
Like beforeEach there is another function available called afterEach and it is best place to do some cleanup. In our case we can utilize that method to remove the view.
Drop the below code after beforeEach.
afterEach(function () { if (view) { view.remove(); } });
Listing 52. loginspec.js
Having our hooks ready, let's clean-up our tests.
define([ 'jquery', 'underscore', 'extensions', 'session', 'adapters/notification', 'models/credential', 'views/login', 'templates' ], function ($, _, extensions, session, notification, Credential, LoginView, templates) { 'use strict'; describe('login view', function () { var view, model, persistedModel; beforeEach(function () { model = new Credential(); persistedModel = new Credential({ id: 'Safe-Credential' }); view = new LoginView({ model: model, credential: persistedModel }); }); describe('when constructed', function () { describe('without model', function () { it('should throw error', function () { expect(function () { new LoginView(); }).toThrow(new Error('model is required')); }); }); describe('without credential', function () { var model = new Credential(); it('should throw error', function () { expect(function () { new LoginView({ model: model }); }).toThrow(new Error('credential is required')); }); }); describe('with required arguments', function () { it('should exist with properties initialized', function () { expect(view).toBeDefined(); expect(view.model).toBe(model); expect(view.credential).toBe(persistedModel); expect(view.template).toBe(templates.login); }); }); }); describe('when rendered', function () { beforeEach(function () { view.render(); }); it('should contain password textbox empty, maxlength 15 and autocomplete off', function () { var $password = view.$el.find('#password'); expect($password).toHaveValue(''); expect($password).toHaveAttr('maxlength', '15'); expect($password).toHaveAttr('autocomplete', 'off'); }); it('should contain submit button as disabled', function () { expect(view.$el.find('#login')).toBeDisabled(); }); it('should contain the forgot password link', function () { expect(view.$el.find('.forgot-password')).toHaveAttr('href', '#forgotpassword'); }); }); describe('when changes', function () { // TODO: }); }); });
Listing 53. loginspec.js
The beforeEach can occur inside the child describe functions as well. This can be well understood by reading the "when rendered" case describe function. When it occurs inside a child describe then before executing each of the it function Jasmine invokes that beforeEach function.
Alright, let's complete the tests for the last stage.
13.4 Changing/User Interactions Stage
Lot of scenarios occurs during the changing stage and hence we've to verify quite lot of things. Mostly these scenarios happens due to user interactions. Following table specifies the important scenarios with their expectations.
Scenario | Expectation |
---|---|
When the password textbox becomes empty |
|
When the password textbox not empty |
|
When form is submitted | |
Without any password |
|
With invalid password | |
And fetching the persisted credential failed |
|
And fetch succeeded |
|
With valid password and fetch succeeded |
|
Below are the tests to verify the above scenarios.
describe('when changes', function () { beforeEach(function () { view.render(); }); describe('with the password textbox empty', function () { beforeEach(function () { view.$el.find('#password').val('').trigger('change'); }); it('then password property of the model should be empty', function () { expect(view.model.get('password')).toBe(''); }); it('then the submit button should be disabled', function () { expect(view.$el.find('#login')).toBeDisabled(); }); }); describe('with the password textbox not empty', function () { beforeEach(function () { view.$el.find('#password').val('S@f3').trigger('change'); }); it('then the password property of the model should not be empty', function () { expect(view.model.get('password')).toBe('S@f3'); }); it('then the submit button should be enabled', function () { expect(view.$el.find('#login')).not.toBeDisabled(); }); }); describe('when form is submitted', function () { describe('without any password', function () { beforeEach(function () { view.$el.find('#password').val('').trigger('change'); view.$el.find('#login-form').trigger('submit'); }); it('should alert an error', function () { expect(notification.alert).toHaveBeenCalledWith('Enter the password.', 'Error', 'Ok'); }); }); describe('with invalid password', function () { describe('and fetch failed', function () { beforeEach(function () { spyOn(persistedModel, 'fetch').and.callFake(function () { return _.reject(); }); view.$el.find('#password').val('pass123').trigger('change'); view.$el.find('#login-form').trigger('submit'); }); it('should alert an error', function () { expect(notification.alert).toHaveBeenCalledWith('Failed to retrieve credential. Please try again.', 'Error', 'Ok'); }); it('the submit button should be enabled', function () { expect(view.$el.find('#login')).not.toBeDisabled(); }); }); describe('and fetch succeeded', function () { beforeEach(function () { spyOn(persistedModel, 'fetch').and.callFake(function () { var d = $.Deferred(); persistedModel.set({ password: 'S@f3', key: '007', securityQuestion: 'CODE WELCOME', securityAnswer: 'CODE GET LOST' }); d.resolve(); return d.promise(); }); view.$el.find('#password').val('pass123').trigger('change'); view.$el.find('#login-form').trigger('submit'); }); it('should alert an error', function () { expect(notification.alert).toHaveBeenCalledWith('Invalid password.', 'Error', 'Ok'); }); it('the submit button should be enabled', function () { expect(view.$el.find('#login')).not.toBeDisabled(); }); }); }); describe('with valid password and fetch suceeded', function () { beforeEach(function () { spyOn(persistedModel, 'fetch').and.callFake(function () { var d = $.Deferred(); persistedModel.set({ password: 'S@f3', key: '007', securityQuestion: 'CODE WELCOME', securityAnswer: 'CODE GET LOST' }); d.resolve(); return d.promise(); }); spyOn(session, 'store'); spyOn(view, 'navigateTo'); view.$el.find('#password').val('S@f3').trigger('change'); view.$el.find('#login-form').trigger('submit'); }); it('the submit button should be disabled', function () { expect(view.$el.find('#login')).toBeDisabled(); }); it('should update the state session variable as LOGGED_IN', function () { expect(session.store).toHaveBeenCalledWith('state', 'LOGGED_IN'); }); it('should store the encryption/decryption key in session', function () { expect(session.store).toHaveBeenCalledWith('key', '007'); }); it('should return back to photos page', function () { expect(view.navigateTo).toHaveBeenCalledWith('#photos'); }); }); }); });
Listing 54. loginspec.js
Sorry for throwing that big piece of code to your face. Most of the things are pretty much same as you saw in previous tests. I would like to talk about one important thing though! If you read the code for the test "fetch failed", you can see how we are mocking the fetch call of the persisted credential using the Jasmine's spyOn method.
spyOn(persistedModel, 'fetch').and.callFake(function () { return _.reject(); });
Listing 55. loginspec.js
The spyOn method is a very powerful method and using it you can mock any call to an object and provide a fake implementation. In the above case, to fail the fetch call we've provided a fake implementation that rejects the promise.
Our tests for the login view are ready! Let's run the grunt command and see what happens.
Looks like we've some errors. These errors occurs due to call to the the alert function of notification object. To fix these errors we've to mock the alert call to the notification object using the spyOn function. Let's do this right in our first beforeEach function.
describe('login view', function () { ... beforeEach(function () { // mock the alert call of notification object. spyOn(notification, 'alert').and.callFake(function () { return _.resolve(); }); ... }); });
Listing 56. loginspec.js
Let's run the command again and you should notice the errors are gone for good!
We've seen the tests for only the login view, if you want to see the tests for other views please visit the github source link at the bottom.
Before wrapping up this part... there is a small work pending in our gruntfile. We've the "karma" task to run the tests but we haven't chained this task yet with the other build tasks. There is no point of running all the tasks and deploying the app before make sure all the tests passes. To run the tests every-time we build the app, we've to include "karma" to the "buildweb".
This is how our "buildweb" task looks now.
grunt.registerTask('buildweb', [ 'jshint', 'clean', 'handlebars', 'requirejs', 'cssmin', 'processhtml', 'copy' ]);
Listing 57. Gruntfile.js
Let's add the "karma" task after the "jshint".
grunt.registerTask('buildweb', [ 'jshint', 'karma', 'clean', 'handlebars', 'requirejs', 'cssmin', 'processhtml', 'copy' ]);
Listing 58. Gruntfile.js
Also, we aren't performing jshint validation over our tests files. To do that, create another sub-task called "tests" under the "jshint" with the below definition.
jshint: { options: { jshintrc: '.jshintrc', reporter: require('jshint-stylish') }, gruntfile: 'Gruntfile.js', src: [ '<%= config.src %>/js/**/*.js', '!<%= config.src %>/js/templates.js' ], tests: [ '<%= config.tests %>/**/*.js' ] }
Listing 59. Gruntfile.js
Finally, if you want to only run the tests instead of just calling karma, it's better we first lint the tests files and then run karma. To achieve let's create a wrapper tests task that chains both of them.
grunt.registerTask('tests', [ 'jshint:tests', 'karma' ]);
Listing 60. Gruntfile.js
Now, if you run the grunt build command you should notice the tests runs and followingly the other tasks fires up.
Whew! We made it!! Congratulations!!! We've completed our first hybrid mobile app.
14. What's Next?
That's a quite longgggg journey, huh? Sorry for taking such a long time to finish the writing. It's my pleasure to walking you through this experience in creating this awesome hybrid mobile app ("Safe"). I hope you all have gained the knowledge to create a hybrid app that can runs in both iOS and Android platforms. You've seen the challenges, know the solutions and learnt the tricks to make the development robust and easier. So.. what's next?
Actually you can do more! How about securing textual information like passwords and other stuff? How about saving multiple images as an album?? How about directly uploading images from camera? There are more and more things you can try! Sky is the limit!! Bring it on, try different things and share your experience with me. Thanks for reading this.