Deleting users

Now that the users index is complete, there’s only one canonical REST action left: destroy.

Administrative users

We will identify privileged administrative users with a boolean admin attribute in the User model, which will lead automatically to an admin? boolean method to test for admin status.

app/models/user.js

var Sequelize = require('sequelize');
var sequelize = CONFIG.database;
var bcrypt = require('bcrypt');
var secureRandom = require('secure-random');
var URLSafeBase64 = require('urlsafe-base64');

var User = sequelize.define('user', {
	...
	admin: {
		type: Sequelize.BOOLEAN,
		defaultValue: false
	}
}, {
	...
});

...

As usual, we add the admin attribute with a migration

~/sample_app $ sequelize migration:create --name add_admin_to_users

db/migrate/[timestamp]-add_admin_to_users.js

'use strict';

module.exports = {
	up: function (queryInterface, Sequelize) {
		queryInterface.addColumn(
			'user',
			'admin',
			{
				type: Sequelize.BOOLEAN,
				defaultValue: false
			}
		)
	},

	down: function (queryInterface, Sequelize) {

	}
};

Next, we migrate as usual

~/sample_app $ sequelize db:migrate

As a final step, let’s update our seed data to make the first user an admin by default

db/seeds.js

require('trainjs').initServer();
var faker = require('faker');

var time = 1;
function createUser() {
	var name = faker.name.findName();
	var email = 'example-'+time+'@railstutorial.org';
	var password = 'password';
	User.create({name: name, email: email, password: password, password_confirmation: password}).then(function() {
		if (time <= 98) {
			createUser();
		}
	});
	time++;
}

User.create({
	name:  "Example User",
	email: "[email protected]",
	password: "123456",
	password_confirmation: "123456",
	admin: true
}).then(function() {
	User.create({
		name:  "Example User",
		email: "[email protected]",
		password: "password",
		password_confirmation: "password"
	}).then(function() {
		createUser();
	});
});

Then reset the database

~/sample_app $ rm -f db/development.sqlite3
~/sample_app $ sequelize db:migrate
~/sample_app $ node db/seeds.js

The destroy action

The final step needed to complete the Users resource is to add delete links and a destroy action. We’ll start by adding a delete link for each user on the users index page, restricting access to administrative users.

public/partials/users/index.html

<h1>All users</h1>

<uib-pagination total-items="totalItems" ng-model="currentPage" ng-change="pageChanged()" max-size="5" class="pagination-sm" boundary-link-numbers="true" rotate="false" items-per-page="itemsPerPage"></uib-pagination>

<ul class="users" ng-include="'partials/users/_user.html'"></ul>

<uib-pagination total-items="totalItems" ng-model="currentPage" ng-change="pageChanged()" max-size="5" class="pagination-sm" boundary-link-numbers="true" rotate="false" items-per-page="itemsPerPage"></uib-pagination>

public/partials/users/_user.html

<li ng-repeat="user in users">
   	<img gravatar_for="{{ user.email }}" alt="{{ user.name }}" options-size="50" />
	<a href ui-sref="user_detail({id: user.id})" ui-sref-opts="{reload: true}">{{ user.name }}</a>
	<span ng-if="current_user.id != user.id && current_user.admin"> | <a href ng-click="deleteUser(user.id)">delete</a></span>
</li>

public/controllers/users_controller.js

'use strict';

var usersController = angular.module('usersController', []);
....

usersController.controller(
	'UsersIndexCtrl',
	['$scope', 'users', '$state', '$stateParams', 'user', 'User', 'flashHelper', function ($scope, users, $state, $stateParams, user, User, flashHelper) {
		...

		$scope.deleteUser = function(id) {
			if (window.confirm("You sure?")) {
				User.delete({id: id}, function() {
					flashHelper.set({type: "success", content: "User deleted"});
					$state.transitionTo($state.current, $stateParams, {
						reload: true, inherit: false, notify: true
					});
				});
			}
		};

	}]
);

public/services/user.js

var userService = angular.module('userService', ['ngResource']);

userService.factory('User', ['$resource', function($resource){
	return $resource('users/:id', {id:'@id'}, {
		'get':    {method: 'GET'},
		'create': {method:'POST'},
		'update': {method:'PUT'},
		'query':  {method:'GET', isArray:false},
		'delete': {method:'DELETE'}
	});
}]);

To get the delete links to work, we need to add a destroy action

app/controllers/users_controller.js

var sessionHelper = require('../helpers/sessions_helper.js');

function UsersController() {
	this.before_action = [
		{ action: 'logged_in_user', only: ['index', 'update', 'destroy'] },
		{ action: 'correct_user', only: ['update'] }
	];

	this.destroy = function(req, res, next) {
		var user = ModelSync( User.findById(req.params.id) );
		user.destroy();
		res.end(JSON.stringify({}));
	};

	...
}

module.exports = UsersController;

As in “Authorization” Section, we’ll enforce access control using a before filter, this time to restrict access to the destroy action to admins.

app/controllers/users_controller.js

var sessionHelper = require('../helpers/sessions_helper.js');

function UsersController() {
	this.before_action = [
		{ action: 'logged_in_user', only: ['index', 'update', 'destroy'] },
		{ action: 'correct_user', only: ['update'] },
		{ action: 'admin_user', only: ['destroy'] }
	];

	...

	this.admin_user = function(req, res, next) {
		if (!sessionHelper.current_user(req).admin) {
			res.statusCode = 401;
			return res.end();
		}
	};
}

module.exports = UsersController;

public/controllers/users_controller.js

'use strict';

var usersController = angular.module('usersController', []);
....

usersController.controller(
	'UsersIndexCtrl',
	['$scope', 'users', '$state', '$stateParams', 'user', 'User', 'flashHelper', function ($scope, users, $state, $stateParams, user, User, flashHelper) {
		....
		$scope.deleteUser = function(id) {
			if (window.confirm("You sure?")) {
				User.delete({id: id}, function() {
					flashHelper.set({type: "success", content: "User deleted"});
					$state.transitionTo($state.current, $stateParams, {
						reload: true, inherit: false, notify: true
					});
				}, function(){
					$state.transitionTo('home', {}, {
						reload: true, inherit: false, notify: true
					});
				});
			}
		};
	}]
);

User destroy tests

With something as dangerous as destroying users, it’s important to have good tests for the expected behavior.

public/test/e2e_test/controllers/users_controller_test.js

describe('usersControllerTest', function() {

	...

	it('unsuccessful delete user when not logged in', function(done) {
		browser.executeAsyncScript(function(callback) {
			var $injector = angular.injector([ 'userService' ]);
			var User = $injector.get( 'User' );
			User.delete({id: 100}, function(user){
				callback(user);
			}, function(error){
				callback(error);
			});
		}).then(function (output) {
			expect( output.status ).toEqual(401);
			done();
		});
	})

	it('should redirect edit when logged in as wrong user', function() {
		var current_url = 'http://localhost:1337/#/login';
		browser.get(current_url);
		element(by.css('[name="email"]')).sendKeys('[email protected]');
		element(by.css('[name="password"]')).sendKeys('password');
		element(by.css('[name="commit"]')).click();

		expect( browser.getCurrentUrl() ).toContain('#/users/');

		browser.get('http://localhost:1337/#/users/1/edit');
		expect( element.all(by.css('.alert-danger')).count() ).toEqual(1);
		expect( browser.getCurrentUrl() ).toContain('#/login');
	})

	...

	it('unsuccessful delete user when logged in as a non-admin', function(done) {
		element(by.css('.dropdown')).click();
		element.all(by.css('[ui-sref="logout"]')).click();

		browser.executeAsyncScript(function(callback) {
			var $injector = angular.injector([ 'userService' ]);
			var User = $injector.get( 'User' );
			User.delete({id: 100}, function(user){
				callback(user);
			}, function(error){
				callback(error);
			});
		}).then(function (output) {
			expect( output.status ).toEqual(401);
			done();
		});
	})

});

public/test/e2e_test/integration/users_index_test.js

describe('UsersIndexTest', function() {
	...

	it('index as admin including pagination and delete links', function() {
		expect( element.all(by.css('[href="#/users/1"] + [ng-click="deleteUser(user.id)"]')).count() ).toEqual(0);
		expect( element.all(by.css('[ng-click="deleteUser(user.id)"]')).count() ).toBeGreaterThan(0);
		element.all( by.css('ul.users > li a[ng-click="deleteUser(user.id)"]') ).last().click();
		browser.switchTo().alert().accept();
		expect( element.all(by.css('.alert-success')).count() ).toEqual(1);
	})

	it('index as non-admin', function() {
		element(by.css('.dropdown')).click();
		element.all(by.css('[ui-sref="logout"]')).click();

		var current_url = 'http://localhost:1337/#/login';
		browser.get(current_url);
		element(by.css('[name="email"]')).clear('');
		element(by.css('[name="password"]')).clear('');
		element(by.css('[name="email"]')).sendKeys('[email protected]');
		element(by.css('[name="password"]')).sendKeys('password');
		element(by.css('[name="commit"]')).click();
		expect(browser.getCurrentUrl()).toContain('#/users/');
		browser.get('http://localhost:1337/#/users');

		expect( element.all(by.css('[ng-click="deleteUser(user.id)"]')).count() ).toEqual(0);
	})
})

At this point, our deletion code is well-tested, and the test suite should be successful

~/sample_app $ protractor protractor.conf.js
27 specs, 0 failures