diff --git a/Gruntfile.js b/Gruntfile.js index e56e6c8900e..61a7755014f 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -480,7 +480,7 @@ module.exports = function(grunt) { `curl 'http://localhost:4984/_cluster_setup' -H 'Content-Type: application/json' --data-binary '{"action":"enable_single_node","username":"admin","password":"pass","bind_address":"0.0.0.0","port":5984,"singlenode":true}'`, 'COUCH_URL=http://admin:pass@localhost:4984/medic COUCH_NODE_NAME=nonode@nohost grunt secure-couchdb', // yo dawg, I heard you like grunt... // Useful for debugging etc, as it allows you to use Fauxton easily - `curl -X PUT "http://admin:pass@localhost:4984/_node/nonode@nohost/_config/httpd/WWW-Authenticate" -d '"Basic realm=\\"administrator\\""' -H "Content-Type: application/json"'` + `curl -X PUT "http://admin:pass@localhost:4984/_node/nonode@nohost/_config/httpd/WWW-Authenticate" -d '"Basic realm=\\"administrator\\""' -H "Content-Type: application/json"` ].join('&& ') }, 'clean-test-database': { diff --git a/admin/src/css/configuration.less b/admin/src/css/configuration.less index 2ea051208f9..1af407d1a6e 100644 --- a/admin/src/css/configuration.less +++ b/admin/src/css/configuration.less @@ -113,6 +113,10 @@ dl.horizontal { min-width: 100%; margin-left: -15px; margin-right: -15px; + table-layout: fixed; + td.break { + word-break: break-word; + } } td, th { diff --git a/admin/src/js/controllers/edit-user.js b/admin/src/js/controllers/edit-user.js index a91f75a4545..04abc523fd5 100644 --- a/admin/src/js/controllers/edit-user.js +++ b/admin/src/js/controllers/edit-user.js @@ -1,4 +1,5 @@ const passwordTester = require('simple-password-tester'); +const phoneNumber = require('@medic/phone-number'); const PASSWORD_MINIMUM_LENGTH = 8; const PASSWORD_MINIMUM_SCORE = 50; const USERNAME_WHITELIST = /^[a-z0-9_-]+$/; @@ -17,6 +18,7 @@ angular ContactTypes, CreateUser, DB, + FormatDate, Languages, Select2Search, Settings, @@ -26,15 +28,11 @@ angular 'use strict'; 'ngInject'; - $scope.cancel = function() { - $uibModalInstance.dismiss(); - }; + $scope.cancel = () => $uibModalInstance.dismiss(); - Languages().then(function(languages) { - $scope.enabledLocales = languages; - }); + Languages().then(languages => $scope.enabledLocales = languages); - const getRole = function(roles) { + const getRole = roles => { if (!roles || !roles.length) { return; } @@ -58,13 +56,29 @@ angular } }; + const allowTokenLogin = settings => settings.token_login && settings.token_login.enabled; + const determineEditUserModel = function() { // Edit a user that's not the current user. // $scope.model is the user object passed in by controller creating the Modal. // If $scope.model === {}, we're creating a new user. - if ($scope.model) { - return Settings().then(function(settings) { + return Settings() + .then(settings => { $scope.roles = settings.roles; + $scope.allowTokenLogin = allowTokenLogin(settings); + if (!$scope.model) { + return $q.resolve({}); + } + + const tokenLoginData = $scope.model.token_login; + const tokenLoginEnabled = tokenLoginData && + { + expirationDate: FormatDate.datetime(tokenLoginData.expiration_date), + active: tokenLoginData.active, + loginDate: tokenLoginData.login_date && FormatDate.datetime(tokenLoginData.login_date), + expired: tokenLoginData.expiration_date <= new Date().getTime(), + }; + return $q.resolve({ id: $scope.model._id, username: $scope.model.name, @@ -80,24 +94,22 @@ angular // ^ Same with contactSelect vs. contact contactSelect: $scope.model.contact_id, contact: $scope.model.contact_id, + tokenLoginEnabled: tokenLoginEnabled, }); }); - } else { - return $q.resolve({}); - } }; determineEditUserModel() - .then(function(model) { + .then(model => { $scope.editUserModel = model; }) - .catch(function(err) { + .catch(err => { $log.error('Error determining user model', err); }); $uibModalInstance.rendered .then(() => ContactTypes.getAll()) - .then(function(contactTypes) { + .then(contactTypes => { // only the #edit-user-profile modal has these fields const personTypes = contactTypes.filter(type => type.person).map(type => type.id); Select2Search($('#edit-user-profile [name=contactSelect]'), personTypes); @@ -105,7 +117,7 @@ angular Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes); }); - const validateRequired = function(fieldName, fieldDisplayName) { + const validateRequired = (fieldName, fieldDisplayName) => { if (!$scope.editUserModel[fieldName]) { Translate.fieldIsRequired(fieldDisplayName) .then(function(value) { @@ -116,9 +128,38 @@ angular return true; }; - const validatePasswordForEditUser = function() { + const validateTokenLogin = () => { + if (!$scope.editUserModel.token_login) { + return $q.resolve(true); + } + + return Settings().then(settings => { + const phone = $scope.editUserModel.phone; + if (!phoneNumber.validate(settings, phone)) { + $translate('configuration.enable.token.login.phone').then(value => { + $scope.errors.phone = value; + }); + return false; + } + + // remove assigned password + $scope.editUserModel.password = ''; + $scope.editUserModel.passwordConfirm = ''; + + return true; + }); + }; + + const validatePasswordForEditUser = () => { const newUser = !$scope.editUserModel.id; - if (newUser) { + const tokenLogin = $scope.editUserModel.token_login; + if (tokenLogin) { + // when enabling token_login, password is not required + return true; + } + + if (newUser || tokenLogin === false) { + // for new users or when disabling token_login for users who have it enabled return validatePasswordFields(); } @@ -132,7 +173,7 @@ angular return true; }; - const validateConfirmPasswordMatches = function() { + const validateConfirmPasswordMatches = () => { if ( $scope.editUserModel.password !== $scope.editUserModel.passwordConfirm ) { @@ -144,18 +185,18 @@ angular return true; }; - const validatePasswordStrength = function() { + const validatePasswordStrength = () => { const password = $scope.editUserModel.password || ''; if (password.length < PASSWORD_MINIMUM_LENGTH) { $translate('password.length.minimum', { minimum: PASSWORD_MINIMUM_LENGTH, - }).then(function(value) { + }).then(value => { $scope.errors.password = value; }); return false; } if (passwordTester(password) < PASSWORD_MINIMUM_SCORE) { - $translate('password.weak').then(function(value) { + $translate('password.weak').then(value => { $scope.errors.password = value; }); return false; @@ -163,7 +204,7 @@ angular return true; }; - const validatePasswordFields = function() { + const validatePasswordFields = () => { return ( validateRequired('password', 'Password') && (!$scope.editUserModel.currentPassword || @@ -173,7 +214,7 @@ angular ); }; - const validateName = function() { + const validateName = () => { if ($scope.editUserModel.id) { // username is readonly when editing so ignore it return true; @@ -182,7 +223,7 @@ angular return false; } if (!USERNAME_WHITELIST.test($scope.editUserModel.username)) { - $translate('username.invalid').then(function(value) { + $translate('username.invalid').then(value => { $scope.errors.username = value; }); return false; @@ -190,7 +231,7 @@ angular return true; }; - const validateContactAndFacility = function() { + const validateContactAndFacility = () => { const role = $scope.roles && $scope.roles[$scope.editUserModel.role]; if (!role || !role.offline) { return !$scope.editUserModel.contact || validateRequired('place', 'Facility'); @@ -200,7 +241,7 @@ angular return hasPlace && hasContact; }; - const validateContactIsInPlace = function() { + const validateContactIsInPlace = () => { const placeId = $scope.editUserModel.place; const contactId = $scope.editUserModel.contact; if (!placeId || !contactId) { @@ -219,13 +260,13 @@ angular parent = parent.parent; } if (!valid) { - $translate('configuration.user.place.contact').then(function(value) { + $translate('configuration.user.place.contact').then(value => { $scope.errors.contact = value; }); } return valid; }) - .catch(function(err) { + .catch(err => { $log.error( 'Error trying to validate contact. Trying to save anyway.', err @@ -234,50 +275,51 @@ angular }); }; - const validateRole = function() { - return validateRequired('role', 'configuration.role'); + const validateRole = () => validateRequired('role', 'configuration.role'); + + const getUpdatedKeys = (model, existingModel) => { + return Object.keys(model).filter(key => { + if (key === 'id') { + return false; + } + if (key === 'language') { + return existingModel[key].code !== (model[key] && model[key].code); + } + if (key === 'password') { + return model[key] && model[key] !== ''; + } + const metaFields = [ + 'currentPassword', + 'passwordConfirm', + 'facilitySelect', + 'contactSelect', + 'tokenLoginEnabled', + ]; + if (metaFields.includes(key)) { + // We don't want to return these 'meta' fields + return false; + } + + return existingModel[key] !== model[key]; + }); }; - const changedUpdates = function(model) { - return determineEditUserModel().then(function(existingModel) { - const updates = {}; - Object.keys(model) - .filter(function(k) { - if (k === 'id') { - return false; - } - if (k === 'language') { - return existingModel[k].code !== (model[k] && model[k].code); - } - if (k === 'password') { - return model[k] && model[k] !== ''; - } - if ( - [ - 'currentPassword', - 'passwordConfirm', - 'facilitySelect', - 'contactSelect', - ].indexOf(k) !== -1 - ) { - // We don't want to return these 'meta' fields - return false; - } - - return existingModel[k] !== model[k]; - }) - .forEach(function(k) { - if (k === 'language') { - updates[k] = model[k].code; - } else if (k === 'role') { - updates.roles = [model[k]]; + const changedUpdates = model => { + return determineEditUserModel() + .then(existingModel => { + const updates = {}; + getUpdatedKeys(model, existingModel).forEach(key => { + if (key === 'language') { + updates[key] = model[key].code; + } else if (key === 'role') { + updates.roles = [model[key]]; } else { - updates[k] = model[k]; + updates[key] = model[key]; } }); - return updates; - }); + return updates; + }); }; let previousQuery; @@ -313,7 +355,7 @@ angular }); }; - const computeFields = function() { + const computeFields = () => { $scope.editUserModel.place = $( '#edit-user-profile [name=facilitySelect]' ).val(); @@ -322,90 +364,93 @@ angular ).val(); }; - const haveUpdates = function(updates) { - return Object.keys(updates).length; - }; + const haveUpdates = updates => Object.keys(updates).length; - const validateEmailAddress = function(){ + const validateEmailAddress = () => { if (!$scope.editUserModel.email){ return true; } if (!isEmailValid($scope.editUserModel.email)){ - $translate('email.invalid').then(function(value) { - $scope.errors.email = value; - }); + $translate('email.invalid').then(value => $scope.errors.email = value); return false; } return true; }; - const isEmailValid = function(email){ - return email.match(/.+@.+/); - }; + const isEmailValid = email => email.match(/.+@.+/); // #edit-user-profile is the admin view, which has additional fields. - $scope.editUser = function() { + $scope.editUser = () => { $scope.setProcessing(); $scope.errors = {}; computeFields(); - if ( - validateName() && - validateRole() && - validateContactAndFacility() && - validatePasswordForEditUser() && - validateEmailAddress() - ) { - validateContactIsInPlace() - .then(function(valid) { - if (!valid) { - $scope.setError(); - return; - } - return validateReplicationLimit().then(() => { - changedUpdates($scope.editUserModel).then(function(updates) { - $q.resolve() - .then(function() { - if (!haveUpdates(updates)) { - return; - } else if ($scope.editUserModel.id) { - return UpdateUser($scope.editUserModel.username, updates); - } else { - return CreateUser(updates); - } - }) - .then(function() { - $scope.setFinished(); - // TODO: change this from a broadcast to a changes watcher - // https://github.com/medic/medic/issues/4094 - $rootScope.$broadcast( - 'UsersUpdated', - $scope.editUserModel.id - ); - $uibModalInstance.close(); - }) - .catch(function(err) { - if (err && err.data && err.data.error && err.data.error.translationKey) { - $translate(err.data.error.translationKey, err.data.error.translationParams).then(function(value) { - $scope.setError(err, value); - }); - } else { - $scope.setError(err, 'Error updating user'); - } - }); - }); - }); - }) - .catch(function(err) { - if (err.key) { - $translate(err.key, err.params).then(value => $scope.setError(err, value, err.severity)); - } else { - $scope.setError(err, 'Error validating user'); - } - }); - } else { + + const synchronousValidations = validateName() && + validateRole() && + validateContactAndFacility() && + validatePasswordForEditUser() && + validateEmailAddress(); + + if (!synchronousValidations) { $scope.setError(); + return; } + + const asynchronousValidations = $q + .all([ + validateContactIsInPlace(), + validateTokenLogin(), + ]) + .then(responses => responses.every(response => response)); + + return asynchronousValidations + .then(valid => { + if (!valid) { + $scope.setError(); + return; + } + return validateReplicationLimit() + .then(() => changedUpdates($scope.editUserModel)) + .then(updates => { + if (!haveUpdates(updates)) { + return; + } + + if ($scope.editUserModel.id) { + return UpdateUser($scope.editUserModel.username, updates); + } + + return CreateUser(updates); + }) + .then(() => { + $scope.setFinished(); + // TODO: change this from a broadcast to a changes watcher + // https://github.com/medic/medic/issues/4094 + $rootScope.$broadcast( + 'UsersUpdated', + $scope.editUserModel.id + ); + $uibModalInstance.close(); + }) + .catch(err => { + if (err && err.data && err.data.error && err.data.error.translationKey) { + $translate(err.data.error.translationKey, err.data.error.translationParams).then(function(value) { + $scope.setError(err, value); + }); + } else { + $scope.setError(err, 'Error updating user'); + } + }); + }) + .catch(err => { + if (err.key) { + $translate(err.key, err.params).then(value => $scope.setError(err, value, err.severity)); + } else { + $scope.setError(err, 'Error validating user'); + } + }); + }; }); diff --git a/admin/src/js/main.js b/admin/src/js/main.js index 9ca94655084..ca14be88dab 100644 --- a/admin/src/js/main.js +++ b/admin/src/js/main.js @@ -91,6 +91,7 @@ require('../../../webapp/src/js/services/db'); require('../../../webapp/src/js/services/export'); require('../../../webapp/src/js/services/extract-lineage'); require('../../../webapp/src/js/services/file-reader'); +require('../../../webapp/src/js/services/format-date'); require('../../../webapp/src/js/services/get-data-records'); require('../../../webapp/src/js/services/get-subject-summaries'); require('../../../webapp/src/js/services/get-summaries'); @@ -102,6 +103,7 @@ require('../../../webapp/src/js/services/languages'); require('../../../webapp/src/js/services/lineage-model-generator'); require('../../../webapp/src/js/services/location'); require('../../../webapp/src/js/services/modal'); +require('../../../webapp/src/js/services/moment-locale-data'); require('../../../webapp/src/js/services/resource-icons'); require('../../../webapp/src/js/services/search'); require('../../../webapp/src/js/services/select2-search'); diff --git a/admin/src/templates/edit_user.html b/admin/src/templates/edit_user.html index e6bc263f02f..7f4fcddb2d0 100644 --- a/admin/src/templates/edit_user.html +++ b/admin/src/templates/edit_user.html @@ -28,10 +28,11 @@ {{errors.email}} -
+
user.phone.help + {{errors.phone}}
@@ -64,13 +65,42 @@ {{errors.contact}}
-
+
+ + +
configuration.enable.token.login.help
+
+ +
+

+ +

+
+ + +
+
+ + +
+
+ + +
+
configuration.enable.token.login.refresh.help
+
+ +
{{errors.password}}
-
+
diff --git a/admin/src/templates/message_queue_tab.html b/admin/src/templates/message_queue_tab.html index 73838ee677c..e09dc621ddf 100644 --- a/admin/src/templates/message_queue_tab.html +++ b/admin/src/templates/message_queue_tab.html @@ -30,7 +30,7 @@
- +

{{message.task}}

{{message.content}}
diff --git a/admin/tests/unit/controllers/edit-user.js b/admin/tests/unit/controllers/edit-user.js index bcf249ff6f4..5b7b53c7078 100644 --- a/admin/tests/unit/controllers/edit-user.js +++ b/admin/tests/unit/controllers/edit-user.js @@ -149,33 +149,38 @@ describe('EditUserCtrl controller', () => { describe('initialisation', () => { it('edits the given user', done => { mockEditAUser(userToEdit); - setTimeout(() => { - chai.expect(scope.enabledLocales.length).to.equal(2); - chai.expect(scope.enabledLocales[0].code).to.equal('en'); - chai.expect(scope.enabledLocales[1].code).to.equal('fr'); - chai.expect(translationsDbQuery.callCount).to.equal(1); - chai - .expect(translationsDbQuery.args[0][0]) - .to.equal('medic-client/doc_by_type'); - chai - .expect(translationsDbQuery.args[0][1].key[0]) - .to.equal('translations'); - chai.expect(translationsDbQuery.args[0][1].key[1]).to.equal(true); - chai.expect(scope.editUserModel).to.deep.equal({ - id: userToEdit._id, - username: userToEdit.name, - fullname: userToEdit.fullname, - email: userToEdit.email, - phone: userToEdit.phone, - facilitySelect: userToEdit.facility_id, - place: userToEdit.facility_id, - role: userToEdit.roles[0], - language: { code: userToEdit.language }, - contactSelect: userToEdit.contact_id, - contact: userToEdit.contact_id, + try { + setTimeout(() => { + chai.expect(scope.enabledLocales.length).to.equal(2); + chai.expect(scope.enabledLocales[0].code).to.equal('en'); + chai.expect(scope.enabledLocales[1].code).to.equal('fr'); + chai.expect(translationsDbQuery.callCount).to.equal(1); + chai + .expect(translationsDbQuery.args[0][0]) + .to.equal('medic-client/doc_by_type'); + chai + .expect(translationsDbQuery.args[0][1].key[0]) + .to.equal('translations'); + chai.expect(translationsDbQuery.args[0][1].key[1]).to.equal(true); + chai.expect(scope.editUserModel).to.deep.equal({ + id: userToEdit._id, + username: userToEdit.name, + fullname: userToEdit.fullname, + email: userToEdit.email, + phone: userToEdit.phone, + facilitySelect: userToEdit.facility_id, + place: userToEdit.facility_id, + role: userToEdit.roles[0], + language: { code: userToEdit.language }, + contactSelect: userToEdit.contact_id, + contact: userToEdit.contact_id, + tokenLoginEnabled: undefined, + }); + done(); }); - done(); - }); + } catch(err) { + done(err); + } }); }); @@ -183,71 +188,86 @@ describe('EditUserCtrl controller', () => { it('username must be present', done => { mockEditAUser(userToEdit); Translate.fieldIsRequired.withArgs('User Name').returns(Promise.resolve('User Name field must be filled')); - setTimeout(() => { - scope.editUserModel.id = null; - scope.editUserModel.username = ''; - - scope.editUserModel.password = '1QrAs$$3%%kkkk445234234234'; - scope.editUserModel.passwordConfirm = scope.editUserModel.password; - scope.editUser(); + try { setTimeout(() => { - chai.expect(scope.errors.username).to.equal('User Name field must be filled'); - done(); + scope.editUserModel.id = null; + scope.editUserModel.username = ''; + + scope.editUserModel.password = '1QrAs$$3%%kkkk445234234234'; + scope.editUserModel.passwordConfirm = scope.editUserModel.password; + scope.editUser(); + setTimeout(() => { + chai.expect(scope.errors.username).to.equal('User Name field must be filled'); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('password must be filled when creating new user', done => { mockCreateNewUser(); - setTimeout(() => { - scope.editUserModel.username = 'newuser'; - scope.editUserModel.role = 'data-entry'; - Translate.fieldIsRequired.withArgs('Password').returns(Promise.resolve('Password field is a required field')); + try { setTimeout(() => { - scope.editUser(); + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + Translate.fieldIsRequired.withArgs('Password').returns(Promise.resolve('Password field is a required field')); setTimeout(() => { - chai - .expect(scope.errors.password) - .to.equal('Password field is a required field'); - done(); + scope.editUser(); + setTimeout(() => { + chai + .expect(scope.errors.password) + .to.equal('Password field is a required field'); + done(); + }); }); }); - }); + } catch(err) { + done(err); + } }); it(`password doesn't need to be filled when editing user`, done => { mockEditAUser(userToEdit); translate.returns(Promise.resolve('something')); Translate.fieldIsRequired = key => Promise.resolve(key); - setTimeout(() => { - chai.expect(scope.editUserModel).not.to.have.property('password'); - scope.editUser(); + try { setTimeout(() => { - chai.expect(scope.errors).not.to.have.property('password'); - done(); + chai.expect(scope.editUserModel).not.to.have.property('password'); + scope.editUser(); + setTimeout(() => { + chai.expect(scope.errors).not.to.have.property('password'); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('error if password and confirm do not match when creating new user', done => { mockEditCurrentUser(); - - setTimeout(() => { - scope.editUserModel.username = 'newuser'; - scope.editUserModel.role = 'data-entry'; - translate.withArgs('Passwords must match').returns( - Promise.resolve('wrong') - ); + try { setTimeout(() => { - const password = '1QrAs$$3%%kkkk445234234234'; - scope.editUserModel.password = password; - scope.editUser(); + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + translate.withArgs('Passwords must match').returns( + Promise.resolve('wrong') + ); setTimeout(() => { - chai.expect(scope.errors.password).to.equal('wrong'); - done(); + const password = '1QrAs$$3%%kkkk445234234234'; + scope.editUserModel.password = password; + scope.editUser(); + setTimeout(() => { + chai.expect(scope.errors.password).to.equal('wrong'); + done(); + }); }); }); - }); + } catch(err) { + done(err); + } }); it('should not change password when none is supplied', done => { @@ -256,127 +276,145 @@ describe('EditUserCtrl controller', () => { mockContact(userToEdit.contact_id); mockFacility(userToEdit.facility_id); mockContactGet(userToEdit.contact_id); + try { + setTimeout(() => { + scope.editUserModel.currentPassword = 'blah'; + scope.editUserModel.password = ''; + scope.editUserModel.passwordConfirm = ''; - setTimeout(() => { - scope.editUserModel.currentPassword = 'blah'; - scope.editUserModel.password = ''; - scope.editUserModel.passwordConfirm = ''; - - // when - scope.editUser(); + // when + scope.editUser(); - // then - setTimeout(() => { - chai.expect(UpdateUser.called).to.equal(false); + // then + setTimeout(() => { + chai.expect(UpdateUser.called).to.equal(false); - done(); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('must have associated place if user type is offline user', done => { mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.role = 'district-manager'; + mockFacility(null); + mockContact(userToEdit.contact_id); + Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field')); - setTimeout(() => { - scope.editUserModel.role = 'district-manager'; - mockFacility(null); - mockContact(userToEdit.contact_id); - Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field')); - - // when - scope.editUser(); + // when + scope.editUser(); - // expect - setTimeout(() => { - chai.expect(scope.errors.place).to.equal('Facility field is a required field'); - done(); + // expect + setTimeout(() => { + chai.expect(scope.errors.place).to.equal('Facility field is a required field'); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('must have associated contact if user type is offline user', done => { mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.role = 'district-manager'; + mockFacility(userToEdit.facility_id); + mockContact(null); + Translate.fieldIsRequired.withArgs('associated.contact') + .returns(Promise.resolve('An associated contact is required')); - setTimeout(() => { - scope.editUserModel.role = 'district-manager'; - mockFacility(userToEdit.facility_id); - mockContact(null); - Translate.fieldIsRequired.withArgs('associated.contact') - .returns(Promise.resolve('An associated contact is required')); - - // when - scope.editUser(); + // when + scope.editUser(); - // expect - setTimeout(() => { - chai.expect(scope.errors.contact).to.equal('An associated contact is required'); - done(); + // expect + setTimeout(() => { + chai.expect(scope.errors.contact).to.equal('An associated contact is required'); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('must have associated place and contact if user type is offline user', done => { mockEditAUser(userToEdit); - - setTimeout(() => { - scope.editUserModel.role = 'district-manager'; - mockFacility(null); - mockContact(null); - Translate.fieldIsRequired.withArgs('associated.contact') - .returns(Promise.resolve('An associated contact is required')); - Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is required')); - - // when - scope.editUser(); - - // expect + try { setTimeout(() => { - chai.expect(scope.errors.place).to.equal('Facility field is required'); - chai.expect(scope.errors.contact).to.equal('An associated contact is required'); - done(); + scope.editUserModel.role = 'district-manager'; + mockFacility(null); + mockContact(null); + Translate.fieldIsRequired.withArgs('associated.contact') + .returns(Promise.resolve('An associated contact is required')); + Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is required')); + + // when + scope.editUser(); + + // expect + setTimeout(() => { + chai.expect(scope.errors.place).to.equal('Facility field is required'); + chai.expect(scope.errors.contact).to.equal('An associated contact is required'); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it(`doesn't need associated place and contact if user type is not restricted user`, done => { mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.role = 'some-other-type'; + mockFacility(null); + mockContact(null); - setTimeout(() => { - scope.editUserModel.role = 'some-other-type'; - mockFacility(null); - mockContact(null); - - // when - scope.editUser(); + // when + scope.editUser(); - // expect - chai.expect(scope.errors).not.to.have.property('facility_id'); - chai.expect(scope.errors).not.to.have.property('contact_id'); - done(); - }); + // expect + chai.expect(scope.errors).not.to.have.property('facility_id'); + chai.expect(scope.errors).not.to.have.property('contact_id'); + done(); + }); + } catch(err) { + done(err); + } }); it('associated place must be parent of contact', done => { mockEditAUser(userToEdit); - - setTimeout(() => { - scope.editUserModel.type = 'district-manager'; - mockContact(userToEdit.contact_id); - mockFacility(userToEdit.facility_id); - mockContactGet('some-other-id'); - translate.withArgs('configuration.user.place.contact').returns( - Promise.resolve('outside') - ); - - // when - scope.editUser(); - - // expect + try { setTimeout(() => { - chai.expect(scope.errors.contact).to.equal('outside'); - done(); + scope.editUserModel.type = 'district-manager'; + mockContact(userToEdit.contact_id); + mockFacility(userToEdit.facility_id); + mockContactGet('some-other-id'); + translate.withArgs('configuration.user.place.contact').returns( + Promise.resolve('outside') + ); + + // when + scope.editUser(); + + // expect + setTimeout(() => { + chai.expect(scope.errors.contact).to.equal('outside'); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('user is updated', done => { @@ -385,67 +423,74 @@ describe('EditUserCtrl controller', () => { mockFacility(userToEdit.facility_id); mockContactGet(userToEdit.contact_id); http.get.withArgs('/api/v1/users-info').resolves({ data: { total_docs: 1000, warn: false, limit: 10000 }}); + try { + setTimeout(() => { + scope.editUserModel.fullname = 'fullname'; + scope.editUserModel.email = 'email@email.com'; + scope.editUserModel.phone = 'phone'; + scope.editUserModel.facilitySelect = 'facility_id'; + scope.editUserModel.contactSelect = 'contact_id'; + scope.editUserModel.language.code = 'language-code'; + scope.editUserModel.password = 'medic.1234'; + scope.editUserModel.passwordConfirm = 'medic.1234'; + scope.editUserModel.role = 'supervisor'; - setTimeout(() => { - scope.editUserModel.fullname = 'fullname'; - scope.editUserModel.email = 'email@email.com'; - scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'facility_id'; - scope.editUserModel.contactSelect = 'contact_id'; - scope.editUserModel.language.code = 'language-code'; - scope.editUserModel.password = 'medic.1234'; - scope.editUserModel.passwordConfirm = 'medic.1234'; - scope.editUserModel.role = 'supervisor'; + scope.editUser(); - scope.editUser(); + setTimeout(() => { + chai.expect(UpdateUser.called).to.equal(true); + const updateUserArgs = UpdateUser.getCall(0).args; - setTimeout(() => { - chai.expect(UpdateUser.called).to.equal(true); - const updateUserArgs = UpdateUser.getCall(0).args; - - chai.expect(updateUserArgs[0]).to.equal('user.name'); - - const updates = updateUserArgs[1]; - chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname); - chai.expect(updates.email).to.equal(scope.editUserModel.email); - chai.expect(updates.phone).to.equal(scope.editUserModel.phone); - chai.expect(updates.place).to.equal(scope.editUserModel.facility_id); - chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id); - chai.expect(updates.language).to.equal(scope.editUserModel.language.code); - chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role); - chai.expect(updates.password).to.deep.equal(scope.editUserModel.password); - chai.expect(http.get.callCount).to.equal(1); - chai.expect(http.get.args[0]).to.deep.equal([ - '/api/v1/users-info', - { params: { - role: 'supervisor', - facility_id: scope.editUserModel.place, - contact_id: scope.editUserModel.contact - }} - ]); - done(); + chai.expect(updateUserArgs[0]).to.equal('user.name'); + + const updates = updateUserArgs[1]; + chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname); + chai.expect(updates.email).to.equal(scope.editUserModel.email); + chai.expect(updates.phone).to.equal(scope.editUserModel.phone); + chai.expect(updates.place).to.equal(scope.editUserModel.facility_id); + chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id); + chai.expect(updates.language).to.equal(scope.editUserModel.language.code); + chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role); + chai.expect(updates.password).to.deep.equal(scope.editUserModel.password); + chai.expect(http.get.callCount).to.equal(1); + chai.expect(http.get.args[0]).to.deep.equal([ + '/api/v1/users-info', + { params: { + role: 'supervisor', + facility_id: scope.editUserModel.place, + contact_id: scope.editUserModel.contact + }} + ]); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('associated contact must have place when creating a new user', done => { mockCreateNewUser(); - setTimeout(() => { - scope.editUserModel.username = 'newuser'; - scope.editUserModel.role = 'data-entry'; - mockContact('xyz'); - - Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field')); + try { setTimeout(() => { - scope.editUser(); + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + mockContact('xyz'); + + Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is a required field')); setTimeout(() => { - chai - .expect(scope.errors.place) - .to.equal('Facility field is a required field'); - done(); + scope.editUser(); + setTimeout(() => { + chai + .expect(scope.errors.place) + .to.equal('Facility field is a required field'); + done(); + }); }); }); - }); + } catch(err) { + done(err); + } }); it('should not query users-info when user role is not offline', done => { @@ -453,37 +498,40 @@ describe('EditUserCtrl controller', () => { mockContact(userToEdit.contact_id); mockFacility(userToEdit.facility_id); mockContactGet(userToEdit.contact_id); + try { + setTimeout(() => { + scope.editUserModel.fullname = 'fullname'; + scope.editUserModel.email = 'email@email.com'; + scope.editUserModel.phone = 'phone'; + scope.editUserModel.facilitySelect = 'facility_id'; + scope.editUserModel.contactSelect = 'contact_id'; + scope.editUserModel.language.code = 'language-code'; + scope.editUserModel.password = 'medic.1234'; + scope.editUserModel.passwordConfirm = 'medic.1234'; + scope.editUserModel.role = 'national-manager'; - setTimeout(() => { - scope.editUserModel.fullname = 'fullname'; - scope.editUserModel.email = 'email@email.com'; - scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'facility_id'; - scope.editUserModel.contactSelect = 'contact_id'; - scope.editUserModel.language.code = 'language-code'; - scope.editUserModel.password = 'medic.1234'; - scope.editUserModel.passwordConfirm = 'medic.1234'; - scope.editUserModel.role = 'national-manager'; - - scope.editUser(); + scope.editUser(); - setTimeout(() => { - chai.expect(UpdateUser.called).to.equal(true); - chai.expect(http.get.callCount).to.equal(0); - chai.expect(UpdateUser.args[0]).to.deep.equal([ - 'user.name', - { - fullname: 'fullname', - email: 'email@email.com', - phone: 'phone', - roles: ['national-manager'], - language: 'language-code', - password: 'medic.1234' - } - ]); - done(); + setTimeout(() => { + chai.expect(UpdateUser.called).to.equal(true); + chai.expect(http.get.callCount).to.equal(0); + chai.expect(UpdateUser.args[0]).to.deep.equal([ + 'user.name', + { + fullname: 'fullname', + email: 'email@email.com', + phone: 'phone', + roles: ['national-manager'], + language: 'language-code', + password: 'medic.1234' + } + ]); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('should not save user if offline and is warned by users-info', done => { @@ -492,30 +540,33 @@ describe('EditUserCtrl controller', () => { mockFacility('new_facility_id'); mockContactGet(userToEdit.contact_id); http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: true, total_docs: 10200, limit: 10000 } }); + try { + setTimeout(() => { + scope.editUserModel.fullname = 'fullname'; + scope.editUserModel.email = 'email@email.com'; + scope.editUserModel.phone = 'phone'; + scope.editUserModel.facilitySelect = 'new_facility'; + scope.editUserModel.contactSelect = 'new_contact'; + scope.editUserModel.language.code = 'language-code'; + scope.editUserModel.password = 'medic.1234'; + scope.editUserModel.passwordConfirm = 'medic.1234'; + scope.editUserModel.role = 'supervisor'; - setTimeout(() => { - scope.editUserModel.fullname = 'fullname'; - scope.editUserModel.email = 'email@email.com'; - scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'new_facility'; - scope.editUserModel.contactSelect = 'new_contact'; - scope.editUserModel.language.code = 'language-code'; - scope.editUserModel.password = 'medic.1234'; - scope.editUserModel.passwordConfirm = 'medic.1234'; - scope.editUserModel.role = 'supervisor'; - - scope.editUser(); + scope.editUser(); - setTimeout(() => { - chai.expect(UpdateUser.callCount).to.equal(0); - chai.expect(http.get.callCount).to.equal(1); - chai.expect(http.get.args[0]).to.deep.equal([ - '/api/v1/users-info', - { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} - ]); - done(); + setTimeout(() => { + chai.expect(UpdateUser.callCount).to.equal(0); + chai.expect(http.get.callCount).to.equal(1); + chai.expect(http.get.args[0]).to.deep.equal([ + '/api/v1/users-info', + { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} + ]); + done(); + }); }); - }); + } catch(err) { + done(err); + } }); it('should save user if offline and warned when user clicks on submit the 2nd time', done => { @@ -524,47 +575,275 @@ describe('EditUserCtrl controller', () => { mockFacility('new_facility_id'); mockContactGet(userToEdit.contact_id); http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: true, total_docs: 10200, limit: 10000 } }); + try { + setTimeout(() => { + scope.editUserModel.fullname = 'fullname'; + scope.editUserModel.email = 'email@email.com'; + scope.editUserModel.phone = 'phone'; + scope.editUserModel.facilitySelect = 'new_facility'; + scope.editUserModel.contactSelect = 'new_contact'; + scope.editUserModel.language.code = 'language-code'; + scope.editUserModel.password = 'medic.1234'; + scope.editUserModel.passwordConfirm = 'medic.1234'; + scope.editUserModel.role = 'supervisor'; + + scope.editUser(); - setTimeout(() => { - scope.editUserModel.fullname = 'fullname'; - scope.editUserModel.email = 'email@email.com'; - scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'new_facility'; - scope.editUserModel.contactSelect = 'new_contact'; - scope.editUserModel.language.code = 'language-code'; - scope.editUserModel.password = 'medic.1234'; - scope.editUserModel.passwordConfirm = 'medic.1234'; - scope.editUserModel.role = 'supervisor'; + setTimeout(() => { + chai.expect(UpdateUser.callCount).to.equal(0); + chai.expect(http.get.callCount).to.equal(1); + chai.expect(http.get.args[0]).to.deep.equal([ + '/api/v1/users-info', + { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} + ]); + + scope.editUser(); + setTimeout(() => { + chai.expect(UpdateUser.callCount).to.equal(1); + chai.expect(http.get.callCount).to.equal(1); + + const updateUserArgs = UpdateUser.args[0]; + chai.expect(updateUserArgs[0]).to.equal('user.name'); + const updates = updateUserArgs[1]; + chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname); + chai.expect(updates.email).to.equal(scope.editUserModel.email); + chai.expect(updates.phone).to.equal(scope.editUserModel.phone); + chai.expect(updates.language).to.equal(scope.editUserModel.language.code); + chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role); + chai.expect(updates.password).to.deep.equal(scope.editUserModel.password); + + done(); + }); + }); + }); + } catch(err) { + done(err); + } + }); - scope.editUser(); + it('should require phone when token_login is enabled for new user', done => { + Settings = sinon.stub().resolves({ + roles: { + 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, + supervisor: { name: 'qrt', offline: true }, + 'national-manager': { name: 'national-manager', offline: false } + }, + token_login: { + translation_key: 'key', + app_url: 'url', + } + }); + mockCreateNewUser(); + try { setTimeout(() => { - chai.expect(UpdateUser.callCount).to.equal(0); - chai.expect(http.get.callCount).to.equal(1); - chai.expect(http.get.args[0]).to.deep.equal([ - '/api/v1/users-info', - { params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} - ]); + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + scope.editUserModel.token_login = true; + + translate.withArgs('configuration.enable.token.login.phone').resolves('phone required'); - scope.editUser(); setTimeout(() => { - chai.expect(UpdateUser.callCount).to.equal(1); - chai.expect(http.get.callCount).to.equal(1); + scope.editUser(); + setTimeout(() => { + chai.expect(scope.errors.phone).to.equal('phone required'); + done(); + }); + }); + }); + } catch(err) { + done(err); + } + }); - const updateUserArgs = UpdateUser.args[0]; - chai.expect(updateUserArgs[0]).to.equal('user.name'); - const updates = updateUserArgs[1]; - chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname); - chai.expect(updates.email).to.equal(scope.editUserModel.email); - chai.expect(updates.phone).to.equal(scope.editUserModel.phone); - chai.expect(updates.language).to.equal(scope.editUserModel.language.code); - chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role); - chai.expect(updates.password).to.deep.equal(scope.editUserModel.password); + it('should require phone when token_login is enabled for existent user', (done) => { + Settings = sinon.stub().resolves({ + roles: { + 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, + supervisor: { name: 'qrt', offline: true }, + 'national-manager': { name: 'national-manager', offline: false } + }, + token_login: { + translation_key: 'key', + app_url: 'url', + } + }); - done(); + mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + scope.editUserModel.token_login = true; + scope.editUserModel.phone = ''; + + translate.withArgs('configuration.enable.token.login.phone').resolves('phone required'); + + setTimeout(() => { + scope.editUser(); + setTimeout(() => { + chai.expect(scope.errors.phone).to.equal('phone required'); + done(); + }); }); }); + } catch(err) { + done(err); + } + }); + + it('should require valid phone when token_login is enabled', (done) => { + Settings = sinon.stub().resolves({ + roles: { + 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, + supervisor: { name: 'qrt', offline: true }, + 'national-manager': { name: 'national-manager', offline: false } + }, + token_login: { + translation_key: 'key', + app_url: 'url', + } }); + + mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + scope.editUserModel.token_login = true; + scope.editUserModel.phone = 'gfdkjlg'; + + translate.withArgs('configuration.enable.token.login.phone').resolves('phone required'); + + setTimeout(() => { + scope.editUser(); + setTimeout(() => { + chai.expect(scope.errors.phone).to.equal('phone required'); + done(); + }); + }); + }); + } catch(err) { + done(err); + } + }); + + it('should not require password when token_login is enabled for new users', (done) => { + Settings = sinon.stub().resolves({ + roles: { + 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, + supervisor: { name: 'qrt', offline: true }, + 'national-manager': { name: 'national-manager', offline: false } + }, + token_login: { + translation_key: 'key', + app_url: 'url', + } + }); + + mockCreateNewUser(); + http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: false, total_docs: 100, limit: 10000 } }); + try { + setTimeout(() => { + scope.editUserModel.username = 'newuser'; + scope.editUserModel.role = 'data-entry'; + scope.editUserModel.token_login = true; + scope.editUserModel.phone = '+40755696969'; + + setTimeout(() => { + scope.editUser(); + setTimeout(() => { + chai.expect(CreateUser.callCount).to.equal(1); + chai.expect(CreateUser.args[0][0]).to.deep.include({ + username: 'newuser', + phone: '+40755696969', + roles: ['data-entry'], + token_login: true, + }); + done(); + }, 10); + }); + }); + } catch(err) { + done(err); + } + }); + + it('should not overwrite token_login when editing and making no changes', (done) => { + Settings = sinon.stub().resolves({ + roles: { + 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, + supervisor: { name: 'qrt', offline: true }, + 'national-manager': { name: 'national-manager', offline: false } + }, + token_login: { + translation_key: 'key', + app_url: 'url', + } + }); + http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: false, total_docs: 100, limit: 10000 } }); + Translate.fieldIsRequired.resolves('Facility field is a required field'); + + userToEdit.token_login = true; + mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.phone = '+40755696969'; + scope.editUserModel.role = 'data-entry'; + + setTimeout(() => { + scope.editUser(); + setTimeout(() => { + chai.expect(UpdateUser.callCount).to.equal(1); + chai.expect(UpdateUser.args[0][1]).to.deep.include({ + phone: '+40755696969', + roles: ['data-entry'] + }); + done(); + }, 10); + }); + }); + } catch(err) { + done(err); + } + }); + + it('should require password when disabling token_login', (done) => { + Settings = sinon.stub().resolves({ + roles: { + 'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, + supervisor: { name: 'qrt', offline: true }, + 'national-manager': { name: 'national-manager', offline: false } + }, + token_login: { + translation_key: 'key', + app_url: 'url', + } + }); + http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: false, total_docs: 100, limit: 10000 } }); + Translate.fieldIsRequired.withArgs('Password').resolves('password required'); + + userToEdit.token_login = true; + mockEditAUser(userToEdit); + try { + setTimeout(() => { + scope.editUserModel.token_login = false; + scope.editUserModel.phone = ''; + scope.editUserModel.role = 'data-entry'; + + + setTimeout(() => { + scope.editUser(); + setTimeout(() => { + chai.expect(UpdateUser.callCount).to.equal(0); + chai.expect(scope.errors.password).to.equal('password required'); + done(); + }, 10); + }); + }); + } catch(err) { + done(err); + } }); }); }); diff --git a/api/src/config.js b/api/src/config.js index 26851bd81b8..b927eab15bd 100644 --- a/api/src/config.js +++ b/api/src/config.js @@ -171,7 +171,8 @@ module.exports = { logger.error('Failed to reload settings: %o', err); process.exit(1); }) - .then(() => initTransitionLib()); + .then(() => initTransitionLib()) + .then(() => logger.debug('Settings updated')); } else if (change.id.startsWith('messages-')) { logger.info('Detected translations change - reloading'); return loadTranslations().then(() => initTransitionLib()); diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index f16023ec297..d2e506ddf7d 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -8,6 +8,7 @@ const auth = require('../auth'); const environment = require('../environment'); const config = require('../config'); const users = require('../services/users'); +const tokenLogin = require('../services/token-login'); const SESSION_COOKIE_RE = /AuthSession=([^;]*);/; const ONE_YEAR = 31536000000; const logger = require('../logger'); @@ -15,7 +16,34 @@ const db = require('../db'); const production = process.env.NODE_ENV === 'production'; const localeUtils = require('locale'); -let loginTemplate; +const templates = { + login: { + content: null, + file: 'index.html', + translationStrings: [ + 'login', + 'login.error', + 'login.incorrect', + 'online.action.message', + 'User Name', + 'Password' + ], + }, + tokenLogin: { + content: null, + file: 'token-login.html', + translationStrings: [ + 'login.token.missing', + 'login.token.expired', + 'login.token.invalid', + 'login.token.timeout', + 'login.token.general.error', + 'login.token.loading', + 'login.token.redirect.login.info', + 'login.token.redirect.login', + ], + }, +}; const getHomeUrl = userCtx => { // https://github.com/medic/medic/issues/5035 @@ -55,25 +83,19 @@ const getEnabledLocales = () => { }); }; -const getLoginTemplate = () => { - if (loginTemplate) { - return loginTemplate; +const getTemplate = (page) => { + if (templates[page].content) { + return templates[page].content; } - const filepath = path.join(__dirname, '..', 'templates', 'login', 'index.html'); - loginTemplate = promisify(fs.readFile)(filepath, { encoding: 'utf-8' }) + const filepath = path.join(__dirname, '..', 'templates', 'login', templates[page].file); + templates[page].content = promisify(fs.readFile)(filepath, { encoding: 'utf-8' }) .then(file => _.template(file)); - return loginTemplate; + return templates[page].content; }; -const getTranslationsString = () => { - return encodeURIComponent(JSON.stringify(config.getTranslationValues([ - 'login', - 'login.error', - 'login.incorrect', - 'online.action.message', - 'User Name', - 'Password' - ]))); +const getTranslationsString = page => { + const translationStrings = templates[page].translationStrings; + return encodeURIComponent(JSON.stringify(config.getTranslationValues(translationStrings))); }; const getBestLocaleCode = (acceptedLanguages, locales, defaultLocale) => { @@ -82,18 +104,23 @@ const getBestLocaleCode = (acceptedLanguages, locales, defaultLocale) => { return headerLocales.best(supportedLocales).language; }; -const renderLogin = (req, branding) => { - return Promise.all([ - getLoginTemplate(), - getEnabledLocales() - ]) +const render = (page, req, branding, extras = {}) => { + return Promise + .all([ + getTemplate(page), + getEnabledLocales(), + ]) .then(([ template, locales ]) => { - return template({ - branding: branding, - defaultLocale: getBestLocaleCode(req.headers['accept-language'], locales, config.get('locale')), - locales: locales, - translations: getTranslationsString() - }); + const options = Object.assign( + { + branding: branding, + locales: locales, + defaultLocale: getBestLocaleCode(req.headers['accept-language'], locales, config.get('locale')), + translations: getTranslationsString(page) + }, + extras + ); + return template(options); }); }; @@ -165,8 +192,7 @@ const updateUserLanguageIfRequired = (user, current, selected) => { const setCookies = (req, res, sessionRes) => { const sessionCookie = getSessionCookie(sessionRes); if (!sessionCookie) { - res.status(401).json({ error: 'Not logged in' }); - return; + throw { status: 401, error: 'Not logged in' }; } const options = { headers: { Cookie: sessionCookie } }; return auth @@ -191,13 +217,11 @@ const setCookies = (req, res, sessionRes) => { setLocaleCookie(res, selectedLocale); return updateUserLanguageIfRequired(req.body.user, language, selectedLocale); }) - .then(() => { - res.status(302).send(getRedirectUrl(userCtx, req.body.redirect)); - }); + .then(() => getRedirectUrl(userCtx, req.body.redirect)); }) .catch(err => { logger.error(`Error getting authCtx %o`, err); - res.status(401).json({ error: 'Error getting authCtx' }); + throw { status: 401, error: 'Error getting authCtx' }; }); }; @@ -229,10 +253,72 @@ const getBranding = () => { }); }; +const renderTokenLogin = (req, res) => { + return getBranding() + .then(branding => render('tokenLogin', req, branding, { tokenUrl: req.url })) + .then(body => res.send(body)); +}; + +const createSessionRetry = (req, retry=10) => { + return createSession(req).then(sessionRes => { + if (sessionRes.statusCode === 200) { + return sessionRes; + } + + if (retry > 0) { + return new Promise((resolve, reject) => { + setTimeout(() => { + createSessionRetry(req, retry - 1).then(resolve).catch(reject); + }, 10); + }); + } + + throw { status: 408, message: 'Login failed after 10 retries' }; + }); +}; + +/** + * Generates a session cookie for a user identified by supplied token and hash request params. + * The user's password is reset in the process. + */ +const loginByToken = (req, res) => { + if (!tokenLogin.isTokenLoginEnabled()) { + return res.status(400).json({ error: 'disabled', reason: 'Token login disabled' }); + } + + if (!req.params || !req.params.token) { + return res.status(400).json({ error: 'missing', reason: 'Missing required param' }); + } + + return tokenLogin + .getUserByToken(req.params.token) + .then(userId => { + if (!userId) { + throw { status: 401, error: 'invalid' }; + } + + return tokenLogin.resetPassword(userId).then(({ user, password }) => { + req.body = { user, password }; + + return createSessionRetry(req) + .then(sessionRes => setCookies(req, res, sessionRes)) + .then(redirectUrl => { + return tokenLogin.deactivateTokenLogin(userId).then(() => res.status(302).send(redirectUrl)); + }); + }); + }) + .catch((err = {}) => { + logger.error('Error while logging in with token', err); + const status = err.status || err.code || 400; + const message = err.error || err.message || 'Unexpected error logging in'; + res.status(status).json({ error: message }); + }); +}; + module.exports = { get: (req, res, next) => { return getBranding() - .then(branding => renderLogin(req, branding)) + .then(branding => render('login', req, branding)) .then(body => res.send(body)) .catch(next); }, @@ -243,7 +329,14 @@ module.exports = { res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); return; } - return setCookies(req, res, sessionRes); + return setCookies(req, res, sessionRes) + .then(redirectUrl => res.status(302).send(redirectUrl)) + .catch(err => { + if (err.status === 401) { + return res.status(err.status).json({ error: err.error }); + } + throw err; + }); }) .catch(err => { logger.error('Error logging in: %o', err); @@ -263,7 +356,26 @@ module.exports = { return res.send(); }); }, + + tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next), + tokenPost: (req, res, next) => { + return auth + .getUserCtx(req) + .then(userCtx => { + return res.status(302).send(getRedirectUrl(userCtx)); + }) + .catch(err => { + if (err.code === 401) { + return loginByToken(req, res); + } + next(err); + }); + }, + // exposed for testing _safePath: getRedirectUrl, - _reset: () => { loginTemplate = null; } + _reset: () => { + templates.login.content = null; + templates.tokenLogin.content = null; + }, }; diff --git a/api/src/controllers/users.js b/api/src/controllers/users.js index 168d1a2c0f4..1c9a44578fd 100644 --- a/api/src/controllers/users.js +++ b/api/src/controllers/users.js @@ -101,6 +101,10 @@ const getAllowedDocIds = userCtx => { .then(allowedDocIds => purgedDocs.getUnPurgedIds(userCtx.roles, allowedDocIds)); }; +// this might not be correct. +// In express4, req.host strips off the port number: https://expressjs.com/en/guide/migrating-5.html#req.host +const getAppUrl = (req) => `${req.protocol}://${req.hostname}`; + module.exports = { get: (req, res) => { auth @@ -110,9 +114,9 @@ module.exports = { .catch(err => serverUtils.error(err, req, res)); }, create: (req, res) => { - auth + return auth .check(req, 'can_create_users') - .then(() => usersService.createUser(req.body)) + .then(() => usersService.createUser(req.body, getAppUrl(req))) .then(body => res.json(body)) .catch(err => serverUtils.error(err, req, res)); }, @@ -124,7 +128,7 @@ module.exports = { const username = req.params.username; const credentials = auth.basicAuthCredentials(req); - Promise.all([ + return Promise.all([ hasFullPermission(req), isUpdatingSelf(req, credentials, username), basicAuthValid(credentials, username), @@ -169,7 +173,7 @@ module.exports = { } return usersService - .updateUser(username, req.body, !!fullPermission) + .updateUser(username, req.body, !!fullPermission, getAppUrl(req)) .then(result => { logger.info( `REQ ${ diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 7f4de93e5e3..a51ed3c7995 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -5,6 +5,10 @@ const setState = function(className) { document.getElementById('form').className = className; }; +const setTokenState = className => { + document.getElementById('wrapper').className = `has-error ${className}`; +}; + const request = function(method, url, payload, callback) { const xmlhttp = new XMLHttpRequest(); xmlhttp.onreadystatechange = function() { @@ -46,6 +50,37 @@ const submit = function(e) { }); }; +const requestTokenLogin = (retry = 20) => { + const url = document.getElementById('tokenLogin').action; + request('POST', url, '', xmlhttp => { + let response = {}; + try { + response = JSON.parse(xmlhttp.responseText); + } catch (err) { + // no body + } + switch (xmlhttp.status) { + case 302: + window.location = xmlhttp.response; + break; + case 401: + setTokenState(response && response.error === 'expired' ? 'tokenexpired' : 'tokeninvalid'); + break; + case 408: + if (retry <= 0) { + return setTokenState('tokentimeout'); + } + requestTokenLogin(retry - 1); + break; + case 400: + setTokenState(response && response.error === 'missing' ? 'tokenmissing' : 'tokenerror'); + break; + default: + setTokenState('tokenerror'); + } + }); +}; + const focusOnPassword = function(e) { if (e.keyCode === 13) { e.preventDefault(); @@ -146,24 +181,26 @@ const checkSession = function() { }; document.addEventListener('DOMContentLoaded', function() { - checkSession(); - translations = parseTranslations(); selectedLocale = getLocale(); translate(); - document.getElementById('login').addEventListener('click', submit, false); + document.getElementById('locale').addEventListener('click', handleLocaleSelection, false); - const user = document.getElementById('user'); - user.addEventListener('keydown', focusOnPassword, false); - user.focus(); + if (document.getElementById('tokenLogin')) { + requestTokenLogin(); + } else { + checkSession(); + document.getElementById('login').addEventListener('click', submit, false); - document.getElementById('password').addEventListener('keydown', focusOnSubmit, false); - - document.getElementById('locale').addEventListener('click', handleLocaleSelection, false); + const user = document.getElementById('user'); + user.addEventListener('keydown', focusOnPassword, false); + user.focus(); - if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('/service-worker.js'); + document.getElementById('password').addEventListener('keydown', focusOnSubmit, false); + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/service-worker.js'); + } } }); diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css index 15e493730c3..5ba0b88fa06 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -40,10 +40,13 @@ input { line-height: 1.42857; border-radius: 4px; display: block; - margin-top: 2em; + margin: 2em auto; font-size: 1em; width: 302px; padding: 6px 10px; + text-decoration: none; + max-width: 100%; + box-sizing: border-box; } .loading .btn { cursor: not-allowed; @@ -79,12 +82,34 @@ form { color: #DA4548; display: none; } +.has-error .loading { + display: none; +} + .loading .loader, .loginoffline .error.offline, .loginincorrect .error.incorrect, -.loginerror .error.unknown { +.loginerror .error.unknown, +.tokeninvalid .error.invalid, +.tokenmissing .error.missing, +.tokentimeout .error.timeout, +.tokenexpired .error.expired, +.tokenerror .error.unknown +{ + display: block; +} + +.login-link { + display: none; +} +.tokenerror .login-link, +.tokeninvalid .login-link, +.tokenmissing .login-link, +.tokentimeout .login-link, +.tokenexpired .login-link { display: block; } + .loader { display: none; margin: 1em auto; diff --git a/api/src/routing.js b/api/src/routing.js index 5bd11e584d1..113db05a70b 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -235,6 +235,8 @@ app.use(express.static(extractedResourceDirectory)); app.get(routePrefix + 'login', login.get); app.get(routePrefix + 'login/identity', login.getIdentity); app.postJson(routePrefix + 'login', login.post); +app.get(routePrefix + 'login/token/:token?', login.tokenGet); +app.postJson(routePrefix + 'login/token/:token?', login.tokenPost); // saves CouchDB _session information as `userCtx` in the `req` object app.use(authorization.getUserCtx); diff --git a/api/src/services/token-login.js b/api/src/services/token-login.js new file mode 100644 index 00000000000..d5bd7e02d24 --- /dev/null +++ b/api/src/services/token-login.js @@ -0,0 +1,371 @@ +const _ = require('lodash'); +const passwordTester = require('simple-password-tester'); +const db = require('../db'); +const config = require('../config'); +const taskUtils = require('@medic/task-utils'); +const phoneNumber = require('@medic/phone-number'); +const TOKEN_EXPIRE_TIME = 24 * 60 * 60 * 1000; // 24 hours +const PASSWORD_MINIMUM_LENGTH = 20; +const PASSWORD_MINIMUM_SCORE = 50; +const TOKEN_LENGTH = 64; + +/** + * Generates a complex enough password with a given length + * @param {Number} length + * @returns {string} - the generated password + */ +const generatePassword = (length = PASSWORD_MINIMUM_LENGTH) => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:!$-='; + let password; + do { + password = Array.from({ length }).map(() => _.sample(chars)).join(''); + } while (passwordTester(password) < PASSWORD_MINIMUM_SCORE); + + return password; +}; + +/** + * Given user data, validates whether the phone field is filled with a valid phone number. + * Throws an error if the phone number field is empty or value is not valid. + * If valid, normalizes the field value. + * @param {Object} data - the user data + * @param {String} data.phone - the phone number to validate + */ +const validateAndNormalizePhone = (data) => { + const settings = config.get(); + if (!phoneNumber.validate(settings, data.phone)) { + return { + msg: 'A valid phone number is required for SMS login.', + key: 'configuration.enable.token.login.phone' + }; + } + + data.phone = phoneNumber.normalize(settings, data.phone); +}; + +/** + * Generates a unique 64 character token. + * @returns {String} + */ +const generateToken = () => { + const tokens = Array.from({ length: 10 }).map(() => generatePassword(TOKEN_LENGTH)); + const docIds = tokens.map(token => getTokenLoginDocId(token)); + return db.medic.allDocs({ keys: docIds }).then(results => { + const idx = results.rows.findIndex(row => row.error); + if (idx === -1) { + throw new Error('Failed to generate unique token'); + } + return tokens[idx]; + }); +}; + +/** + * Enables token-login for a user + * - a new document is created in the `medic` database that contains tasks (SMSs) to be sent to the phone number + * belonging to the current user (userSettings.phone) that contain instructions and a url to login in the app + * - if the user already had token-login enabled, the previous sms document's tasks are cleared + * - updates the user document to contain information about the active `token-login` context for the user + * - updates the user-settings document to contain some information to be displayed in the admin page + * @param {String} appUrl - the base URL of the application + * @param {Object} response - the response of previous actions + * @returns {Promise<{Object}>} - updated response to be sent to the client + */ +const enableTokenLogin = (appUrl, response) => { + return Promise + .all([ + db.users.get(response.user.id), + db.medic.get(response['user-settings'].id), + generateToken() + ]) + .then(([ user, userSettings, token ]) => { + return generateTokenLoginDoc(user, userSettings, token, appUrl) + .then(() => { + user.token_login = { + active: true, + token, + expiration_date: new Date().getTime() + TOKEN_EXPIRE_TIME, + }; + + userSettings.token_login = { + active: true, + expiration_date: user.token_login.expiration_date, + }; + + response.token_login = { expiration_date: user.token_login.expiration_date }; + + return Promise.all([ db.users.put(user), db.medic.put(userSettings) ]); + }); + }) + .then(() => response); +}; + +/** + * Disables token-login for a user. + * Deletes the `token_login` properties from the user and userSettings doc. + * Clears pending tasks in existent SMSs + * + * @param {Object} response - the response of previous actions + * @returns {Promise<{Object}>} - updated response to be sent to the client + */ +const disableTokenLogin = (response) => { + return Promise + .all([ + db.users.get(response.user.id), + db.medic.get(response['user-settings'].id), + ]) + .then(([ user, userSettings ]) => { + if (!user.token_login) { + return; + } + + return clearOldTokenLoginDoc(user).then(() => { + delete user.token_login; + delete userSettings.token_login; + + return Promise.all([ + db.medic.put(userSettings), + db.users.put(user), + ]); + }); + }) + .then(() => response); +}; + +/** + * Enables or disables token-login for a user + * When enabling, if `token-login` configuration is missing or invalid, no changes are made. + * @param {Object} data - the request body + * @param {String} appUrl - the base URL of the application + * @param {Object} response - the response of previous actions + * @returns {Promise<{Object}>} - updated response to be sent to the client + */ +const manageTokenLogin = (data, appUrl, response) => { + if (shouldDisableTokenLogin(data)) { + return disableTokenLogin(response); + } + + if (!shouldEnableTokenLogin(data)) { + return Promise.resolve(response); + } + + return enableTokenLogin(appUrl, response); +}; + +const getTokenLoginDocId = token => `token:login:${token}`; + +/** + * Clears pending tasks in the currently assigned `token_login_sms` doc + * Used to either disable or refresh token-login + * + * @param {Object} user - the _users doc + * @param {Object} user.token_login - token-login information + * @param {Boolean} user.token_login.active - whether the token-login is active or not + * @param {String} user.token_login.token - the current token + * @returns {Promise} + */ +const clearOldTokenLoginDoc = ({ token_login: { token }={} }={}) => { + if (!token) { + return Promise.resolve(); + } + + return db.medic + .get(getTokenLoginDocId(token)) + .then(doc => { + if (!doc || !doc.tasks) { + return; + } + + const pendingTasks = doc.tasks.filter(task => task.state === 'pending'); + if (!pendingTasks.length) { + return; + } + + pendingTasks.forEach(task => taskUtils.setTaskState(task, 'cleared')); + return db.medic.put(doc); + }) + .catch(err => { + if (err.status === 404) { + return; + } + throw err; + }); +}; + +/** + * Generates a doc of type `token_login` that contains the two tasks required for token login: one reserved + * for the URL and one containing instructions for the user. + * If the user already had token-login activated, the pending tasks of the existent `token_login` doc are cleared. + * This `token_login` doc also connects the user to the token and is an admin-only editable doc. + * @param {Object} user - the _users document + * @param {Object} userSettings - the user-settings document from the main database + * @param {String} token - the randomly generated token + * @param {String} appUrl - the base URL of the application + * @returns {Promise} - returns the result of saving the new token_login_sms document + */ +const generateTokenLoginDoc = (user, userSettings, token, appUrl) => { + return clearOldTokenLoginDoc(user).then(() => { + const doc = { + _id: getTokenLoginDocId(token), + type: 'token_login', + reported_date: new Date().getTime(), + user: user._id, + tasks: [], + }; + + appUrl = (config.get('app_url') || appUrl).replace(/\/+$/, ''); + const url = `${appUrl}/medic/login/token/${token}`; + + const messagesLib = config.getTransitionsLib().messages; + const tokenLoginConfig = config.get('token_login'); + const context = { templateContext: Object.assign({}, userSettings, user) }; + messagesLib.addMessage(doc, tokenLoginConfig, userSettings.phone, context); + messagesLib.addMessage(doc, { message: url }, userSettings.phone); + + return db.medic.put(doc); + }); +}; + +const shouldEnableTokenLogin = data => isTokenLoginEnabled() && data.token_login; +const shouldDisableTokenLogin = data => data.token_login === false; + +const isTokenLoginEnabled = () => { + const tokenLoginConfig = config.get('token_login'); + return !!(tokenLoginConfig && tokenLoginConfig.enabled); +}; + +const validateTokenLoginCreate = (data) => { + if (!shouldEnableTokenLogin(data)) { + return; + } + + // if token-login is requested, we will need to send sms-s to the user's phone number. + const phoneError = validateAndNormalizePhone(data); + if (phoneError) { + return phoneError; + } + data.password = generatePassword(); +}; + +const validateTokenLoginEdit = (data, user, userSettings) => { + if (shouldDisableTokenLogin(data) && user.token_login && !data.password) { + // when disabling token login for a user that had it enabled, setting a password is required + return { + msg: 'Password is required when disabling token login.', + key: 'password.length.minimum', + }; + } + + if (shouldEnableTokenLogin(data)) { + // if token-login is requested, we will need to send sms-s to the user's phone number. + const phoneError = validateAndNormalizePhone(userSettings); + if (phoneError) { + return phoneError; + } + user.password = generatePassword(); + } +}; + +const validateTokenLogin = (data, newUser = true, user = {}, userSettings = {}) => { + if (newUser) { + return validateTokenLoginCreate(data); + } + return validateTokenLoginEdit(data, user, userSettings); +}; + +/** + * Searches for a user with active, not expired token-login that matches the requested token and user. + * + * @param {String} token + * @returns {Promise} the id of the matched user + */ +const getUserByToken = (token) => { + const invalid = { status: 401, error: 'invalid' }; + const expired = { status: 401, error: 'expired' }; + if (!token) { + return Promise.reject(invalid); + } + + const loginTokenDocId = getTokenLoginDocId(token); + return db.medic + .get(loginTokenDocId) + .then(loginTokenDoc => db.users.get(loginTokenDoc.user)) + .then(user => { + if (!user.token_login || !user.token_login.active) { + throw invalid; + } + + if (user.token_login.token !== token) { + throw invalid; + } + + if (user.token_login.expiration_date <= new Date().getTime()) { + throw expired; + } + + return user._id; + }) + .catch(err => { + if (err.status === 404) { + throw invalid; + } + throw err; + }); +}; + + +/** + * Generates a new random password for the user. Returns this password to be used to generate a session for this user. + * @param userId + * @returns {Promise} - object containing the user's name and the new password + */ +const resetPassword = userId => { + return db.users.get(userId).then(user => { + if (!user.token_login || !user.token_login.active) { + return Promise.reject({ code: 400, message: 'invalid user' }); + } + + user.password = generatePassword(); + return db.users.put(user).then(() => ({ user: user.name, password: user.password })); + }); +}; + + +/** + * Updates the user and userSettings docs when the token login link is accessed successfully. + * @param {String} userId - the user's id to login via token link + * @returns {Promise} + */ +const deactivateTokenLogin = userId => { + return Promise + .all([ + db.users.get(userId), + db.medic.get(userId), + ]) + .then(([ user, userSettings ]) => { + if (!user.token_login || !user.token_login.active) { + return Promise.reject({ code: 400, message: 'invalid user' }); + } + + const updates = { + active: false, + login_date: new Date().getTime(), + }; + user.token_login = Object.assign(user.token_login, updates); + userSettings.token_login = Object.assign(userSettings.token_login, updates); + + return Promise.all([ + db.medic.put(userSettings), + db.users.put(user), + ]); + }); +}; + +module.exports = { + shouldEnableTokenLogin, + validateTokenLogin, + manageTokenLogin, + isTokenLoginEnabled, + getUserByToken, + resetPassword, + deactivateTokenLogin, +}; diff --git a/api/src/services/users.js b/api/src/services/users.js index 31cc1129dc9..7eafa1bf4e6 100644 --- a/api/src/services/users.js +++ b/api/src/services/users.js @@ -6,6 +6,7 @@ const db = require('../db'); const lineage = require('@medic/lineage')(Promise, db.medic); const getRoles = require('./types-and-roles'); const auth = require('../auth'); +const tokenLogin = require('./token-login'); const USER_PREFIX = 'org.couchdb.user:'; const ONLINE_ROLE = 'mm-online'; @@ -44,8 +45,10 @@ const SETTINGS_EDITABLE_FIELDS = RESTRICTED_SETTINGS_EDITABLE_FIELDS.concat([ 'roles', ]); +const META_FIELDS = ['token_login']; + const ALLOWED_RESTRICTED_EDITABLE_FIELDS = - RESTRICTED_SETTINGS_EDITABLE_FIELDS.concat(RESTRICTED_USER_EDITABLE_FIELDS); + RESTRICTED_SETTINGS_EDITABLE_FIELDS.concat(RESTRICTED_USER_EDITABLE_FIELDS, META_FIELDS); const illegalDataModificationAttempts = data => Object.keys(data).filter(k => !ALLOWED_RESTRICTED_EDITABLE_FIELDS.includes(k)); @@ -100,7 +103,7 @@ const validateContact = (id, placeID) => { if (!people.isAPerson(doc)) { return Promise.reject(error400('Wrong type, contact is not a person.','contact.type.wrong')); } - if (!module.exports._hasParent(doc, placeID)) { + if (!hasParent(doc, placeID)) { return Promise.reject(error400('Contact is not within place.','configuration.user.place.contact')); } return doc; @@ -252,7 +255,7 @@ const setContactParent = data => { // contact parent must exist return places.getPlace(data.contact.parent) .then(place => { - if (!module.exports._hasParent(place, data.place)) { + if (!hasParent(place, data.place)) { return Promise.reject(error400('Contact is not within place.','configuration.user.place.contact')); } // save result to contact object @@ -399,7 +402,7 @@ const deleteUser = id => { return Promise.all([ usersDbPromise, medicDbPromise ]); }; -const validatePassword = password => { +const validatePassword = (password) => { if (password.length < PASSWORD_MINIMUM_LENGTH) { return error400( `The password must be at least ${PASSWORD_MINIMUM_LENGTH} characters long.`, @@ -416,7 +419,14 @@ const validatePassword = password => { }; const missingFields = data => { - const required = ['username', 'password']; + const required = ['username']; + + if (tokenLogin.shouldEnableTokenLogin(data)) { + required.push('phone'); + } else { + required.push('password'); + } + if (data.roles && auth.isOffline(data.roles)) { required.push('place', 'contact'); } @@ -432,8 +442,8 @@ const missingFields = data => { const getUpdatedUserDoc = (username, data) => { const userID = createID(username); - return module.exports._validateUser(userID).then(doc => { - const user = Object.assign(doc, module.exports._getUserUpdates(username, data)); + return validateUser(userID).then(doc => { + const user = Object.assign(doc, getUserUpdates(username, data)); user._id = userID; return user; }); @@ -441,8 +451,8 @@ const getUpdatedUserDoc = (username, data) => { const getUpdatedSettingsDoc = (username, data) => { const userID = createID(username); - return module.exports._validateUserSettings(userID).then(doc => { - const settings = Object.assign(doc, module.exports._getSettingsUpdates(username, data)); + return validateUserSettings(userID).then(doc => { + const settings = Object.assign(doc, getSettingsUpdates(username, data)); settings._id = userID; return settings; }); @@ -453,30 +463,12 @@ const getUpdatedSettingsDoc = (username, data) => { * to export functions needed for testing. */ module.exports = { - _createUser: createUser, - _createContact: createContact, - _createPlace: createPlace, - _createUserSettings: createUserSettings, - _getType : getType, - _getAllUsers: getAllUsers, - _getAllUserSettings: getAllUserSettings, - _getFacilities: getFacilities, - _getSettingsUpdates: getSettingsUpdates, - _getUserUpdates: getUserUpdates, - _hasParent: hasParent, - _lineage: lineage, - _setContactParent: setContactParent, - _storeUpdatedPlace: storeUpdatedPlace, - _validateContact: validateContact, - _validateNewUsername: validateNewUsername, - _validateUser: validateUser, - _validateUserSettings: validateUserSettings, deleteUser: username => deleteUser(createID(username)), getList: () => { return Promise.all([ - module.exports._getAllUsers(), - module.exports._getAllUserSettings(), - module.exports._getFacilities() + getAllUsers(), + getAllUserSettings(), + getFacilities() ]) .then(([ users, settings, facilities ]) => { return mapUsers(users, settings, facilities); @@ -487,9 +479,10 @@ module.exports = { * objects. Returns the response body in the callback. * * @param {Object} data - request body + * @param {String} appUrl - request protocol://hostname * @api public */ - createUser: data => { + createUser: (data, appUrl) => { const missing = missingFields(data); if (missing.length > 0) { return Promise.reject(error400( @@ -498,18 +491,24 @@ module.exports = { { 'fields': missing.join(', ') } )); } + + const tokenLoginError = tokenLogin.validateTokenLogin(data, true); + if (tokenLoginError) { + return Promise.reject(error400(tokenLoginError.msg, tokenLoginError.key)); + } const passwordError = validatePassword(data.password); if (passwordError) { return Promise.reject(passwordError); } const response = {}; - return module.exports._validateNewUsername(data.username) - .then(() => module.exports._createPlace(data)) - .then(() => module.exports._setContactParent(data)) - .then(() => module.exports._createContact(data, response)) - .then(() => module.exports._storeUpdatedPlace(data)) - .then(() => module.exports._createUser(data, response)) - .then(() => module.exports._createUserSettings(data, response)) + return validateNewUsername(data.username) + .then(() => createPlace(data)) + .then(() => setContactParent(data)) + .then(() => createContact(data, response)) + .then(() => storeUpdatedPlace(data)) + .then(() => createUser(data, response)) + .then(() => createUserSettings(data, response)) + .then(() => tokenLogin.manageTokenLogin(data, appUrl, response)) .then(() => response); }, @@ -518,10 +517,9 @@ module.exports = { */ createAdmin: userCtx => { const data = { username: userCtx.name, roles: ['admin'] }; - return module.exports - ._validateNewUsername(userCtx.name) - .then(() => module.exports._createUser(data, {})) - .then(() => module.exports._createUserSettings(data, {})); + return validateNewUsername(userCtx.name) + .then(() => createUser(data, {})) + .then(() => createUserSettings(data, {})); }, /** @@ -539,8 +537,9 @@ module.exports = { * @param {Object} data Changes to make * @param {Boolean} fullAccess Are we allowed to update * security-related things? + * @param {String} appUrl request protocol://hostname */ - updateUser: (username, data, fullAccess) => { + updateUser: (username, data, fullAccess, appUrl) => { // Reject update attempts that try to modify data they're not allowed to if (!fullAccess) { const illegalAttempts = illegalDataModificationAttempts(data); @@ -551,7 +550,7 @@ module.exports = { } } - const props = _.uniq(USER_EDITABLE_FIELDS.concat(SETTINGS_EDITABLE_FIELDS)); + const props = _.uniq(USER_EDITABLE_FIELDS.concat(SETTINGS_EDITABLE_FIELDS, META_FIELDS)); // Online users can remove place or contact if (!_.isNull(data.place) && @@ -564,6 +563,7 @@ module.exports = { { 'fields': props.join(', ') } )); } + if (data.password) { const passwordError = validatePassword(data.password); if (passwordError) { @@ -571,11 +571,17 @@ module.exports = { } } - return Promise.all([ - getUpdatedUserDoc(username, data), - getUpdatedSettingsDoc(username, data), - ]) + return Promise + .all([ + getUpdatedUserDoc(username, data), + getUpdatedSettingsDoc(username, data), + ]) .then(([ user, settings ]) => { + const tokenLoginError = tokenLogin.validateTokenLogin(data, false, user, settings); + if (tokenLoginError) { + return Promise.reject(error400(tokenLoginError.msg, tokenLoginError.key)); + } + const response = {}; return Promise.resolve() @@ -583,7 +589,9 @@ module.exports = { if (data.place) { settings.facility_id = user.facility_id; return places.getPlace(user.facility_id); - } else if (_.isNull(data.place)) { + } + + if (_.isNull(data.place)) { if (settings.roles && auth.isOffline(settings.roles)) { return Promise.reject(error400( 'Place field is required for offline users', @@ -597,8 +605,10 @@ module.exports = { }) .then(() => { if (data.contact) { - return module.exports._validateContact(settings.contact_id, user.facility_id); - } else if (_.isNull(data.contact)) { + return validateContact(settings.contact_id, user.facility_id); + } + + if (_.isNull(data.contact)) { if (settings.roles && auth.isOffline(settings.roles)) { return Promise.reject(error400( 'Contact field is required for offline users', @@ -623,9 +633,10 @@ module.exports = { rev: resp.rev }; }) + .then(() => tokenLogin.manageTokenLogin(data, appUrl, response)) .then(() => response); }); }, - DOC_IDS_WARN_LIMIT + DOC_IDS_WARN_LIMIT, }; diff --git a/api/src/templates/login/token-login.html b/api/src/templates/login/token-login.html new file mode 100644 index 00000000000..ff0d121e166 --- /dev/null +++ b/api/src/templates/login/token-login.html @@ -0,0 +1,38 @@ + + + + + {{ branding.name }} + + + + +
+
+ +
+
+

+
+
+

+

+

+

+

+ +
+
+ <% locales.forEach(function(locale) { %> + {{ locale.label }} + <% }); %> +
+
+
+
+ + + diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js index c5ccb2ff7e7..5604e1112e4 100644 --- a/api/tests/mocha/controllers/login.spec.js +++ b/api/tests/mocha/controllers/login.spec.js @@ -5,6 +5,7 @@ const environment = require('../../../src/environment'); const auth = require('../../../src/auth'); const cookie = require('../../../src/services/cookie'); const users = require('../../../src/services/users'); +const tokenLogin = require('../../../src/services/token-login'); const db = require('../../../src/db').medic; const sinon = require('sinon'); const config = require('../../../src/config'); @@ -211,8 +212,8 @@ describe('login controller', () => { it('uses application default locale if accept-language header is undefined', () => { req.headers = { 'accept-language': undefined }; - sinon.stub(db, 'query').resolves({ rows: [ - { doc: { code: 'fr', name: 'French' } } + sinon.stub(db, 'query').resolves({ rows: [ + { doc: { code: 'fr', name: 'French' } } ]}); sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'}); const send = sinon.stub(res, 'send'); @@ -236,12 +237,12 @@ describe('login controller', () => { chai.expect(send.args[0][0]).to.equal('LOGIN PAGE GOES HERE. de'); }); }); - + it('uses best request header locale available', () => { req.headers = { 'accept-language': 'fr_CA, en' }; - sinon.stub(db, 'query').resolves({ rows: [ + sinon.stub(db, 'query').resolves({ rows: [ { doc: { code: 'en', name: 'English' } }, - { doc: { code: 'fr', name: 'French' } } + { doc: { code: 'fr', name: 'French' } } ]}); sinon.stub(db, 'get').rejects({ error: 'not_found', docId: 'branding'}); const send = sinon.stub(res, 'send'); @@ -253,6 +254,207 @@ describe('login controller', () => { }); }); + describe('get login/token', () => { + it('should render the token login page', () => { + sinon.stub(db, 'query').resolves({ rows: [] }); + sinon.stub(db, 'get').resolves({ + _id: 'branding', + resources: { + logo: 'xyz' + }, + _attachments: { + xyz: { + content_type: 'zes', + data: 'xsd' + } + } + }); + sinon.stub(res, 'send'); + sinon.stub(fs, 'readFile').callsArgWith(2, null, 'TOKEN PAGE GOES HERE. {{ translations }}'); + sinon.stub(config, 'getTranslationValues').returns({ en: { login: 'English' } }); + req.params = { token: 'my_token', hash: 'my_hash' }; + return controller.tokenGet(req, res).then(() => { + chai.expect(db.get.callCount).to.equal(1); + chai.expect(res.send.callCount).to.equal(1); + chai.expect(res.send.args[0][0]) + .to.equal('TOKEN PAGE GOES HERE. %7B%22en%22%3A%7B%22login%22%3A%22English%22%7D%7D'); + chai.expect(fs.readFile.callCount).to.equal(1); + chai.expect(db.query.callCount).to.equal(1); + }); + }); + }); + + describe('POST login/token', () => { + it('should redirect the user directly if they have a valid session', () => { + sinon.stub(auth, 'getUserCtx').resolves({ name: 'user' }); + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(res, 'send').returns(res); + sinon.stub(res, 'status').returns(res); + return controller.tokenPost(req, res).then(() => { + chai.expect(auth.getUserCtx.callCount).to.equal(1); + chai.expect(auth.getUserCtx.args[0]).to.deep.equal([req]); + chai.expect(tokenLogin.isTokenLoginEnabled.callCount).to.equal(0); + }); + }); + + it('should fail early when token login not enabled', () => { + sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(false); + sinon.stub(res, 'json').returns(res); + sinon.stub(res, 'status').returns(res); + return controller.tokenPost(req, res).then(() => { + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([400]); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ error: 'disabled', reason: 'Token login disabled' }]); + }); + }); + + it('should fail early with no params', () => { + sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(res, 'json').returns(res); + sinon.stub(res, 'status').returns(res); + req.params = {}; + return controller.tokenPost(req, res).then(() => { + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([400]); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ error: 'missing', reason: 'Missing required param' }]); + }); + }); + + it('should send 401 when token incorrect', () => { + sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(tokenLogin, 'getUserByToken').resolves(false); + sinon.stub(res, 'json').returns(res); + sinon.stub(res, 'status').returns(res); + req.params = { token: 'my_token' }; + return controller.tokenPost(req, res).then(() => { + chai.expect(tokenLogin.getUserByToken.callCount).to.equal(1); + chai.expect(tokenLogin.getUserByToken.args[0]).to.deep.equal( [ 'my_token' ]); + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([401]); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ error: 'invalid' }]); + }); + }); + + it('should send error when error thrown while validating token', () => { + sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(tokenLogin, 'getUserByToken').rejects({ some: 'err' }); + sinon.stub(res, 'json').returns(res); + sinon.stub(res, 'status').returns(res); + req.params = { token: 'a' }; + return controller.tokenPost(req, res).then(() => { + chai.expect(tokenLogin.getUserByToken.callCount).to.equal(1); + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([400]); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ error: 'Unexpected error logging in' }]); + }); + }); + + it('should login the user when token is valid', () => { + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(tokenLogin, 'getUserByToken').resolves('userId'); + sinon.stub(tokenLogin, 'resetPassword').resolves({ user: 'user_name', password: 'secret' }); + sinon.stub(tokenLogin, 'deactivateTokenLogin').resolves(); + sinon.stub(request, 'post').resolves({ statusCode: 200, headers: { 'set-cookie': [ 'AuthSession=abc;' ] } }); + sinon.stub(res, 'status').returns(res); + sinon.stub(res, 'send').returns(res); + sinon.stub(res, 'cookie'); + sinon.stub(auth, 'getUserSettings').resolves({ language: 'es' }); + const userCtx = { name: 'user_name', roles: [ 'project-stuff' ] }; + sinon.stub(auth, 'getUserCtx') + .onCall(0).rejects({ code: 401 }) + .onCall(1).resolves(userCtx); + req.params = { token: 'a', userId: 'b' }; + return controller.tokenPost(req, res).then(() => { + chai.expect(auth.getUserCtx.callCount).to.equal(2); + chai.expect(auth.getUserCtx.args[0]).to.deep.equal([req]); + chai.expect(auth.getUserCtx.args[1]).to.deep.equal([{ headers: { 'Cookie': 'AuthSession=abc;' } }]); + chai.expect(tokenLogin.getUserByToken.callCount).to.equal(1); + chai.expect(tokenLogin.resetPassword.callCount).to.equal(1); + chai.expect(tokenLogin.resetPassword.args[0]).to.deep.equal(['userId']); + chai.expect(tokenLogin.deactivateTokenLogin.callCount).to.equal(1); + chai.expect(tokenLogin.deactivateTokenLogin.args[0]).to.deep.equal(['userId']); + chai.expect(res.cookie.callCount).to.equal(3); + chai.expect(res.cookie.args[0].slice(0, 2)).to.deep.equal(['AuthSession', 'abc']); + chai.expect(res.cookie.args[1].slice(0, 2)).to.deep.equal(['userCtx', JSON.stringify(userCtx) ]); + chai.expect(res.cookie.args[2].slice(0, 2)).to.deep.equal(['locale', 'es']); + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([302]); + chai.expect(res.send.callCount).to.equal(1); + chai.expect(res.send.args[0]).to.deep.equal(['/']); + }); + }); + + it('should retry logging in when login fails', () => { + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(tokenLogin, 'getUserByToken').resolves('userId'); + sinon.stub(tokenLogin, 'resetPassword').resolves({ user: 'user_name', password: 'secret' }); + sinon.stub(tokenLogin, 'deactivateTokenLogin').resolves(); + sinon.stub(request, 'post') + .onCall(0).resolves({ statusCode: 401 }) + .onCall(1).resolves({ statusCode: 401 }) + .onCall(2).resolves({ statusCode: 401 }) + .onCall(3).resolves({ statusCode: 401 }) + .resolves({ statusCode: 200, headers: { 'set-cookie': [ 'AuthSession=cde;' ] } }); + + sinon.stub(res, 'status').returns(res); + sinon.stub(res, 'cookie'); + sinon.stub(res, 'send'); + sinon.stub(auth, 'getUserSettings').resolves({ language: 'hi' }); + const userCtx = { name: 'user_name', roles: [ 'roles' ] }; + sinon.stub(auth, 'getUserCtx') + .onCall(0).rejects({ code: 401 }) + .onCall(1).resolves(userCtx); + req.params = { token: 'a', userId: 'b' }; + return controller.tokenPost(req, res).then(() => { + chai.expect(tokenLogin.getUserByToken.callCount).to.equal(1); + chai.expect(tokenLogin.resetPassword.callCount).to.equal(1); + chai.expect(tokenLogin.resetPassword.args[0]).to.deep.equal(['userId']); + chai.expect(tokenLogin.deactivateTokenLogin.callCount).to.equal(1); + chai.expect(tokenLogin.deactivateTokenLogin.args[0]).to.deep.equal(['userId']); + chai.expect(request.post.callCount).to.equal(5); + chai.expect(res.cookie.callCount).to.equal(3); + chai.expect(res.cookie.args[0].slice(0, 2)).to.deep.equal(['AuthSession', 'cde']); + chai.expect(res.cookie.args[1].slice(0, 2)).to.deep.equal(['userCtx', JSON.stringify(userCtx) ]); + chai.expect(res.cookie.args[2].slice(0, 2)).to.deep.equal(['locale', 'hi']); + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([302]); + chai.expect(res.send.callCount).to.equal(1); + chai.expect(res.send.args[0]).to.deep.equal(['/']); + }); + }); + + it('should abandon logging in after retrying 11 times', () => { + sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); + sinon.stub(tokenLogin, 'isTokenLoginEnabled').returns(true); + sinon.stub(tokenLogin, 'getUserByToken').resolves('userId'); + sinon.stub(tokenLogin, 'resetPassword').resolves({ user: 'user_name', password: 'secret' }); + sinon.stub(tokenLogin, 'deactivateTokenLogin'); + sinon.stub(res, 'status').returns(res); + sinon.stub(res, 'json'); + sinon.stub(request, 'post').resolves({ statusCode: 401 }); + req.params = { token: 'a', userId: 'b' }; + return controller.tokenPost(req, res).then(() => { + chai.expect(res.status.callCount).to.equal(1); + chai.expect(res.status.args[0]).to.deep.equal([408]); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ error: 'Login failed after 10 retries' }]); + chai.expect(tokenLogin.getUserByToken.callCount).to.equal(1); + chai.expect(tokenLogin.resetPassword.callCount).to.equal(1); + chai.expect(tokenLogin.resetPassword.args[0]).to.deep.equal(['userId']); + chai.expect(tokenLogin.deactivateTokenLogin.callCount).to.equal(0); + chai.expect(request.post.callCount).to.equal(11); + }); + }); + }); + describe('post', () => { it('returns errors from session create', () => { diff --git a/api/tests/mocha/controllers/users.spec.js b/api/tests/mocha/controllers/users.spec.js index 74184c84879..1447ba13d83 100644 --- a/api/tests/mocha/controllers/users.spec.js +++ b/api/tests/mocha/controllers/users.spec.js @@ -5,6 +5,7 @@ const auth = require('../../../src/auth'); const authorization = require('../../../src/services/authorization'); const serverUtils = require('../../../src/server-utils'); const purgedDocs = require('../../../src/services/purged-docs'); +const users = require('../../../src/services/users'); let req; let userCtx; @@ -12,6 +13,8 @@ let res; describe('Users Controller', () => { beforeEach(() => { + req = {}; + res = {}; sinon.stub(authorization, 'getAuthorizationContext'); sinon.stub(authorization, 'getAllowedDocIds'); sinon.stub(auth, 'isOnlineOnly'); @@ -407,4 +410,112 @@ describe('Users Controller', () => { }); }); }); + + describe('create', () => { + it('should respond with error when requester has no permission', () => { + sinon.stub(auth, 'check').rejects({ status: 403 }); + return controller.create(req, res).then(() => { + chai.expect(auth.check.callCount).to.equal(1); + chai.expect(auth.check.args[0]).to.deep.equal([req, 'can_create_users']); + chai.expect(serverUtils.error.callCount).to.equal(1); + chai.expect(serverUtils.error.args[0]).to.deep.equal([{ status: 403 }, req, res]); + }); + }); + + it('should respond with error when creating fails', () => { + sinon.stub(auth, 'check').resolves(); + sinon.stub(users, 'createUser').rejects({ some: 'err' }); + req = { protocol: 'http', hostname: 'thehost.com', body: { name: 'user' } }; + res = { json: sinon.stub() }; + return controller.create(req, res).then(() => { + chai.expect(serverUtils.error.callCount).to.equal(1); + chai.expect(serverUtils.error.args[0]).to.deep.equal([{ some: 'err' }, req, res]); + chai.expect(users.createUser.callCount).to.equal(1); + chai.expect(users.createUser.args[0]).to.deep.equal([req.body, 'http://thehost.com']); + chai.expect(res.json.callCount).to.equal(0); + + }); + }); + + it('should create the user and respond', () => { + sinon.stub(auth, 'check').resolves(); + sinon.stub(users, 'createUser').resolves({ user: { id: 'aaa' } }); + req = { protocol: 'https', hostname: 'host.com', body: { name: 'user' } }; + res = { json: sinon.stub() }; + return controller.create(req, res).then(() => { + chai.expect(serverUtils.error.callCount).to.equal(0); + chai.expect(users.createUser.callCount).to.equal(1); + chai.expect(users.createUser.args[0]).to.deep.equal([req.body, 'https://host.com']); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ user: { id: 'aaa' } }]); + }); + }); + }); + + describe('update', () => { + it('should respond with error when empty body', () => { + req.body = {}; + // this is probably not a promise, but we make it a promise so we can make certain we're testing the whole thing + sinon.stub(serverUtils, 'emptyJSONBodyError').resolves(); + return controller.update(req, res).then(() => { + chai.expect(serverUtils.emptyJSONBodyError.callCount).to.equal(1); + }); + }); + + it('should respond with error when not permitted', () => { + sinon.stub(auth, 'check').rejects({ code: 403 }); + sinon.stub(auth, 'getUserCtx').resolves({ name: 'alpha' }); + sinon.stub(auth, 'basicAuthCredentials').returns({ name: 'alpha' }); + sinon.stub(auth, 'validateBasicAuth').resolves(); + req = { params: { username: 'beta' }, body: { field: 'update' } }; + + return controller.update(req, res).then(() => { + chai.expect(serverUtils.error.callCount).to.equal(1); + chai.expect(serverUtils.error.args[0]).to.deep.equal([ + { code: 403, message: 'You do not have permissions to modify this person' }, + req, + res, + ]); + chai.expect(auth.check.callCount).to.equal(1); + chai.expect(auth.check.args[0]).to.deep.equal([req, 'can_update_users']); + chai.expect(auth.getUserCtx.callCount).to.equal(2); + }); + }); + + it('should allow user to update himself', () => { + sinon.stub(auth, 'check').rejects({ code: 403 }); + sinon.stub(auth, 'getUserCtx').resolves({ name: 'alpha' }); + sinon.stub(auth, 'basicAuthCredentials').returns({ username: 'alpha' }); + sinon.stub(auth, 'validateBasicAuth').resolves(); + req = { params: { username: 'alpha' }, protocol: 'http', hostname: 'myhost.net', body: { field: 'update' } }; + res = { json: sinon.stub() }; + sinon.stub(users, 'updateUser').resolves({ response: true }); + + return controller.update(req, res).then(() => { + chai.expect(serverUtils.error.callCount).to.equal(0); + chai.expect(users.updateUser.callCount).to.equal(1); + chai.expect(users.updateUser.args[0]).to.deep.equal(['alpha', { field: 'update' }, false, 'http://myhost.net']); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ response: true }]); + }); + }); + + it('should allow admins to update other users', () => { + sinon.stub(auth, 'check').resolves(); + sinon.stub(auth, 'getUserCtx').resolves({ name: 'alpha' }); + sinon.stub(auth, 'basicAuthCredentials').returns({ username: 'alpha' }); + sinon.stub(auth, 'validateBasicAuth').resolves(); + req = { params: { username: 'beta' }, protocol: 'https', hostname: 'myhost.io', body: { field: 'update' } }; + res = { json: sinon.stub() }; + sinon.stub(users, 'updateUser').resolves({ updated: true }); + + return controller.update(req, res).then(() => { + chai.expect(serverUtils.error.callCount).to.equal(0); + chai.expect(users.updateUser.callCount).to.equal(1); + chai.expect(users.updateUser.args[0]).to.deep.equal(['beta', { field: 'update' }, true, 'https://myhost.io']); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0]).to.deep.equal([{ updated: true }]); + }); + }); + }); }); diff --git a/api/tests/mocha/ddoc-extraction.spec.js b/api/tests/mocha/ddoc-extraction.spec.js index bdc9254fab8..0238be630b9 100644 --- a/api/tests/mocha/ddoc-extraction.spec.js +++ b/api/tests/mocha/ddoc-extraction.spec.js @@ -58,7 +58,7 @@ describe('DDoc extraction', () => { const getUsersMetaAttachment = getAttachment .withArgs('_design/medic', 'ddocs/users-meta.json') .resolves(Buffer.from(JSON.stringify(usersMetaAttachment))); - + const getMedicNew = medicGet.withArgs('_design/medic-new').rejects({ status: 404 }); const getMedicUpdated = medicGet.withArgs('_design/medic-updated').resolves({ _id: '_design/medic-updated', _rev: '1', views: { doc_by_valed: { map: 'function() { return true; }' } } @@ -105,7 +105,7 @@ describe('DDoc extraction', () => { getUsersMetaUnchanged.callCount.should.equal(1); getSwMeta.callCount.should.equal(1); getSettings.callCount.should.equal(0); - + bulkMedic.callCount.should.equal(1); const medicDocs = bulkMedic.args[0][0].docs; medicDocs.length.should.equal(2); @@ -113,7 +113,7 @@ describe('DDoc extraction', () => { chai.expect(medicDocs[0]._rev).to.equal(undefined); medicDocs[1]._id.should.equal('_design/medic-updated'); medicDocs[1]._rev.should.equal('1'); - + bulkSentinel.callCount.should.equal(1); const sentinelDocs = bulkSentinel.args[0][0].docs; sentinelDocs.length.should.equal(2); @@ -121,7 +121,7 @@ describe('DDoc extraction', () => { chai.expect(sentinelDocs[0]._rev).to.equal(undefined); sentinelDocs[1]._id.should.equal('_design/sentinel-updated'); sentinelDocs[1]._rev.should.equal('1'); - + bulkUsersMeta.callCount.should.equal(1); const usersMetaDocs = bulkUsersMeta.args[0][0].docs; usersMetaDocs.length.should.equal(2); diff --git a/api/tests/mocha/services/token-login.spec.js b/api/tests/mocha/services/token-login.spec.js new file mode 100644 index 00000000000..74bf157e595 --- /dev/null +++ b/api/tests/mocha/services/token-login.spec.js @@ -0,0 +1,1046 @@ +const chai = require('chai'); +const sinon = require('sinon'); + +const config = require('../../../src/config'); +const db = require('../../../src/db'); +const service = require('../../../src/services/token-login'); + +const oneDayInMS = 24 * 60 * 60 * 1000; + +let clock; + +describe('TokenLogin service', () => { + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + afterEach(() => { + clock.restore(); + sinon.restore(); + }); + + describe('isTokenLoginEnabled', () => { + it('should return falsy when no setting', () => { + sinon.stub(config, 'get').returns(); + chai.expect(service.isTokenLoginEnabled()).to.equal(false); + chai.expect(config.get.callCount).to.deep.equal(1); + chai.expect(config.get.args[0]).to.deep.equal(['token_login']); + }); + + it('should return falsy when not enabled', () => { + sinon.stub(config, 'get').withArgs('token_login').returns({ enabled: false }); + chai.expect(service.isTokenLoginEnabled()).to.equal(false); + }); + + it('should return true when enabled', () => { + sinon.stub(config, 'get').withArgs('token_login').returns({ enabled: true }); + chai.expect(service.isTokenLoginEnabled()).to.equal(true); + }); + }); + + describe('shouldEnableTokenLogin', () => { + it('should return falsey when not token login not configured', () => { + sinon.stub(config, 'get').returns({}); + chai.expect(service.shouldEnableTokenLogin({ token_login: true })).to.equal(false); + }); + + it('should return falsey when data does not request token_login to be enabled', () => { + sinon.stub(config, 'get').withArgs('token_login').returns({ enabled: true, message: 'message' }); + chai.expect(service.shouldEnableTokenLogin({})).to.equal(undefined); + }); + + it('should return true when configured and requested', () => { + sinon.stub(config, 'get').withArgs('token_login').returns({ enabled: true, message: 'message' }); + chai.expect(service.shouldEnableTokenLogin({ token_login: true })).to.equal(true); + }); + }); + + describe('validateTokenLogin', () => { + beforeEach(() => { + sinon.stub(config, 'get').withArgs('token_login').returns({ enabled: true, message: 'message' }); + }); + + describe('on create', () => { + it('should do nothing when token login not required', () => { + const data = {}; + service.validateTokenLogin(data, true); + chai.expect(data).to.deep.equal({}); + }); + + it('should return an error when phone number is not present', () => { + const data = { token_login: true }; + const result = service.validateTokenLogin(data, true); + chai.expect(result).to.deep.equal({ + msg: 'A valid phone number is required for SMS login.', + key: 'configuration.enable.token.login.phone' + }); + chai.expect(data).to.deep.equal({ token_login: true }); + }); + + it('should return an error when phone number is not valid', () => { + const data = { token_login: true, phone: 'aaaaa' }; + const result = service.validateTokenLogin(data, true); + chai.expect(result).to.deep.equal({ + msg: 'A valid phone number is required for SMS login.', + key: 'configuration.enable.token.login.phone' + }); + chai.expect(data).to.deep.equal({ token_login: true, phone: 'aaaaa' }); + }); + + it('should assign password and normalize phone when phone is valid', () => { + const data = { token_login: true, phone: '+40 755 336-699' }; + const result = service.validateTokenLogin(data, true); + chai.expect(result).to.equal(undefined); + chai.expect(data).to.have.keys(['token_login', 'phone', 'password']); + chai.expect(data).to.include({ token_login: true, phone: '+40755336699' }); + chai.expect(data.password.length).to.equal(20); + }); + }); + + describe('on edit', () => { + it('should do nothing when no changes', () => { + const user = { _id: 'user', name: 'user', known: true, facility_id: 'aaa' }; + const settings = { _id: 'user', name: 'user', contact_id: 'bbb' }; + const data = {}; + const result = service.validateTokenLogin(data, false, user, settings); + // no changes to provided data + chai.expect(result).to.equal(undefined); + chai.expect(user).to.deep.equal({ _id: 'user', name: 'user', known: true, facility_id: 'aaa' }); + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'bbb' }); + }); + + describe('when disabling', () => { + it('should do nothing when token login not enabled', () => { + const user = { _id: 'user', name: 'user', known: true, facility_id: 'aaa' }; + const settings = { _id: 'user', name: 'user', contact_id: 'bbb' }; + const data = { token_login: false }; + const result = service.validateTokenLogin(data, false, user, settings); + // no changes to provided data + chai.expect(result).to.equal(undefined); + chai.expect(user).to.deep.equal({ _id: 'user', name: 'user', known: true, facility_id: 'aaa' }); + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'bbb' }); + }); + + it('should require password', () => { + const user = { _id: 'user', name: 'user', token_login: true }; + const settings = { _id: 'user', name: 'user', contact_id: 'bbb', token_login: true }; + const data = { token_login: false }; + const result = service.validateTokenLogin(data, false, user, settings); + chai.expect(result).to.deep.equal({ + msg: 'Password is required when disabling token login.', + key: 'password.length.minimum', + }); + // no changes to provided data + chai.expect(user).to.deep.equal({ _id: 'user', name: 'user', token_login: true }); + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'bbb', token_login: true }); + chai.expect(data).to.deep.equal({ token_login: false }); + }); + + it('should do nothing when password is present', () => { + const user = { _id: 'user', name: 'user', token_login: true }; + const settings = { _id: 'user', name: 'user', contact_id: 'bbb', token_login: true }; + const data = { token_login: false, password: 'superSecret' }; + const result = service.validateTokenLogin(data, false, user, settings); + chai.expect(result).to.deep.equal(undefined); + // no changes to provided data + chai.expect(user).to.deep.equal({ _id: 'user', name: 'user', token_login: true }); + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'bbb', token_login: true }); + chai.expect(data).to.deep.equal({ token_login: false, password: 'superSecret' }); + }); + }); + + describe('when enabling', () => { + it('should return an error when no phone number', () => { + const user = { _id: 'user', name: 'user' }; + const settings = { _id: 'user', name: 'user', contact_id: 'aaa' }; + const data = { token_login: true }; + const result = service.validateTokenLogin(data, false, user, settings); + chai.expect(result).to.deep.equal({ + msg: 'A valid phone number is required for SMS login.', + key: 'configuration.enable.token.login.phone' + }); + // no changes to provided data + chai.expect(user).to.deep.equal({ _id: 'user', name: 'user' }); + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'aaa' }); + chai.expect(data).to.deep.equal({ token_login: true }); + }); + + it('should return an error when phone is invalid', () => { + const user = { _id: 'user', name: 'user' }; + const settings = { _id: 'user', name: 'user', contact_id: 'aaa', phone: 'aaaa' }; + const data = { token_login: true }; + const result = service.validateTokenLogin(data, false, user, settings); + chai.expect(result).to.deep.equal({ + msg: 'A valid phone number is required for SMS login.', + key: 'configuration.enable.token.login.phone' + }); + // no changes to provided data + chai.expect(user).to.deep.equal({ _id: 'user', name: 'user' }); + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'aaa', phone: 'aaaa' }); + chai.expect(data).to.deep.equal({ token_login: true }); + }); + + it('should normalize phone and reset password when phone is valid', () => { + const user = { _id: 'user', name: 'user' }; + const settings = { _id: 'user', name: 'user', contact_id: 'aaa', phone: '+40 (766) 23-23-23' }; + const data = { token_login: true }; + const result = service.validateTokenLogin(data, false, user, settings); + chai.expect(result).to.deep.equal(undefined); + // new password + chai.expect(user).to.have.all.keys(['_id', 'name', 'password']); + chai.expect(user.password.length).to.equal(20); + // normalized phone + chai.expect(settings).to.deep.equal({ _id: 'user', name: 'user', contact_id: 'aaa', phone: '+40766232323' }); + chai.expect(data).to.deep.equal({ token_login: true }); + }); + }); + }); + }); + + describe('getUserByToken', () => { + it('should reject with no input', () => { + sinon.stub(db.users, 'get'); + return service + .getUserByToken() + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ status: 401, error: 'invalid' }); + chai.expect(db.users.get.callCount).to.equal(0); + }); + }); + + it('should throw when token_login doc not found', () => { + sinon.stub(db.medic, 'get').rejects({ status: 404 }); + const token = 'my_token'; + return service + .getUserByToken(token) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ status: 401, error: 'invalid' }); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.args[0]).to.deep.equal([`token:login:${token}`]); + }); + }); + + it('should throw when user not found', () => { + sinon.stub(db.medic, 'get').resolves({ user: 'org.couchdb.user:someuser' }); + sinon.stub(db.users, 'get').rejects({ status: 404 }); + return service + .getUserByToken('omgtoken') + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ status: 401, error: 'invalid' }); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.args[0]).to.deep.equal([`token:login:omgtoken`]); + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:someuser']); + }); + }); + + it('should return false when no matches found', () => { + sinon.stub(db.medic, 'get').resolves({ user: 'org.couchdb.user:otheruser' }); + sinon.stub(db.users, 'get').resolves({ token_login: { token: 'not token' } }); + return service + .getUserByToken('sometoken') + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ status: 401, error: 'invalid' }); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.args[0]).to.deep.equal([`token:login:sometoken`]); + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:otheruser']); + }); + }); + + it('should throw when match is expired', () => { + sinon.stub(db.medic, 'get').resolves({ user: 'org.couchdb.user:user' }); + sinon.stub(db.users, 'get').resolves({ token_login: { active: true, token: 'the_token', expiration_date: 0 } }); + return service + .getUserByToken('the_token') + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ status: 401, error: 'expired' }); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.args[0]).to.deep.equal([`token:login:the_token`]); + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:user']); + }); + }); + + it('should return the row id when match is not expired', () => { + const future = new Date().getTime() + 1000; + sinon.stub(db.medic, 'get').resolves({ user: 'org.couchdb.user:user_id' }); + sinon.stub(db.users, 'get').resolves({ + _id: 'org.couchdb.user:user_id', + token_login: { + active: true, + token: 'the_token', + expiration_date: future + }, + }); + return service.getUserByToken('the_token').then(response => { + chai.expect(response).to.equal('org.couchdb.user:user_id'); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.args[0]).to.deep.equal([`token:login:the_token`]); + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:user_id']); + }); + }); + + it('should throw when get errors', () => { + sinon.stub(db.medic, 'get').resolves({ user: 'org.couchdb.user:user_id' }); + sinon.stub(db.users, 'get').rejects({ some: 'err' }); + return service + .getUserByToken('t', 'h') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => chai.expect(err).to.deep.equal({ some: 'err' })); + }); + + it('should throw when get errors', () => { + sinon.stub(db.medic, 'get').rejects({ other: 'err' }); + return service + .getUserByToken('t', 'h') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => chai.expect(err).to.deep.equal({ other: 'err' })); + }); + }); + + describe('resetPassword', () => { + it('should throw an error when user not found', () => { + sinon.stub(db.users, 'get').rejects({ status: 404 }); + + return service + .resetPassword('userId') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err).to.include({ status: 404 }); + }); + }); + + it('should throw an error when user is invalid', () => { + sinon.stub(db.users, 'get').resolves({ name: 'user' }); + return service + .resetPassword('userId') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ code: 400, message: 'invalid user' }); + }); + }); + + it('should throw an error when user token not active', () => { + sinon.stub(db.users, 'get').resolves({ name: 'user', token_login: { active: false } }); + return service + .resetPassword('userId') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ code: 400, message: 'invalid user' }); + }); + }); + + it('should update the users password', () => { + const user = { + name: 'sally', + roles: ['a', 'b'], + facilty_id: 'c', + type: 'user', + token_login: { + active: true, + token: 'aaaa', + expiration_date: 0, + }, + }; + + sinon.stub(db.users, 'get').resolves(user); + sinon.stub(db.users, 'put').resolves(); + + return service.resetPassword('userID').then(response => { + chai.expect(response).to.deep.equal({ + password: user.password, + user: 'sally' + }); + chai.expect(user.password.length).to.equal(20); + + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.args[0]).to.deep.equal(['userID']); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0]).to.deep.equal([{ + name: 'sally', + roles: ['a', 'b'], + facilty_id: 'c', + type: 'user', + token_login: { + active: true, + token: 'aaaa', + expiration_date: 0, + }, + password: user.password, + }]); + }); + }); + }); + + describe('deactivate token login', () => { + it('should throw an error when user not found', () => { + sinon.stub(db.users, 'get').rejects({ status: 404 }); + sinon.stub(db.medic, 'get').rejects({ status: 404 }); + + return service + .deactivateTokenLogin('userId') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err).to.include({ status: 404 }); + }); + }); + + it('should throw an error when user is invalid', () => { + sinon.stub(db.users, 'get').resolves({ name: 'user' }); + sinon.stub(db.medic, 'get').resolves({ name: 'user' }); + return service + .deactivateTokenLogin('userId') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ code: 400, message: 'invalid user' }); + }); + }); + + it('should throw an error when user token not active', () => { + sinon.stub(db.users, 'get').resolves({ name: 'user', token_login: { active: false } }); + sinon.stub(db.medic, 'get').resolves({ name: 'user', token_login: { active: false } }); + return service + .deactivateTokenLogin('userId') + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err).to.deep.equal({ code: 400, message: 'invalid user' }); + }); + }); + + it('should de-activate token login', () => { + const user = { + name: 'sally', + roles: ['a', 'b'], + facilty_id: 'c', + type: 'user', + token_login: { + active: true, + token: 'aaaa', + expiration_date: 0, + }, + }; + const userSettings = { + name: 'sally', + roles: ['a', 'b'], + phone: 'c', + type: 'user-settings', + token_login: { active: true, expiration_date: 0 }, + }; + + sinon.stub(db.users, 'get').resolves(user); + sinon.stub(db.medic, 'get').resolves(userSettings); + sinon.stub(db.users, 'put').resolves(); + sinon.stub(db.medic, 'put').resolves(); + clock.tick(123); + + return service.deactivateTokenLogin('userID').then(() => { + chai.expect(db.users.get.callCount).to.equal(1); + chai.expect(db.users.get.args[0]).to.deep.equal(['userID']); + chai.expect(db.medic.get.callCount).to.equal(1); + chai.expect(db.medic.get.args[0]).to.deep.equal(['userID']); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0]).to.deep.equal([{ + name: 'sally', + roles: ['a', 'b'], + facilty_id: 'c', + type: 'user', + token_login: { + active: false, + login_date: 123, + token: 'aaaa', + expiration_date: 0, + }, + }]); + chai.expect(db.medic.put.callCount).to.equal(1); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + name: 'sally', + roles: ['a', 'b'], + phone: 'c', + type: 'user-settings', + token_login: { active: false, expiration_date: 0, login_date: 123 }, + }]); + }); + }); + }); + + describe('manageTokenLogin', () => { + it('should do nothing when undefined', () => { + return service.manageTokenLogin({}, '',{ user: { id: 'user' } }).then(actual => { + chai.expect(actual).to.deep.equal({ user: { id: 'user' } }); + }); + }); + + it('should do nothing when no config', () => { + sinon.stub(config, 'get').withArgs('token_login').returns(); + return service.manageTokenLogin({ token_login: true }, '',{ user: { id: 'user' } }).then(actual => { + chai.expect(actual).to.deep.equal({ user: { id: 'user' } }); + }); + }); + + describe('disabling token login', () => { + it('should do nothing when user does not have token_login configured', () => { + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + sinon.stub(db.medic, 'get').withArgs('userID').resolves({ _id: 'userID' }); + sinon.stub(db.users, 'get').withArgs('userID').resolves({ _id: 'userID' }); + + return service.manageTokenLogin({ token_login: false }, '', response).then(actual => { + chai.expect(actual).to.deep.equal({ user: { id: 'userID' }, 'user-settings': { id: 'userID' } }); + }); + }); + + it('should disable token login when requested', () => { + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + const responseCopy = Object.assign({}, response); + sinon.stub(db.medic, 'get') + .withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + token_login: { active: true, expiration_date: 123 }, + }) + .withArgs('token:login:aaa').resolves({ + _id: 'token:login:aaa', + type: 'token_login', + user: 'userID', + tasks: [ + { state: 'pending', messages: [{ message: 'sms1' }] }, + { state: 'pending', messages: [{ message: 'sms2' }] }, + ] + }); + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + token_login: { + active: true, + expiration_date: 123, + token: 'aaa', + } + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + + return service.manageTokenLogin({ token_login: false }, '', response).then(actual => { + chai.expect(db.medic.put.callCount).to.equal(2); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'token:login:aaa', + type: 'token_login', + user: 'userID', + tasks: [ + { + state: 'cleared', + messages: [{ message: 'sms1' }], + gateway_ref: undefined, + state_details: undefined, + state_history: [{ state: 'cleared', state_details: undefined, timestamp: new Date().toISOString() }], + }, + { + state: 'cleared', + messages: [{ message: 'sms2' }], + gateway_ref: undefined, + state_details: undefined, + state_history: [{ state: 'cleared', state_details: undefined, timestamp: new Date().toISOString() }], + }, + ] + }]); + chai.expect(db.medic.put.args[1]).to.deep.equal([{ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }]); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0]).to.deep.equal([{ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }]); + chai.expect(actual).to.deep.equal(responseCopy); + }); + }); + + it('should only clear pending messages', () => { + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + const responseCopy = Object.assign({}, response); + sinon.stub(db.medic, 'get') + .withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + token_login: { active: true, expiration_date: 123 }, + }) + .withArgs('token:login:bbb').resolves({ + _id: 'token:login:bbb', + type: 'token_login', + user: 'userID', + tasks: [ + { state: 'sent', messages: [{ message: 'sms1' }] }, + { state: 'forwarded-by-gateway', messages: [{ message: 'sms2' }] }, + ] + }); + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + token_login: { + active: true, + expiration_date: 123, + token: 'bbb', + } + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + + return service.manageTokenLogin({ token_login: false }, '', response).then(actual => { + chai.expect(db.medic.put.callCount).to.equal(1); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }]); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0]).to.deep.equal([{ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }]); + chai.expect(actual).to.deep.equal(responseCopy); + }); + }); + + it('should work when old login token doc not found', () => { + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + sinon.stub(db.medic, 'get') + .withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + token_login: { active: true, expiration_date: 123 }, + }) + .withArgs('token:login:ccc').rejects({ status: 404 }); + + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + token_login: { + active: true, + expiration_date: 123, + token: 'ccc', + } + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + + return service.manageTokenLogin({ token_login: false }, '', response).then(actual => { + chai.expect(db.medic.put.callCount).to.equal(1); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }]); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0]).to.deep.equal([{ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }]); + chai.expect(actual).to.deep.equal(response); + }); + }); + }); + + describe('enabling token login', () => { + it('should generate password, token, create sms and update user docs', () => { + sinon.stub(config, 'get') + .withArgs('token_login').returns({ message: 'the sms', enabled: true }) + .withArgs('app_url').returns('http://host'); + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + + sinon.stub(db.medic, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + phone: '+40755232323', + }); + + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + sinon.stub(db.medic, 'allDocs').resolves({ rows: [{ error: 'not_found' }] }); + + clock.tick(2000); + + return service.manageTokenLogin({ token_login: true }, '', response).then(actual => { + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }); + chai.expect(db.users.put.args[0][0].token_login).to.deep.include({ + active: true, + expiration_date: 2000 + oneDayInMS, + }); + const token = db.users.put.args[0][0].token_login.token; + + chai.expect(db.medic.put.callCount).to.equal(2); + chai.expect(db.medic.put.args[0][0]).to.deep.nested.include({ + _id: `token:login:${token}`, + type: 'token_login', + reported_date: 2000, + user: 'userID', + 'tasks[0].state': 'pending', + 'tasks[0].messages[0].to': '+40755232323', + 'tasks[0].messages[0].message': 'the sms', + 'tasks[1].messages[0].to': '+40755232323', + 'tasks[1].messages[0].message': `http://host/medic/login/token/${token}`, + }); + chai.expect(db.medic.put.args[1]).to.deep.equal([{ + _id: 'userID', + name: 'user', + phone: '+40755232323', + roles: ['a', 'b'], + token_login: { active: true, expiration_date: 2000 + oneDayInMS }, + }]); + + chai.expect(actual).to.deep.equal({ + user: { id: 'userID' }, + 'user-settings': { id: 'userID' }, + token_login: { expiration_date: 2000 + oneDayInMS } + }); + + chai.expect(db.medic.allDocs.callCount).to.equal(1); + chai.expect(db.medic.allDocs.args[0][0].keys[0]).to.equal(`token:login:${token}`); + }); + }); + + it('should clear previous token_login sms', () => { + sinon.stub(config, 'get') + .withArgs('token_login').returns({ message: 'the sms', enabled: true }) + .withArgs('app_url').returns('http://host'); + const response = { user: { id: 'my_user' }, 'user-settings': { id: 'my_user' } }; + + sinon.stub(db.medic, 'get').withArgs('my_user').resolves({ + _id: 'my_user', + name: 'user', + roles: ['a', 'b'], + phone: 'phone', + token_login: { active: true, expiration_date: 2500 }, + }); + db.medic.get.withArgs('token:login:oldtoken').resolves({ + _id: 'token:login:oldtoken', + type: 'token_login', + reported_date: 1000, + user: 'my_user', + tasks: [ + { + state: 'pending', + messages: [{ to: 'oldphone', message: 'old message' }], + }, + { + state: 'pending', + messages: [{ to: 'oldphone', message: 'old link' }], + }, + ], + }); + + sinon.stub(db.users, 'get').withArgs('my_user').resolves({ + _id: 'my_user', + name: 'user', + roles: ['a', 'b'], + token_login: { + active: true, + expiration_date: 2500, + token: 'oldtoken', + }, + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + sinon.stub(db.medic, 'allDocs').resolves({ rows: [{ error: 'not_found' }] }); + + clock.tick(2000); + + return service.manageTokenLogin({ token_login: true }, '', response).then(actual => { + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + _id: 'my_user', + name: 'user', + roles: ['a', 'b'], + }); + chai.expect(db.users.put.args[0][0].token_login).to.deep.include({ + active: true, + expiration_date: 2000 + oneDayInMS, + }); + const token = db.users.put.args[0][0].token_login.token; + + chai.expect(token).not.to.equal('oldtoken'); + chai.expect(token.length).to.equal(64); + + chai.expect(db.medic.put.callCount).to.equal(3); + + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'token:login:oldtoken', + type: 'token_login', + reported_date: 1000, + user: 'my_user', + tasks: [ + { + state: 'cleared', + messages: [{ to: 'oldphone', message: 'old message' }], + gateway_ref: undefined, + state_details: undefined, + state_history: [{ state: 'cleared', state_details: undefined, timestamp: new Date().toISOString() }] + }, + { + state: 'cleared', + messages: [{ to: 'oldphone', message: 'old link' }], + gateway_ref: undefined, + state_details: undefined, + state_history: [{ state: 'cleared', state_details: undefined, timestamp: new Date().toISOString() }] + }, + ], + }]); + + chai.expect(db.medic.put.args[1][0]).to.deep.nested.include({ + _id: `token:login:${token}`, + type: 'token_login', + reported_date: 2000, + user: 'my_user', + 'tasks[0].state': 'pending', + 'tasks[0].messages[0].to': 'phone', + 'tasks[0].messages[0].message': 'the sms', + 'tasks[1].messages[0].to': 'phone', + 'tasks[1].messages[0].message': `http://host/medic/login/token/${token}`, + }); + + chai.expect(db.medic.put.args[2]).to.deep.equal([{ + _id: 'my_user', + name: 'user', + phone: 'phone', + roles: ['a', 'b'], + token_login: { active: true, expiration_date: 2000 + oneDayInMS }, + }]); + + chai.expect(actual).to.deep.equal({ + user: { id: 'my_user' }, + 'user-settings': { id: 'my_user' }, + token_login: { expiration_date: 2000 + oneDayInMS } + }); + + chai.expect(db.medic.allDocs.callCount).to.equal(1); + chai.expect(db.medic.allDocs.args[0][0].keys[0]).to.equal(`token:login:${token}`); + }); + }); + + it('should only clear pending tasks in previous token_login sms', () => { + sinon.stub(config, 'get') + .withArgs('token_login').returns({ message: 'the sms', enabled: true }) + .withArgs('app_url').returns('http://host'); + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + + sinon.stub(db.medic, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'username', + roles: ['a', 'b'], + phone: 'newphone', + token_login: { active: true, expiration_date: 2500 }, + }); + db.medic.get.withArgs('token:login:oldtoken').resolves({ + _id: 'token:login:oldtoken', + type: 'token_login', + reported_date: 1000, + user: 'userID', + tasks: [ + { + state: 'pending', + messages: [{ to: 'oldphone', message: 'old message' }], + }, + { + state: 'sent', + messages: [{ to: 'oldphone', message: 'old link' }], + }, + ], + }); + + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'username', + roles: ['a', 'b'], + token_login: { + active: true, + doc_id: 'oldSms', + expiration_date: 2500, + token: 'oldtoken', + }, + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + sinon.stub(db.medic, 'allDocs').resolves({ rows: [{ error: 'not_found' }] }); + + clock.tick(5000); + + return service.manageTokenLogin({ token_login: true }, '', response).then(actual => { + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0].token_login).to.deep.include({ + active: true, + expiration_date: 5000 + oneDayInMS, + }); + const token = db.users.put.args[0][0].token_login.token; + + chai.expect(db.medic.put.callCount).to.equal(3); + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'token:login:oldtoken', + type: 'token_login', + reported_date: 1000, + user: 'userID', + tasks: [ + { + state: 'cleared', + messages: [{ to: 'oldphone', message: 'old message' }], + gateway_ref: undefined, + state_details: undefined, + state_history: [{ state: 'cleared', state_details: undefined, timestamp: new Date().toISOString() }] + }, + { + state: 'sent', + messages: [{ to: 'oldphone', message: 'old link' }], + }, + ], + }]); + + chai.expect(db.medic.put.args[1][0]).to.deep.nested.include({ + _id: `token:login:${token}`, + type: 'token_login', + reported_date: 5000, + user: 'userID', + 'tasks[0].state': 'pending', + 'tasks[0].messages[0].to': 'newphone', + 'tasks[0].messages[0].message': 'the sms', + 'tasks[1].messages[0].to': 'newphone', + 'tasks[1].messages[0].message': `http://host/medic/login/token/${token}`, + }); + + chai.expect(actual).to.deep.equal({ + user: { id: 'userID' }, + 'user-settings': { id: 'userID' }, + token_login: { expiration_date: 5000 + oneDayInMS } + }); + }); + }); + + it('should try to generate a unique token', () => { + sinon.stub(config, 'get') + .withArgs('token_login').returns({ message: 'the sms', enabled: true }) + .withArgs('app_url').returns('http://host'); + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + + sinon.stub(db.medic, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + phone: '+40755232323', + }); + + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }); + + sinon.stub(db.medic, 'put').resolves(); + sinon.stub(db.users, 'put').resolves(); + sinon.stub(db.medic, 'allDocs').resolves({ rows: [{}, {}, {}, { error: 'not_found' }] }); // 4th token + + clock.tick(2000); + + return service.manageTokenLogin({ token_login: true }, '', response).then(actual => { + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }); + chai.expect(db.users.put.args[0][0].token_login).to.deep.include({ + active: true, + expiration_date: 2000 + oneDayInMS, + }); + const token = db.users.put.args[0][0].token_login.token; + + chai.expect(db.medic.put.callCount).to.equal(2); + chai.expect(db.medic.put.args[0][0]).to.deep.nested.include({ + _id: `token:login:${token}`, + type: 'token_login', + }); + chai.expect(db.medic.put.args[1]).to.deep.equal([{ + _id: 'userID', + name: 'user', + phone: '+40755232323', + roles: ['a', 'b'], + token_login: { active: true, expiration_date: 2000 + oneDayInMS }, + }]); + + chai.expect(actual).to.deep.equal({ + user: { id: 'userID' }, + 'user-settings': { id: 'userID' }, + token_login: { expiration_date: 2000 + oneDayInMS } + }); + + chai.expect(db.medic.allDocs.callCount).to.equal(1); + chai.expect(db.medic.allDocs.args[0][0].keys[3]).to.equal(`token:login:${token}`); + }); + }); + + it('should throw an error when not able to generate a unique token', () => { + sinon.stub(config, 'get') + .withArgs('token_login').returns({ message: 'the sms', enabled: true }) + .withArgs('app_url').returns('http://host'); + const response = { user: { id: 'userID' }, 'user-settings': { id: 'userID' } }; + + sinon.stub(db.medic, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + phone: '+40755232323', + }); + sinon.stub(db.users, 'get').withArgs('userID').resolves({ + _id: 'userID', + name: 'user', + roles: ['a', 'b'], + }); + + sinon.stub(db.medic, 'allDocs').resolves({ rows: [] }); + + clock.tick(2000); + + return service + .manageTokenLogin({ token_login: true }, '', response) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err.message).to.equal('Failed to generate unique token'); + }); + }); + }); + }); + +}); diff --git a/api/tests/mocha/services/users.spec.js b/api/tests/mocha/services/users.spec.js index 398f23d37d3..ba09964c004 100644 --- a/api/tests/mocha/services/users.spec.js +++ b/api/tests/mocha/services/users.spec.js @@ -1,6 +1,7 @@ const chai = require('chai'); const sinon = require('sinon'); -const service = require('../../../src/services/users'); +const rewire = require('rewire'); + const people = require('../../../src/controllers/people'); const places = require('../../../src/controllers/places'); const config = require('../../../src/config'); @@ -13,15 +14,19 @@ const facilityb = { _id: 'b', name: 'brian' }; const facilityc = { _id: 'c', name: 'cathy' }; let userData; +let clock; +const oneDayInMS = 24 * 60 * 60 * 1000; -describe('Users service', () => { +let service; +describe('Users service', () => { beforeEach(() => { - sinon.stub(service, '_getFacilities').returns([ + service = rewire('../../../src/services/users'); + service.__set__('getFacilities', sinon.stub().returns([ facilitya, facilityb, facilityc, - ]); + ])); userData = { username: 'x', password: COMPLEX_PASSWORD, @@ -29,21 +34,22 @@ describe('Users service', () => { contact: { 'parent': 'x' }, type: 'national-manager' }; + clock = sinon.useFakeTimers(); }); afterEach(() => { sinon.restore(); + clock.restore(); }); describe('getSettingsUpdates', () => { - it('sets type property', done => { - const settings = service._getSettingsUpdates('john', {}); + it('sets type property', () => { + const settings = service.__get__('getSettingsUpdates')('john', {}); chai.expect(settings.type).to.equal('user-settings'); - done(); }); - it('removes user doc specific fields', done => { + it('removes user doc specific fields', () => { const data = { name: 'john', email: 'john@gmail.com', @@ -51,80 +57,74 @@ describe('Users service', () => { roles: ['foo'], starsign: 'libra' }; - const settings = service._getSettingsUpdates('john', data); + const settings = service.__get__('getSettingsUpdates')('john', data); chai.expect(settings.password).to.equal(undefined); - done(); }); - it('reassigns place and contact fields', done => { + it('reassigns place and contact fields', () => { const data = { place: 'abc', contact: '123', fullname: 'John' }; - const settings = service._getSettingsUpdates('john', data); + const settings = service.__get__('getSettingsUpdates')('john', data); chai.expect(settings.place).to.equal(undefined); chai.expect(settings.contact).to.equal(undefined); chai.expect(settings.contact_id).to.equal('123'); chai.expect(settings.facility_id).to.equal('abc'); chai.expect(settings.fullname).to.equal('John'); - done(); }); - it('supports external_id field', done => { + it('supports external_id field', () => { const data = { fullname: 'John', external_id: 'CHP020' }; - const settings = service._getSettingsUpdates('john', data); + const settings = service.__get__('getSettingsUpdates')('john', data); chai.expect(settings.external_id).to.equal('CHP020'); chai.expect(settings.fullname).to.equal('John'); - done(); }); }); describe('getUserUpdates', () => { - it('enforces name field based on id', done => { + it('enforces name field based on id', () => { const data = { name: 'sam', email: 'john@gmail.com' }; - const user = service._getUserUpdates('john', data); + const user = service.__get__('getUserUpdates')('john', data); chai.expect(user.name ).to.equal('john'); - done(); }); - it('reassigns place field', done => { + it('reassigns place field', () => { const data = { place: 'abc' }; - const user = service._getUserUpdates('john', data); + const user = service.__get__('getUserUpdates')('john', data); chai.expect(user.place).to.equal(undefined); chai.expect(user.facility_id).to.equal('abc'); - done(); }); }); describe('getType', () => { - it('returns unknown when roles is empty', done => { + it('returns unknown when roles is empty', () => { const user = { name: 'sam', roles: [] }; const admins = {}; - chai.expect(service._getType(user, admins)).to.equal('unknown'); - done(); + chai.expect(service.__get__('getType')(user, admins)).to.equal('unknown'); }); }); describe('hasParent', () => { - it('works as expected', done => { + it('works as expected', () => { const facility = { _id: 'foo', color: 'red', @@ -137,11 +137,11 @@ describe('Users service', () => { } } }; - chai.expect(service._hasParent(facility, 'baz')).to.equal(true); - chai.expect(service._hasParent(facility, 'slime')).to.equal(false); - chai.expect(service._hasParent(facility, 'bar')).to.equal(true); - chai.expect(service._hasParent(facility, 'foo')).to.equal(true); - chai.expect(service._hasParent(facility, 'goo')).to.equal(false); + chai.expect(service.__get__('hasParent')(facility, 'baz')).to.equal(true); + chai.expect(service.__get__('hasParent')(facility, 'slime')).to.equal(false); + chai.expect(service.__get__('hasParent')(facility, 'bar')).to.equal(true); + chai.expect(service.__get__('hasParent')(facility, 'foo')).to.equal(true); + chai.expect(service.__get__('hasParent')(facility, 'goo')).to.equal(false); // does not modify facility object chai.expect(facility).to.deep.equal({ _id: 'foo', @@ -155,33 +155,30 @@ describe('Users service', () => { } } }); - done(); }); }); describe('validateUser', () => { - it('defines custom error when not found', done => { + it('defines custom error when not found', () => { sinon.stub(db.users, 'get').returns(Promise.reject({ status: 404 })); - service._validateUser('x').catch(err => { + return service.__get__('validateUser')('x').catch(err => { chai.expect(err.message).to.equal('Failed to find user.'); - done(); }); }); }); describe('validateUserSettings', () => { - it('defines custom error when not found', done => { + it('defines custom error when not found', () => { sinon.stub(db.medic, 'get').returns(Promise.reject({ status: 404 })); - service._validateUserSettings('x').catch(err => { + return service.__get__('validateUserSettings')('x').catch(err => { chai.expect(err.message).to.equal('Failed to find user settings.'); - done(); }); }); }); describe('getType', () => { - it('returns role when user is in admins list and has role', done => { + it('returns role when user is in admins list and has role', () => { const user = { name: 'sam', roles: ['driver'] @@ -189,15 +186,14 @@ describe('Users service', () => { const admins = { 'sam': 'x' }; - chai.expect(service._getType(user, admins)).to.equal('driver'); - done(); + chai.expect(service.__get__('getType')(user, admins)).to.equal('driver'); }); }); describe('getList', () => { it('collects user infos', () => { - sinon.stub(service, '_getAllUsers').resolves([ + const allUsers = [ { id: 'org.couchdb.user:x', doc: { @@ -214,8 +210,8 @@ describe('Users service', () => { roles: [ 'district-admin' ] } } - ]); - sinon.stub(service, '_getAllUserSettings').resolves([ + ]; + const allUsersSettings = [ { _id: 'org.couchdb.user:x', name: 'lucas', @@ -231,7 +227,9 @@ describe('Users service', () => { phone: '987654321', external_id: 'LTT093' } - ]); + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves(allUsersSettings)); return service.getList().then(data => { chai.expect(data.length).to.equal(2); const lucas = data[0]; @@ -255,7 +253,7 @@ describe('Users service', () => { }); it('filters out non-users', () => { - sinon.stub(service, '_getAllUsers').resolves([ + const allUsers = [ { id: 'x', doc: { @@ -278,8 +276,8 @@ describe('Users service', () => { roles: [ 'district-admin' ] } } - ]); - sinon.stub(service, '_getAllUserSettings').resolves([ + ]; + const allUserSettings = [ { _id: 'org.couchdb.user:x', name: 'lucas', @@ -294,7 +292,10 @@ describe('Users service', () => { email: 'm@a.com', phone: '987654321' } - ]); + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves(allUserSettings)); + return service.getList().then(data => { chai.expect(data.length).to.equal(1); const milan = data[0]; @@ -310,15 +311,16 @@ describe('Users service', () => { }); it('handles minimal users', () => { - sinon.stub(service, '_getAllUsers').resolves([ + const allUsers = [ { id: 'org.couchdb.user:x', doc: { name: 'lucas' } } - ]); - sinon.stub(service, '_getAllUserSettings').resolves([]); + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves([])); return service.getList().then(data => { chai.expect(data.length).to.equal(1); const lucas = data[0]; @@ -332,17 +334,16 @@ describe('Users service', () => { }); }); - it('returns errors from users service', done => { - sinon.stub(service, '_getAllUsers').returns(Promise.reject('not found')); - sinon.stub(service, '_getAllUserSettings').returns(Promise.reject('not found')); - service.getList().catch(err => { - chai.expect(err).to.equal('not found'); - done(); + it('returns errors from users service', () => { + service.__set__('getAllUsers', sinon.stub().rejects('not found')); + service.__set__('getAllUserSettings', sinon.stub().rejects('not found')); + return service.getList().catch(err => { + chai.expect(err.name).to.equal('not found'); }); }); - it('returns errors from facilities service', done => { - sinon.stub(service, '_getAllUsers').resolves([ + it('returns errors from facilities service', () => { + const allUsers = [ { id: 'x', doc: { @@ -365,13 +366,12 @@ describe('Users service', () => { roles: [ 'district-admin' ] } } - ]); - sinon.stub(service, '_getAllUserSettings').resolves([]); - service._getFacilities.restore(); - sinon.stub(service, '_getFacilities').returns(Promise.reject('BOOM')); - service.getList().catch(err => { - chai.expect(err).to.equal('BOOM'); - done(); + ]; + service.__set__('getAllUsers', sinon.stub().resolves(allUsers)); + service.__set__('getAllUserSettings', sinon.stub().resolves([])); + service.__set__('getFacilities', sinon.stub().rejects('BOOM')); + return service.getList().catch(err => { + chai.expect(err.name).to.equal('BOOM'); }); }); @@ -379,15 +379,14 @@ describe('Users service', () => { describe('deleteUser', () => { - it('returns _users insert errors', done => { + it('returns _users insert errors', () => { sinon.stub(db.users, 'get').resolves({}); sinon.stub(db.medic, 'get').resolves({}); - const put = sinon.stub(db.users, 'put').returns(Promise.reject('Not Found')); - sinon.stub(db.medic, 'put').returns(Promise.reject('Not Found')); - service.deleteUser('foo').catch(err => { - chai.expect(err).to.equal('Not Found'); + const put = sinon.stub(db.users, 'put').rejects('Not Found'); + sinon.stub(db.medic, 'put').rejects('Not Found'); + return service.deleteUser('foo').catch(err => { + chai.expect(err.name).to.equal('Not Found'); chai.expect(put.callCount).to.equal(1); - done(); }); }); @@ -434,7 +433,7 @@ describe('Users service', () => { describe('createPlace', () => { it('assigns new place', () => { sinon.stub(places, 'getOrCreatePlace').resolves({ _id: 'santos' }); - return service._createPlace(userData).then(() => { + return service.__get__('createPlace')(userData).then(() => { chai.expect(userData.place._id).to.equal('santos'); }); }); @@ -442,11 +441,10 @@ describe('Users service', () => { describe('createUserSettings', () => { - it('returns error from db insert', done => { - sinon.stub(db.medic, 'put').returns(Promise.reject('yucky')); - service._createUserSettings(userData).catch(err => { - chai.expect(err).to.equal('yucky'); - done(); + it('returns error from db insert', () => { + sinon.stub(db.medic, 'put').rejects('yucky'); + return service.__get__('createUserSettings')(userData).catch(err => { + chai.expect(err.name).to.equal('yucky'); }); }); @@ -456,7 +454,7 @@ describe('Users service', () => { rev: '1-xyz' }); const response = {}; - return service._createUserSettings(userData, response).then(() => { + return service.__get__('createUserSettings')(userData, response).then(() => { chai.expect(response).to.deep.equal({ 'user-settings': { id: 'abc', @@ -470,18 +468,17 @@ describe('Users service', () => { describe('createContact', () => { - it('returns error from db insert', done => { + it('returns error from db insert', () => { sinon.stub(people, 'createPerson').returns(Promise.reject('yucky')); - service._createContact(userData, {}).catch(err => { + return service.__get__('createContact')(userData, {}).catch(err => { chai.expect(err).to.equal('yucky'); - done(); }); }); it('updates contact property', () => { sinon.stub(people, 'getOrCreatePerson').resolves({ id: 'abc' }); const response = {}; - return service._createContact(userData, response).then(() => { + return service.__get__('createContact')(userData, response).then(() => { chai.expect(userData.contact).to.deep.equal({ id: 'abc' }); }); }); @@ -489,7 +486,7 @@ describe('Users service', () => { it('sets up response', () => { sinon.stub(people, 'getOrCreatePerson').resolves({ _id: 'abc', _rev: '1-xyz' }); const response = {}; - return service._createContact(userData, response).then(() => { + return service.__get__('createContact')(userData, response).then(() => { chai.expect(response).to.deep.equal({ contact: { id: 'abc', @@ -501,13 +498,12 @@ describe('Users service', () => { }); - describe('_createUser', () => { + describe('createUser', () => { - it('returns error from db insert', done => { - sinon.stub(db.users, 'put').returns(Promise.reject('yucky')); - service._createUser(userData, {}).catch(err => { - chai.expect(err).to.equal('yucky'); - done(); + it('returns error from db insert', () => { + sinon.stub(db.users, 'put').rejects('yucky'); + return service.__get__('createUser')(userData, {}).catch(err => { + chai.expect(err.name).to.equal('yucky'); }); }); @@ -517,7 +513,7 @@ describe('Users service', () => { rev: '1-xyz' }); const response = {}; - return service._createUser(userData, response).then(() => { + return service.__get__('createUser')(userData, response).then(() => { chai.expect(response).to.deep.equal({ 'user': { id: 'abc', @@ -531,56 +527,23 @@ describe('Users service', () => { describe('createUser', () => { - it('returns error if missing fields', done => { - // empty - service.createUser({}).catch(err => { - chai.expect(err.code).to.equal(400); - }); - // missing username - service.createUser({ - password: 'x', - place: 'x', - contact: { parent: 'x' } - }).catch(err => { - chai.expect(err.code).to.equal(400); - }); - // missing password - service.createUser({ - username: 'x', - place: 'x', - contact: { parent: 'x' } - }).catch(err => { - chai.expect(err.code).to.equal(400); - }); - // missing place - service.createUser({ - username: 'x', - password: 'x', - contact: { parent: 'x' } - }).catch(err => { - chai.expect(err.code).to.equal(400); - }); - // missing contact - service.createUser({ - username: 'x', - place: 'x', - contact: { parent: 'x' } - }).catch(err => { - chai.expect(err.code).to.equal(400); - }); - // missing contact.parent - service.createUser({ - username: 'x', - place: 'x', - contact: {} - }).catch(err => { - chai.expect(err.code).to.equal(400); - }); - done(); - }); - - it('returns error if short password', done => { - service.createUser({ + it('returns error if missing fields', () => { + return service.createUser({}) + .catch(err => chai.expect(err.code).to.equal(400)) // empty + .then(() => service.createUser({ password: 'x', place: 'x', contact: { parent: 'x' }})) // missing username + .catch(err => chai.expect(err.code).to.equal(400)) + .then(() => service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }})) // missing password + .catch(err => chai.expect(err.code).to.equal(400)) + .then(() => service.createUser({ username: 'x', password: 'x', contact: { parent: 'x' }})) // missing place + .catch(err => chai.expect(err.code).to.equal(400)) + .then(() => service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }})) // missing contact + .catch(err => chai.expect(err.code).to.equal(400)) + .then(() => service.createUser({ username: 'x', place: 'x', contact: {}})) // missing contact.parent + .catch(err => chai.expect(err.code).to.equal(400)); + }); + + it('returns error if short password', () => { + return service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }, @@ -591,12 +554,11 @@ describe('Users service', () => { chai.expect(err.message.message).to.equal('The password must be at least 8 characters long.'); chai.expect(err.message.translationKey).to.equal('password.length.minimum'); chai.expect(err.message.translationParams).to.have.property('minimum'); - done(); }); }); - it('returns error if weak password', done => { - service.createUser({ + it('returns error if weak password', () => { + return service.createUser({ username: 'x', place: 'x', contact: { parent: 'x' }, @@ -607,32 +569,29 @@ describe('Users service', () => { chai.expect(err.message.message) .to.equal('The password is too easy to guess. Include a range of types of characters to increase the score.'); chai.expect(err.message.translationKey).to.equal('password.weak'); - done(); }); }); - it('returns error if contact.parent lookup fails', done => { - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); - sinon.stub(service, '_setContactParent').returns(Promise.reject('kablooey')); - service.createUser(userData).catch(err => { - chai.expect(err).to.equal('kablooey'); - done(); + it('returns error if contact.parent lookup fails', () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); + service.__set__('setContactParent', sinon.stub().rejects('kablooey')); + return service.createUser(userData).catch(err => { + chai.expect(err.name).to.equal('kablooey'); }); }); - it('returns error if place lookup fails', done => { - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').returns(Promise.reject('fail')); - service.createUser(userData).catch(err => { - chai.expect(err).to.equal('fail'); - done(); + it('returns error if place lookup fails', () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().rejects('fail')); + return service.createUser(userData).catch(err => { + chai.expect(err.name).to.equal('fail'); }); }); - it('returns error if place is not within contact', done => { - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); + it('returns error if place is not within contact', () => { + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); sinon.stub(places, 'getPlace').resolves({ _id: 'miami', parent: { @@ -640,33 +599,32 @@ describe('Users service', () => { } }); userData.place = 'georgia'; - service.createUser(userData).catch(err => { + return service.createUser(userData).catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.translationKey).to.equal('configuration.user.place.contact'); chai.expect(err.message.message).to.equal('Contact is not within place.'); - done(); }); }); it('succeeds if contact and place are the same', () => { - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createContact').resolves(); - sinon.stub(service, '_storeUpdatedPlace').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createContact', sinon.stub().resolves()); + service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); sinon.stub(places, 'getPlace').resolves({ _id: 'foo' }); userData.place = 'foo'; return service.createUser(userData); }); it('succeeds if contact is within place', () => { - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createContact').resolves(); - sinon.stub(service, '_storeUpdatedPlace').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createContact', sinon.stub().resolves()); + service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); sinon.stub(places, 'getPlace').resolves({ _id: 'miami', parent: { @@ -679,41 +637,38 @@ describe('Users service', () => { }); }); - it('fails if new username does not validate', done => { - sinon.stub(service, '_validateNewUsername').returns(Promise.reject('sorry')); + it('fails if new username does not validate', () => { + service.__set__('validateNewUsername', sinon.stub().rejects('sorry')); const insert = sinon.stub(db.medic, 'put'); - service.createUser(userData).catch(err => { - chai.expect(err).to.equal('sorry'); + return service.createUser(userData).catch(err => { + chai.expect(err.name).to.equal('sorry'); chai.expect(insert.callCount).to.equal(0); - done(); }); }); - it('errors if username exists in _users db', done => { + it('errors if username exists in _users db', () => { sinon.stub(db.users, 'get').resolves('bob lives here already.'); sinon.stub(db.medic, 'get').resolves(); const insert = sinon.stub(db.medic, 'put'); - service.createUser(userData).catch(err => { + return service.createUser(userData).catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.message).to.equal('Username "x" already taken.'); chai.expect(err.message.translationKey).to.equal('username.taken'); chai.expect(err.message.translationParams).to.have.property('username'); chai.expect(insert.callCount).to.equal(0); - done(); }); }); - it('errors if username exists in medic db', done => { + it('errors if username exists in medic db', () => { sinon.stub(db.users, 'get').resolves(); sinon.stub(db.medic, 'get').resolves('jane lives here too.'); const insert = sinon.stub(db.medic, 'put'); - service.createUser(userData).catch(err => { + return service.createUser(userData).catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.message).to.equal('Username "x" already taken.'); chai.expect(err.message.translationKey).to.equal('username.taken'); chai.expect(err.message.translationParams).to.have.property('username'); chai.expect(insert.callCount).to.equal(0); - done(); }); }); @@ -722,23 +677,24 @@ describe('Users service', () => { describe('setContactParent', () => { it('resolves contact parent in waterfall', () => { - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); + service.__set__('hasParent', sinon.stub().resolves()); + service.__set__('createContact', sinon.stub().resolves()); + service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); + service.__set__('createUser', sinon.stub().resolves()); + sinon.stub(places, 'getPlace').resolves({ _id: 'a', biz: 'marquee' }); - sinon.stub(service, '_hasParent').returns(true); - sinon.stub(service, '_createContact').resolves(); - sinon.stub(service, '_storeUpdatedPlace').resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); return service.createUser(userData).then(() => { - chai.expect(service._createContact.args[0][0].contact.parent).to.deep.equal({ _id: 'a' }); + chai.expect(service.__get__('createContact').args[0][0].contact.parent).to.deep.equal({ _id: 'a' }); }); }); - it('fails validation if contact is not in place using id', done => { + it('fails validation if contact is not in place using id', () => { const userData = { username: 'x', password: COMPLEX_PASSWORD, @@ -746,8 +702,8 @@ describe('Users service', () => { contact: 'def', type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); sinon.stub(db.medic, 'get').resolves({ _id: 'def', type: 'person', @@ -757,11 +713,10 @@ describe('Users service', () => { } }); sinon.stub(people, 'isAPerson').returns(true); - service.createUser(userData).catch(err => { + return service.createUser(userData).catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.message).to.equal('Contact is not within place.'); chai.expect(err.message.translationKey).to.equal('configuration.user.place.contact'); - done(); }); }); @@ -773,8 +728,8 @@ describe('Users service', () => { contact: 'def', type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); - sinon.stub(service, '_createPlace').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); sinon.stub(db.medic, 'get').resolves({ _id: 'def', type: 'person', @@ -784,12 +739,12 @@ describe('Users service', () => { } }); sinon.stub(people, 'isAPerson').returns(true); - sinon.stub(service, '_createContact').resolves(); - sinon.stub(service, '_storeUpdatedPlace').resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('createContact', sinon.stub().resolves()); + service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); return service.createUser(userData).then(() => { - chai.expect(service._createContact.args[0][0].contact).to.equal('def'); + chai.expect(service.__get__('createContact').args[0][0].contact).to.equal('def'); }); }); @@ -805,7 +760,7 @@ describe('Users service', () => { contact: { name: 'mickey' }, type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); sinon.stub(places, 'getOrCreatePlace').resolves({ _id: 'place_id', _rev: 1, @@ -820,8 +775,8 @@ describe('Users service', () => { }); sinon.stub(db.medic, 'get').resolves(place); sinon.stub(db.medic, 'put').resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); return service.createUser(userData).then(() => { chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); @@ -842,7 +797,8 @@ describe('Users service', () => { contact: { name: 'mickey' }, type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); + + service.__set__('validateNewUsername', sinon.stub().resolves()); const placeRev1 = { _id: 'place_id', _rev: 1, name: 'x', parent: 'parent' }; const placeRev2 = { _id: 'place_id', _rev: 2, name: 'x', parent: 'parent', place_id: 'aaaa' }; @@ -858,8 +814,9 @@ describe('Users service', () => { sinon.stub(db.medic, 'put') .onCall(0).rejects({ status: 409, reason: 'conflict' }) .onCall(1).resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); + return service.createUser(userData).then(() => { chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); @@ -883,7 +840,7 @@ describe('Users service', () => { contact: { name: 'mickey' }, type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); const placeRev1 = { _id: 'place_id', _rev: 1, name: 'x', parent: 'parent' }; const placeRev2 = { _id: 'place_id', _rev: 2, name: 'x', parent: 'parent', place_id: 'aaaa' }; @@ -902,8 +859,8 @@ describe('Users service', () => { .onCall(1).rejects({ status: 409, reason: 'conflict' }) .onCall(2).rejects({ status: 409, reason: 'conflict' }) .onCall(3).resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); return service.createUser(userData).then(() => { chai.expect(userData.contact).to.deep.equal({ _id: 'b', name: 'mickey' }); chai.expect(userData.place.contact).to.deep.equal({ _id: 'b' }); @@ -927,7 +884,8 @@ describe('Users service', () => { contact: { name: 'mickey' }, type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); + + service.__set__('validateNewUsername', sinon.stub().resolves()); const placeRev1 = { _id: 'place_id', _rev: 1, name: 'x', parent: 'parent' }; const placeRev2 = { _id: 'place_id', _rev: 2, name: 'x', parent: 'parent', place_id: 'aaaa' }; @@ -951,8 +909,8 @@ describe('Users service', () => { .onCall(2).rejects(conflictErr) .onCall(3).rejects(conflictErr) .onCall(4).resolves(); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); return service .createUser(userData) .then(() => { throw 'should have thrown'; }) @@ -967,7 +925,7 @@ describe('Users service', () => { chai.expect(db.medic.put.args[1]).to.deep.equal([{ _id: 'place_id', _rev: 2, name: 'x', contact: { _id: 'b' }, parent: 'parent', place_id: 'aaaa', }]); - chai.expect(service._createUser.callCount).to.equal(0); + chai.expect(service.__get__('createUser').callCount).to.equal(0); }); }); @@ -979,7 +937,7 @@ describe('Users service', () => { contact: { name: 'mickey' }, type: 'national-manager' }; - sinon.stub(service, '_validateNewUsername').resolves(); + service.__set__('validateNewUsername', sinon.stub().resolves()); const place = { _id: 'place_id', _rev: 1, name: 'x', parent: 'parent' }; sinon.stub(places, 'getOrCreatePlace').resolves(place); @@ -990,8 +948,8 @@ describe('Users service', () => { }); sinon.stub(db.medic, 'get').resolves(place); sinon.stub(db.medic, 'put').rejects({ status: 400, reason: 'not-a-conflict' }); - sinon.stub(service, '_createUser').resolves(); - sinon.stub(service, '_createUserSettings').resolves(); + service.__set__('createUser', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); return service .createUser(userData) .then(() => { throw 'should have thrown'; }) @@ -1003,7 +961,7 @@ describe('Users service', () => { chai.expect(db.medic.put.args[0]).to.deep.equal([{ _id: 'place_id', _rev: 1, name: 'x', contact: { _id: 'b' }, parent: 'parent', }]); - chai.expect(service._createUser.callCount).to.equal(0); + chai.expect(service.__get__('createUser').callCount).to.equal(0); }); }); @@ -1011,79 +969,72 @@ describe('Users service', () => { describe('updateUser', () => { - it('errors if place, type and password is undefined', done => { - service.updateUser('paul', {}, true).catch(err => { + it('errors if place, type and password is undefined', () => { + return service.updateUser('paul', {}, true).catch(err => { chai.expect(err.code).to.equal(400); - done(); }); }); - it('errors on unknown property', done => { - service.updateUser('paul', {foo: 'bar'}, true).catch(err => { + it('errors on unknown property', () => { + return service.updateUser('paul', {foo: 'bar'}, true).catch(err => { chai.expect(err.code).to.equal(400); - done(); }); }); - it('fails if place fetch fails', done => { + it('fails if place fetch fails', () => { const data = { place: 'x' }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); - sinon.stub(places, 'getPlace').returns(Promise.reject('Not today pal.')); + service.__set__('validateUser', sinon.stub().resolves()); + service.__set__('validateUserSettings', sinon.stub().resolves()); + sinon.stub(places, 'getPlace').rejects('Not today pal.'); const update = sinon.stub(db.medic, 'put'); - service.updateUser('paul', data, true).catch(() => { + return service.updateUser('paul', data, true).catch(() => { chai.expect(update.callCount).to.equal(0); - done(); }); }); - it('fails if user not found', done => { + it('fails if user not found', () => { const data = { type: 'x' }; - sinon.stub(service, '_validateUser').returns(Promise.reject('not found')); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().rejects('not found')); + service.__set__('validateUserSettings', sinon.stub().resolves({})); const update = sinon.stub(db.medic, 'put'); - service.updateUser('paul', data, true).catch(() => { + return service.updateUser('paul', data, true).catch(() => { chai.expect(update.callCount).to.equal(0); - done(); }); }); - it('fails if user settings not found', done => { + it('fails if user settings not found', () => { const data = { type: 'x' }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').returns(Promise.reject('too rainy today')); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().rejects('too rainy today')); const update = sinon.stub(db.medic, 'put'); - service.updateUser('paul', data, true).catch(() => { + return service.updateUser('paul', data, true).catch(() => { chai.expect(update.callCount).to.equal(0); - done(); }); }); - it('fails if users db insert fails', done => { - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + it('fails if users db insert fails', () => { + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').resolves(); sinon.stub(db.users, 'put').returns(Promise.reject('shiva was here')); - service.updateUser('georgi', {type: 'x'}, true).catch(err => { + return service.updateUser('georgi', {type: 'x'}, true).catch(err => { chai.expect(err).to.equal('shiva was here'); - done(); }); }); - it('fails if medic db insert fails', done => { - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + it('fails if medic db insert fails', () => { + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').returns(Promise.reject('shiva strikes again')); sinon.stub(db.users, 'put').resolves({}); - service.updateUser('georgi', {type: 'x'}, true).catch(err => { + return service.updateUser('georgi', {type: 'x'}, true).catch(err => { chai.expect(err).to.equal('shiva strikes again'); - done(); }); }); @@ -1091,8 +1042,8 @@ describe('Users service', () => { const data = { type: 'x' }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); return service.updateUser('paul', data, true).then(() => { @@ -1105,8 +1056,8 @@ describe('Users service', () => { const data = { password: COMPLEX_PASSWORD }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); return service.updateUser('paul', data, true).then(() => { @@ -1119,8 +1070,8 @@ describe('Users service', () => { const data = { place: 'x' }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(places, 'getPlace').resolves(); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); @@ -1134,8 +1085,8 @@ describe('Users service', () => { const data = { roles: [ 'rebel' ] }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); sinon.stub(auth, 'isOffline').withArgs(['rebel']).returns(false); @@ -1151,8 +1102,8 @@ describe('Users service', () => { const data = { roles: [ 'rebel' ] }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); sinon.stub(auth, 'isOffline').withArgs(['rebel']).returns(true); @@ -1168,8 +1119,8 @@ describe('Users service', () => { const data = { password: COMPLEX_PASSWORD }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(places, 'getPlace').resolves(); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); @@ -1180,37 +1131,35 @@ describe('Users service', () => { }); }); - it('returns error if short password', done => { + it('returns error if short password', () => { const data = { password: 'short' }; sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); - service.updateUser('paul', data, true).catch(err => { + return service.updateUser('paul', data, true).catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.translationKey).to.equal('password.length.minimum'); chai.expect(err.message.translationParams).to.have.property('minimum'); chai.expect(err.message.message).to.equal('The password must be at least 8 characters long.'); chai.expect(db.medic.put.callCount).to.equal(0); chai.expect(db.users.put.callCount).to.equal(0); - done(); }); }); - it('returns error if weak password', done => { + it('returns error if weak password', () => { const data = { password: 'aaaaaaaa' }; sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); - service.updateUser('paul', data, true).catch(err => { + return service.updateUser('paul', data, true).catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.translationKey).to.equal('password.weak'); chai.expect(err.message.message) .to.equal('The password is too easy to guess. Include a range of types of characters to increase the score.'); chai.expect(db.medic.put.callCount).to.equal(0); chai.expect(db.users.put.callCount).to.equal(0); - done(); }); }); @@ -1218,8 +1167,8 @@ describe('Users service', () => { const data = { place: 'paris' }; - sinon.stub(service, '_validateUser').resolves({ facility_id: 'maine' }); - sinon.stub(service, '_validateUserSettings').resolves({ facility_id: 'maine' }); + service.__set__('validateUser', sinon.stub().resolves({ facility_id: 'maine' })); + service.__set__('validateUserSettings', sinon.stub().resolves({ facility_id: 'maine' })); sinon.stub(places, 'getPlace').resolves(); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); @@ -1236,14 +1185,14 @@ describe('Users service', () => { place: null, contact: null }; - sinon.stub(service, '_validateUser').resolves({ + service.__set__('validateUser', sinon.stub().resolves({ facility_id: 'maine', roles: ['mm-online'] - }); - sinon.stub(service, '_validateUserSettings').resolves({ + })); + service.__set__('validateUserSettings', sinon.stub().resolves({ facility_id: 'maine', contact_id: 1 - }); + })); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); return service.updateUser('paul', data, true).then(() => { @@ -1264,16 +1213,16 @@ describe('Users service', () => { type: 'rambler', password: COMPLEX_PASSWORD }; - sinon.stub(service, '_validateUser').resolves({ + service.__set__('validateUser', sinon.stub().resolves({ facility_id: 'maine', roles: ['bartender'], shoes: 'dusty boots' - }); - sinon.stub(service, '_validateUserSettings').resolves({ + })); + service.__set__('validateUserSettings', sinon.stub().resolves({ facility_id: 'maine', phone: '123', known: false - }); + })); sinon.stub(places, 'getPlace').resolves(); sinon.stub(db.medic, 'put').resolves({}); sinon.stub(db.users, 'put').resolves({}); @@ -1303,16 +1252,16 @@ describe('Users service', () => { roles: ['chp'], password: COMPLEX_PASSWORD }; - sinon.stub(service, '_validateUser').resolves({ + service.__set__('validateUser', sinon.stub().resolves({ facility_id: 'maine', roles: ['chp'], shoes: 'dusty boots' - }); - sinon.stub(service, '_validateUserSettings').resolves({ + })); + service.__set__('validateUserSettings', sinon.stub().resolves({ facility_id: 'maine', phone: '123', known: false - }); + })); sinon.stub(config, 'get').returns({ chp: { offline: true } }); sinon.stub(places, 'getPlace').resolves(); sinon.stub(db.medic, 'put').resolves({}); @@ -1332,8 +1281,8 @@ describe('Users service', () => { const data = { fullname: 'George' }; - sinon.stub(service, '_validateUser').resolves({}); - sinon.stub(service, '_validateUserSettings').resolves({}); + service.__set__('validateUser', sinon.stub().resolves({})); + service.__set__('validateUserSettings', sinon.stub().resolves({})); sinon.stub(db.medic, 'put').resolves({ id: 'abc', rev: '1-xyz' }); sinon.stub(db.users, 'put').resolves({ id: 'def', rev: '1-uvw' }); return service.updateUser('georgi', data, true).then(resp => { @@ -1352,49 +1301,46 @@ describe('Users service', () => { describe('validateNewUsername', () => { - it('fails if a user already exists with that name', done => { + it('fails if a user already exists with that name', () => { const usersGet = sinon.stub(db.users, 'get').resolves({ id: 'abc', rev: '1-xyz' }); sinon.stub(db.medic, 'get').returns(Promise.reject({ status: 404 })); - service._validateNewUsername('georgi').catch(err => { + return service.__get__('validateNewUsername')('georgi').catch(err => { chai.expect(usersGet.callCount).to.equal(1); chai.expect(usersGet.args[0][0]).to.equal('org.couchdb.user:georgi'); chai.expect(err.code).to.equal(400); chai.expect(err.message.message).to.equal('Username "georgi" already taken.'); chai.expect(err.message.translationKey).to.equal('username.taken'); chai.expect(err.message.translationParams).to.have.property('username'); - done(); }); }); - it('fails if a user settings already exists with that name', done => { + it('fails if a user settings already exists with that name', () => { sinon.stub(db.users, 'get').returns(Promise.reject({ status: 404 })); const medicGet = sinon.stub(db.medic, 'get').resolves({ id: 'abc', rev: '1-xyz' }); - service._validateNewUsername('georgi').catch(err => { + return service.__get__('validateNewUsername')('georgi').catch(err => { chai.expect(medicGet.callCount).to.equal(1); chai.expect(medicGet.args[0][0]).to.equal('org.couchdb.user:georgi'); chai.expect(err.code).to.equal(400); chai.expect(err.message.message).to.equal('Username "georgi" already taken.'); chai.expect(err.message.translationKey).to.equal('username.taken'); chai.expect(err.message.translationParams).to.have.property('username'); - done(); }); }); - it('fails if username contains invalid characters', done => { - service._validateNewUsername('^_^').catch(err => { + it('fails if username contains invalid characters', () => { + return service.__get__('validateNewUsername')('^_^').catch(err => { chai.expect(err.code).to.equal(400); chai.expect(err.message.message).to.equal( 'Invalid user name. Valid characters are lower case letters, numbers, underscore (_), and hyphen (-).' ); chai.expect(err.message.translationKey).to.equal('username.invalid'); - done(); }); }); it('passes if no user exists', () => { sinon.stub(db.users, 'get').returns(Promise.reject({ status: 404 })); sinon.stub(db.medic, 'get').returns(Promise.reject({ status: 404 })); - return service._validateNewUsername('georgi'); + return service.__get__('validateNewUsername')('georgi'); }); }); @@ -1473,5 +1419,362 @@ describe('Users service', () => { }); }); + describe('create a user with token_login', () => { + it('should require a phone number', () => { + const user = { + username: 'sally', + roles: ['a', 'b'], + token_login: true, + }; + + const tokenLoginConfig = { translation_key: 'sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('url'); + sinon.stub(auth, 'isOffline').returns(false); + + return service.createUser(user) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.nested.include({ + code: 400, + 'message.message': 'Missing required fields: phone', + }); + }); + }); + + it('should require a valid phone number', () => { + const user = { + username: 'sally', + roles: ['a', 'b'], + phone: '123', + token_login: true, + }; + + const tokenLoginConfig = { translation_key: 'sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('url'); + sinon.stub(auth, 'isOffline').returns(false); + + return service.createUser(user) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.nested.include({ + code: 400, + 'message.message': 'A valid phone number is required for SMS login.', + }); + }); + }); + + it('should normalize phone number and change password (if provided)', () => { + const tokenLoginConfig = { message: 'sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns(''); + + sinon.stub(auth, 'isOffline').returns(false); + + const user = { + username: 'sally', + roles: ['a', 'b'], + phone: '+40 755 69-69-69', + password: 'random', + token_login: true, + }; + + sinon.stub(db.medic, 'put').withArgs(sinon.match({ _id: 'org.couchdb.user:sally' })) + .resolves({ id: 'org.couchdb.user:sally' }); + sinon.stub(db.users, 'put').withArgs(sinon.match({ _id: 'org.couchdb.user:sally' })) + .resolves({ id: 'org.couchdb.user:sally' }); + + sinon.stub(db.users, 'get').withArgs('org.couchdb.user:sally') + .onCall(0).rejects({ status: 404 }) + .onCall(1).resolves({ + _id: 'org.couchdb.user:sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + name: 'sally', + }); + sinon.stub(db.medic, 'get').withArgs('org.couchdb.user:sally') + .onCall(0).rejects({ status: 404 }) + .onCall(1).resolves({ + _id: 'org.couchdb.user:sally', + type: 'user-settings', + roles: ['a', 'b', 'mm-online'], + phone: '+40755696969', + name: 'sally', + }); + + sinon.stub(db.medic, 'allDocs').resolves({ rows: [{ error: 'not_found' }] }); + + return service.createUser(user, 'http://realhost').then(response => { + chai.expect(response).to.deep.equal({ + user: { id: 'org.couchdb.user:sally', rev: undefined }, + 'user-settings': { id: 'org.couchdb.user:sally', rev: undefined }, + token_login: { expiration_date: oneDayInMS }, + }); + chai.expect(db.medic.put.callCount).to.equal(3); + chai.expect(db.users.put.callCount).to.equal(2); + + chai.expect(db.medic.put.args[0]).to.deep.equal([{ + _id: 'org.couchdb.user:sally', + name: 'sally', + type: 'user-settings', + phone: '+40755696969', // normalized phone + roles: ['a', 'b', 'mm-online'], + }]); + + chai.expect(db.users.put.args[0][0]).to.deep.include({ + _id: 'org.couchdb.user:sally', + name: 'sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + chai.expect(db.users.put.args[0][0].password).not.to.equal('random'); + chai.expect(db.users.put.args[0][0].password.length).to.equal(20); + + chai.expect(db.medic.put.args[2][0]).to.deep.equal({ + _id: 'org.couchdb.user:sally', + name: 'sally', + type: 'user-settings', + phone: '+40755696969', // normalized phone + roles: ['a', 'b', 'mm-online'], + token_login: { + active: true, + expiration_date: oneDayInMS, + }, + }); + + chai.expect(db.users.put.args[1][0]).to.deep.include({ + _id: 'org.couchdb.user:sally', + name: 'sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + + chai.expect(db.users.put.args[1][0].token_login).to.deep.include({ + active: true, + expiration_date: oneDayInMS, + }); + const token = db.users.put.args[1][0].token_login.token; + chai.expect(token.length).to.equal(64); + + chai.expect(db.medic.put.args[1][0]).to.deep.nested.include({ + _id: `token:login:${token}`, + type: 'token_login', + reported_date: 0, + user: 'org.couchdb.user:sally', + 'tasks[0].messages[0].to': '+40755696969', + 'tasks[0].messages[0].message': 'sms', + 'tasks[0].state': 'pending', + 'tasks[1].messages[0].to': '+40755696969', + 'tasks[1].messages[0].message': `http://realhost/medic/login/token/${token}`, + 'tasks[1].state': 'pending', + }); + }); + }); + }); + + describe('update a user with token_login', () => { + it('should require a phone number', () => { + const updates = { token_login: true }; + + const tokenLoginConfig = { translation_key: 'sms', enabled: true}; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('url'); + sinon.stub(auth, 'isOffline').returns(false); + + sinon.stub(db.medic, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user-settings', + roles: ['a', 'b', 'mm-online'], + }); + sinon.stub(db.users, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + + return service.updateUser('sally', updates) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.nested.include({ + code: 400, + 'message.message': 'A valid phone number is required for SMS login.', + }); + }); + }); + + it('should require a valid phone number', () => { + const updates = { token_login: true, phone: '456' }; + const tokenLoginConfig = { translation_key: 'sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('url'); + sinon.stub(auth, 'isOffline').returns(false); + + sinon.stub(db.medic, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user-settings', + roles: ['a', 'b', 'mm-online'], + phone: '123', + }); + sinon.stub(db.users, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + + return service.updateUser('sally', updates) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.nested.include({ + code: 400, + 'message.message': 'A valid phone number is required for SMS login.', + }); + }); + }); + + it('should normalize phone number and change password', () => { + const tokenLoginConfig = { message: 'the sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('http://host'); + sinon.stub(auth, 'isOffline').returns(false); + + const updates = { token_login: true, phone: '+40 755 89-89-89' }; + sinon.stub(db.medic, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user-settings', + roles: ['a', 'b', 'mm-online'], + phone: '123', + }); + sinon.stub(db.users, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + sinon.stub(db.medic, 'put').withArgs(sinon.match({ _id: 'org.couchdb.user:sally' })) + .resolves({ id: 'org.couchdb.user:sally' }); + sinon.stub(db.users, 'put').withArgs(sinon.match({ _id: 'org.couchdb.user:sally' })) + .resolves({ id: 'org.couchdb.user:sally' }); + + sinon.stub(db.medic, 'allDocs').resolves({ rows: [{ error: 'not_found' }] }); + clock.tick(5000); + + return service.updateUser('sally', updates, true, 'https://realhost').then(response => { + chai.expect(response).to.deep.equal({ + user: { id: 'org.couchdb.user:sally', rev: undefined }, + 'user-settings': { id: 'org.couchdb.user:sally', rev: undefined }, + token_login: { expiration_date: 5000 + oneDayInMS }, + }); + + chai.expect(db.medic.put.callCount).to.equal(3); + chai.expect(db.medic.put.args[2][0]).to.deep.equal({ + _id: 'org.couchdb.user:sally', + name: 'sally', + type: 'user-settings', + phone: '+40755898989', // normalized phone + roles: ['a', 'b', 'mm-online'], + token_login: { + active: true, + expiration_date: 5000 + oneDayInMS, + }, + }); + + chai.expect(db.users.put.callCount).to.equal(2); + chai.expect(db.users.put.args[1][0]).to.deep.include({ + _id: 'org.couchdb.user:sally', + name: 'sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + + chai.expect(db.users.put.args[0][0].token_login).to.deep.include({ + active: true, + expiration_date: 5000 + oneDayInMS, + }); + chai.expect(db.users.put.args[0][0].password.length).to.equal(20); + + const token = db.users.put.args[0][0].token_login.token; + chai.expect(token.length).to.equal(64); + + chai.expect(db.medic.put.args[1][0]).to.deep.nested.include({ + _id: `token:login:${token}`, + type: 'token_login', + user: 'org.couchdb.user:sally', + reported_date: 5000, + 'tasks[0].messages[0].to': '+40755898989', + 'tasks[0].messages[0].message': 'the sms', + 'tasks[0].state': 'pending', + 'tasks[1].messages[0].to': '+40755898989', + 'tasks[1].messages[0].message': `http://host/medic/login/token/${token}`, + 'tasks[1].state': 'pending', + }); + }); + }); + + it('should require password when removing token_login', () => { + const tokenLoginConfig = { message: 'the sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('http://host'); + sinon.stub(auth, 'isOffline').returns(false); + + const updates = { token_login: false }; + sinon.stub(db.medic, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user-settings', + roles: ['a', 'b', 'mm-online'], + token_login: { active: true }, + }); + sinon.stub(db.users, 'get').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + token_login: { active: true }, + }); + + return service.updateUser('sally', updates) + .then(() => chai.assert.fail('Should have thrown')) + .catch(err => { + chai.expect(err).to.deep.nested.include({ + code: 400, + 'message.message': 'Password is required when disabling token login.', + }); + }); + }); + + it('should not require password when not changing token_login', () => { + const tokenLoginConfig = { message: 'the sms', enabled: true }; + sinon.stub(config, 'get') + .withArgs('token_login').returns(tokenLoginConfig) + .withArgs('app_url').returns('http://host'); + sinon.stub(auth, 'isOffline').returns(false); + + const updates = { token_login: false }; + sinon.stub(db.medic, 'get').withArgs('org.couchdb.user:sally').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user-settings', + roles: ['a', 'b', 'mm-online'], + }); + sinon.stub(db.users, 'get').withArgs('org.couchdb.user:sally').resolves({ + _id: 'org.couchdb.user:sally', + type: 'user', + roles: ['a', 'b', 'mm-online'], + }); + sinon.stub(db.users, 'put').resolves({ id: 'org.couchdb.user:sally' }); + sinon.stub(db.medic, 'put').resolves({ id: 'org.couchdb.user:sally' }); + + return service.updateUser('sally', updates).then(response => { + chai.expect(response).to.deep.equal({ + user: { id: 'org.couchdb.user:sally', rev: undefined }, + 'user-settings': { id: 'org.couchdb.user:sally', rev: undefined }, + }); + }); + }); + }); }); diff --git a/ddocs/medic/_attachments/translations/messages-en.properties b/ddocs/medic/_attachments/translations/messages-en.properties index d7b255a30e3..f6a1a21c8da 100644 --- a/ddocs/medic/_attachments/translations/messages-en.properties +++ b/ddocs/medic/_attachments/translations/messages-en.properties @@ -404,6 +404,16 @@ clinic.field.parent = Health center close = Close configuration.authorization = Roles & permissions configuration.date.format.help = Moment.js format +configuration.enable.token.login = Enable login via SMS link +configuration.enable.token.login.help = Upon saving, the user will receive login instructions and a login link via SMS. Requires the user to have a valid phone number. +configuration.enable.token.login.phone = A valid phone number is required for SMS login. +configuration.enable.token.login.enabled.active = SMS login is enabled for this user. The link expires on {{date}}. +configuration.enable.token.login.enabled.expired = SMS login is enabled for this user. The link expired on {{date}}. +configuration.enable.token.login.enabled.inactive = SMS login is enabled for this user. The link was accessed on {{date}}. +configuration.enable.token.login.disable = Disable login via SMS. +configuration.enable.token.login.refresh = Regenerate and re-send login SMS. +configuration.enable.token.login.no.modify = Make no changes to SMS login. +configuration.enable.token.login.refresh.help = Disabling or regenerating the login SMS will result in changing the user's password, which will result in the user being logged out. configuration.messagetest = Message test configuration.permission = Permission configuration.permissions = Permissions @@ -712,6 +722,14 @@ invalid.query = That query is invalid. Read our advanced search help page for mo login = Login login.error = Unexpected error while logging in. Please try again. login.incorrect = Incorrect user name or password. Please try again. +login.token.missing = Your link is missing required information. Contact admin to receive a new link. +login.token.invalid = Your link is invalid. Contact admin to receive a new link. +login.token.expired = Your link has expired. Contact admin to receive a new link. +login.token.timeout = A timeout occurred while trying to log in. Please try again. If this happens again, contact admin to receive a new link. +login.token.general.error = Something went wrong when processing your request. Please try again. If this happens again, contact admin to receive a new link. +login.token.redirect.login.info = If you know your username and password, click on the following link to load the login page. +login.token.redirect.login = Login page +login.token.loading = Logging you in. Please wait. message.characters.left = {{characters}} characters left message.characters.left.multiple = {{messages}} SMS, {{characters}} characters left message.characters.left.multiple.many = {{messages}} SMS, {{characters}} characters left - too many messages\! @@ -1357,4 +1375,4 @@ year = Year year.plural = Years years = years yes = Yes -yesterday = yesterday \ No newline at end of file +yesterday = yesterday diff --git a/ddocs/medic/validate_doc_update.js b/ddocs/medic/validate_doc_update.js index 5939dfad63b..3a601e91862 100644 --- a/ddocs/medic/validate_doc_update.js +++ b/ddocs/medic/validate_doc_update.js @@ -9,7 +9,7 @@ */ function(newDoc, oldDoc, userCtx, secObj) { - var ADMIN_ONLY_TYPES = [ 'form', 'translations' ]; + var ADMIN_ONLY_TYPES = [ 'form', 'translations', 'token_login' ]; var ADMIN_ONLY_IDS = [ 'resources', 'service-worker-meta', 'zscore-charts', 'settings', 'branding', 'partners' ]; var _err = function(msg) { diff --git a/scripts/e2e/e2e-servers.js b/scripts/e2e/e2e-servers.js index 8fcf32036b5..940b004a1b7 100755 --- a/scripts/e2e/e2e-servers.js +++ b/scripts/e2e/e2e-servers.js @@ -83,6 +83,11 @@ if(!fs.existsSync('tests/logs')) { const app = express(); +const started = { + api: false, + sentinel: false, +}; + app.post('/:server/:action', (req, res) => { const { server, action } = req.params; let p = Promise.resolve(); @@ -101,11 +106,11 @@ app.post('/:server/:action', (req, res) => { if (['start', 'restart'].includes(action)) { if (['api', 'all'].includes(server)) { console.log('Starting API...'); - p = p.then(() => startServer('api', true)); + p = p.then(() => startServer('api', started.api)).then(() => started.api = true); } if (['sentinel', 'all'].includes(server)) { console.log('Starting Sentinel...'); - p = p.then(() => startServer('sentinel', true)); + p = p.then(() => startServer('sentinel', started.sentinel)).then(() => started.sentinel = true); } } diff --git a/sentinel/src/config.js b/sentinel/src/config.js index a139450ed40..89b217f5e08 100644 --- a/sentinel/src/config.js +++ b/sentinel/src/config.js @@ -60,6 +60,8 @@ const initConfig = () => { .then(doc => { _.defaults(doc.settings, DEFAULT_CONFIG); config = doc.settings; + initTransitionLib(); + require('./transitions').loadTransitions(); logger.debug( 'Reminder messages allowed between %s:%s and %s:%s', config.schedule_morning_hours, @@ -67,8 +69,6 @@ const initConfig = () => { config.schedule_evening_hours, config.schedule_evening_minutes ); - initTransitionLib(); - require('./transitions').loadTransitions(); }) .catch(err => { logger.error('%o', err); diff --git a/tests/e2e/api/controllers/login.spec.js b/tests/e2e/api/controllers/login.spec.js new file mode 100644 index 00000000000..412fc2fb8aa --- /dev/null +++ b/tests/e2e/api/controllers/login.spec.js @@ -0,0 +1,225 @@ +const chai = require('chai'); +chai.use(require('chai-shallow-deep-equal')); +const utils = require('../../../utils'); + +let user; +const password = 'passwordSUP3RS3CR37!'; +const parentPlace = { + _id: 'PARENT_PLACE', + type: 'district_hospital', + name: 'Big Parent Hostpital' +}; + +const loginWithData = data => { + const opts = { + path: '/medic/login?aaa=aaa', + method: 'POST', + simple: false, + noAuth: true, + body: data, + followRedirect: false, + }; + return utils.request(opts); +}; + +const loginWithTokenLink = (token = '') => { + const opts = { + path: `/medic/login/token/${token}`, + method: 'POST', + simple: false, + resolveWithFullResponse: true, + noAuth: true, + followRedirect: false, + body: {}, + }; + return utils.request(opts); +}; + +const expectLoginToWork = (response) => { + chai.expect(response).to.include({ statusCode: 302 }); + chai.expect(response.headers['set-cookie']).to.be.an('array'); + chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; + chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + chai.expect(response.body).to.equal('/'); +}; + +const expectLoginToFail = (response) => { + chai.expect(response.headers['set-cookie']).to.be.undefined; + chai.expect(response.statusCode).to.equal(401); +}; + +const getUser = (user) => { + const getUserId = n => `org.couchdb.user:${n}`; + const opts = { path: `/_users/${getUserId(user.username)}` }; + return utils.request(opts); +}; + +const setupTokenLoginSettings = (configureAppUrl = false) => { + const settings = { token_login: { translation_key: 'login_sms', enabled: true } }; + if (configureAppUrl) { + settings.app_url = utils.getOrigin(); + } + return utils + .updateSettings(settings, 'api') + .then(() => utils.addTranslations('en', { login_sms: 'Instructions sms' })); +}; + +describe('login', () => { + beforeAll(() => utils.saveDoc(parentPlace)); + afterAll(() => utils.revertDb()); + + beforeEach(() => { + user = { + username: 'testuser', + password, + roles: ['district_admin'], + place: { + _id: 'fixture:test', + type: 'health_center', + name: 'TestVille', + parent: 'PARENT_PLACE' + }, + contact: { + _id: 'fixture:user:testuser', + name: 'Bob' + }, + }; + }); + afterEach(() => utils.deleteUsers([user]).then(() => utils.revertDb(['PARENT_PLACE'], []))); + + describe('default login', () => { + it('should fail with no data', () => { + return loginWithData({ user: '', password: '' }) + .then(response => expectLoginToFail(response)); + }); + + it('should fail with random credentials', () => { + return loginWithData({ user: 'random', password: 'random' }) + .then(response => expectLoginToFail(response)); + }); + + it('should fail with wrong credentials', () => { + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + return utils + .request(opts) + .then(() => loginWithData({ user: user.username, password: 'random' })) + .then(response => expectLoginToFail(response)); + }); + + it('should succeed with right credentials', () => { + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + return utils + .request(opts) + .then(() => loginWithData({ user: user.username, password })) + .then(response => expectLoginToWork(response)); + }); + }); + + describe('token login', () => { + it('should fail with invalid url', () => { + return setupTokenLoginSettings() + .then(() => loginWithTokenLink()) + .then(response => chai.expect(response).to.deep.include({ statusCode: 401 })); + }); + + it('should fail with invalid data', () => { + return setupTokenLoginSettings() + .then(() => loginWithTokenLink('token')) + .then(response => expectLoginToFail(response)); + }); + + it('should fail with mismatched data', () => { + user.phone = '+40755565656'; + user.token_login = true; + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + const optsEdit = { + path: `/api/v1/users/${user.username}`, + method: 'POST', + body: { token_login: true }, + }; + let firstToken; + return setupTokenLoginSettings() + .then(() => utils.request(opts)) + .then(() => loginWithData({ user: user.username, password })) + .then(response => expectLoginToFail(response)) + .then(() => getUser(user)) + .then(user => firstToken = user.token_login.token) + .then(() => utils.request(optsEdit)) // generate a new token + .then(() => loginWithTokenLink(firstToken)) + .then(response => expectLoginToFail(response)); + }); + + it('should fail with expired data', () => { + user.phone = '+40755565656'; + user.token_login = true; + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + let tokenLogin; + return setupTokenLoginSettings() + .then(() => utils.request(opts)) + .then(() => getUser(user)) + .then(user => { + // cheat and set the expiration date in the past + user.token_login.expiration_date = 0; + tokenLogin = user.token_login; + return utils.request({ method: 'PUT', path: `/_users/${user._id}`, body: user }); + }) + .then(() => loginWithTokenLink(tokenLogin.token)) + .then(response => expectLoginToFail(response)); + }); + + it('should succeed with correct data', () => { + user.phone = '+40755565656'; + user.token_login = true; + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + let tokenLogin; + return setupTokenLoginSettings() + .then(() => utils.request(opts)) + .then(() => getUser(user)) + .then(user => tokenLogin = user.token_login) + .then(() => loginWithTokenLink(tokenLogin.token)) + .then(response => expectLoginToWork(response)) + .then(() => loginWithTokenLink(tokenLogin.token)) + .then(response => expectLoginToFail(response)); // fails after being activated the 1st time + }); + + it('should succeed with correct data and configured app_url', () => { + user.phone = '+40755565656'; + user.token_login = true; + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user, + headers: { 'Host': 'definitely-not-our-host.com' }, + }; + let tokenLogin; + return setupTokenLoginSettings(true) + .then(() => utils.request(opts)) + .then(() => getUser(user)) + .then(user => tokenLogin = user.token_login) + .then(() => loginWithTokenLink(tokenLogin.token)) + .then(response => expectLoginToWork(response)) + .then(() => loginWithTokenLink(tokenLogin.token)) + .then(response => expectLoginToFail(response)); // fails after being activated the 1st time + }); + }); +}); diff --git a/tests/e2e/api/controllers/users.js b/tests/e2e/api/controllers/users.js index 4ab4c50b019..1a4b879854f 100644 --- a/tests/e2e/api/controllers/users.js +++ b/tests/e2e/api/controllers/users.js @@ -4,16 +4,17 @@ const utils = require('../../../utils'); const uuid = require('uuid'); const querystring = require('querystring'); const chai = require('chai'); +chai.use(require('chai-shallow-deep-equal')); const sentinelUtils = require('../../sentinel/utils'); -const user = n => `org.couchdb.user:${n}`; +const getUserId = n => `org.couchdb.user:${n}`; describe('Users API', () => { describe('POST /api/v1/users/{username}', () => { const username = 'test' + new Date().getTime(); const password = 'pass1234!'; const _usersUser = { - _id: user(username), + _id: getUserId(username), type: 'user', name: username, password: password, @@ -30,7 +31,7 @@ describe('Users API', () => { const medicData = [ { - _id: user(username), + _id: getUserId(username), facility_id: null, contact_id: null, name: username, @@ -99,12 +100,12 @@ describe('Users API', () => { })); afterAll(() => - utils.request(`/_users/${user(username)}`) + utils.request(`/_users/${getUserId(username)}`) .then(({_rev}) => utils.request({ - path: `/_users/${user(username)}`, + path: `/_users/${getUserId(username)}`, method: 'PUT', body: { - _id: user(username), + _id: getUserId(username), _rev: _rev, _deleted: true } @@ -119,7 +120,7 @@ describe('Users API', () => { place: newPlaceId } }) - .then(() => utils.getDoc(user(username))) + .then(() => utils.getDoc(getUserId(username))) .then(doc => { expect(doc.facility_id).toBe(newPlaceId); })); @@ -164,7 +165,7 @@ describe('Users API', () => { }, auth: { username, password}, }) - .then(() => utils.getDoc(user(username))) + .then(() => utils.getDoc(getUserId(username))) .then(doc => { expect(doc.fullname).toBe('Awesome Guy'); })); @@ -448,4 +449,681 @@ describe('Users API', () => { }); }); }); -}) ; + + describe('token-login', () => { + let user; + const password = 'passwordSUP3RS3CR37!'; + + const getUser = (user) => { + const opts = { path: `/_users/${getUserId(user.username)}` }; + return utils.request(opts); + }; + const getUserSettings = (user) => { + return utils.requestOnMedicDb({ path: `/${getUserId(user.username)}` }); + }; + + const parentPlace = { + _id: 'PARENT_PLACE', + type: 'district_hospital', + name: 'Big Parent Hostpital' + }; + + beforeAll(() => utils.saveDoc(parentPlace)); + afterAll(() => utils.revertDb()); + + beforeEach(() => { + user = { + username: 'testuser', + password, + roles: ['district_admin'], + place: { + _id: 'fixture:test', + type: 'health_center', + name: 'TestVille', + parent: 'PARENT_PLACE' + }, + contact: { + _id: 'fixture:user:testuser', + name: 'Bob' + }, + }; + }); + afterEach(() => utils.deleteUsers([user]).then(() => utils.revertDb(['PARENT_PLACE'], []))); + + const expectCorrectUser = (user, extra = {}) => { + const defaultProps = { + name: 'testuser', + type: 'user', + roles: ['district_admin'], + facility_id: 'fixture:test', + }; + chai.expect(user).to.shallowDeepEqual(Object.assign(defaultProps, extra)); + }; + const expectCorrectUserSettings = (userSettings, extra = {}) => { + const defaultProps = { + name: 'testuser', + type: 'user-settings', + roles: ['district_admin'], + facility_id: 'fixture:test', + contact_id: 'fixture:user:testuser', + }; + chai.expect(userSettings).to.shallowDeepEqual(Object.assign(defaultProps, extra)); + }; + + const expectPasswordLoginToWork = (user) => { + const opts = { + path: '/login', + method: 'POST', + simple: false, + noAuth: true, + body: { user: user.username, password: user.password }, + followRedirect: false, + }; + + return utils + .requestOnMedicDb(opts) + .then(response => { + chai.expect(response).to.include({ + statusCode: 302, + body: '/', + }); + chai.expect(response.headers['set-cookie']).to.be.an('array'); + chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; + chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + }); + }; + + const expectPasswordLoginToFail = (user) => { + const opts = { + path: '/login', + method: 'POST', + simple: false, + noAuth: true, + body: { user: user.username, password: user.password }, + }; + + return utils + .requestOnMedicDb(opts) + .then(response => { + chai.expect(response).to.deep.include({ statusCode: 401, body: { error: 'Not logged in' } }); + }); + }; + + const expectTokenLoginToSucceed = (url) => { + const opts = { + uri: url, + method: 'POST', + simple: false, + resolveWithFullResponse: true, + noAuth: true, + followRedirect: false, + body: {}, + }; + return utils.request(opts).then(response => { + chai.expect(response).to.include({ statusCode: 302, body: '/' }); + chai.expect(response.headers['set-cookie']).to.be.an('array'); + chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('AuthSession'))).to.be.ok; + chai.expect(response.headers['set-cookie'].find(cookie => cookie.startsWith('userCtx'))).to.be.ok; + }); + }; + + const expectTokenLoginToFail = (url, expired = false) => { + const opts = { + uri: url, + method: 'POST', + simple: false, + noAuth: true, + followRedirect: false, + resolveWithFullResponse: true, + body: {}, + }; + return utils.request(opts).then(response => { + chai.expect(response.headers['set-cookie']).to.be.undefined; + chai.expect(response).to.deep.include({ statusCode: 401, body: { error: expired ? 'expired': 'invalid' } }); + }); + }; + + const expectSendableSms = (doc) => { + const opts = { + path: '/api/sms', + method: 'POST', + body: {}, + }; + + const viewifyMessage = ({ uuid, message, to }) => ({ to, id: uuid, content: message }); + + return utils.request(opts).then(response => { + chai.expect(response.messages).to.be.an('array'); + chai.expect(response.messages.length).to.equal(doc.tasks.length); + chai.expect(response.messages).to.have.deep.members(doc.tasks.map(task => viewifyMessage(task.messages[0]))); + }); + }; + + const getLoginTokenDocId = token => `token:login:${token}`; + + describe('when token-login configuration is missing', () => { + it('should create and update a user correctly w/o token_login', () => { + return utils + .request({ path: '/api/v1/users', method: 'POST', body: user }) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + contact: { id: 'fixture:user:testuser' }, + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + }) + .then(() => expectPasswordLoginToWork(user)) + .then(() => { + const updates = { + roles: ['new_role'], + phone: '12345', + }; + + const opts = { path: `/api/v1/users/${user.username}`, body: updates, method: 'POST' }; + return utils.request(opts); + }) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user, { roles: ['new_role'] }); + expectCorrectUserSettings(userSettings, { roles: ['new_role'], phone: '12345' }); + }) + .then(() => expectPasswordLoginToWork(user)); + }); + + it('should create and update a user correctly with token_login', () => { + user.token_login = true; + + return utils + .request({ path: '/api/v1/users', method: 'POST', body: user }) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + contact: { id: 'fixture:user:testuser' }, + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + chai.expect(user.token_login).to.be.undefined; + chai.expect(userSettings.token_login).to.be.undefined; + }) + .then(() => expectPasswordLoginToWork(user)) + .then(() => { + const updates = { + roles: ['new_role'], + phone: '12345', + token_login: true, + }; + + const opts = { path: `/api/v1/users/${user.username}`, body: updates, method: 'POST' }; + return utils.request(opts); + }) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user, { roles: ['new_role'] }); + expectCorrectUserSettings(userSettings, { roles: ['new_role'], phone: '12345' }); + chai.expect(user.token_login).to.be.undefined; + chai.expect(userSettings.token_login).to.be.undefined; + }) + .then(() => expectPasswordLoginToWork(user)); + }); + }); + + describe('when token-login is configured', () => { + it('should create and update a user correctly w/o token_login', () => { + const settings = { token_login: { translation_key: 'token_login_sms', enabled: true } }; + return utils + .updateSettings(settings, 'api') + .then(() => utils.addTranslations('en', { token_login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + contact: { id: 'fixture:user:testuser' }, + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + chai.expect(user.token_login).to.be.undefined; + chai.expect(userSettings.token_login).to.be.undefined; + }) + .then(() => expectPasswordLoginToWork(user)) + .then(() => { + const updates = { + roles: ['new_role'], + phone: '12345', + }; + + return utils.request({ path: `/api/v1/users/${user.username}`, body: updates, method: 'POST' }); + }) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user, { roles: ['new_role'] }); + expectCorrectUserSettings(userSettings, { roles: ['new_role'], phone: '12345' }); + chai.expect(user.token_login).to.be.undefined; + chai.expect(userSettings.token_login).to.be.undefined; + }) + .then(() => expectPasswordLoginToWork(user)); + }); + + it('should throw an error when phone is missing when creating a user with token_login', () => { + const settings = { token_login: { translation_key: 'token_login_sms', enabled: true } }; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { token_login_sms: 'Instructions sms' })) + .then(() => { + user.token_login = true; + return utils.request({ path: '/api/v1/users', method: 'POST', body: user }); + }) + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err.response).to.shallowDeepEqual({ + statusCode: 400, + body: { code: 400, error: { message: 'Missing required fields: phone' }} + }); + }); + }); + + it('should throw an error when phone is missing when updating a user with token_login', () => { + const settings = { token_login: { translation_key: 'token_login_sms', enabled: true }, app_url: 'https://host/' }; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { token_login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(() => { + user.token_login = true; + user.roles = ['whatever']; + return utils.request({ path: '/api/v1/users', method: 'POST', body: user }); + }) + .then(() => chai.assert.fail('should have thrown')) + .catch(err => { + chai.expect(err.response).to.shallowDeepEqual({ + statusCode: 400, + body: { code: 400, error: { message: 'Missing required fields: phone' }} + }); + + return Promise.all([ getUser(user), getUserSettings(user) ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + chai.expect(user.token_login).to.be.undefined; + chai.expect(userSettings.token_login).to.be.undefined; + }) + .then(() => expectPasswordLoginToWork(user)); + }); + + it('should create a user correctly with token_login', () => { + const settings = { + app_url: utils.getOrigin(), + token_login: { + translation_key: 'token_login_sms', + enabled: true, + } + }; + user.token_login = true; + user.phone = '+40755898989'; + + let tokenUrl; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { token_login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + contact: { id: 'fixture:user:testuser' }, + }); + chai.expect(response.token_login).to.have.keys('expiration_date'); + return Promise.all([ + getUser(user), + getUserSettings(user), + ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + + chai.expect(user.token_login).to.be.ok; + chai.expect(user.token_login).to.have.keys(['active', 'token', 'expiration_date' ]); + chai.expect(user.token_login).to.include({ active: true }); + + chai.expect(userSettings.token_login).to.be.ok; + chai.expect(userSettings.token_login).to.have.keys(['active', 'expiration_date' ]); + + tokenUrl = `${utils.getOrigin()}/medic/login/token/${user.token_login.token}`; + + return utils.getDoc(getLoginTokenDocId(user.token_login.token)); + }) + .then(loginTokenDoc => { + chai.expect(loginTokenDoc).to.include({ + type: 'token_login', + user: 'org.couchdb.user:testuser', + }); + chai.expect(loginTokenDoc.tasks).to.be.ok; + chai.expect(loginTokenDoc.tasks.length).to.equal(2); + chai.expect(loginTokenDoc.tasks).to.shallowDeepEqual([ + { + messages: [{ to: '+40755898989', message: 'Instructions sms' }], + state: 'pending', + }, + { + messages: [{ to: '+40755898989', message: tokenUrl }], + state: 'pending', + } + ]); + + return expectSendableSms(loginTokenDoc); + }) + .then(() => expectPasswordLoginToFail(user)) + .then(() => expectTokenLoginToSucceed(tokenUrl)) + .then(() => Promise.all([ getUser(user), getUserSettings(user) ])) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + + chai.expect(user.token_login).to.be.ok; + chai.expect(user.token_login).to.have.keys([ 'active', 'token', 'expiration_date', 'login_date' ]); + chai.expect(user.token_login.active).to.equal(false); + + chai.expect(userSettings.token_login).to.be.ok; + chai.expect(userSettings.token_login).to.have.keys(['active', 'expiration_date', 'login_date' ]); + chai.expect(userSettings.token_login.active).to.equal(false); + }) + .then(() => expectTokenLoginToFail(tokenUrl)); // fails the 2nd time! + }); + + it('should update a user correctly with token_login', () => { + const settings = { token_login: { translation_key: 'sms_text', enabled: true }, app_url: utils.getOrigin() }; + let tokenUrl; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { sms_text: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(() => { + const updates = { + phone: '+40755696969', + token_login: true, + }; + return utils.request({ path: `/api/v1/users/${user.username}`, method: 'POST', body: updates }); + }) + .then(response => { + chai.expect(response).to.shallowDeepEqual({ + user: { id: getUserId(user.username) }, + 'user-settings': { id: getUserId(user.username) }, + }); + chai.expect(response.token_login).to.have.keys('expiration_date'); + return Promise.all([ + getUser(user), + getUserSettings(user), + ]); + }) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + + chai.expect(user.token_login).to.be.ok; + chai.expect(user.token_login).to.have.keys(['active', 'token', 'expiration_date']); + chai.expect(user.token_login).to.include({ active: true }); + + chai.expect(userSettings.token_login).to.be.ok; + chai.expect(userSettings.token_login).to.have.keys(['active', 'expiration_date' ]); + + tokenUrl = `${utils.getOrigin()}/medic/login/token/${user.token_login.token}`; + + return utils.getDoc(getLoginTokenDocId(user.token_login.token)); + }) + .then(loginTokenDoc => { + chai.expect(loginTokenDoc).to.include({ + type: 'token_login', + user: 'org.couchdb.user:testuser', + }); + chai.expect(loginTokenDoc.tasks).to.be.ok; + chai.expect(loginTokenDoc.tasks.length).to.equal(2); + chai.expect(loginTokenDoc.tasks).to.shallowDeepEqual([ + { + messages: [{ to: '+40755696969', message: 'Instructions sms' }], + state: 'pending', + }, + { + messages: [{ to: '+40755696969', message: tokenUrl }], + state: 'pending', + } + ]); + + return expectSendableSms(loginTokenDoc); + }) + .then(() => expectPasswordLoginToFail(user)) + .then(() => expectTokenLoginToSucceed(tokenUrl)) + .then(() => Promise.all([ getUser(user), getUserSettings(user) ])) + .then(([ user, userSettings ]) => { + expectCorrectUser(user); + expectCorrectUserSettings(userSettings); + + chai.expect(user.token_login).to.be.ok; + chai.expect(user.token_login).to.have.keys([ 'active', 'token', 'expiration_date', 'login_date' ]); + chai.expect(user.token_login.active).to.equal(false); + + chai.expect(userSettings.token_login).to.be.ok; + chai.expect(userSettings.token_login).to.have.keys(['active', 'expiration_date', 'login_date' ]); + chai.expect(userSettings.token_login.active).to.equal(false); + }) + .then(() => expectTokenLoginToFail(tokenUrl)); // fails the 2nd time! + }); + + it('should not re-generate the token on subsequent updates, when token_login not specifically requested', () => { + const settings = { token_login: { translation_key: 'login_sms', enabled: true }, app_url: utils.getOrigin() }; + user.token_login = true; + user.phone = '+40755232323'; + let tokenLogin; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(() => getUser(user)) + .then(user => tokenLogin = user.token_login) + .then(() => { + const updates = { roles: ['whatever'] }; + return utils.request({ path: `/api/v1/users/${user.username}`, method: 'POST', body: updates }); + }) + .then(response => { + chai.expect(response.token_login).to.be.undefined; + }) + .then(() => expectPasswordLoginToFail(user)) + .then(() => Promise.all([ getUser(user), getUserSettings(user) ])) + .then(([ user, userSettings ]) => { + chai.expect(user.token_login).to.deep.equal(tokenLogin); + chai.expect(userSettings.token_login) + .to.deep.equal({ active: true, expiration_date: tokenLogin.expiration_date }); + + return utils.getDoc(getLoginTokenDocId(user.token_login.token)); + }) + .then(loginTokenDoc => { + return expectTokenLoginToSucceed(loginTokenDoc.tasks[1].messages[0].message); + }); + }); + + it('should clear the old SMS tasks when token is re-generated', () => { + const settings = { token_login: { translation_key: 'login_sms', enabled: true }, app_url: utils.getOrigin() }; + user.token_login = true; + user.phone = '+40755242424'; + let firstTokenLogin; + let secondTokenLogin; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(() => getUser(user)) + .then(user => firstTokenLogin = user.token_login) + .then(() => { + const updates = { phone: '+40755989898', token_login: true }; + return utils.request({ path: `/api/v1/users/${user.username}`, method: 'POST', body: updates }); + }) + .then(response => { + chai.expect(response.token_login).to.have.keys('expiration_date'); + return Promise.all([ + getUser(user), + getUserSettings(user), + ]); + }) + .then(([ user, userSettings ]) => { + chai.expect(user.token_login).not.to.deep.equal(firstTokenLogin); + chai.expect(userSettings.token_login) + .to.deep.equal({ active: true, expiration_date: user.token_login.expiration_date }); + + secondTokenLogin = user.token_login; + return utils.getDocs([ + getLoginTokenDocId(firstTokenLogin.token), + getLoginTokenDocId(secondTokenLogin.token), + ]); + }) + .then(([ firstTokenLoginDoc, secondTokenLoginDoc ]) => { + const firstUrl = `${utils.getOrigin()}/medic/login/token/${firstTokenLogin.token}`; + const secondUrl = `${utils.getOrigin()}/medic/login/token/${secondTokenLogin.token}`; + + chai.expect(firstTokenLoginDoc.tasks).to.shallowDeepEqual([ + { state: 'cleared', messages: [{ to: '+40755242424', message: 'Instructions sms' }] }, + { state: 'cleared', messages: [{ to: '+40755242424', message: firstUrl }] }, + ]); + chai.expect(secondTokenLoginDoc.tasks).to.shallowDeepEqual([ + { state: 'pending', messages: [{ to: '+40755989898', message: 'Instructions sms' }] }, + { state: 'pending', messages: [{ to: '+40755989898', message: secondUrl }] }, + ]); + + return Promise.all([ + expectTokenLoginToFail(firstUrl), + expectTokenLoginToSucceed(secondUrl), + ]); + }); + }); + + it('should disable token_login for a user when requested', () => { + const settings = { token_login: { translation_key: 'login_sms', enabled: true }, app_url: utils.getOrigin() }; + user.token_login = true; + user.phone = '+40755969696'; + let firstTokenLogin; + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(() => getUser(user)) + .then(user => firstTokenLogin = user.token_login) + .then(() => { + const updates = { token_login: false, password }; + return utils.request({ path: `/api/v1/users/${user.username}`, method: 'POST', body: updates }); + }) + .then(response => { + chai.expect(response.token_login).to.be.undefined; + return Promise.all([ + getUser(user), + getUserSettings(user), + utils.getDoc(getLoginTokenDocId(firstTokenLogin.token)) + ]); + }) + .then(([ user, userSettings, smsDoc]) => { + chai.expect(user.token_login).to.be.undefined; + chai.expect(userSettings.token_login).to.be.undefined; + + const tokenUrl = `${utils.getOrigin()}/medic/login/token/${firstTokenLogin.token}`; + chai.expect(smsDoc.tasks).to.shallowDeepEqual([ + { state: 'cleared', messages: [{ to: '+40755969696', message: 'Instructions sms' }] }, + { state: 'cleared', messages: [{ to: '+40755969696', message: tokenUrl }] }, + ]); + + return expectTokenLoginToFail(tokenUrl); + }) + .then(() => expectPasswordLoginToWork(user)); + }); + }); + + it('should non-admin users cannot edit token_login docs', () => { + const settings = { token_login: { translation_key: 'login_sms', enabled: true }, app_url: utils.getOrigin() }; + user.token_login = true; + user.phone = '+40755969696'; + + const onlineUser = { + username: 'onlineuser', + password, + roles: ['national_manager'], + place: { + _id: 'fixture:online', + type: 'health_center', + name: 'TestVille', + parent: 'PARENT_PLACE' + }, + contact: { + _id: 'fixture:user:online', + name: 'Bob' + }, + }; + + let tokenLoginDocId; + + return utils + .updateSettings(settings, true) + .then(() => utils.addTranslations('en', { login_sms: 'Instructions sms' })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: onlineUser })) + .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) + .then(() => getUser(user)) + .then(user => { + tokenLoginDocId = `token:login:${user.token_login.token}`; + return utils.getDoc(tokenLoginDocId); + }) + .then(tokenLoginDoc => { + chai.expect(tokenLoginDoc.user).to.equal('org.couchdb.user:testuser'); + + const onlineRequestOpts = { + auth: { user: 'onlineuser', password }, + method: 'PUT', + path: `/${tokenLoginDoc._id}`, + body: tokenLoginDoc, + }; + return utils.requestOnTestDb(onlineRequestOpts).catch(err => err); + }) + .then(err => { + chai.expect(err.response).to.deep.include({ + statusCode: 403, + body: { + error: 'forbidden', + reason: 'Insufficient privileges' + }, + }); + }); + }); + }); +}); diff --git a/tests/e2e/login/token-login.spec.js b/tests/e2e/login/token-login.spec.js new file mode 100644 index 00000000000..cf9ebdec1bc --- /dev/null +++ b/tests/e2e/login/token-login.spec.js @@ -0,0 +1,137 @@ +const auth = require('../../auth')(); +const commonElements = require('../../page-objects/common/common.po.js'); +const loginPage = require('../../page-objects/login/login.po.js'); +const utils = require('../../utils'); +const helper = require('../../helper'); + +const INVALID = 'Your link is invalid.'; +const EXPIRED = 'Your link has expired.'; +const MISSING = 'Your link is missing required information'; +const TOLOGIN = 'If you know your username and password, click on the following link to load the login page.'; +const ERROR = 'Something went wrong when processing your request'; + +let user; + +const getUrl = token => `${utils.getOrigin()}/medic/login/token/${token}`; +const setupTokenLoginSettings = () => { + // we're configuring app_url here because we're serving api on a port, and in express4 req.hostname strips the port + // https://expressjs.com/en/guide/migrating-5.html#req.host + const settings = { token_login: {message: 'token_login_sms', enabled: true }, app_url: utils.getOrigin() }; + const waitForApiUpdate = utils.waitForLogs('api.e2e.log', /Settings updated/); + return utils.updateSettings(settings, 'api').then(() => waitForApiUpdate.promise); +}; + +const createUser = (user) => { + return utils.request({ path: '/api/v1/users', method: 'POST', body: user }); +}; + +const getUser = id => utils.request({ path: `/_users/${id}`}); + +const getTokenUrl = ({ token_login: { token } } = {}) => { + const id = `token:login:${token}`; + return utils.getDoc(id).then(doc => { + return doc.tasks[1].messages[0].message; + }); +}; + +const expireToken = (user) => { + return utils.request(`/_users/${user._id}`).then(userDoc => { + const rightNow = new Date().getTime(); + userDoc.token_login.expiration_date = rightNow - 1000; // token expired a second ago + return utils.request({ path: `/_users/${user._id}`, method: 'PUT', body: userDoc }); + }); +}; + +describe('token login', () => { + beforeEach(() => { + user = { + username: 'testusername', + roles: ['national_admin'], + phone: '+40766565656', + token_login: true, + known: true, + }; + browser.manage().deleteAllCookies(); + }); + afterEach(() => utils.deleteUsers([user]).then(() => utils.revertDb([], []))); + + afterAll(() => { + commonElements.goToLoginPage(); + loginPage.login(auth.username, auth.password); + return utils.revertDb(); + }); + + const waitForLoaderToDisappear = () => { + try { + helper.waitElementToDisappear(by.css('.loader')); + } catch(err) { + // element can go stale + } + }; + + it('should redirect the user to the app if already logged in', () => { + commonElements.goToLoginPage(); + loginPage.login(auth.username, auth.password); + browser.driver.get(getUrl('this is a random string')); + waitForLoaderToDisappear(); + browser.waitForAngular(); + helper.waitUntilReady(element(by.id('message-list'))); + }); + + it('should display an error when token login is disabled', () => { + browser.driver.get(getUrl('this is a random string')); + waitForLoaderToDisappear(); + expect(helper.isTextDisplayed(ERROR)).toBe(true); + expect(helper.isTextDisplayed(TOLOGIN)).toBe(true); + expect(element(by.css('.btn[href="/medic/login"]')).isDisplayed()).toBe(true); + }); + + it('should display an error with incorrect url', () => { + browser.wait(() => setupTokenLoginSettings().then(() => true)); + browser.driver.get(`${utils.getOrigin()}/medic/login/token`); + waitForLoaderToDisappear(); + expect(helper.isTextDisplayed(MISSING)).toBe(true); + expect(helper.isTextDisplayed(TOLOGIN)).toBe(true); + expect(element(by.css('.btn[href="/medic/login"]')).isDisplayed()).toBe(true); + }); + + it('should display an error when accessing with random strings', () => { + browser.wait(() => setupTokenLoginSettings().then(() => true)); + browser.driver.get(getUrl('this is a random string')); + waitForLoaderToDisappear(); + expect(helper.isTextDisplayed(INVALID)).toBe(true); + expect(helper.isTextDisplayed(TOLOGIN)).toBe(true); + expect(element(by.css('.btn[href="/medic/login"]')).isDisplayed()).toBe(true); + }); + + it('should display an error when token is expired', () => { + browser.wait(() => { + return setupTokenLoginSettings() + .then(() => createUser(user)) + .then(response => getUser(response.user.id)) + .then(user => Promise.all([ + getTokenUrl(user), + expireToken(user), + ])) + .then(([ url ]) => browser.driver.get(url)) + .then(() => true); + }); + waitForLoaderToDisappear(); + expect(helper.isTextDisplayed(EXPIRED)).toBe(true); + expect(helper.isTextDisplayed(TOLOGIN)).toBe(true); + expect(element(by.css('.btn[href="/medic/login"]')).isDisplayed()).toBe(true); + }); + + it('should log the user in when token is correct', () => { + browser.wait(() => { + return setupTokenLoginSettings() + .then(() => createUser(user)) + .then(response => getUser(response.user.id)) + .then(user => getTokenUrl(user)) + .then(url => browser.driver.get(url)) + .then(() => true); + }); + browser.waitForAngular(); + helper.waitUntilReady(element(by.id('message-list'))); + }); +}); diff --git a/tests/e2e/users/add-user.specs.js b/tests/e2e/users/add-user.specs.js index cc123169c46..16d61a83bcb 100644 --- a/tests/e2e/users/add-user.specs.js +++ b/tests/e2e/users/add-user.specs.js @@ -31,8 +31,9 @@ describe('Add user : ', () => { return true; }); }, 2000); - expect(helper.isTextDisplayed(addedUser)); - expect(helper.isTextDisplayed(fullName)); + helper.waitForAngularComplete(); + expect(helper.isTextDisplayed(addedUser)).toBe(true); + expect(helper.isTextDisplayed(fullName)).toBe(true); }); it('should reject passwords shorter than 8 characters', () => { diff --git a/tests/helper.js b/tests/helper.js index 07b9a1c7c2f..17bdad6b5b5 100644 --- a/tests/helper.js +++ b/tests/helper.js @@ -119,9 +119,9 @@ module.exports = { isTextDisplayed: text => { const selectedElement = element( - by.xpath('//*[text()[normalize-space() = " ' + text + '"]]') + by.xpath(`//*[contains(normalize-space(text()), "${text}")]`) ); - return selectedElement.isPresent(); + return selectedElement.isDisplayed(); }, logConsoleErrors: spec => { diff --git a/tests/page-objects/forms/new-pregnancy-form.po.js b/tests/page-objects/forms/new-pregnancy-form.po.js index b48a6b8ac91..2e4c9295197 100644 --- a/tests/page-objects/forms/new-pregnancy-form.po.js +++ b/tests/page-objects/forms/new-pregnancy-form.po.js @@ -43,7 +43,7 @@ module.exports = { reset: () => { element(by.css('.icon.icon-refresh')).click(); }, - + getEstimatedDeliveryDate: () => { return element(by.css('[data-value=" /pregnancy/group_lmp/g_edd "]')).getText(); }, diff --git a/tests/page-objects/login/login.po.js b/tests/page-objects/login/login.po.js index 3de30b2bdc9..4ab2846e772 100644 --- a/tests/page-objects/login/login.po.js +++ b/tests/page-objects/login/login.po.js @@ -23,7 +23,7 @@ module.exports = { getLoginButton().click(); browser.waitForAngular(); if (shouldFail) { - expect(helper.isTextDisplayed(incorrectCredentialsText)); + expect(helper.isTextDisplayed(incorrectCredentialsText)).toBe(true); } }, }; diff --git a/tests/utils.js b/tests/utils.js index 56a95f60a58..d2b9ca25d31 100644 --- a/tests/utils.js +++ b/tests/utils.js @@ -39,7 +39,8 @@ const request = (options, { debug } = {}) => { options.transform = (body, response, resolveWithFullResponse) => { // we might get a json response for a non-json request. - if (response.headers['content-type'].startsWith('application/json') && !options.json) { + const contentType = response.headers['content-type']; + if (contentType && contentType.startsWith('application/json') && !options.json) { response.body = JSON.parse(response.body); } // return full response if `resolveWithFullResponse` or if non-2xx status code (so errors can be inspected) @@ -351,7 +352,7 @@ module.exports = { specReporter: new specReporter({ spec: { - displayStacktrace: 'pretty', + displayStacktrace: 'raw', displayDuration: true } }), @@ -639,6 +640,45 @@ module.exports = { }; }, + /** + * Watches a given logfile until at least one line matches one of the given regular expressions. + * Watch expires after 10 seconds. + * @param {String} logFilename - filename of file in local logs directory + * @param {[RegExp]} regex - matching expression(s) run against lines + * @returns {Object} that contains the promise to resolve when logs lines are matched and a cancel function + */ + waitForLogs: (logFilename, ...regex) => { + const tail = new Tail(`./tests/logs/${logFilename}`); + let timeout; + const promise = new Promise((resolve, reject) => { + timeout = setTimeout(() => { + tail.unwatch(); + reject({ message: 'timeout exceeded' }); + }, 10000); + + tail.on('line', data => { + if (regex.find(r => r.test(data))) { + tail.unwatch(); + clearTimeout(timeout); + resolve(); + } + }); + tail.on('error', err => { + tail.unwatch(); + clearTimeout(timeout); + reject(err); + }); + }); + + return { + promise, + cancel: () => { + tail.unwatch(); + clearTimeout(timeout); + }, + }; + }, + // delays executing a function that returns a promise with the provided interval (in ms) delayPromise: (promiseFn, interval) => { return new Promise((resolve, reject) => { diff --git a/webapp/src/js/controllers/configuration-user.js b/webapp/src/js/controllers/configuration-user.js index a1af240a024..aa3e309f3d8 100644 --- a/webapp/src/js/controllers/configuration-user.js +++ b/webapp/src/js/controllers/configuration-user.js @@ -1,12 +1,18 @@ angular.module('inboxControllers').controller('ConfigurationUserCtrl', function ( - Modal + Modal, + UserSettings ) { 'use strict'; 'ngInject'; const ctrl = this; + this.loading = true; + UserSettings().then(user => { + this.loading = false; + this.user = user; + }); ctrl.updatePassword = function() { Modal({ diff --git a/webapp/src/templates/partials/configuration_user.html b/webapp/src/templates/partials/configuration_user.html index 6d250c58c60..117fd9bbeb6 100644 --- a/webapp/src/templates/partials/configuration_user.html +++ b/webapp/src/templates/partials/configuration_user.html @@ -4,18 +4,21 @@