diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 3bc93546cdc..04456b4157c 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = Delete record? bulkdelete.confirm.title.plural = Delete selected records? call = Call case_id = Case ID +change.password.confirm.password = Confirm password +change.password.hint = Use uppercase letters, numbers, and special characters. +change.password.new.password = New password +change.password.submit = Change password +change.password.title = Change your password child_birth_date = Child birth date child_birth_outcome = Child birth outcome child_birth_weight = Child birth weight @@ -964,8 +969,11 @@ partner.logo.upload = Upload partner logo partner.name.field = Partner name partner.supporting = Supporting partners partner.tab.partners = Partners +password.current.incorrect = Current password is not correct password.incorrect = Password is not correct. password.length.minimum = The password must be at least {{minimum}} characters long. +password.must.match = Password and confirm password must match +password.same = New password must be different from current password password.update = Update password password.weak = The password is too easy to guess. Include a range of characters to make it more complex. patient\ id\ not\ found\ response = Send the following response message if the validations pass but the Medic ID is not located. diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 4dcc0aa8506..f9e64bc7023 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = ¿Eliminar el registro? bulkdelete.confirm.title.plural = ¿Eliminar registros seleccionados? call = Llamar case_id = Identificación del caso +change.password.confirm.password = Confirmar contraseña +change.password.hint = Utilice letras mayúsculas, números y caracteres especiales. +change.password.new.password = Nueva contraseña +change.password.submit = Cambiar la contraseña +change.password.title = Cambiar contraseña child_birth_date = Fecha de nacimiento del niño child_birth_outcome = Resultado del nacimiento del niño child_birth_weight = Peso del niño al nacer @@ -964,8 +969,11 @@ partner.logo.upload = Subir logo del socio partner.name.field = Nombre del socio partner.supporting = Socios que está apoyando partner.tab.partners = Socios +password.current.incorrect = La contraseña actual no es correcta password.incorrect = La contraseña no es correcta. password.length.minimum = La contraseña debe tener al menos {{minimum}} caracteres. +password.must.match = Las contraseñas y la contraseña de confirmación deben coincidir +password.same = La nueva contraseña debe ser diferente de la contraseña actual password.update = Actualizar contraseña password.weak = La contraseña es demasiado fácil de adivinar. Incluya más variedad de caracteres para hacerlo más complejo. patient\ id\ not\ found\ response = Enviar el siguiente mensaje de respuesta, sí las validaciones pasan correctamente pero no se encontró el Medic ID. diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 0b9923ec74a..6b458ebb6c1 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = Supprimer l'enregistrement? bulkdelete.confirm.title.plural = Supprimer les enregistrements sélectionnés? call = Appeler case_id = ID du cas +change.password.confirm.password = Confirmer le mot de passe +change.password.hint = Utilisez une combinaison de lettres majuscules, de chiffres et de caractères spéciaux. +change.password.new.password = Nouveau mot de passe +change.password.submit = Changer le mot de passe +change.password.title = Changez votre mot de passe child_birth_date = Date de naissance de l'enfant child_birth_outcome = Résultat de la naissance de l'enfant child_birth_weight = Poids de l'enfant à la naissance @@ -964,8 +969,11 @@ partner.logo.upload = Télécharger le logo du partenaire partner.name.field = Nom du partenaire partner.supporting = Partenaires de soutien partner.tab.partners = Partenaires +password.current.incorrect = Le mot de passe actuel n'est pas correct password.incorrect = Mot de passe incorrect password.length.minimum = Le mot de passe doit être au moins {{minimum}} caractères. +password.must.match = Le mot de passe et la confirmation du mot de passe doivent correspondre +password.same = Le nouveau mot de passe doit être différent du mot de passe actuel password.update = Mettre à jour mot de passe password.weak = Le mot de passe est trop facile à deviner. Inclure au moins une lettre majuscule, un chiffre et un caractère spécial. patient\ id\ not\ found\ response = Envoyer cette réponse si les validations passent, mais l'ID du patient n'est pas retrouvé. diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index 28e9a589877..2fac8ec0f35 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -388,6 +388,11 @@ bulkdelete.confirm.title = Hapus pencatatan? bulkdelete.confirm.title.plural = Hapus pencatatan yang dipilih? call = Telepon case_id = +change.password.confirm.password = Konfirmasikan kata sandi +change.password.hint = Gunakan huruf besar, angka, dan karakter khusus. +change.password.new.password = Kata sandi baru +change.password.submit = Ubah kata sandi +change.password.title = Ubah kata sandi Anda child_birth_date = Tanggal Lahir Anak child_birth_outcome = Outcome Anak dilahirkan child_birth_weight = Berat Lahir Anak @@ -881,9 +886,12 @@ partner.logo.field = partner.logo.upload = partner.name.field = partner.supporting = -partner.tab.partners = +partner.tab.partners = +password.current.incorrect = Kata sandi saat ini salah password.incorrect = Kata sandi tidak benar. password.length.minimum = Kata sandi harus setidaknya {{minimum}} karakter. +password.must.match = Kata sandi dan konfirmasi kata sandi harus cocok +password.same = Kata sandi baru harus berbeda dengan kata sandi saat ini password.update = Perbaharui Kata Sandi password.weak = Kata sandinya terlalu mudah. Sertakan setidaknya 1 huruf besar, 1 angka, dan 1 karakter khusus. patient\ id\ not\ found\ response = Kirim pesan respon ini bila lolos validasi tetapi Medic ID tidak ditemukan diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index 78fa02deecf..3cb75f9e1e7 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -400,6 +400,11 @@ bulkdelete.confirm.title = रेकर्ड मेटाउने हो? bulkdelete.confirm.title.plural = चयन गरिएका रेकर्डहरू मेट्ने हो? call = कल case_id = केस आईडी +change.password.confirm.password = पासवर्ड पुष्टि गर्नुहोस् +change.password.hint = ठूला अक्षर, अङ्क र चिन्हहरूको मिश्रण भएको एउटा भरपर्दो पासवर्ड सिर्जना गर्नुहोस् +change.password.new.password = नयाँ पासवर्ड +change.password.submit = पासवर्ड परिवर्तन गर्नुहोस् +change.password.title = आफ्नो पासवर्ड परिवर्तन गर्नुहोस् child_birth_date = बच्चाको जन्म मिति child_birth_outcome = बच्चाको जन्मावस्था child_birth_weight = बच्चाको जन्म तौल @@ -964,8 +969,11 @@ partner.logo.upload = पार्टनर लोगो अपलोड गर partner.name.field = पार्टनरको नाम partner.supporting = सहयोगी पार्टनरहरू partner.tab.partners = पार्टनरहरू +password.current.incorrect = वर्तमान पासवर्ड गलत छ password.incorrect = पासवर्ड मिलेन। password.length.minimum = पासवर्ड कम्तीमा {{minimum}} अक्षरको हुनुपर्छ। +password.must.match = तपाईंले पासवर्ड हाल्नुहोस् र पासवर्ड पुष्टि गर्नुहोस् नामक फिल्डमा हाल्नुभएको पासवर्ड एउटै छैन। फेरि प्रयास गर्नुहोस्। +password.same = नयाँ पासवर्ड वर्तमान पासवर्ड भन्दा फरक हुनुपर्छ password.update = अपडेट पासवर्ड password.weak = यो पासवर्ड कमजोर छ। patient\ id\ not\ found\ response = बिरामिको आईडी नपाइएमा पठाइने सन्देश diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index 13c4eadb09e..24162f8cbe0 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -402,6 +402,11 @@ bulkdelete.confirm.title = Futa rekodi? bulkdelete.confirm.title.plural = Ungependa kufuta rekodi ulizochagua? call = Piga simu case_id = Kitambulisho cha kesi +change.password.confirm.password = Thibitisha nenosiri +change.password.hint = Tumia herufi kubwa, nambari na herufi maalum. +change.password.new.password = Nenosiri mpya +change.password.submit = Badilisha nenosiri +change.password.title = Badilisha nenosiri lako child_birth_date = Tarehe ya kuzaliwa mtoto child_birth_outcome = Matokeo ya mtoto mzaliwa child_birth_weight = Uzani wa mtoto mzaliwa @@ -964,8 +969,11 @@ partner.logo.upload = Pakia nembo ya mshirika partner.name.field = Jina la mshirika partner.supporting = Washirika wanaounga mkono partner.tab.partners = Washirika +password.current.incorrect = Nenosiri la sasa si sahihi password.incorrect = Nenosiri si sahihi password.length.minimum = Nenosiri inapaswa kuwa na wahusika {{minimum}} kwenda juu +password.must.match = Nenosiri na uthibitisho wa nenosiri lazima zilingane +password.same = Nenosiri mpya lazima liwe tofauti na nenosiri la sasa password.update = Badilisha nenosiri password.weak = Nywila ni rahisi sana nadhani. Jumuisha anuwai ya herufi ili kuifanya iwe ngumu zaidi. patient\ id\ not\ found\ response = Tuma ujumbe wa majibu ufuatao kama validations zimepitishwa lakini ID ya mgonjwa haiko diff --git a/api/src/auth.js b/api/src/auth.js index 508ffc81906..99e7335a6e8 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -76,6 +76,20 @@ module.exports = { }); }, + checkPasswordChange: async (req) => { + if (roles.isDbAdmin(req.userCtx)) { + return; + } + + const user = await users.getUserDoc(req.userCtx.name); + if (user.password_change_required && !user.token_login) { + const error = new Error('Password change required'); + error.code = 403; + error.error = 'Password change required'; + throw error; + } + }, + /** * Extract Basic Auth credentials from a request * diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index f522bcce7bf..3bddd96f9b2 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -9,7 +9,7 @@ const privacyPolicy = require('../services/privacy-policy'); const logger = require('@medic/logger'); const db = require('../db'); const dataContext = require('../services/data-context'); -const { tokenLogin, roles, users } = require('@medic/user-management')(config, db, dataContext); +const { tokenLogin, roles, users, validatePassword } = require('@medic/user-management')(config, db, dataContext); const localeUtils = require('locale'); const cookie = require('../services/cookie'); const brandingService = require('../services/branding'); @@ -18,6 +18,17 @@ const template = require('../services/template'); const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); +const PASSWORD_RESET_URL = '/medic/password-reset'; + +const ERROR_KEY_MAPPING = { + // Ignore Sonar false positive that these are hard-coded credentials. + // These are css error classes for password reset html + 'password.weak': 'password-weak', //NoSONAR + 'password.length.minimum': 'password-short', //NoSONAR + 'password.current.incorrect': 'current-password-incorrect', //NoSONAR + 'password.same': 'password-same', //NoSONAR +}; + const templates = { login: { file: path.join(__dirname, '..', 'templates', 'login', 'index.html'), @@ -51,6 +62,28 @@ const templates = { 'privacy.policy' ], }, + passwordReset: { + file: path.join(__dirname, '..', 'templates', 'login', 'password-reset.html'), + translationStrings: [ + 'login.show_password', + 'login.hide_password', + 'change.password.title', + 'change.password.hint', + 'change.password.submit', + 'change.password.new.password', + 'change.password.confirm.password', + 'password.weak', + 'password.length.minimum', + 'password.must.match', + 'user.password.current', + 'password.current.incorrect', + 'password.same' + ], + } +}; + +const skipPasswordChange = (user) => { + return !user?.password_change_required; }; const getHomeUrl = userCtx => { @@ -190,36 +223,46 @@ const setUserCtxCookie = (res, userCtx) => { cookie.setUserCtx(res, JSON.stringify(content)); }; -const setCookies = (req, res, sessionRes) => { +const setCookies = async (req, res, sessionRes) => { const sessionCookie = getSessionCookie(sessionRes); if (!sessionCookie) { throw { status: 401, error: 'Not logged in' }; } const options = { headers: { Cookie: sessionCookie } }; - return getUserCtxRetry(options) - .then(userCtx => { - cookie.setSession(res, sessionCookie); - setUserCtxCookie(res, userCtx); - // Delete login=force cookie - res.clearCookie('login'); - - return Promise.resolve() - .then(() => { - if (roles.isDbAdmin(userCtx)) { - return users.createAdmin(userCtx); - } - }) - .then(() => { - const selectedLocale = req.body.locale - || config.get('locale'); - cookie.setLocale(res, selectedLocale); - return getRedirectUrl(userCtx, req.body.redirect); - }); - }) - .catch(err => { - logger.error(`Error getting authCtx %o`, err); - throw { status: 401, error: 'Error getting authCtx' }; - }); + try { + const userCtx = await getUserCtxRetry(options); + if (roles.isDbAdmin(userCtx)) { + await users.createAdmin(userCtx); + } + + const user = await users.getUserDoc(userCtx.name); + if (!skipPasswordChange(user)) { + return redirectToPasswordReset(req, res, userCtx); + } + return redirectToApp({ req, res, sessionCookie, userCtx }); + } catch (err) { + logger.error(`Error getting authCtx %o`, err); + throw { status: 401, error: 'Error getting authCtx' }; + } +}; + +const redirectToApp = async ({ req, res, sessionCookie, userCtx }) => { + cookie.setSession(res, sessionCookie); + setUserCtxCookie(res, userCtx); + cookie.clearCookie(res, 'login'); + setUserLocale(req, res); + return getRedirectUrl(userCtx, req.body.redirect); +}; + +const redirectToPasswordReset = (req, res, userCtx) => { + setUserCtxCookie(res, userCtx); + setUserLocale(req, res); + return PASSWORD_RESET_URL; +}; + +const setUserLocale = (req, res) => { + const selectedLocale = req.body.locale || config.get('locale'); + cookie.setLocale(res, selectedLocale); }; const renderTokenLogin = (req, res) => { @@ -300,26 +343,122 @@ const renderLogin = (req) => { return render('login', req); }; +const renderPasswordReset = (req) => { + return render('passwordReset', req); +}; + +const validatePasswordReset = (password) => { + const error = validatePassword(password); + + if (!error) { + return { isValid: true }; + } + + return { + isValid: false, + error: ERROR_KEY_MAPPING[error.message.translationKey], + params: error.message.translationParams + }; +}; + +const validateSession = async (req) => { + const sessionRes = await createSession(req); + if (sessionRes.statusCode !== 200) { + const error = new Error('Not logged in'); + error.status = sessionRes.statusCode; + error.error = 'Not logged in'; + throw error; + } + return sessionRes; +}; + +const sendLoginErrorResponse = (e, res) => { + if (e.status === 401) { + return res.status(401).json({ error: e.error }); + } + logger.error('Error logging in: %o', e); + return res.status(500).json({ error: 'Unexpected error logging in' }); +}; + const login = async (req, res) => { try { - const sessionRes = await createSession(req); - if (sessionRes.statusCode !== 200) { - res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); - } else { - const redirectUrl = await setCookies(req, res, sessionRes); - res.status(302).send(redirectUrl); - } + const sessionRes = await validateSession(req); + const redirectUrl = await setCookies(req, res, sessionRes); + res.status(302).send(redirectUrl); } catch (e) { - if (e.status === 401) { - return res.status(401).json({ error: e.error }); + return sendLoginErrorResponse(e, res); + } +}; + +const updatePassword = async (user, newPassword, retry = 10) => { + const updatedUser = { + ...user, + password: newPassword, + password_change_required: false + }; + try { + await db.users.put(updatedUser); + return updatedUser; + } catch (err) { + if (retry > 0 && err && err.code === 401) { + await new Promise(r => setTimeout(r, 20)); + return updatePassword(user, newPassword, --retry); + } + throw err; + } +}; + +const validateCurrentPassword = async (username, currentPassword, newPassword) => { + try { + await request.get({ + url: new URL('/_session', environment.serverUrlNoAuth).toString(), + json: true, + resolveWithFullResponse: true, + auth: { user: username, pass: currentPassword }, + }); + + if (currentPassword === newPassword) { + return { + isValid: false, + error: ERROR_KEY_MAPPING['password.same'], + }; } - logger.error('Error logging in: %o', e); - res.status(500).json({ error: 'Unexpected error logging in' }); + return { isValid: true }; + } catch (err) { + if (err.statusCode === 401) { + return { + isValid: false, + error: ERROR_KEY_MAPPING['password.current.incorrect'], + }; + } + throw err; } }; +const passwordResetValidation = async (username, currentPassword, password) => { + const validation = validatePasswordReset(password); + if (!validation.isValid) { + return { + status: 400, + ...validation, + }; + } + + const currentPasswordValidation = await validateCurrentPassword(username, currentPassword, password); + if (!currentPasswordValidation.isValid) { + return { + status: 400, + ...currentPasswordValidation, + }; + } + + return { isValid: true }; +}; + + module.exports = { renderLogin, + renderPasswordReset, get: (req, res, next) => { return renderLogin(req) @@ -328,6 +467,7 @@ module.exports = { 'Link', '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script' ); res.send(body); @@ -355,6 +495,48 @@ module.exports = { }); }, + getPasswordReset: (req, res, next) => { + return renderPasswordReset(req) + .then(body => { + res.setHeader( + 'Link', + '; rel=preload; as=style, ' + + '; rel=preload; as=script, ' + + '; rel=preload; as=script' + ); + res.send(body); + }) + .catch(next); + }, + resetPassword: async (req, res) => { + const limited = await rateLimitService.isLimited(req); + if (limited) { + return serverUtils.rateLimited(req, res); + } + + try { + const { username, currentPassword, password, locale } = req.body; + const validationResult = await passwordResetValidation(username, currentPassword, password); + if (!validationResult.isValid) { + return res.status(validationResult.status).json({ + error: validationResult.error, + params: validationResult.params + }); + } + + const userDoc = await users.getUserDoc(username); + await updatePassword(userDoc, password); + + req.body = { user: username, password, locale }; + const sessionRes = await createSessionRetry(req); + const redirectUrl = await setCookies(req, res, sessionRes); + return res.status(302).send(redirectUrl); + } catch (err) { + logger.error('Error updating password: %o', err); + const status = err.status || 500; + res.status(status).json({ error: err.error || 'Error updating password' }); + } + }, tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next), tokenPost: async (req, res, next) => { const limited = await rateLimitService.isLimited(req); diff --git a/api/src/generate-service-worker.js b/api/src/generate-service-worker.js index a827e389755..9042cff62d5 100644 --- a/api/src/generate-service-worker.js +++ b/api/src/generate-service-worker.js @@ -53,6 +53,10 @@ const getLoginPageContents = async () => { return await loginController.renderLogin(); }; +const getPasswordResetPageContents = async () => { + return await loginController.renderPasswordReset(); +}; + const appendExtensionLibs = async (config) => { const libs = await extensionLibs.getAll(); // cache this even if there are no libs so offline client knows there are no libs @@ -99,6 +103,7 @@ const writeServiceWorkerFile = async () => { templatedURLs: { '/': ['webapp/index.html'], // Webapp's entry point '/medic/login': await getLoginPageContents(), + '/medic/password-reset': await getPasswordResetPageContents(), '/medic/_design/medic/_rewrite/': ['webapp/appcache-upgrade.html'] }, ignoreURLParametersMatching: [/redirect/, /username/], diff --git a/api/src/middleware/authorization.js b/api/src/middleware/authorization.js index fe02207beca..beae46ec045 100644 --- a/api/src/middleware/authorization.js +++ b/api/src/middleware/authorization.js @@ -40,7 +40,9 @@ module.exports = { return serverUtils.error('Authentication error', req, res); } - next(); + return auth.checkPasswordChange(req) + .then(() => next()) + .catch(err => serverUtils.error(err, req, res)); }, handleAuthErrorsAllowingAuthorized: (req, res, next) => { diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js new file mode 100644 index 00000000000..2c0ba654b88 --- /dev/null +++ b/api/src/public/login/auth-utils.js @@ -0,0 +1,128 @@ +window.AuthUtils = (function() { + + const setState = (className) => { + const form = document.getElementById('form'); + if (!form) { + return; + } + form.className = className; + }; + + const request = (method, url, payload, callback) => { + const xmlhttp = new XMLHttpRequest(); + xmlhttp.onreadystatechange = () => { + if (xmlhttp.readyState === XMLHttpRequest.DONE) { + callback(xmlhttp); + } + }; + xmlhttp.open(method, url, true); + xmlhttp.setRequestHeader('Content-Type', 'application/json'); + xmlhttp.setRequestHeader('Accept', 'application/json'); + xmlhttp.send(payload); + }; + + const extractCookie = (cookies, name) => { + for (const cookie of cookies) { + const [cookieName, cookieValue] = cookie.trim().split('='); + if (cookieName === name) { + return cookieValue.trim(); + } + } + return null; + }; + + const getCookie = (name) => { + if (!document.cookie) { + return null; + } + + const cookies = document.cookie.split(';'); + return extractCookie(cookies, name); + }; + + const getUserCtx = () => { + const cookie = getCookie('userCtx'); + if (cookie) { + try { + return JSON.parse(decodeURIComponent(cookie)); + } catch (e) { + console.error('Error parsing cookie', e); + } + } + }; + + const getLocale = (translations) => { + const selectedLocale = getCookie('locale'); + const defaultLocale = document.body.getAttribute('data-default-locale'); + const locale = selectedLocale || defaultLocale; + if (translations[locale]) { + return locale; + } + const validLocales = Object.keys(translations); + if (validLocales.length) { + return validLocales[0]; + } + }; + + const parseTranslations = () => { + const raw = document.body.getAttribute('data-translations'); + return JSON.parse(decodeURIComponent(raw)); + }; + + const replaceTranslationPlaceholders = (text, translateValues) => { + if (!text || !translateValues) { + return text; + } + + try { + const values = JSON.parse(translateValues); + return Object + .entries(values) + .reduce((result, [key, value]) => result.replace(new RegExp(`{{${key}}}`, 'g'), value), text); + } catch (e) { + console.error('Error parsing translation placeholders', e); + return text; + } + }; + + const baseTranslate = (selectedLocale, translations) => { + if (!selectedLocale) { + return console.error('No enabled locales found - not translating'); + } + document + .querySelectorAll('[translate]') + .forEach(elem => { + let text = translations[selectedLocale][elem.getAttribute('translate')]; + const translateValues = elem.getAttribute('translate-values'); + if (translateValues) { + text = replaceTranslationPlaceholders(text, translateValues); + } + elem.innerText = text; + }); + document + .querySelectorAll('[translate-title]') + .forEach(elem => elem.title = translations[selectedLocale][elem.getAttribute('translate-title')]); + }; + + const togglePassword = (passwordInputId, passwordContainerId) => { + const passwordInput = document.getElementById(passwordInputId); + if (!passwordInput) { + return; + } + + const displayType = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = displayType; + document.getElementById(passwordContainerId)?.classList.toggle('hidden-password'); + }; + + return { + setState, + request, + getCookie, + getUserCtx, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + }; +})(); diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js new file mode 100644 index 00000000000..e9c31cbd6c1 --- /dev/null +++ b/api/src/public/login/password-reset.js @@ -0,0 +1,108 @@ +const { + setState, + request, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + getUserCtx +} = window.AuthUtils; + +let selectedLocale; +let translations; + +const PASSWORD_INPUT_ID = 'password'; +const CONFIRM_PASSWORD_INPUT_ID = 'confirm-password'; +const CURRENT_PASSWORD_INPUT_ID = 'current-password'; + +const checkSession = function() { + const userCtx = getUserCtx(); + if (!userCtx || !userCtx.name) { + // only logged-in users should access password reset page + window.location = '/medic/login'; + } +}; + +const translate = () => { + baseTranslate(selectedLocale, translations); +}; + +const displayPasswordValidationError = (serverResponse) => { + const { error, params } = JSON.parse(serverResponse); + setState(error); + + const passwordError = document.querySelector('.error.password-short'); + if (params?.minimum && passwordError) { + passwordError.setAttribute('translate-values', JSON.stringify(params)); + baseTranslate(selectedLocale, translations); + } +}; + +const validatePasswordMatch = (password, confirmPassword) => { + if (password !== confirmPassword) { + return { + isValid: false, + error: 'password-mismatch', + }; + } + return { isValid: true }; +}; + +const submit = function(e) { + e.preventDefault(); + if (document.getElementById('form')?.className === 'loading') { + // debounce double clicks + return; + } + const currentPassword = document.getElementById(CURRENT_PASSWORD_INPUT_ID)?.value; + const password = document.getElementById(PASSWORD_INPUT_ID)?.value; + const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID)?.value; + const validation = validatePasswordMatch(password, confirmPassword); + if (!validation.isValid) { + displayPasswordValidationError(JSON.stringify({ error: validation.error })); + return; + } + + setState('loading'); + const url = document.getElementById('form')?.action; + const userCtx = getUserCtx(); + + const payload = JSON.stringify({ + username: userCtx.name, + password: password, + currentPassword: currentPassword, + locale: selectedLocale + }); + + request('POST', url, payload, function(xmlhttp) { + if (xmlhttp.status === 302) { + // success - redirect to app + localStorage.setItem('passwordStatus', 'PASSWORD_CHANGED'); + window.location = xmlhttp.response; + } else if (xmlhttp.status === 400) { + // password validation failed + displayPasswordValidationError(xmlhttp.response); + } else { + setState('error'); + console.error('Error updating password', xmlhttp.response); + } + }); +}; + +document.addEventListener('DOMContentLoaded', function() { + translations = parseTranslations(); + selectedLocale = getLocale(translations); + translate(); + checkSession(); + + document.getElementById('update-password')?.addEventListener('click', submit, false); + + const passwordToggle = document.getElementById('password-toggle'); + if (passwordToggle) { + passwordToggle.addEventListener('click', () => { + togglePassword('password', 'password-container'); + togglePassword('confirm-password', 'confirm-password-container'); + togglePassword('current-password', 'current-password-container'); + }, false); + } +}); diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 557fc12d213..4e6c3a300b2 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -1,40 +1,34 @@ +const { + setState, + request, + getCookie, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + getUserCtx +} = window.AuthUtils; + let selectedLocale; let translations; const PASSWORD_INPUT_ID = 'password'; -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() { - if (xmlhttp.readyState === XMLHttpRequest.DONE) { - callback(xmlhttp); - } - }; - xmlhttp.open(method, url, true); - xmlhttp.setRequestHeader('Content-Type', 'application/json'); - xmlhttp.setRequestHeader('Accept', 'application/json'); - xmlhttp.send(payload); -}; - const submit = function(e) { e.preventDefault(); - if (document.getElementById('form').className === 'loading') { + if (document.getElementById('form')?.className === 'loading') { // debounce double clicks return; } setState('loading'); - const url = document.getElementById('form').action; + const url = document.getElementById('form')?.action; const payload = JSON.stringify({ user: getUsername(), - password: document.getElementById(PASSWORD_INPUT_ID).value, + password: document.getElementById(PASSWORD_INPUT_ID)?.value, redirect: getRedirectUrl(), locale: selectedLocale }); @@ -54,7 +48,7 @@ const submit = function(e) { }; const requestTokenLogin = (retry = 20) => { - const url = document.getElementById('tokenLogin').action; + const url = document.getElementById('tokenLogin')?.action; const payload = JSON.stringify({ locale: selectedLocale }); request('POST', url, payload, xmlhttp => { let response = {}; @@ -88,20 +82,19 @@ const requestTokenLogin = (retry = 20) => { const focusOnPassword = function(e) { if (e.keyCode === 13) { e.preventDefault(); - document.getElementById(PASSWORD_INPUT_ID).focus(); + document.getElementById(PASSWORD_INPUT_ID)?.focus(); } }; const focusOnSubmit = function(e) { if (e.keyCode === 13) { - document.getElementById('login').focus(); + document.getElementById('login')?.focus(); } }; const highlightSelectedLocale = function() { const locales = document.getElementsByClassName('locale'); - for (let i = 0; i < locales.length; i++) { - const elem = locales[i]; + for (const elem of locales) { elem.className = (elem.name === selectedLocale) ? 'locale selected' : 'locale'; } }; @@ -114,52 +107,13 @@ const handleLocaleSelection = function(e) { } }; -const getCookie = function(name) { - const cookies = document.cookie && document.cookie.split(';'); - if (cookies) { - for (const cookie of cookies) { - const parts = cookie.trim().split('='); - if (parts[0] === name) { - return parts[1].trim(); - } - } - } -}; - -const getLocale = function() { - const selectedLocale = getCookie('locale'); - const defaultLocale = document.body.getAttribute('data-default-locale'); - const locale = selectedLocale || defaultLocale; - if (translations[locale]) { - return locale; - } - const validLocales = Object.keys(translations); - if (validLocales.length) { - return validLocales[0]; - } - return; -}; - const translate = () => { - if (!selectedLocale) { - return console.error('No enabled locales found - not translating'); - } + baseTranslate(selectedLocale, translations); highlightSelectedLocale(); - document - .querySelectorAll('[translate]') - .forEach(elem => elem.innerText = translations[selectedLocale][elem.getAttribute('translate')]); - document - .querySelectorAll('[translate-title]') - .forEach(elem => elem.title = translations[selectedLocale][elem.getAttribute('translate-title')]); -}; - -const parseTranslations = function() { - const raw = document.body.getAttribute('data-translations'); - return JSON.parse(decodeURIComponent(raw)); }; const getUsername = function() { - return document.getElementById('user').value.toLowerCase().trim(); + return document.getElementById('user')?.value.toLowerCase().trim(); }; const getRedirectUrl = function() { @@ -171,17 +125,6 @@ const getRedirectUrl = function() { } }; -const getUserCtx = function() { - const cookie = getCookie('userCtx'); - if (cookie) { - try { - return JSON.parse(decodeURIComponent(cookie)); - } catch (e) { - console.error('Error parsing cookie', e); - } - } -}; - const checkSession = function() { if (getCookie('login') === 'force') { // require user to login regardless of session state @@ -236,42 +179,36 @@ const checkUnsupportedBrowser = () => { } if (typeof outdatedComponentKey !== 'undefined') { - document.getElementById('unsupported-browser-update').setAttribute('translate', outdatedComponentKey); + document.getElementById('unsupported-browser-update')?.setAttribute('translate', outdatedComponentKey); document.getElementById('unsupported-browser-update').innerText = translations[selectedLocale][outdatedComponentKey]; - document.getElementById('unsupported-browser').classList.remove('hidden'); + document.getElementById('unsupported-browser')?.classList.remove('hidden'); } }; -const togglePassword = () => { - const input = document.getElementById(PASSWORD_INPUT_ID); - input.type = input.type === 'password' ? 'text' : 'password'; - document.getElementById('password-container').classList.toggle('hidden-password'); -}; - document.addEventListener('DOMContentLoaded', function() { translations = parseTranslations(); - selectedLocale = getLocale(); + selectedLocale = getLocale(translations); translate(); - document.getElementById('locale').addEventListener('click', handleLocaleSelection, false); + document.getElementById('locale')?.addEventListener('click', handleLocaleSelection, false); const passwordToggle = document.getElementById('password-toggle'); if (passwordToggle) { - passwordToggle.addEventListener('click', togglePassword, false); + passwordToggle.addEventListener('click', () => togglePassword(PASSWORD_INPUT_ID), false); } if (document.getElementById('tokenLogin')) { requestTokenLogin(); } else { checkSession(); - document.getElementById('login').addEventListener('click', submit, false); + document.getElementById('login')?.addEventListener('click', submit, false); const user = document.getElementById('user'); user.addEventListener('keydown', focusOnPassword, false); user.focus(); - document.getElementById(PASSWORD_INPUT_ID).addEventListener('keydown', focusOnSubmit, false); + document.getElementById(PASSWORD_INPUT_ID)?.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 952de1dc01f..f47dc543fe6 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -101,7 +101,13 @@ form { .tokenmissing .error.missing, .tokentimeout .error.timeout, .tokenexpired .error.expired, -.tokenerror .error.unknown +.tokenerror .error.unknown, +.password-weak .error.password-weak, +.password-short .error.password-short, +.password-mismatch .error.password-mismatch, +.password-required .error.password-required, +.password-same .error.password-same, +.current-password-incorrect .error.current-password-incorrect { display: block; } diff --git a/api/src/routing.js b/api/src/routing.js index 6f1ce353a56..c6f7ae4ba96 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -298,6 +298,8 @@ 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); +app.get(routePrefix + 'password-reset', login.getPasswordReset); +app.postJson(routePrefix + 'password-reset', login.resetPassword); app.get(routePrefix + 'privacy-policy', privacyPolicyController.get); // authorization for `_compact`, `_view_cleanup`, `_revs_limit` endpoints is handled by CouchDB diff --git a/api/src/services/cookie.js b/api/src/services/cookie.js index e78ef7c1901..d655e4f734b 100644 --- a/api/src/services/cookie.js +++ b/api/src/services/cookie.js @@ -42,6 +42,10 @@ const extractCookieAttributes = (cookieString) => { }; module.exports = { + clearCookie: (res, name) => { + const options = getCookieOptions({ httpOnly: true }); + res.clearCookie(name, options); + }, get: (req, name) => { const cookies = req.headers && req.headers.cookie; if (!cookies) { diff --git a/api/src/templates/login/index.html b/api/src/templates/login/index.html index 204b7a7b9c0..8b635eb860a 100644 --- a/api/src/templates/login/index.html +++ b/api/src/templates/login/index.html @@ -44,6 +44,7 @@ <% } %> + diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html new file mode 100644 index 00000000000..876449161fd --- /dev/null +++ b/api/src/templates/login/password-reset.html @@ -0,0 +1,56 @@ + + + + + + {{ branding.name }} + + + + +
+
+ +

+

+
+ + +
+ + +
+ +
+ show-password-ion + hide-password-ion +
+
+ +
+ + +
+ +

+

+

+

+

+ + +
+
+
+ + + + diff --git a/api/src/templates/login/token-login.html b/api/src/templates/login/token-login.html index 9b26748fc3d..b942c78f82c 100644 --- a/api/src/templates/login/token-login.html +++ b/api/src/templates/login/token-login.html @@ -40,6 +40,7 @@
+ diff --git a/api/tests/mocha/auth.spec.js b/api/tests/mocha/auth.spec.js index d0dfea5e319..373319e1f45 100644 --- a/api/tests/mocha/auth.spec.js +++ b/api/tests/mocha/auth.spec.js @@ -5,6 +5,9 @@ const sinon = require('sinon'); const auth = require('../../src/auth'); const config = require('../../src/config'); const environment = require('@medic/environment'); +const db = require('../../src/db'); +const dataContext = require('../../src/services/data-context'); +const { roles, users } = require('@medic/user-management')(config, db, dataContext); let req; @@ -202,4 +205,84 @@ describe('Auth', () => { }); }); + describe('checkPasswordChange', () => { + it('should return immediately for db admin users', () => { + const req = { + userCtx: { + name: 'admin', + roles: ['admin'] + } + }; + + sinon.stub(roles, 'isDbAdmin').returns(true); + + return auth.checkPasswordChange(req).then(() => { + chai.expect(roles.isDbAdmin.callCount).to.equal(1); + chai.expect(roles.isDbAdmin.args[0][0]).to.deep.equal(req.userCtx); + }); + }); + + it('returns error when password change is required', () => { + const req = { + userCtx: { + name: 'user', + roles: ['district_admin'] + } + }; + + sinon.stub(roles, 'isDbAdmin').returns(false); + const getUserDoc = sinon.stub(users, 'getUserDoc').resolves({ + password_change_required: true, + }); + + return auth.checkPasswordChange(req).catch(err => { + chai.expect(err.message).to.equal('Password change required'); + chai.expect(err.code).to.equal(403); + chai.expect(err.error).to.equal('Password change required'); + chai.expect(getUserDoc.callCount).to.equal(1); + chai.expect(getUserDoc.args[0][0]).to.equal('user'); + }); + }); + + it('return no error when password change is not required', () => { + const req = { + userCtx: { + name: 'user', + roles: ['district_admin'] + } + }; + + sinon.stub(roles, 'isDbAdmin').returns(false); + const getUserDoc = sinon.stub(users, 'getUserDoc').resolves({ + password_change_required: false + }); + + return auth.checkPasswordChange(req) + .then(() => { + chai.expect(getUserDoc.callCount).to.equal(1); + chai.expect(getUserDoc.args[0][0]).to.equal('user'); + }); + }); + + it('succeeds when using token login despite password change being required', () => { + const req = { + userCtx: { + name: 'user', + roles: ['district_admin'] + } + }; + + sinon.stub(roles, 'isDbAdmin').returns(false); + const getUserDoc = sinon.stub(users, 'getUserDoc').resolves({ + password_change_required: true, + token_login: true + }); + + return auth.checkPasswordChange(req) + .then(() => { + chai.expect(getUserDoc.callCount).to.equal(1); + chai.expect(getUserDoc.args[0][0]).to.equal('user'); + }); + }); + }); }); diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js index a61e0657af6..34a092cb589 100644 --- a/api/tests/mocha/controllers/login.spec.js +++ b/api/tests/mocha/controllers/login.spec.js @@ -10,7 +10,7 @@ const auth = require('../../../src/auth'); const cookie = require('../../../src/services/cookie'); const branding = require('../../../src/services/branding'); const rateLimit = require('../../../src/services/rate-limit'); -const db = require('../../../src/db').medic; +const db = require('../../../src/db'); const translations = require('../../../src/translations'); const privacyPolicy = require('../../../src/services/privacy-policy'); const config = require('../../../src/config'); @@ -60,6 +60,8 @@ describe('login controller', () => { sinon.stub(rateLimit, 'isLimited').returns(false); sinon.stub(serverUtils, 'rateLimited').resolves(); + sinon.stub(db.medic, 'get'); + sinon.stub(db.users, 'get'); }); afterEach(() => { @@ -154,6 +156,7 @@ describe('login controller', () => { sinon.stub(translations, 'getEnabledLocales').resolves([]); const linkResources = '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script'; const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); const send = sinon.stub(res, 'send'); @@ -176,6 +179,7 @@ describe('login controller', () => { it('when branding doc missing send login page', () => { const linkResources = '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script'; const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); sinon.stub(translations, 'getEnabledLocales').resolves([]); @@ -200,6 +204,7 @@ describe('login controller', () => { sinon.stub(res, 'cookie').returns(res); const readFile = sinon.stub(fs.promises, 'readFile').resolves('file content'); sinon.stub(config, 'translate').returns('TRANSLATED VALUE.'); + sinon.stub(users, 'getUserDoc').resolves(); const template = sinon.stub(_, 'template').returns(sinon.stub()); sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); return controller.get(req, res) // first request @@ -218,12 +223,14 @@ describe('login controller', () => { it('hides locale selector when there is only one option', () => { const linkResources = '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script'; const setHeader = sinon.stub(res, 'setHeader'); sinon.stub(translations, 'getEnabledLocales').resolves([{ code: 'en', name: 'English' }]); sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); const send = sinon.stub(res, 'send'); sinon.stub(fs.promises, 'readFile').resolves('LOGIN PAGE GOES HERE. {{ locales.length }}'); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(config, 'translate').returns('TRANSLATED VALUE.'); sinon.stub(cookie, 'get').returns('en'); return controller.get(req, res).then(() => { @@ -276,6 +283,121 @@ describe('login controller', () => { }); }); + describe('passwordReset', () => { + it('getPasswordReset should render password reset page', () => { + sinon.stub(translations, 'getEnabledLocales').resolves([]); + const linkResources = '; rel=preload; as=style, ' + + '; rel=preload; as=script, ' + + '; rel=preload; as=script'; + const brandingGet = sinon.stub(branding, 'get').resolves(DEFAULT_BRANDING); + const send = sinon.stub(res, 'send'); + const setHeader = sinon.stub(res, 'setHeader'); + sinon.stub(fs.promises, 'readFile').resolves('PASSWORD RESET PAGE GOES HERE. {{ translations }}'); + sinon.stub(config, 'getTranslations').returns({ en: { password: 'Password' } }); + return controller.getPasswordReset(req, res).then(() => { + chai.expect(brandingGet.callCount).to.equal(1); + chai.expect(send.callCount).to.equal(1); + chai.expect(send.args[0][0]) + .to.equal('PASSWORD RESET PAGE GOES HERE. %7B%22en%22%3A%7B%22password%22%3A%22Password%22%7D%7D'); + chai.expect(setHeader.callCount).to.equal(1); + chai.expect(setHeader.args[0][0]).to.equal('Link'); + chai.expect(setHeader.args[0][1]).to.equal(linkResources); + chai.expect(fs.promises.readFile.callCount).to.equal(1); + chai.expect(translations.getEnabledLocales.callCount).to.equal(1); + }); + }); + + it('should return 429 when rate limited', () => { + rateLimit.isLimited.returns(true); + return controller.resetPassword(req, res).then(() => { + chai.expect(rateLimit.isLimited.callCount).to.equal(1); + chai.expect(rateLimit.isLimited.args[0][0]).to.equal(req); + chai.expect(serverUtils.rateLimited.callCount).to.equal(1); + }); + }); + + it('should return 400 if new password is invalid', () => { + req.body = { + username: 'user1', + currentPassword: 'current', + password: 'weak', + locale: 'en' + }; + + const status = sinon.stub(res, 'status').returns(res); + const json = sinon.stub(res, 'json').returns(res); + + return controller.resetPassword(req, res).then(() => { + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(400); + chai.expect(json.callCount).to.equal(1); + chai.expect(json.args[0][0]).to.deep.equal({ + error: 'password-short', + params: { minimum: 8 } + }); + }); + }); + + it('should reset password when it is valid', () => { + req.body = { + username: 'sharon', + currentPassword: 'oldPass', + password: 'newPass123', + locale: 'en' + }; + + const postResponse = { + statusCode: 200, + headers: { 'set-cookie': [ 'AuthSession=abc;' ] } + }; + const post = sinon.stub(request, 'post').resolves(postResponse); + const send = sinon.stub(res, 'send'); + const status = sinon.stub(res, 'status').returns(res); + const cookie = sinon.stub(res, 'cookie').returns(res); + + const userCtx = { name: 'sharon', roles: [ 'project-stuff' ] }; + const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); + + const userDoc = { + name: 'sharon', + type: 'user', + password: 'oldPass' + }; + sinon.stub(users, 'getUserDoc').resolves(userDoc); + sinon.stub(db.users, 'put').resolves(); + sinon.stub(request, 'get').resolves({ + statusCode: 200, + body: { userCtx: { name: 'sharon' } } + }); + + return controller.resetPassword(req, res).then(() => { + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(302); + chai.expect(send.args[0][0]).to.equal('/'); + chai.expect(post.callCount).to.equal(1); + chai.expect(post.args[0][0].url).to.equal('http://test.com:1234/_session'); + chai.expect(post.args[0][0].body.name).to.equal('sharon'); + chai.expect(post.args[0][0].body.password).to.equal('newPass123'); + chai.expect(getUserCtx.callCount).to.equal(1); + chai.expect(getUserCtx.args[0][0].headers.Cookie).to.equal('AuthSession=abc;'); + chai.expect(cookie.callCount).to.equal(3); + chai.expect(cookie.args[0][0]).to.equal('AuthSession'); + chai.expect(cookie.args[0][1]).to.equal('abc'); + chai.expect(cookie.args[1][0]).to.equal('userCtx'); + chai.expect(cookie.args[1][1]).to.equal(JSON.stringify(userCtx)); + chai.expect(cookie.args[2][0]).to.equal('locale'); + chai.expect(cookie.args[2][1]).to.equal('en'); + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'sharon', + type: 'user', + password: 'newPass123', + password_change_required: false + }); + }); + }); + }); + describe('get login/token', () => { it('should render the token login page', () => { sinon.stub(translations, 'getEnabledLocales').resolves([]); @@ -389,6 +511,7 @@ describe('login controller', () => { sinon.stub(res, 'send').returns(res); sinon.stub(res, 'cookie'); sinon.stub(auth, 'getUserSettings').resolves({}); + sinon.stub(users, 'getUserDoc').resolves(); const userCtx = { name: 'user_name', roles: [ 'project-stuff' ] }; sinon.stub(auth, 'getUserCtx') .onCall(0).rejects({ code: 401 }) @@ -430,6 +553,7 @@ describe('login controller', () => { sinon.stub(res, 'status').returns(res); sinon.stub(res, 'cookie'); sinon.stub(res, 'send'); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserSettings').resolves({}); const userCtx = { name: 'user_name', roles: [ 'roles' ] }; sinon.stub(auth, 'getUserCtx') @@ -496,12 +620,17 @@ describe('login controller', () => { }); it('returns invalid credentials', () => { + sinon.stub(users, 'getUserDoc').resolves(); req.body = { user: 'sharon', password: 'p4ss' }; - const post = sinon.stub(request, 'post').resolves({ statusCode: 401 }); + const errorResponse = { + status: 401, + error: 'Not logged in' + }; + sinon.stub(request, 'post').rejects(errorResponse); const status = sinon.stub(res, 'status').returns(res); const json = sinon.stub(res, 'json').returns(res); return controller.post(req, res).then(() => { - chai.expect(post.callCount).to.equal(1); + chai.expect(request.post.callCount).to.equal(1); chai.expect(status.callCount).to.equal(1); chai.expect(status.args[0][0]).to.equal(401); chai.expect(json.callCount).to.equal(1); @@ -530,6 +659,7 @@ describe('login controller', () => { sinon.stub(res, 'status').returns(res); sinon.stub(res, 'send').returns(res); sinon.stub(res, 'cookie'); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').rejects({ code: 401 }); auth.getUserCtx.onCall(9).resolves({ name: 'shazza', roles: [ 'project-stuff' ] }); @@ -611,6 +741,91 @@ describe('login controller', () => { const clearCookie = sinon.stub(res, 'clearCookie').returns(res); const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); + sinon.stub(users, 'getUserDoc').resolves(); + sinon.stub(auth, 'getUserSettings').resolves({}); + return controller.post(req, res).then(() => { + chai.expect(post.callCount).to.equal(1); + chai.expect(post.args[0][0].url).to.equal('http://test.com:1234/_session'); + chai.expect(post.args[0][0].body.name).to.equal('sharon'); + chai.expect(post.args[0][0].body.password).to.equal('p4ss'); + chai.expect(post.args[0][0].auth.user).to.equal('sharon'); + chai.expect(post.args[0][0].auth.pass).to.equal('p4ss'); + chai.expect(getUserCtx.callCount).to.equal(1); + chai.expect(getUserCtx.args[0][0].headers.Cookie).to.equal('AuthSession=abc;'); + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(302); + chai.expect(send.args[0][0]).to.deep.equal('/'); + chai.expect(cookie.callCount).to.equal(3); + chai.expect(cookie.args[0][0]).to.equal('AuthSession'); + chai.expect(cookie.args[0][1]).to.equal('abc'); + chai.expect(cookie.args[0][2]).to.deep.equal({ sameSite: 'lax', secure: false, httpOnly: true }); + chai.expect(cookie.args[1][0]).to.equal('userCtx'); + chai.expect(cookie.args[1][1]).to.equal(JSON.stringify(userCtx)); + chai.expect(cookie.args[1][2]).to.deep.equal({ sameSite: 'lax', secure: false, maxAge: 31536000000 }); + chai.expect(cookie.args[2][0]).to.equal('locale'); + chai.expect(cookie.args[2][1]).to.equal('es'); + chai.expect(cookie.args[2][2]).to.deep.equal({ sameSite: 'lax', secure: false, maxAge: 31536000000 }); + chai.expect(clearCookie.callCount).to.equal(1); + chai.expect(clearCookie.args[0][0]).to.equal('login'); + }); + }); + + it('logs in successfully and redirects to password-reset for new users', () => { + req.body = { user: 'sharon', password: 'p4ss', locale: 'es' }; + const postResponse = { + statusCode: 200, + headers: { 'set-cookie': [ 'AuthSession=abc;' ] } + }; + const post = sinon.stub(request, 'post').resolves(postResponse); + const send = sinon.stub(res, 'send'); + const status = sinon.stub(res, 'status').returns(res); + const cookie = sinon.stub(res, 'cookie').returns(res); + const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; + const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); + sinon.stub(users, 'getUserDoc').resolves({ + name: 'sharon', + type: 'user', + password_change_required: true + }); + sinon.stub(auth, 'getUserSettings').resolves({}); + return controller.post(req, res).then(() => { + chai.expect(post.callCount).to.equal(1); + chai.expect(post.args[0][0].url).to.equal('http://test.com:1234/_session'); + chai.expect(post.args[0][0].body.name).to.equal('sharon'); + chai.expect(post.args[0][0].body.password).to.equal('p4ss'); + chai.expect(getUserCtx.callCount).to.equal(1); + chai.expect(getUserCtx.args[0][0].headers.Cookie).to.equal('AuthSession=abc;'); + chai.expect(status.callCount).to.equal(1); + chai.expect(status.args[0][0]).to.equal(302); + chai.expect(send.args[0][0]).to.deep.equal('/medic/password-reset'); + chai.expect(cookie.callCount).to.equal(2); + chai.expect(cookie.args[0][0]).to.equal('userCtx'); + chai.expect(cookie.args[0][1]).to.equal(JSON.stringify(userCtx)); + chai.expect(cookie.args[0][2]).to.deep.equal({ sameSite: 'lax', secure: false, maxAge: 31536000000 }); + chai.expect(cookie.args[1][0]).to.equal('locale'); + chai.expect(cookie.args[1][1]).to.equal('es'); + chai.expect(cookie.args[1][2]).to.deep.equal({ sameSite: 'lax', secure: false, maxAge: 31536000000 }); + }); + }); + + it('logs in successfully and skips password-reset with can_skip_password_change permission', () => { + req.body = { user: 'sharon', password: 'p4ss', locale: 'es' }; + const postResponse = { + statusCode: 200, + headers: { 'set-cookie': [ 'AuthSession=abc;' ] } + }; + const post = sinon.stub(request, 'post').resolves(postResponse); + const send = sinon.stub(res, 'send'); + const status = sinon.stub(res, 'status').returns(res); + const cookie = sinon.stub(res, 'cookie').returns(res); + const clearCookie = sinon.stub(res, 'clearCookie').returns(res); + const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; + const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); + sinon.stub(users, 'getUserDoc').resolves({ + name: 'sharon', + type: 'user', + password_change_required: false + }); sinon.stub(auth, 'getUserSettings').resolves({}); return controller.post(req, res).then(() => { chai.expect(post.callCount).to.equal(1); @@ -649,6 +864,7 @@ describe('login controller', () => { sinon.stub(res, 'send'); sinon.stub(res, 'status').returns(res); const cookie = sinon.stub(res, 'cookie').returns(res); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').resolves({ name: 'shazza', roles: [ 'project-stuff' ] }); sinon.stub(auth, 'hasAllPermissions').returns(false); sinon.stub(auth, 'getUserSettings').resolves({ }); @@ -674,6 +890,7 @@ describe('login controller', () => { sinon.stub(res, 'send'); sinon.stub(res, 'status').returns(res); const cookie = sinon.stub(res, 'cookie').returns(res); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').resolves({ name: 'shazza', roles: [ 'project-stuff' ] }); sinon.stub(auth, 'hasAllPermissions').returns(false); sinon.stub(auth, 'getUserSettings').resolves({ language: 'fr' }); @@ -702,6 +919,7 @@ describe('login controller', () => { const userCtx = { name: 'shazza', roles: [ 'project-stuff' ] }; const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); const hasAllPermissions = sinon.stub(auth, 'hasAllPermissions').returns(true); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserSettings').resolves({ language: 'es' }); return controller.post(req, res).then(() => { chai.expect(post.callCount).to.equal(1); @@ -728,6 +946,7 @@ describe('login controller', () => { const getUserCtx = sinon.stub(auth, 'getUserCtx').resolves(userCtx); roles.isOnlineOnly.returns(true); sinon.stub(auth, 'hasAllPermissions').returns(true); + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserSettings').resolves({ language: 'es' }); return controller.post(req, res).then(() => { chai.expect(post.callCount).to.equal(1); @@ -757,6 +976,7 @@ describe('login controller', () => { sinon.stub(res, 'status').returns(res); sinon.stub(users, 'createAdmin').resolves(); const userCtx = { name: 'shazza', roles: [ '_admin' ] }; + sinon.stub(users, 'getUserDoc').resolves(); sinon.stub(auth, 'getUserCtx').resolves(userCtx); roles.isOnlineOnly.returns(true); sinon.stub(roles, 'isDbAdmin').returns(true); diff --git a/api/tests/mocha/generate-service-worker.spec.js b/api/tests/mocha/generate-service-worker.spec.js index 169fac460b9..08a6f129dc8 100644 --- a/api/tests/mocha/generate-service-worker.spec.js +++ b/api/tests/mocha/generate-service-worker.spec.js @@ -20,6 +20,7 @@ describe('generate service worker', () => { sinon.stub(resources, 'staticPath').value('/absolute/path/to/build/static/'); sinon.stub(resources, 'webappPath').value('/absolute/path/to/build/static/webapp/'); sinon.stub(loginController, 'renderLogin'); + sinon.stub(loginController, 'renderPasswordReset'); sinon.stub(db.medic, 'get'); sinon.stub(db.medic, 'put'); sinon.stub(extensionLibsService, 'getAll'); @@ -46,6 +47,7 @@ describe('generate service worker', () => { it('should generate the service worker file and update the service worker meta doc', async () => { loginController.renderLogin.resolves('loginpage html'); + loginController.renderPasswordReset.resolves('passwordresetpage html'); extensionLibsService.getAll.resolves([{ name: 'bar.js', data: 'barcode' }]); sinon.stub(workbox, 'generateSW').returns(); db.medic.get.resolves({ _id: 'service-worker-meta' }); @@ -84,6 +86,7 @@ describe('generate service worker', () => { templatedURLs: { '/': [ 'webapp/index.html' ], '/medic/login': 'loginpage html', + '/medic/password-reset': 'passwordresetpage html', '/medic/_design/medic/_rewrite/': [ 'webapp/appcache-upgrade.html' ], diff --git a/api/tests/mocha/middleware/authorization.spec.js b/api/tests/mocha/middleware/authorization.spec.js index 1a3a5af0629..f753403f04e 100644 --- a/api/tests/mocha/middleware/authorization.spec.js +++ b/api/tests/mocha/middleware/authorization.spec.js @@ -14,6 +14,7 @@ describe('Authorization middleware', () => { sinon.stub(auth, 'getUserCtx'); sinon.stub(auth, 'isOnlineOnly'); sinon.stub(auth, 'getUserSettings'); + sinon.stub(auth, 'checkPasswordChange').resolves(true); sinon.stub(serverUtils, 'error'); proxy = { web: sinon.stub().resolves() }; next = sinon.stub().resolves(); @@ -72,43 +73,55 @@ describe('Authorization middleware', () => { }); describe('handleAuthErrors', () => { - it('should not allow authorized with no userCtx', () => { + it('should not allow authorized with no userCtx', async () => { testReq.authorized = true; - middleware.handleAuthErrors(testReq, testRes, next); + await middleware.handleAuthErrors(testReq, testRes, next); next.callCount.should.equal(0); serverUtils.error.callCount.should.equal(1); }); - it('should allow authorized when request has no error and has userctx', () => { + it('should allow authorized when request has no error and has userctx', async () => { testReq.authorized = true; testReq.userCtx = { }; - middleware.handleAuthErrors(testReq, testRes, next); + await middleware.handleAuthErrors(testReq, testRes, next); next.callCount.should.equal(1); serverUtils.error.callCount.should.equal(0); }); - it('should allow non-authorized when request has no auth error', () => { + it('should allow non-authorized when request has no auth error', async () => { testReq.authorized = false; testReq.userCtx = {}; - middleware.handleAuthErrors(testReq, testRes, next); + await middleware.handleAuthErrors(testReq, testRes, next); next.callCount.should.equal(1); serverUtils.error.callCount.should.equal(0); }); - it('should write the auth error', () => { + it('should write the auth error', async () => { testReq.authErr = { some: 'error' }; - middleware.handleAuthErrors(testReq, testRes, next); + await middleware.handleAuthErrors(testReq, testRes, next); next.callCount.should.equal(0); serverUtils.error.callCount.should.equal(1); serverUtils.error.args[0].should.deep.equal([{ some: 'error' }, testReq, testRes]); }); - it('should error when no authErr and no userCtx', () => { - middleware.handleAuthErrors(testReq, testRes, next); + it('should error when no authErr and no userCtx', async () => { + await middleware.handleAuthErrors(testReq, testRes, next); next.callCount.should.equal(0); serverUtils.error.callCount.should.equal(1); serverUtils.error.args[0].should.deep.equal(['Authentication error', testReq, testRes]); }); + it('should error when user password change is required', async () => { + testReq.userCtx = {}; + auth.checkPasswordChange.rejects({ code: 403, message: 'Password change required'}); + await middleware.handleAuthErrors(testReq, testRes, next); + next.callCount.should.equal(0); + serverUtils.error.callCount.should.equal(1); + serverUtils.error.args[0].should.deep.equal([ + { code: 403, message: 'Password change required' }, + testReq, + testRes + ]); + }); }); describe('handleAuthErrorsAllowingAuthorized', () => { @@ -119,10 +132,10 @@ describe('Authorization middleware', () => { serverUtils.error.callCount.should.equal(0); }); - it('should allow non-authorized when the request has no error', () => { + it('should allow non-authorized when the request has no error', async () => { testReq.authorized = false; testReq.userCtx = { }; - middleware.handleAuthErrorsAllowingAuthorized(testReq, testRes, next); + await middleware.handleAuthErrorsAllowingAuthorized(testReq, testRes, next); next.callCount.should.equal(1); serverUtils.error.callCount.should.equal(0); }); diff --git a/api/tests/mocha/services/cookie.spec.js b/api/tests/mocha/services/cookie.spec.js index 75802505e78..5f93443e8e0 100644 --- a/api/tests/mocha/services/cookie.spec.js +++ b/api/tests/mocha/services/cookie.spec.js @@ -12,6 +12,7 @@ describe('cookie service', () => { service = rewire('../../../src/services/cookie'); res = { cookie: sinon.stub(), + clearCookie: sinon.stub(), }; }); @@ -128,6 +129,39 @@ describe('cookie service', () => { }); }); + describe('clearCookie', () => { + it('should clear cookie with correct security options', () => { + sinon.stub(process, 'env').value({}); + const cookieName = 'testCookie'; + service.clearCookie(res, cookieName); + chai.expect(res.clearCookie.callCount).to.equal(1); + chai.expect(res.clearCookie.args[0]).to.deep.equal([ + 'testCookie', + { + sameSite: 'lax', + secure: false, + httpOnly: true + } + ]); + }); + + it('should clear cookie with secure option in production environment', () => { + sinon.stub(process, 'env').value({ NODE_ENV: 'production' }); + service = rewire('../../../src/services/cookie'); + const cookieName = 'testCookie'; + service.clearCookie(res, cookieName); + chai.expect(res.clearCookie.callCount).to.equal(1); + chai.expect(res.clearCookie.args[0]).to.deep.equal([ + 'testCookie', + { + sameSite: 'lax', + secure: true, + httpOnly: true + } + ]); + }); + }); + describe('setSession', () => { it('should work with simple cookie', () => { const cookieString = 'AuthSession=sessionID'; diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 07d76c5b722..04333b9f748 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -93,6 +93,9 @@ "can_access_gateway_api": [ "gateway" ], + "can_aggregate_targets": [ + "chw_supervisor" + ], "can_bulk_delete_reports": [ "program_officer", "chw_supervisor", @@ -122,6 +125,7 @@ "can_create_users": [ "program_officer" ], + "can_default_facility_filter": [], "can_delete_contacts": [ "program_officer", "chw_supervisor", @@ -156,6 +160,9 @@ "chw_supervisor", "chw" ], + "can_export_devices_details": [ + "national_admin" + ], "can_export_all": [ "program_officer", "crfo" @@ -174,7 +181,9 @@ "chw_supervisor", "chw" ], + "can_have_multiple_places": [], "can_log_out_on_android": [], + "can_skip_password_change": [], "can_update_places": [ "program_officer", "chw_supervisor", @@ -188,6 +197,9 @@ "can_update_users": [ "program_officer" ], + "can_upgrade": [ + "program_officer" + ], "can_export_dhis": [ "national_admin", "crfo" @@ -264,6 +276,7 @@ "can_view_tasks_group": [ "chw" ], + "can_view_old_navigation": [], "can_view_unallocated_data_records": [ "gateway", "program_officer", @@ -273,19 +286,7 @@ "can_view_users": [ "program_officer" ], - "can_write_wealth_quintiles": [], - "can_aggregate_targets": [ - "chw_supervisor" - ], - "can_upgrade": [ - "program_officer" - ], - "can_view_old_navigation": [], - "can_default_facility_filter": [], - "can_have_multiple_places": [], - "can_export_devices_details": [ - "national_admin" - ] + "can_write_wealth_quintiles": [] }, "uhc": { "contacts_default_sort": "", diff --git a/config/demo/app_settings.json b/config/demo/app_settings.json index 645ae7d2896..7722dbea4ea 100644 --- a/config/demo/app_settings.json +++ b/config/demo/app_settings.json @@ -90,6 +90,9 @@ "can_access_gateway_api": [ "gateway" ], + "can_aggregate_targets": [ + "chw_supervisor" + ], "can_bulk_delete_reports": [ "program_officer", "chw_supervisor", @@ -162,6 +165,9 @@ "chw_supervisor", "chw" ], + "can_export_dhis": [ + "crfo" + ], "can_export_feedback": [ "program_officer" ], @@ -172,6 +178,7 @@ "chw" ], "can_log_out_on_android": [], + "can_skip_password_change": [], "can_update_places": [ "program_officer", "chw_supervisor", @@ -185,8 +192,8 @@ "can_update_users": [ "program_officer" ], - "can_export_dhis": [ - "crfo" + "can_upgrade": [ + "program_officer" ], "can_verify_reports": [ "program_officer", @@ -269,14 +276,8 @@ "can_view_users": [ "program_officer" ], - "can_write_wealth_quintiles": [], - "can_aggregate_targets": [ - "chw_supervisor" - ], - "can_upgrade": [ - "program_officer" - ], - "can_view_old_navigation": [] + "can_view_old_navigation": [], + "can_write_wealth_quintiles": [] }, "uhc": { "contacts_default_sort": "", diff --git a/shared-libs/user-management/src/index.js b/shared-libs/user-management/src/index.js index 86eecbdf20f..8863aea0e5a 100644 --- a/shared-libs/user-management/src/index.js +++ b/shared-libs/user-management/src/index.js @@ -17,7 +17,8 @@ module.exports = (sourceConfig, sourceDb, sourceDataContext) => { bulkUploadLog, roles, tokenLogin, - users + users, + validatePassword: users.validatePassword }; }; diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 8c53b6750d5..bd9c48bf29d 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -198,15 +198,16 @@ const validateNewUsername = username => { ]); }; -const createUser = (data, response) => { - const user = getUserUpdates(data.username, data); +const createUser = async (data, response) => { + const user = await getUserUpdates(data.username, data, true); user._id = createID(data.username); - return db.users.put(user).then(body => { - response.user = { - id: body.id, - rev: body.rev - }; - }); + return db.users.put(user) + .then(body => { + response.user = { + id: body.id, + rev: body.rev + }; + }); }; const hasUserCreateFlag = doc => doc?.user_for_contact?.create; @@ -423,7 +424,7 @@ const getCommonFieldsUpdates = (userDoc, data) => { if (data.roles) { userDoc.roles = data.roles; } - + if (!_.isUndefined(data.place)) { userDoc.facility_id = data.facility_id; } @@ -452,7 +453,32 @@ const getSettingsUpdates = (username, data) => { return settings; }; -const getUserUpdates = (username, data) => { +const requirePasswordChange = async (username, fullAccess) => { + if (!fullAccess) { + return false; + } + + return !(await isDbAdmin(username)); +}; + +const isPasswordChangeRequired = async (username, data, fullAccess) => { + if (!data.password || tokenLogin.shouldEnableTokenLogin(data)) { + return false; + } + + const passwordChange = await requirePasswordChange(username, fullAccess); + if (!passwordChange) { + return false; + } + + const canSkip = roles.hasAllPermissions(data.roles, ['can_skip_password_change']) || + roles.isDbAdmin(data.roles) || + (data.token_login && data.token_login.active); + + return !canSkip; +}; + +const getUserUpdates = async (username, data, fullAccess = false) => { const ignore = ['type', 'place', 'contact']; const user = { @@ -460,6 +486,10 @@ const getUserUpdates = (username, data) => { type: 'user' }; + if (data.password) { + user.password_change_required = await isPasswordChangeRequired(username, data, fullAccess); + } + USER_EDITABLE_FIELDS.forEach(key => { if (!_.isUndefined(data[key]) && ignore.indexOf(key) === -1) { user[key] = data[key]; @@ -491,6 +521,12 @@ const deleteUser = id => { }; const validatePassword = (password) => { + if (!password) { + return error400( + 'Password is not correct.', + 'password-incorrect' + ) + } if (password.length < PASSWORD_MINIMUM_LENGTH) { return error400( `The password must be at least ${PASSWORD_MINIMUM_LENGTH} characters long.`, @@ -542,15 +578,16 @@ const missingFields = data => { return required.filter(prop => isInvalidProp(prop)); }; -const getUpdatedUserDoc = async (username, data) => getUserDoc(username, 'users') - .then(doc => { - return { - ...doc, - ...getUserUpdates(username, data), - _id: createID(username) - }; - }); - +const getUpdatedUserDoc = async (username, data, fullAccess) => { + return getUserDoc(username, 'users') + .then(async doc => { + return { + ...doc, + ...(await getUserUpdates(username, data, fullAccess)), + _id: createID(username) + }; + }); +}; const getUpdatedSettingsDoc = (username, data) => getUserDoc(username, 'medic') .then(doc => { @@ -561,14 +598,14 @@ const getUpdatedSettingsDoc = (username, data) => getUserDoc(username, 'medic') }; }); -const isDbAdmin = user => { +const isDbAdmin = username => { return couchSettings .getCouchConfig('admins') - .then(admins => admins && !!admins[user.name]); + .then(admins => admins && !!admins[username]); }; const saveUserUpdates = async (user) => { - if (user.password && await isDbAdmin(user)) { + if (user.password && await isDbAdmin(user.name)) { throw error400('Admin passwords must be changed manually in the database'); } const savedDoc = await db.users.put(user); @@ -954,6 +991,7 @@ module.exports = { const facilities = await facility.list([user]); return mapUser(user, userSettings, facilities); }, + getUserDoc: (username) => getUserDoc(username, 'users'), getUserSettings, /* eslint-disable max-len */ /** @@ -1132,7 +1170,7 @@ module.exports = { hydratePayload(data); const [user, userSettings] = await Promise.all([ - getUpdatedUserDoc(username, data), + getUpdatedUserDoc(username, data, fullAccess), getUpdatedSettingsDoc(username, data), ]); @@ -1159,13 +1197,15 @@ module.exports = { */ resetPassword: async (username) => { const password = passwords.generate(); - const user = await getUpdatedUserDoc(username, { password }); + const user = await getUpdatedUserDoc(username, { password }, true); await saveUserUpdates(user); return password; }, validateNewUsername, + validatePassword, + /** * Parses a CSV of users to an array of objects. * diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index 2e4e574d250..3d681cc6cc0 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -140,23 +140,23 @@ describe('Users service', () => { describe('getUserUpdates', () => { - it('enforces name field based on id', () => { + it('enforces name field based on id', async () => { const data = { name: 'sam', email: 'john@gmail.com' }; - const user = service.__get__('getUserUpdates')('john', data); + const user = await service.__get__('getUserUpdates')('john', data); chai.expect(user.name ).to.equal('john'); }); - it('reassigns place and contact fields', () => { + it('reassigns place and contact fields', async () => { const data = { place: 'abc', contact: 'xyz', facility_id: ['abc'], contact_id: 'xyz' }; - const user = service.__get__('getUserUpdates')('john', data); + const user = await service.__get__('getUserUpdates')('john', data); chai.expect(user.place).to.equal(undefined); chai.expect(user.contact).to.equal(undefined); chai.expect(user.facility_id).to.deep.equal(['abc']); @@ -705,6 +705,35 @@ describe('Users service', () => { }); }); + describe('getUserDoc', () => { + const userId = 'org.couchdb.user:steve'; + const userDoc = { + _id: userId, + name: 'steve', + type: 'user', + roles: ['district_admin'] + }; + + it('return user document from users database', async () => { + db.users.get.resolves(userDoc); + + const result = await service.getUserDoc('steve'); + + chai.expect(result).to.deep.equal(userDoc); + chai.expect(db.users.get.calledOnce).to.be.true; + chai.expect(db.users.get.args[0][0]).to.equal(userId); + }); + + it('should throw error when user doc not found', () => { + db.users.get.rejects({ status: 404 }); + + return service.getUserDoc('steve') + .catch(err => { + chai.expect(err.message).to.equal('Failed to find user with name [steve] in the [users] database.'); + }); + }); + }); + describe('getUserSettings', () => { it('returns medic user doc with facility from couchdb user doc', () => { @@ -1302,6 +1331,42 @@ describe('Users service', () => { describe('createUser', () => { + it('should set password_change_required to true for new user creation', () => { + const data = { + username: 'newuser', + password: COMPLEX_PASSWORD, + place: 'x', + contact: { parent: 'x' }, + type: 'national-manager' + }; + + service.__set__('validateNewUsername', sinon.stub().resolves()); + service.__set__('createPlace', sinon.stub().resolves()); + service.__set__('setContactParent', sinon.stub().resolves()); + service.__set__('createContact', sinon.stub().resolves()); + service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + service.__set__('createUserSettings', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); + + couchSettings.getCouchConfig.resolves({ + admin1: 'password_1', + admin2: 'password_2' + }); + + db.users.put.resolves({ id: 'org.couchdb.user:newuser' }); + db.medic.put.resolves({ id: 'org.couchdb.user:newuser' }); + + return service.createUser(data).then(() => { + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'newuser', + type: 'user', + password: COMPLEX_PASSWORD, + password_change_required: true + }); + }); + }); + it('returns error if missing fields', () => { return service.createUser({}) .catch(err => chai.expect(err.code).to.equal(400)) // empty @@ -1976,6 +2041,7 @@ describe('Users service', () => { const usersPut = db.users.put; service.__set__('validateNewUsername', sinon.stub().resolves()); service.__set__('storeUpdatedPlace', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); sinon.stub(places, 'getPlace').resolves({ _id: 'foo' }); medicGet.withArgs('user1') .onFirstCall().rejects({ status: 404 }) @@ -2285,13 +2351,16 @@ describe('Users service', () => { type: 'user', _id: 'org.couchdb.user:x', name: 'x', - password: 'password.123' + password: 'password.123', + password_change_required: false }]]); - chai.expect(roles.hasAllPermissions.args).to.deep.equal([[['national-manager'], ['can_have_multiple_places']]]); + chai.expect(roles.hasAllPermissions.args).to.deep.equal( + [[['national-manager'], ['can_have_multiple_places']], [['national-manager'], ['can_skip_password_change']]]); }); it('succeeds without permission for single facility', async () => { service.__set__('validateNewUsername', sinon.stub().resolves()); + sinon.stub(roles, 'hasAllPermissions').returns(false); sinon.stub(places, 'placesExist').resolves(); sinon.stub(people, 'isAPerson').returns(true); db.medic.put.resolves({ id: 'success' }); @@ -2323,7 +2392,8 @@ describe('Users service', () => { type: 'user', _id: 'org.couchdb.user:x', name: 'x', - password: 'password.123' + password: 'password.123', + password_change_required: true }]]); }); }); @@ -2970,6 +3040,7 @@ describe('Users service', () => { db.medic.put.resolves({}); db.users.put.resolves({}); sinon.stub(roles, 'isOffline').withArgs(['rambler']).returns(false); + sinon.stub(roles, 'hasAllPermissions').returns(false); return service.updateUser('paul', data, true).then(() => { chai.expect(db.medic.put.callCount).to.equal(1); const settings = db.medic.put.args[0][0]; @@ -3108,7 +3179,7 @@ describe('Users service', () => { chai.expect(e.code).to.equal(400); chai.expect(db.medic.put.callCount).to.equal(0); chai.expect(db.users.put.callCount).to.equal(0); - chai.expect(couchSettings.getCouchConfig.calledOnce).to.be.true; + chai.expect(couchSettings.getCouchConfig.callCount).to.equal(2); chai.expect(couchSettings.getCouchConfig.args[0]).to.deep.equal(['admins']); } @@ -3168,10 +3239,58 @@ describe('Users service', () => { name: 'anne', type: 'user', password: COMPLEX_PASSWORD, + password_change_required: true }); - chai.expect(couchSettings.getCouchConfig.callCount).to.equal(1); + chai.expect(couchSettings.getCouchConfig.callCount).to.equal(2); chai.expect(couchSettings.getCouchConfig.args[0]).to.deep.equal(['admins']); }); + + it('should set password_change_required to true when admin updates user password', async () => { + const data = { password: COMPLEX_PASSWORD }; + sinon.stub(roles, 'hasAllPermissions').returns(false); + couchSettings.getCouchConfig.resolves({ + admin1: 'password_1', + admin2: 'password_2', + }); + db.users.get.resolves({ + name: 'user', + type: 'user', + roles: ['district_admin'] + }); + db.medic.get.resolves({}); + db.medic.put.resolves({}); + db.users.put.resolves({}); + + await service.updateUser('user', data, true); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'user', + password: COMPLEX_PASSWORD, + password_change_required: true + }); + }); + + it('should set password_change_required to false when user changes their own password', async () => { + const data = { password: COMPLEX_PASSWORD }; + db.users.get.resolves({ + name: 'user', + type: 'user', + roles: ['district_admin'] + }); + db.medic.get.resolves({}); + db.medic.put.resolves({}); + db.users.put.resolves({}); + + await service.updateUser('user', data, false); + + chai.expect(db.users.put.callCount).to.equal(1); + chai.expect(db.users.put.args[0][0]).to.deep.include({ + name: 'user', + password: COMPLEX_PASSWORD, + password_change_required: false + }); + }); }); describe('validateNewUsername', () => { @@ -3248,7 +3367,12 @@ describe('Users service', () => { chai.expect(err).to.deep.equal({ some: 'err' }); chai.expect(db.users.put.callCount).to.equal(1); chai.expect(db.users.put.args[0]).to.deep.equal([ - { name: 'agatha', type: 'user', roles: ['admin'], _id: 'org.couchdb.user:agatha' } + { + name: 'agatha', + type: 'user', + roles: ['admin'], _id: + 'org.couchdb.user:agatha', + } ]); }); }); @@ -3266,7 +3390,12 @@ describe('Users service', () => { chai.expect(err).to.deep.equal({ some: 'err' }); chai.expect(db.users.put.callCount).to.equal(1); chai.expect(db.users.put.args[0]).to.deep.equal([ - { name: 'agatha', type: 'user', roles: ['admin'], _id: 'org.couchdb.user:agatha' } + { + name: 'agatha', + type: 'user', + roles: ['admin'], + _id: 'org.couchdb.user:agatha', + } ]); chai.expect(db.medic.put.callCount).to.equal(1); chai.expect(db.medic.put.args[0]).to.deep.equal([ @@ -3299,7 +3428,12 @@ describe('Users service', () => { return service.createAdmin({ name: 'perseus' }).then(() => { chai.expect(db.users.put.callCount).to.equal(1); chai.expect(db.users.put.args[0]).to.deep.equal([ - { name: 'perseus', type: 'user', roles: ['admin'], _id: 'org.couchdb.user:perseus' } + { + name: 'perseus', + type: 'user', + roles: ['admin'], + _id: 'org.couchdb.user:perseus', + } ]); chai.expect(db.medic.put.callCount).to.equal(1); chai.expect(db.medic.put.args[0]).to.deep.equal([ @@ -3742,7 +3876,7 @@ describe('Users service', () => { chai.expect(db.users.get.callCount).to.equal(1); chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:sally']); chai.expect(db.users.put.callCount).to.equal(1); - chai.expect(db.users.put.args[0][0]).to.include({ password: expectedPassword, }); + chai.expect(db.users.put.args[0][0]).to.include({ password: expectedPassword, password_change_required: true }); }); it('should throw for admin user', async () => { @@ -3768,7 +3902,7 @@ describe('Users service', () => { chai.expect(db.users.get.callCount).to.equal(1); chai.expect(db.users.get.args[0]).to.deep.equal(['org.couchdb.user:sally']); chai.expect(db.users.put.callCount).to.equal(0); - chai.expect(couchSettings.getCouchConfig.callCount).to.equal(1); + chai.expect(couchSettings.getCouchConfig.callCount).to.equal(2); chai.expect(couchSettings.getCouchConfig.args[0]).to.deep.equal(['admins']); } diff --git a/tests/constants.js b/tests/constants.js index 4620da9301b..2ffafe44bce 100644 --- a/tests/constants.js +++ b/tests/constants.js @@ -2,6 +2,7 @@ const USERNAME = 'admin'; const PASSWORD = 'pass'; const API_HOST = `localhost${process.env.NGINX_HTTPS_PORT ? `:${process.env.NGINX_HTTPS_PORT}` : ''}`; const PROTOCOL = 'https://'; +const NEW_PASSWORD = 'Pa33word'; module.exports = { IS_CI: !!process.env.CI, @@ -34,4 +35,6 @@ module.exports = { USERNAME, PASSWORD, + // After first login, the user's password is updated to this NEW_PASSWORD + NEW_PASSWORD, }; diff --git a/tests/e2e/default/admin/admin-access.wdio-spec.js b/tests/e2e/default/admin/admin-access.wdio-spec.js index 0e5ec16fd14..1f750a44a11 100644 --- a/tests/e2e/default/admin/admin-access.wdio-spec.js +++ b/tests/e2e/default/admin/admin-access.wdio-spec.js @@ -5,7 +5,7 @@ const adminPage = require('@page-objects/default/admin/admin.wdio.page'); const common = require('@page-objects/default/common/common.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); -describe('Acessing the admin app', () => { +describe('Accessing the admin app', () => { const offlineUser = userFactory.build({ username: 'offline-user-admin', isOffline: true }); const parent = placeFactory.place().build({ _id: 'dist1', type: 'district_hospital' }); @@ -31,7 +31,8 @@ describe('Acessing the admin app', () => { const error = '{"code":403,"error":"forbidden","details":"Offline users are not allowed access to this endpoint"}'; await utils.saveDocs([parent]); await utils.createUsers([offlineUser]); - await loginPage.cookieLogin({ ...offlineUser, createUser: false }); + + await loginPage.login({ username: offlineUser.username, password: offlineUser.password }); await common.waitForLoaders(); await browser.url('/admin/#/forms'); diff --git a/tests/e2e/default/contacts/edit.wdio-spec.js b/tests/e2e/default/contacts/edit.wdio-spec.js index 2f7dd5972ad..7be486d9536 100644 --- a/tests/e2e/default/contacts/edit.wdio-spec.js +++ b/tests/e2e/default/contacts/edit.wdio-spec.js @@ -30,6 +30,7 @@ describe('Edit ', () => { roles: ['program_officer'], contact: onlineUserContact }); + const newPassword = loginPage.NEW_PASSWORD; before(async () => { await utils.saveDocs([...places.values()]); @@ -61,7 +62,7 @@ describe('Edit ', () => { }); it('should sync and update the offline user\'s home place', async () => { - await loginPage.login(offlineUser); + await loginPage.login({ username: offlineUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToPeople(); await commonPage.logout(); @@ -73,7 +74,7 @@ describe('Edit ', () => { await commonPage.waitForPageLoaded(); await commonPage.logout(); - await loginPage.login(offlineUser); + await loginPage.login({ username: offlineUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToPeople(); diff --git a/tests/e2e/default/db/db-sync.wdio-spec.js b/tests/e2e/default/db/db-sync.wdio-spec.js index e3d7373402a..ffc7b4a3a2d 100644 --- a/tests/e2e/default/db/db-sync.wdio-spec.js +++ b/tests/e2e/default/db/db-sync.wdio-spec.js @@ -98,7 +98,7 @@ describe('db-sync', () => { body: restrictedUser }); await sentinelUtils.waitForSentinel(); - await loginPage.login({ username: restrictedUserName, password: restrictedPass }); + await loginPage.login({ username: restrictedUserName, password: restrictedPass, resetPassword: false }); await (await commonElements.tabsSelector.analyticsTab()).waitForDisplayed(); }); diff --git a/tests/e2e/default/db/initial-replication.wdio-spec.js b/tests/e2e/default/db/initial-replication.wdio-spec.js index 0b3e7f1d7e8..b93190dc69e 100644 --- a/tests/e2e/default/db/initial-replication.wdio-spec.js +++ b/tests/e2e/default/db/initial-replication.wdio-spec.js @@ -149,7 +149,13 @@ describe('initial-replication', () => { }); it('should support "disconnects"', async () => { - await loginPage.login({ ...userAllowedDocs.user, loadPage: false }); + const newPassword = loginPage.NEW_PASSWORD; + await loginPage.login({ + username: userAllowedDocs.user.username, + password: newPassword, + loadPage: false, + resetPassword: false + }); setTimeout(() => browser.refresh(), 1000); setTimeout(() => browser.refresh(), 3000); setTimeout(() => browser.refresh(), 5000); diff --git a/tests/e2e/default/enketo/repeat.wdio-spec.js b/tests/e2e/default/enketo/repeat.wdio-spec.js index b84bac35d2e..fff8e07612d 100644 --- a/tests/e2e/default/enketo/repeat.wdio-spec.js +++ b/tests/e2e/default/enketo/repeat.wdio-spec.js @@ -7,6 +7,7 @@ const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page describe('RepeatForm', () => { const hierarchy = hierarchyFactory.createHierarchy({ name: 'test', user: true, nbrClinics: 1, nbrPersons: 1 }); + const newPassword = loginPage.NEW_PASSWORD; const assertLabels = async ({ selector, count, labelText }) => { const labels = await $$(selector); @@ -39,45 +40,52 @@ describe('RepeatForm', () => { const melbourneLabelPath = `${selectorPrefix}[data-itext-id="/repeat_translation/basic/rep/city_1/melbourne:label"]`; describe('Repeat form with count input', () => { - - it('should display the initial form and its repeated content in Nepali', async () => { - await browser.url('/'); - const neUserName = 'प्रयोगकर्ताको नाम'; - await loginPage.changeLanguage('ne', neUserName); - await loginPage.login({ username: hierarchy.user.username, password: hierarchy.user.password, locale: 'ne' }); + it('should display the initial form and its repeated content in English', async () => { + const enUserName = 'User name'; + await loginPage.changeLanguage('en', enUserName); + await loginPage.login({ + username: hierarchy.user.username, + password: hierarchy.user.password, + locale: 'en' + }); await openRepeatForm('repeat-translation-count'); - expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state: - NE')).to.be.true; + expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state:')).to.be.true; expect(await commonEnketoPage.getInputValue('How many? NE')).to.equal('1'); - - await assertLabels({ selector: cityLabelPath, count: 1, labelText: 'Select a city: - NE' }); - await assertLabels({ selector: melbourneLabelPath, count: 1, labelText: 'ML (NE)' }); + await assertLabels({ selector: cityLabelPath, count: 1, labelText: 'Select a city:' }); + await assertLabels({ selector: melbourneLabelPath, count: 1, labelText: 'Melbourne' }); await commonEnketoPage.setInputValue('How many? NE', 3); await (await genericForm.formTitle()).click(); - await assertLabels({ selector: cityLabelPath, count: 3, labelText: 'Select a city: - NE' }); - await assertLabels({ selector: melbourneLabelPath, count: 3, labelText: 'ML (NE)' }); + await assertLabels({ selector: cityLabelPath, count: 3, labelText: 'Select a city:' }); + await assertLabels({ selector: melbourneLabelPath, count: 3, labelText: 'Melbourne' }); }); - it('should display the initial form and its repeated content in English', async () => { - const enUserName = 'User name'; - await loginPage.changeLanguage('en', enUserName); - await loginPage.login({ username: hierarchy.user.username, password: hierarchy.user.password, locale: 'en' }); + it('should display the initial form and its repeated content in Nepali', async () => { + await browser.url('/'); + const neUserName = 'प्रयोगकर्ताको नाम'; + await loginPage.changeLanguage('ne', neUserName); + await loginPage.login({ + username: hierarchy.user.username, + password: newPassword, + locale: 'ne', + resetPassword: false + }); await openRepeatForm('repeat-translation-count'); - expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state:')).to.be.true; + expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state: - NE')).to.be.true; expect(await commonEnketoPage.getInputValue('How many? NE')).to.equal('1'); - await assertLabels({ selector: cityLabelPath, count: 1, labelText: 'Select a city:' }); - await assertLabels({ selector: melbourneLabelPath, count: 1, labelText: 'Melbourne' }); + await assertLabels({ selector: cityLabelPath, count: 1, labelText: 'Select a city: - NE' }); + await assertLabels({ selector: melbourneLabelPath, count: 1, labelText: 'ML (NE)' }); await commonEnketoPage.setInputValue('How many? NE', 3); await (await genericForm.formTitle()).click(); - await assertLabels({ selector: cityLabelPath, count: 3, labelText: 'Select a city:' }); - await assertLabels({ selector: melbourneLabelPath, count: 3, labelText: 'Melbourne' }); + await assertLabels({ selector: cityLabelPath, count: 3, labelText: 'Select a city: - NE' }); + await assertLabels({ selector: melbourneLabelPath, count: 3, labelText: 'ML (NE)' }); }); }); @@ -91,7 +99,12 @@ describe('RepeatForm', () => { await browser.url('/'); const swUserName = 'Jina la mtumizi'; await loginPage.changeLanguage('sw', swUserName); - await loginPage.login({ username: hierarchy.user.username, password: hierarchy.user.password, locale: 'sw' }); + await loginPage.login({ + username: hierarchy.user.username, + password: newPassword, + locale: 'sw', + resetPassword: false + }); await openRepeatForm('repeat-translation-button'); expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state: - SV')).to.be.true; @@ -109,7 +122,12 @@ describe('RepeatForm', () => { it('should display the initial form and its repeated content in English', async () => { const enUserName = 'User name'; await loginPage.changeLanguage('en', enUserName); - await loginPage.login({ username: hierarchy.user.username, password: hierarchy.user.password, locale: 'en' }); + await loginPage.login({ + username: hierarchy.user.username, + password: newPassword, + locale: 'en', + resetPassword: false + }); await openRepeatForm('repeat-translation-button'); expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state:')).to.be.true; @@ -131,7 +149,12 @@ describe('RepeatForm', () => { await browser.url('/'); const swUserName = 'Jina la mtumizi'; await loginPage.changeLanguage('sw', swUserName); - await loginPage.login({ username: hierarchy.user.username, password: hierarchy.user.password, locale: 'sw' }); + await loginPage.login({ + username: hierarchy.user.username, + password: newPassword, + locale: 'sw', + resetPassword: false + }); await openRepeatForm('repeat-translation-select'); expect(await commonEnketoPage.isElementDisplayed('label', 'Washington')).to.be.true; @@ -144,7 +167,6 @@ describe('RepeatForm', () => { expect(await commonEnketoPage.isElementDisplayed('label', 'Seattle')).to.be.true; expect(await commonEnketoPage.isElementDisplayed('label', 'Redmond')).to.be.true; - }); }); }); diff --git a/tests/e2e/default/logging/logging.wdio-spec.js b/tests/e2e/default/logging/logging.wdio-spec.js index b5a309c62c5..e4acdae730f 100644 --- a/tests/e2e/default/logging/logging.wdio-spec.js +++ b/tests/e2e/default/logging/logging.wdio-spec.js @@ -10,7 +10,7 @@ describe('audit log', () => { it('should mask password on login', async () => { const collectAuditLogs = await utils.collectHaproxyLogs(/POST,\/_session/); - await loginPage.login(auth); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); const auditLogs = (await collectAuditLogs()).filter(log => log.length); expect(auditLogs.length).to.equal(1); expect(auditLogs[0]).to.contain(`{"name":"${constants.USERNAME}","password":"***"}`); diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index e8fdbe0bfdc..5f6bed2dbd8 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -3,8 +3,10 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const modalPage = require('@page-objects/default/common/modal.wdio.page'); const constants = require('@constants'); const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const userFactory = require('@factories/cht/users/users'); -describe('Login page funcionality tests', () => { +describe('Login page functionality tests', () => { const auth = { username: constants.USERNAME, password: constants.PASSWORD @@ -69,13 +71,13 @@ describe('Login page funcionality tests', () => { }); it('should log in using username and password fields', async () => { - await loginPage.login(auth); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); }); it('should set correct cookies', async () => { - await loginPage.login(auth); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); const cookies = await browser.getCookies(); @@ -114,7 +116,7 @@ describe('Login page funcionality tests', () => { it('should display the "session expired" modal and redirect to login page', async () => { // Login and ensure it's redirected to webapp - await loginPage.login(auth); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); // Delete cookies and trigger a request to the server await browser.deleteCookies('AuthSession'); @@ -132,17 +134,22 @@ describe('Login page funcionality tests', () => { }); it('should try to sign in with blank password and verify that credentials were incorrect', async () => { - await loginPage.login({ username: WRONG_USERNAME, password: '', loadPage: false }); + await loginPage.login({ username: WRONG_USERNAME, password: '', loadPage: false, resetPassword: false }); expect(await loginPage.getErrorMessage()).to.equal(INCORRECT_CREDENTIALS_TEXT); }); it('should try to sign in with blank auth and verify that credentials were incorrect', async () => { - await loginPage.login({ username: '', password: '', loadPage: false }); + await loginPage.login({ username: '', password: '', loadPage: false, resetPassword: false }); expect(await loginPage.getErrorMessage()).to.equal(INCORRECT_CREDENTIALS_TEXT); }); it('should try to sign in and verify that credentials were incorrect', async () => { - await loginPage.login({ username: WRONG_USERNAME, password: WRONG_PASSWORD, loadPage: false }); + await loginPage.login({ + username: WRONG_USERNAME, + password: WRONG_PASSWORD, + loadPage: false, + resetPassword: false + }); expect(await loginPage.getErrorMessage()).to.equal(INCORRECT_CREDENTIALS_TEXT); }); @@ -161,7 +168,98 @@ describe('Login page funcionality tests', () => { expect(revealedPassword.type).to.equal('text'); expect(revealedPassword.value).to.equal('pass-456'); - await loginPage.login(auth); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); + await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); + }); + }); + + describe('Password Reset', () => { + const CURRENT_PASSWORD_INCORRECT = 'Current password is not correct'; + const PASSWORD_MISSING = 'The password must be at least 8 characters long.'; + const PASSWORD_WEAK = 'The password is too easy to guess. Include a range of characters to make it more complex.'; + const PASSWORD_MISMATCH = 'Password and confirm password must match'; + const PASSWORD_SAME = 'New password must be different from current password'; + const NEW_PASSWORD = 'Pa33word1'; + const places = placeFactory.generateHierarchy(); + const districtHospital = places.get('district_hospital'); + const user = userFactory.build({ place: districtHospital._id, roles: ['chw'] }); + + before(async () => { + await utils.saveDocs([...places.values()]); + await utils.createUsers([user]); + }); + + after(async () => { + await utils.deleteUsers([user]); + }); + + it('should try to reset password and verify password is missing', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.setPasswordValue(''); + await loginPage.setConfirmPasswordValue(''); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-short')).to.equal(PASSWORD_MISSING); + }); + + it('should try to reset password and verify confirm password is missing', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.setPasswordValue(user.password); + await loginPage.setConfirmPasswordValue(''); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-mismatch')).to.equal(PASSWORD_MISMATCH); + }); + + it('should try to reset password and verify password strength', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.setPasswordValue('12345678'); + await loginPage.setConfirmPasswordValue('12345678'); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-weak')).to.equal(PASSWORD_WEAK); + }); + + it('should try to reset password and verify current password is not correct', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.setCurrentPasswordValue('12'); + await loginPage.setPasswordValue(user.password); + await loginPage.setConfirmPasswordValue(user.password); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('current-password-incorrect')).to.equal( + CURRENT_PASSWORD_INCORRECT + ); + }); + + it('should try to reset password and verify current password is not same as new password', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.setCurrentPasswordValue(user.password); + await loginPage.setPasswordValue(user.password); + await loginPage.setConfirmPasswordValue(user.password); + await (await loginPage.updatePasswordButton()).click(); + expect(await loginPage.getPasswordResetErrorMessage('password-same')).to.equal(PASSWORD_SAME); + }); + + it('should reset password successfully and redirect to webapp', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.passwordReset(user.password, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); + await commonPage.waitForPageLoaded(); await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); }); }); diff --git a/tests/e2e/default/navigation/bfcache.wdio-spec.js b/tests/e2e/default/navigation/bfcache.wdio-spec.js index c5c20a466e7..bf8074ad23a 100644 --- a/tests/e2e/default/navigation/bfcache.wdio-spec.js +++ b/tests/e2e/default/navigation/bfcache.wdio-spec.js @@ -10,6 +10,7 @@ describe('bfcache', () => { username: constants.USERNAME, password: constants.PASSWORD, createUser: true, + resetPassword: false, }); }); diff --git a/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js b/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js index d657552dffa..42754048631 100644 --- a/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js +++ b/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js @@ -11,6 +11,7 @@ describe('Privacy policy', () => { const englishTexts = privacyPolicyFactory.english; const frenchTexts = privacyPolicyFactory.french; const spanishTexts = privacyPolicyFactory.spanish; + const newPassword = loginPage.NEW_PASSWORD; const users = [ userFactory.build({ username: 'offlineuser', isOffline: true }), @@ -51,14 +52,20 @@ describe('Privacy policy', () => { it('should not show on subsequent login', async () => { await commonPage.reloadSession(); - await loginPage.login({ username: user.username, password: user.password }); + await loginPage.login({ username: user.username, password: newPassword, resetPassword: false }); await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); expect(await (await privacyPage.privacyWrapper()).isDisplayed()).to.not.be.true; }); it('should show french policy on secondary login', async () => { await commonPage.reloadSession(); - await loginPage.login({ username: user.username, password: user.password, locale: 'fr', privacyPolicy: true }); + await loginPage.login({ + username: user.username, + password: newPassword, + locale: 'fr', + privacyPolicy: true, + resetPassword: false + }); await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), frenchTexts); expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; }); diff --git a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js index 2ce7e5ccf8a..6ffa17841b3 100644 --- a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js +++ b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js @@ -115,7 +115,11 @@ describe('Reports tab breadcrumbs', () => { }); it('should not remove facility from breadcrumbs when offline user has many facilities associated', async () => { - await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await loginPage.login({ + password: userWithManyPlacesPass, + username: userWithManyPlaces.name, + resetPassword: false + }); await commonElements.waitForPageLoaded(); await commonElements.goToReports(); await (await reportsPage.leftPanelSelectors.firstReport()).waitForDisplayed(); diff --git a/tests/e2e/default/reports/sidebar-filter.wdio-spec.js b/tests/e2e/default/reports/sidebar-filter.wdio-spec.js index 5600e6f79fc..d3d120b80e4 100644 --- a/tests/e2e/default/reports/sidebar-filter.wdio-spec.js +++ b/tests/e2e/default/reports/sidebar-filter.wdio-spec.js @@ -10,6 +10,7 @@ const personFactory = require('@factories/cht/contacts/person'); const reportFactory = require('@factories/cht/reports/generic-report'); describe('Reports Sidebar Filter', () => { + const newPassword = loginPage.NEW_PASSWORD; const places = placeFactory.generateHierarchy(); const districtHospital = places.get('district_hospital'); @@ -113,7 +114,7 @@ describe('Reports Sidebar Filter', () => { }); it('should filter by form', async () => { - await loginPage.login(districtHospitalUser); + await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); @@ -131,7 +132,7 @@ describe('Reports Sidebar Filter', () => { }); it('should filter by place', async () => { - await loginPage.login(districtHospitalUser); + await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); @@ -149,7 +150,7 @@ describe('Reports Sidebar Filter', () => { }); it('should filter by status', async () => { - await loginPage.login(districtHospitalUser); + await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); diff --git a/tests/e2e/default/service-worker/service-worker.wdio-spec.js b/tests/e2e/default/service-worker/service-worker.wdio-spec.js index e3d4e41aa53..ed246540e78 100644 --- a/tests/e2e/default/service-worker/service-worker.wdio-spec.js +++ b/tests/e2e/default/service-worker/service-worker.wdio-spec.js @@ -65,9 +65,17 @@ describe('Service worker cache', () => { const district = placeFactory.generateHierarchy(['district_hospital']).get('district_hospital'); const chw = userFactory.build({ place: district._id }); + const newPassword = loginPage.NEW_PASSWORD; + let passwordChangeRequired = true; const login = async () => { - await loginPage.login(chw); + await browser.throttle('online'); + const loginParams = passwordChangeRequired + ? chw + : { ...chw, password: newPassword, resetPassword: false }; + + passwordChangeRequired = false; + await loginPage.login(loginParams); await commonPage.waitForPageLoaded(); }; @@ -104,7 +112,6 @@ describe('Service worker cache', () => { it('confirm initial list of cached resources', async () => { const cacheDetails = await getCachedRequests(); - expect(cacheDetails.name.startsWith('cht-precache-v2-')).to.be.true; expect(cacheDetails.urls.sort()).to.have.members([ '/', @@ -128,15 +135,18 @@ describe('Service worker cache', () => { '/img/icon.png', '/img/icon-back.svg', '/img/layers.png', + '/login/auth-utils.js', '/login/images/hide-password.svg', '/login/images/show-password.svg', '/login/lib-bowser.js', + '/login/password-reset.js', '/login/script.js', '/login/style.css', '/main.js', '/manifest.json', '/medic/_design/medic/_rewrite/', '/medic/login', + '/medic/password-reset', '/polyfills.js', '/runtime.js', '/scripts.js', diff --git a/tests/e2e/default/sms/messages-sender-data.wdio-spec.js b/tests/e2e/default/sms/messages-sender-data.wdio-spec.js index e610a647144..d9b9c2e2a38 100644 --- a/tests/e2e/default/sms/messages-sender-data.wdio-spec.js +++ b/tests/e2e/default/sms/messages-sender-data.wdio-spec.js @@ -82,7 +82,11 @@ describe('Message Tab - Sender Data', () => { }); it('should not remove facility from breadcrumbs when offline user has many facilities associated', async () => { - await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await loginPage.login({ + password: userWithManyPlacesPass, + username: userWithManyPlaces.name, + resetPassword: false + }); await commonElements.waitForPageLoaded(); await commonElements.goToMessages(); await messagesPage.sendMessageDesktop('Contact', patient.phone, patient.name); @@ -105,7 +109,8 @@ describe('Message Tab - Sender Data', () => { }); it('should display conversation with link and navigate to contact', async () => { - await loginPage.login(offlineUser); + const newPassword = loginPage.NEW_PASSWORD; + await loginPage.login({ password: newPassword, username: offlineUser.username, resetPassword: false }); await commonElements.waitForPageLoaded(); await commonElements.goToMessages(); @@ -118,5 +123,4 @@ describe('Message Tab - Sender Data', () => { const title = await contactsPage.getContactInfoName(); expect(title).to.equal(patient.name); }); - }); diff --git a/tests/e2e/default/targets/target-aggregates.wdio-spec.js b/tests/e2e/default/targets/target-aggregates.wdio-spec.js index 54e0ba9c9be..439b61c8052 100644 --- a/tests/e2e/default/targets/target-aggregates.wdio-spec.js +++ b/tests/e2e/default/targets/target-aggregates.wdio-spec.js @@ -54,6 +54,7 @@ describe('Target aggregates', () => { }); describe('User with one or more places assigned', () => { + const newPassword = loginPage.NEW_PASSWORD; const CURRENT_PERIOD = 'This month'; const NAMES_DH1 = ['Clarissa', 'Prometheus', 'Alabama', 'Jasmine', 'Danielle']; const NAMES_DH2 = ['Viviana', 'Ximena', 'Esteban', 'Luis', 'Marta']; @@ -123,12 +124,9 @@ describe('Target aggregates', () => { }); describe('Online user with one place associated', () => { - beforeEach(async () => { + it('should display no data when no targets are uploaded', async () => { await loginPage.login(onlineUser); await commonPage.waitForPageLoaded(); - }); - - it('should display no data when no targets are uploaded', async () => { await helperFunctions.updateAggregateTargetsSettings( targetAggregatesConfig.TARGETS_CONFIG_WITH_AND_WITHOUT_AGGREGATES, onlineUser ); @@ -155,6 +153,8 @@ describe('Target aggregates', () => { }); it('should display correct data', async () => { + await loginPage.login({ username: onlineUser.username, password: newPassword, resetPassword: false}); + await commonPage.waitForPageLoaded(); const expectedTargets = targetAggregatesConfig.EXPECTED_DEFAULTS_TARGETS; await utils.saveDocs(targetDocs); @@ -195,6 +195,8 @@ describe('Target aggregates', () => { }); it('should route to contact-detail on list item click and display contact summary target card', async () => { + await loginPage.login({ username: onlineUser.username, password: newPassword, resetPassword: false}); + await commonPage.waitForPageLoaded(); const targetsConfig = [ { id: 'a_target', type: 'count', title: { en: 'what a target!' }, aggregate: true }, { id: 'b_target', type: 'percent', title: { en: 'the most target' }, aggregate: true }, @@ -287,6 +289,8 @@ describe('Target aggregates', () => { }); it('should display targets of current user on home place', async () => { + await loginPage.login({ username: onlineUser.username, password: newPassword, resetPassword: false}); + await commonPage.waitForPageLoaded(); const targetsConfig = [ { id: 'a_target', type: 'count', title: { en: 'what a target!' }, aggregate: true }, { id: 'b_target', type: 'percent', title: { en: 'the most target' }, aggregate: true }, @@ -347,7 +351,11 @@ describe('Target aggregates', () => { describe('Offline user with multiple places associated', () => { beforeEach(async () => { - await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await loginPage.login({ + password: userWithManyPlacesPass, + username: userWithManyPlaces.name, + resetPassword: false + }); await commonPage.waitForPageLoaded(); }); diff --git a/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js index 7e4c883a9ad..cf88c99d659 100644 --- a/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js @@ -91,6 +91,7 @@ describe('Tasks tab breadcrumbs', () => { }); describe('for chw', () => { + const newPassword = loginPage.NEW_PASSWORD; afterEach(async () => await commonElements.logout()); after(async () => { @@ -99,7 +100,11 @@ describe('Tasks tab breadcrumbs', () => { }); it('should not remove facility from breadcrumbs when offline user has many facilities associated', async () => { - await loginPage.login({ password: userWithManyPlacesPass, username: userWithManyPlaces.name }); + await loginPage.login({ + password: userWithManyPlacesPass, + username: userWithManyPlaces.name, + resetPassword: false + }); await commonPage.waitForPageLoaded(); await commonPage.goToTasks(); const infos = await tasksPage.getTasksListInfos(await tasksPage.getTasks()); @@ -161,7 +166,7 @@ describe('Tasks tab breadcrumbs', () => { }); it('should open task with expression', async () => { - await loginPage.login(chw); + await loginPage.login({ username: chw.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToTasks(); const task = await tasksPage.getTaskByContactAndForm('patient1', 'person_create'); diff --git a/tests/e2e/default/tasks/tasks.wdio-spec.js b/tests/e2e/default/tasks/tasks.wdio-spec.js index 03a972a974e..298ca55b00d 100644 --- a/tests/e2e/default/tasks/tasks.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks.wdio-spec.js @@ -13,7 +13,7 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const chtDbUtils = require('@utils/cht-db'); describe('Tasks', () => { - + const newPassword = loginPage.NEW_PASSWORD; const places = placeFactory.generateHierarchy(); const clinic = places.get('clinic'); const healthCenter1 = places.get('health_center'); @@ -59,17 +59,14 @@ describe('Tasks', () => { await chtConfUtils.compileAndUploadAppForms(formsPath); }); - beforeEach(async () => { - await loginPage.login(chw); - await commonPage.waitForPageLoaded(); - }); - afterEach(async () => { await commonPage.logout(); await utils.revertSettings(true); }); it('should remove task from list when CHW completes a task successfully', async () => { + await loginPage.login(chw); + await commonPage.waitForPageLoaded(); await tasksPage.compileTasks('tasks-breadcrumbs-config.js', true); await commonPage.goToTasks(); @@ -86,6 +83,8 @@ describe('Tasks', () => { }); it('should add a task when CHW completes a task successfully, and that task creates another task', async () => { + await loginPage.login({ username: chw.username, password: newPassword, resetPassword: false }); + await commonPage.waitForPageLoaded(); await tasksPage.compileTasks('tasks-breadcrumbs-config.js', false); await commonPage.goToTasks(); @@ -104,6 +103,8 @@ describe('Tasks', () => { }); it('should load multiple pages of tasks on infinite scrolling', async () => { + await loginPage.login({ username: chw.username, password: newPassword, resetPassword: false }); + await commonPage.waitForPageLoaded(); await tasksPage.compileTasks('tasks-multiple-config.js', true); await commonPage.goToTasks(); @@ -133,6 +134,8 @@ describe('Tasks', () => { }); it('Should show error message for bad config', async () => { + await loginPage.login({ username: chw.username, password: newPassword, resetPassword: false }); + await commonPage.waitForPageLoaded(); await tasksPage.compileTasks('tasks-error-config.js', true); await commonPage.goToTasks(); diff --git a/tests/e2e/default/telemetry/telemetry.wdio-spec.js b/tests/e2e/default/telemetry/telemetry.wdio-spec.js index 52dc14ea491..86c7351988f 100644 --- a/tests/e2e/default/telemetry/telemetry.wdio-spec.js +++ b/tests/e2e/default/telemetry/telemetry.wdio-spec.js @@ -107,8 +107,10 @@ describe('Telemetry', () => { await commonPage.sync(); const clientDdoc = await utils.getDoc('_design/medic-client'); - - const options = { auth: { username: user.username, password: user.password }, userName: user.username }; + + // After first login, the user's password is updated, we use the newPassword + const newPassword = loginPage.NEW_PASSWORD; + const options = { auth: { username: user.username, password: newPassword }, userName: user.username }; const metaDocs = await utils.requestOnTestMetaDb({ ...options, path: '/_all_docs?include_docs=true' }); const telemetryEntry = metaDocs.rows.find(row => row.id.startsWith(TELEMETRY_PREFIX)); diff --git a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js index 8597e8b2b7b..c39a18163fc 100644 --- a/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js +++ b/tests/e2e/default/transitions/create-user-for-contacts.replace-user.wdio-spec.js @@ -18,6 +18,7 @@ describe('Create user for contacts', () => { const REPLACE_USER_FORM_ID = 'replace_user'; const OTHER_REPLACE_FORM_ID = 'other_replace_form'; const DISABLED_USER_PASSWORD = 'n3wPassword!'; + const newPassword = loginPage.NEW_PASSWORD; const USER_CONTACT = utils.deepFreeze(personFactory.build({ role: 'chw' })); @@ -255,6 +256,8 @@ describe('Create user for contacts', () => { await commonPage.sync(); await commonPage.goToReports(); const basicReportId3 = await createUserForContactsPage.submitBasicForm(); + await commonPage.sync(); + await sentinelUtils.waitForSentinel(); const basicReport3 = await utils.getDoc(basicReportId3); // New reports written by the old user are not re-parented expect(basicReport3.contact._id).to.equal(originalContactId); @@ -628,7 +631,7 @@ describe('Create user for contacts', () => { await commonPage.logout(); // Log back in as original user and sync - await loginPage.login(ORIGINAL_USER); + await loginPage.login({ username: ORIGINAL_USER.username, password: newPassword, resetPassword: false }); await commonPage.sync(); // The user should not be logged out since the replacement was in conflict await commonPage.goToPeople(originalContactId); @@ -758,8 +761,9 @@ describe('Create user for contacts', () => { // Subsequent form reports are *not* re-parented to the new contact await commonPage.reloadSession(); - await loginPage.login(ORIGINAL_USER); + await loginPage.login({ username: ORIGINAL_USER.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); + await commonPage.sync(); await browser.throttle('offline'); const finalOriginalContactLocal = await chtDbUtils.getDoc(originalContactId); assertOriginalContactUpdated(finalOriginalContactLocal, ORIGINAL_USER.username, replacementContactId, 'ERROR'); @@ -798,7 +802,7 @@ describe('Create user for contacts', () => { const updatedOriginalContact = await utils.getDoc(originalContactId); expect(updatedOriginalContact.user_for_contact).to.be.undefined; // Can still login as original user - const resp1 = await submitLoginRequest(ONLINE_USER); + const resp1 = await submitLoginRequest({ username: ONLINE_USER.username, password: newPassword }); expect(resp1.statusCode).to.equal(302); // New user not created const newUserSettings = await utils.getUserSettings({ contactId: replacementContactId }); diff --git a/tests/e2e/default/users/create-meta-db.wdio-spec.js b/tests/e2e/default/users/create-meta-db.wdio-spec.js index da553004b19..33c0058e4ea 100644 --- a/tests/e2e/default/users/create-meta-db.wdio-spec.js +++ b/tests/e2e/default/users/create-meta-db.wdio-spec.js @@ -10,8 +10,10 @@ describe('Create user meta db : ', () => { const FULL_NAME = 'Roger Milla'; const PASSWORD = 'StrongP@ssword1'; + // After first login, the user's password is updated, we use the newPassword + const newPassword = loginPage.NEW_PASSWORD; const OPTIONS = { - auth: { username: USERNAME, password: PASSWORD }, + auth: { username: USERNAME, password: newPassword }, method: 'GET', userName: USERNAME }; diff --git a/tests/e2e/upgrade/admin-user.wdio-spec.js b/tests/e2e/upgrade/admin-user.wdio-spec.js index 20445b214a2..222b095b7f0 100644 --- a/tests/e2e/upgrade/admin-user.wdio-spec.js +++ b/tests/e2e/upgrade/admin-user.wdio-spec.js @@ -24,7 +24,8 @@ describe('admin users', () => { await loginPage.login({ username: adminUser.username, password: adminUser.password, - adminApp: true + adminApp: true, + resetPassword: false, }); await utils.deleteUsers([adminUser]); diff --git a/tests/e2e/upgrade/upgrade.wdio-spec.js b/tests/e2e/upgrade/upgrade.wdio-spec.js index 451aab3611a..25082db2a15 100644 --- a/tests/e2e/upgrade/upgrade.wdio-spec.js +++ b/tests/e2e/upgrade/upgrade.wdio-spec.js @@ -59,11 +59,21 @@ describe('Performing an upgrade', () => { // are not compatible with older versions of the app. await loginPage.login({ username: docs.user.username, password: docs.user.password }); await commonPage.logout(); - await loginPage.login({ username: constants.USERNAME, password: constants.PASSWORD, adminApp: true }); + await loginPage.login({ + username: constants.USERNAME, + password: constants.PASSWORD, + adminApp: true, + resetPassword: false, + }); return; } - await loginPage.login({ username: constants.USERNAME, password: constants.PASSWORD, loadPage: false }); + await loginPage.login({ + username: constants.USERNAME, + password: constants.PASSWORD, + loadPage: false, + resetPassword: false, + }); await oldNavigationPage.goToBase(); }); @@ -130,7 +140,12 @@ describe('Performing an upgrade', () => { }); it('should display upgrade page even without upgrade logs', async () => { - await loginPage.login({ username: constants.USERNAME, password: constants.PASSWORD, adminApp: true }); + await loginPage.login({ + username: constants.USERNAME, + password: constants.PASSWORD, + adminApp: true, + resetPassword: false, + }); await deleteUpgradeLogs(); await upgradePage.goToUpgradePage(); diff --git a/tests/e2e/upgrade/webapp.wdio-spec.js b/tests/e2e/upgrade/webapp.wdio-spec.js index b1c061b409e..193991b1750 100644 --- a/tests/e2e/upgrade/webapp.wdio-spec.js +++ b/tests/e2e/upgrade/webapp.wdio-spec.js @@ -34,7 +34,12 @@ describe('Webapp after upgrade', () => { }); it('should login with admin account', async () => { - await loginPage.login({ username: constants.USERNAME, password: constants.PASSWORD, adminApp: true }); + await loginPage.login({ + username: constants.USERNAME, + password: constants.PASSWORD, + adminApp: true, + resetPassword: false, + }); }); it('report page should display one report', async () => { diff --git a/tests/integration/api/controllers/all-docs.spec.js b/tests/integration/api/controllers/all-docs.spec.js index f67f88b71cd..c2e9f1d9b8e 100644 --- a/tests/integration/api/controllers/all-docs.spec.js +++ b/tests/integration/api/controllers/all-docs.spec.js @@ -7,6 +7,7 @@ chai.use(chaiExclude); const expect = chai.expect; const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -107,6 +108,7 @@ describe('all_docs handler', () => { before(async () => { await utils.saveDoc(parentPlace); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -118,13 +120,13 @@ describe('all_docs handler', () => { beforeEach(() => { offlineRequestOptions = { path: '/_all_docs', - auth: { username: 'offline', password }, + auth: { username: 'offline', password: newPassword }, method: 'GET' }; onlineRequestOptions = { path: '/_all_docs', - auth: { username: 'online', password }, + auth: { username: 'online', password: newPassword }, method: 'GET' }; }); @@ -145,7 +147,7 @@ describe('all_docs handler', () => { it('filters offline users results', () => { const supervisorRequestOptions = { path: '/_all_docs', - auth: { username: 'supervisor', password }, + auth: { username: 'supervisor', password: newPassword }, method: 'GET' }; const lineage = { _id: 'PARENT_PLACE' }; @@ -573,7 +575,7 @@ describe('all_docs handler', () => { const supervisorRequestOptions = { path: '/_all_docs', - auth: { username: 'supervisor', password }, + auth: { username: 'supervisor', password: newPassword }, method: 'GET' }; diff --git a/tests/integration/api/controllers/bulk-docs.spec.js b/tests/integration/api/controllers/bulk-docs.spec.js index fa3b4376054..03f00825190 100644 --- a/tests/integration/api/controllers/bulk-docs.spec.js +++ b/tests/integration/api/controllers/bulk-docs.spec.js @@ -7,6 +7,7 @@ const sUtils = require('@utils/sentinel'); const constants = require('@constants'); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -86,6 +87,7 @@ describe('bulk-docs handler', () => { await sUtils.waitForSentinel(); await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], true); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -97,19 +99,19 @@ describe('bulk-docs handler', () => { beforeEach(() => { offlineRequestOptions = { path: '/_bulk_docs', - auth: { username: 'offline', password }, + auth: { username: 'offline', password: newPassword }, method: 'POST', }; multiRequestOptions = { path: '/_bulk_docs', - auth: { username: 'multi', password }, + auth: { username: 'multi', password: newPassword }, method: 'POST', }; onlineRequestOptions = { path: '/_bulk_docs', - auth: { username: 'online', password }, + auth: { username: 'online', password: newPassword }, method: 'POST', }; }); @@ -381,7 +383,7 @@ describe('bulk-docs handler', () => { it('filters offline tasks and targets', () => { const supervisorRequestOptions = { path: '/_bulk_docs', - auth: { username: 'supervisor', password }, + auth: { username: 'supervisor', password: newPassword }, method: 'POST', }; diff --git a/tests/integration/api/controllers/bulk-get.spec.js b/tests/integration/api/controllers/bulk-get.spec.js index cc23e31f647..b66a8032447 100644 --- a/tests/integration/api/controllers/bulk-get.spec.js +++ b/tests/integration/api/controllers/bulk-get.spec.js @@ -6,6 +6,7 @@ const chaiExclude = require('chai-exclude'); chai.use(chaiExclude); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -75,6 +76,7 @@ describe('bulk-get handler', () => { before(async () => { await utils.saveDoc(parentPlace); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -86,13 +88,13 @@ describe('bulk-get handler', () => { beforeEach(() => { offlineRequestOptions = { path: '/_bulk_get', - auth: { username: 'offline', password }, + auth: { username: 'offline', password: newPassword }, method: 'POST', }; onlineRequestOptions = { path: '/_bulk_get', - auth: { username: 'online', password }, + auth: { username: 'online', password: newPassword }, method: 'POST', }; }); @@ -230,7 +232,7 @@ describe('bulk-get handler', () => { const supervisorRequestOptions = { path: '/_bulk_get', - auth: { username: 'supervisor', password }, + auth: { username: 'supervisor', password: newPassword }, method: 'POST', body: offlineRequestOptions.body }; diff --git a/tests/integration/api/controllers/changes.spec.js b/tests/integration/api/controllers/changes.spec.js index 9daab7da414..341d9318d1c 100644 --- a/tests/integration/api/controllers/changes.spec.js +++ b/tests/integration/api/controllers/changes.spec.js @@ -15,12 +15,13 @@ const requestChanges = (username, params = {}) => { const options = { path: '/_changes', qs: params, - auth: { username, password } + auth: { username, password: newPassword } }; return utils.requestOnTestDb(options); }; const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const users = [ { @@ -181,7 +182,8 @@ describe('changes handler', () => { // Bootstrap users return utils .saveDoc(parentPlace) - .then(() => utils.createUsers(users, true)); + .then(() => utils.createUsers(users, true)) + .then(() => utils.resetUserPassword(users)); }); after( async () => { @@ -272,7 +274,7 @@ describe('changes handler', () => { it('should forward changes requests when db name is not medic', () => { return utils - .requestOnMedicDb({ path: '/_changes', auth: { username: 'bob', password } }) + .requestOnMedicDb({ path: '/_changes', auth: { username: 'bob', password: newPassword } }) .then(results => { return assertChangeIds(results, ...changesIDs, bobUserId); }); @@ -280,7 +282,7 @@ describe('changes handler', () => { it('filters calls with irregular urls which match couchdb endpoint', () => { const options = { - auth: { username: 'bob', password }, + auth: { username: 'bob', password: newPassword }, method: 'GET' }; diff --git a/tests/integration/api/controllers/contacts-by-phone.spec.js b/tests/integration/api/controllers/contacts-by-phone.spec.js index 286f3c9e616..a03ba60f071 100644 --- a/tests/integration/api/controllers/contacts-by-phone.spec.js +++ b/tests/integration/api/controllers/contacts-by-phone.spec.js @@ -5,6 +5,7 @@ const chaiExclude = require('chai-exclude'); chai.use(chaiExclude); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -161,6 +162,7 @@ describe('Contacts by phone API', () => { before(async () => { await utils.saveDocs(contacts); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -169,8 +171,14 @@ describe('Contacts by phone API', () => { }); beforeEach(() => { - offlineRequestOptions = { path: '/api/v1/contacts-by-phone', auth: { username: 'offline', password }, }; - onlineRequestOptions = { path: '/api/v1/contacts-by-phone', auth: { username: 'online', password }, }; + offlineRequestOptions = { + path: '/api/v1/contacts-by-phone', + auth: { username: 'offline', password: newPassword }, + }; + onlineRequestOptions = { + path: '/api/v1/contacts-by-phone', + auth: { username: 'online', password: newPassword }, + }; noAuthRequestOptions = { path: '/api/v1/contacts-by-phone', headers: { 'Accept': 'application/json' }, diff --git a/tests/integration/api/controllers/db-doc.spec.js b/tests/integration/api/controllers/db-doc.spec.js index 45b042fd03e..0320550d608 100644 --- a/tests/integration/api/controllers/db-doc.spec.js +++ b/tests/integration/api/controllers/db-doc.spec.js @@ -6,6 +6,7 @@ const constants = require('@constants'); const uuid = require('uuid').v4; const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -163,6 +164,7 @@ describe('db-doc handler', () => { before(async () => { await utils.saveDoc(parentPlace); await utils.createUsers(users); + await utils.resetUserPassword(users); await utils.saveDocs([...clinics, ...patients]); }); @@ -174,8 +176,8 @@ describe('db-doc handler', () => { afterEach(() => utils.revertDb(DOCS_TO_KEEP, true)); beforeEach(() => { - offlineRequestOptions = { auth: { username: 'offline', password }, }; - onlineRequestOptions = { auth: { username: 'online', password }, }; + offlineRequestOptions = { auth: { username: 'offline', password: newPassword }, }; + onlineRequestOptions = { auth: { username: 'online', password: newPassword }, }; }); describe('does not restrict online users', () => { @@ -1031,7 +1033,7 @@ describe('db-doc handler', () => { }); it('GET tasks and targets', () => { - const supervisorRequestOptions = { auth: { username: 'supervisor', password }, }; + const supervisorRequestOptions = { auth: { username: 'supervisor', password: newPassword }, }; const allowedTask = { _id: 'task1', type: 'task', diff --git a/tests/integration/api/controllers/hydration.spec.js b/tests/integration/api/controllers/hydration.spec.js index df74fee36da..2ee79e42fec 100644 --- a/tests/integration/api/controllers/hydration.spec.js +++ b/tests/integration/api/controllers/hydration.spec.js @@ -5,6 +5,7 @@ const chaiExclude = require('chai-exclude'); chai.use(chaiExclude); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -248,7 +249,8 @@ const hydrateReport = doc => { describe('Hydration API', () => { before(async () => { await utils.saveDocs(docs); - await utils.createUsers(users).then(); + await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -257,8 +259,8 @@ describe('Hydration API', () => { }); beforeEach(() => { - offlineRequestOptions = { path: '/api/v1/hydrate', auth: { username: 'offline', password }, }; - onlineRequestOptions = { path: '/api/v1/hydrate', auth: { username: 'online', password }, }; + offlineRequestOptions = { path: '/api/v1/hydrate', auth: { username: 'offline', password: newPassword }, }; + onlineRequestOptions = { path: '/api/v1/hydrate', auth: { username: 'online', password: newPassword }, }; noAuthRequestOptions = { path: '/api/v1/hydrate', headers: { 'Accept': 'application/json' }, noAuth: true }; }); diff --git a/tests/integration/api/controllers/login.spec.js b/tests/integration/api/controllers/login.spec.js index dc9be54a07e..7481717c740 100644 --- a/tests/integration/api/controllers/login.spec.js +++ b/tests/integration/api/controllers/login.spec.js @@ -51,6 +51,13 @@ const expectLoginToWork = (response) => { chai.expect(response.body).to.equal('/'); }; +const expectRedirectToPasswordReset = (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('userCtx'))).to.be.ok; + chai.expect(response.body).to.equal('/medic/password-reset'); +}; + const expectLoginToFail = (response) => { chai.expect(response.headers['set-cookie']).to.be.undefined; chai.expect(response.statusCode).to.equal(401); @@ -118,7 +125,7 @@ describe('login', () => { .then(response => expectLoginToFail(response)); }); - it('should succeed with right credentials', () => { + it('should succeed with right credentials without redirecting to password-reset', () => { const opts = { path: '/api/v1/users', method: 'POST', @@ -126,9 +133,31 @@ describe('login', () => { }; return utils .request(opts) + .then(() => getUser(user)) + .then(userDoc => { + // Overriding password_change_required for new user + userDoc.password_change_required = false; + return utils.request({ + path: `/_users/${userDoc._id}`, + method: 'PUT', + body: userDoc + }); + }) .then(() => loginWithData({ user: user.username, password })) .then(response => expectLoginToWork(response)); }); + + it('should succeed with right credentials and redirect to password-reset for new users', () => { + const opts = { + path: '/api/v1/users', + method: 'POST', + body: user + }; + return utils + .request(opts) + .then(() => loginWithData({ user: user.username, password })) + .then(response => expectRedirectToPasswordReset(response)); + }); }); describe('token login', () => { diff --git a/tests/integration/api/controllers/places.spec.js b/tests/integration/api/controllers/places.spec.js index 3d0862b4ef6..7a735d6de21 100644 --- a/tests/integration/api/controllers/places.spec.js +++ b/tests/integration/api/controllers/places.spec.js @@ -4,6 +4,7 @@ const chaiExclude = require('chai-exclude'); chai.use(chaiExclude); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const users = [ { @@ -33,6 +34,7 @@ describe('Places API', () => { }; await utils.updateSettings({ permissions }, { ignoreReload: true }); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -41,7 +43,7 @@ describe('Places API', () => { }); beforeEach(() => { - onlineRequestOptions = { path: '/api/v1/places', auth: { username: 'online', password }, }; + onlineRequestOptions = { path: '/api/v1/places', auth: { username: 'online', password: newPassword }, }; }); describe('POST', () => { diff --git a/tests/integration/api/controllers/replication.spec.js b/tests/integration/api/controllers/replication.spec.js index 50fbfeadc37..0265d879028 100644 --- a/tests/integration/api/controllers/replication.spec.js +++ b/tests/integration/api/controllers/replication.spec.js @@ -15,11 +15,12 @@ const isFormOrTranslation = id => defaultDocRegex.test(id); const getIds = docsOrChanges => docsOrChanges.map(elem => elem._id || elem.id); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const requestDocs = (username) => { const options = { path: '/api/v1/replication/get-ids', - auth: { username, password } + auth: { username, password: newPassword } }; return utils.request(options); }; @@ -29,7 +30,7 @@ const requestDeletes = (username, docIds = []) => { path: '/api/v1/replication/get-deletes', method: 'POST', body: { doc_ids: docIds }, - auth: { username, password } + auth: { username, password: newPassword } }; return utils.request(options); }; @@ -196,6 +197,7 @@ describe('replication', () => { await utils.updatePermissions(['district_admin'], ['can_have_multiple_places'], [], true); await utils.saveDoc(parentPlace); await utils.createUsers(users, true); + await utils.resetUserPassword(users); }); after( async () => { diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js index 68910df19c6..e02dea0f9c9 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -12,6 +12,7 @@ const userFactory = require('@factories/cht/users/users'); const getUserId = n => `org.couchdb.user:${n}`; const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', type: 'district_hospital', @@ -25,19 +26,34 @@ const randomIp = () => { describe('Users API', () => { + const getUser = (user) => { + const opts = { path: `/_users/${getUserId(user.username)}` }; + return utils.request(opts); + }; + const expectPasswordLoginToWork = (user) => { - const opts = { - path: '/login', - method: 'POST', - simple: false, - noAuth: true, - body: { user: user.username, password: user.password }, - followRedirect: false, - headers: { 'X-Forwarded-For': randomIp() }, - }; + return getUser(user) + .then(userDoc => { + userDoc.password_change_required = false; + return utils.request({ + path: `/_users/${userDoc._id}`, + method: 'PUT', + body: userDoc + }); + }) + .then(() => { + const opts = { + path: '/login', + method: 'POST', + simple: false, + noAuth: true, + body: { user: user.username, password: user.password }, + followRedirect: false, + headers: { 'X-Forwarded-For': randomIp() }, + }; - return utils - .requestOnMedicDb(opts) + return utils.requestOnMedicDb(opts); + }) .then(response => { chai.expect(response).to.include({ statusCode: 302, @@ -446,6 +462,7 @@ describe('Users API', () => { before(async () => { await utils.saveDoc(parentPlace); await utils.createUsers(users); + await utils.resetUserPassword(users); const docs = Array.from(Array(nbrOfflineDocs), () => ({ _id: `random_contact_${uuid()}`, type: `clinic`, @@ -470,13 +487,13 @@ describe('Users API', () => { beforeEach(() => { offlineRequestOptions = { path: '/api/v1/users-info', - auth: { username: 'offline', password }, + auth: { username: 'offline', password: newPassword }, method: 'GET' }; onlineRequestOptions = { path: '/api/v1/users-info', - auth: { username: 'online', password }, + auth: { username: 'online', password: newPassword }, method: 'GET' }; }); @@ -507,7 +524,7 @@ describe('Users API', () => { it('auth should check for mm-online role when requesting other user docs', () => { const requestOptions = { path: '/api/v1/users-info?role=district_admin&facility_id=fixture:offline', - auth: { username: 'offlineonline', password }, + auth: { username: 'offlineonline', password: newPassword }, method: 'GET' }; @@ -526,7 +543,7 @@ describe('Users API', () => { it('auth should check for mm-online role when requesting with missing params', () => { const requestOptions = { path: '/api/v1/users-info', - auth: { username: 'offlineonline', password }, + auth: { username: 'offlineonline', password: newPassword }, method: 'GET' }; @@ -621,10 +638,6 @@ describe('Users API', () => { describe('token-login', () => { let user; - const getUser = (user) => { - const opts = { path: `/_users/${getUserId(user.username)}` }; - return utils.request(opts); - }; const getUserSettings = (user) => { return utils.requestOnMedicDb({ path: `/${getUserId(user.username)}` }); }; @@ -1599,7 +1612,8 @@ describe('Users API', () => { return utils .updateSettings(settings, { ignoreReload: true }) .then(() => utils.addTranslations('en', { login_sms: 'Instructions sms' })) - .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: onlineUser })) + .then(() => utils.createUsers([onlineUser])) + .then(() => utils.resetUserPassword([onlineUser])) .then(() => utils.request({ path: '/api/v1/users', method: 'POST', body: user })) .then(() => getUser(user)) .then(user => { @@ -1610,7 +1624,7 @@ describe('Users API', () => { chai.expect(tokenLoginDoc.user).to.equal('org.couchdb.user:testuser'); const onlineRequestOpts = { - auth: { user: 'onlineuser', password }, + auth: { user: 'onlineuser', password: newPassword }, method: 'PUT', path: `/${tokenLoginDoc._id}`, body: tokenLoginDoc, @@ -1654,6 +1668,10 @@ describe('Users API', () => { await utils.saveDocs([ facility, person ]); await utils.createUsers([{ ...user, password }, { ...userProgramOfficer, password }]); + await utils.resetUserPassword([ + { ...user, password }, + { ...userProgramOfficer, password }, + ]); await utils.updatePermissions(['program_officer'], ['can_view_users']); }); @@ -1679,7 +1697,7 @@ describe('Users API', () => { it('retrieves a user with can_view_users permission', async () => { const users = await utils.request({ path: `/api/v2/users/${user.username}`, - auth: { username: userProgramOfficer.username, password }, + auth: { username: userProgramOfficer.username, password: newPassword }, }); expect(users).excludingEvery(['_rev']).to.deep.include({ @@ -1708,7 +1726,7 @@ describe('Users API', () => { try { await utils.request({ path: `/api/v2/users/${userProgramOfficer.username}`, - auth: { username: user.username, password }, + auth: { username: user.username, password: newPassword }, }); } catch ({ error }) { expect(error.code).to.equal(403); @@ -1722,7 +1740,7 @@ describe('Users API', () => { it('retrieves self even when user does not have can_view_users permission', async () => { const users = await utils.request({ path: `/api/v2/users/${user.username}`, - auth: { username: user.username, password }, + auth: { username: user.username, password: newPassword }, }); expect(users).excludingEvery(['_rev']).to.deep.include({ diff --git a/tests/integration/api/routing.spec.js b/tests/integration/api/routing.spec.js index 4191f742682..32be95482f2 100644 --- a/tests/integration/api/routing.spec.js +++ b/tests/integration/api/routing.spec.js @@ -5,6 +5,7 @@ const moment = require('moment'); const semver = require('semver'); const password = 'passwordSUP3RS3CR37!'; +const newPassword = 'Pa33word1'; const parentPlace = { _id: 'PARENT_PLACE', @@ -46,14 +47,14 @@ const users = [ ]; const offlineRequestOptions = { - auth: { username: 'offline', password }, + auth: { username: 'offline', password: newPassword }, method: 'GET', }; const getOfflineRequestOptions = (method) => Object.assign({}, offlineRequestOptions, { method }); const onlineRequestOptions = { - auth: { username: 'online', password }, + auth: { username: 'online', password: newPassword }, method: 'GET', }; @@ -73,6 +74,7 @@ describe('routing', () => { before(async () => { await utils.saveDoc(parentPlace); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -720,8 +722,8 @@ describe('routing', () => { path: '/_session', method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: { name: username, password }, - auth: { username, password } + body: { name: username, password: newPassword }, + auth: { username, password: newPassword } }); }; diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 2b50dec116c..e90f9149fbb 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -6,6 +6,8 @@ const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); const userFactory = require('@factories/cht/users/users'); +const newPassword = 'Pa33word1'; + describe('server', () => { describe('JSON-only endpoints', () => { it('should require application/json Content-Type header', () => { @@ -348,9 +350,10 @@ describe('server', () => { await utils.saveDocs([contact, ...placeMap.values()]); await utils.createUsers([offlineUser]); + await utils.resetUserPassword([offlineUser]); reqOptions = { - auth: { username: offlineUser.username, password: offlineUser.password }, + auth: { username: offlineUser.username, password: newPassword }, }; }); @@ -367,15 +370,15 @@ describe('server', () => { const reqID = getReqId(apiLogs[0]); const haproxyRequests = haproxyLogs.filter(entry => getReqId(entry) === reqID); - expect(haproxyRequests.length).to.equal(12); + expect(haproxyRequests.length).to.equal(13); expect(haproxyRequests[0]).to.include('_session'); - expect(haproxyRequests[5]).to.include('/medic-test/_design/medic/_view/contacts_by_depth'); - expect(haproxyRequests[6]).to.include('/medic-test/_design/medic/_view/docs_by_replication_key'); - expect(haproxyRequests[7]).to.include('/medic-test-purged-cache/purged-docs-'); - expect(haproxyRequests[8]).to.include('/medic-test-purged-role-'); - expect(haproxyRequests[9]).to.include('/medic-test-logs/replication-count-'); + expect(haproxyRequests[6]).to.include('/medic-test/_design/medic/_view/contacts_by_depth'); + expect(haproxyRequests[7]).to.include('/medic-test/_design/medic/_view/docs_by_replication_key'); + expect(haproxyRequests[8]).to.include('/medic-test-purged-cache/purged-docs-'); + expect(haproxyRequests[9]).to.include('/medic-test-purged-role-'); expect(haproxyRequests[10]).to.include('/medic-test-logs/replication-count-'); - expect(haproxyRequests[11]).to.include('/medic-test/_all_docs'); + expect(haproxyRequests[11]).to.include('/medic-test-logs/replication-count-'); + expect(haproxyRequests[12]).to.include('/medic-test/_all_docs'); }); it('should propagate ID via couch requests', async () => { @@ -452,6 +455,6 @@ describe('server', () => { await utils.stopApi(); await utils.startApi(); - }); + }); }); }); diff --git a/tests/integration/sentinel/schedules/purging.spec.js b/tests/integration/sentinel/schedules/purging.spec.js index de89c948b1c..66adfee34f2 100644 --- a/tests/integration/sentinel/schedules/purging.spec.js +++ b/tests/integration/sentinel/schedules/purging.spec.js @@ -4,6 +4,7 @@ const chai = require('chai'); const moment = require('moment'); const password = 'SuperS3creT'; +const newPassword = 'Pa33word1'; const docs = [ { _id: 'clinic1', @@ -358,7 +359,7 @@ const purgeSettings = { const requestDocs = async (username) => { const options = { path: `/api/v1/replication/get-ids`, - auth: { username, password }, + auth: { username, password: newPassword }, }; const result = await utils.request(options); return result.doc_ids_revs; @@ -369,7 +370,7 @@ const requestDeletes = async (username, ids) => { path: `/api/v1/replication/get-deletes`, method: 'POST', body: { doc_ids: ids }, - auth: { username, password }, + auth: { username, password: newPassword }, }; const result = await utils.request(options); return result.doc_ids; @@ -408,6 +409,7 @@ describe('Server side purge', () => { await utils.deleteUsers(existingUsers); await utils.saveDocs([...docs, ...tasks, ...targets]); await utils.createUsers(users); + await utils.resetUserPassword(users); }); after(async () => { @@ -581,6 +583,7 @@ describe('Server side purge', () => { roles: ['district_admin', 'purge_regular'], }; await updateUser(updatedUser2); + await utils.resetUserPassword([updatedUser2]); responseDocsUser1 = await requestDocs('user1'); responseDocsUser2 = await requestDocs('user2'); diff --git a/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js b/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js index d534d67137e..38c9463ef9d 100644 --- a/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js +++ b/tests/integration/sentinel/transitions/create-user-for-contacts.spec.js @@ -31,6 +31,8 @@ const ORIGINAL_USER = utils.deepFreeze({ roles: ['chw'], }); +const newPassword = 'Pa33word1'; + const newUsers = []; const getSettings = ({ @@ -101,6 +103,7 @@ describe('create_user_for_contacts', () => { it('does nothing when no users should be created for the contact', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -122,9 +125,10 @@ describe('create_user_for_contacts', () => { it('replaces user but does not create a new user for the same contact in the same transition', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); // Can log in as user - assert.include(await loginAsUser(ORIGINAL_USER), { statusCode: 302 }); + assert.include(await loginAsUser({ ...ORIGINAL_USER, password: newPassword }), { statusCode: 302 }); await utils.saveDoc(NEW_PERSON); // Write another contact that has a user being created and another user being replaced // (This is an approximation of behavior that could happen if Sentinel was down when the @@ -215,9 +219,10 @@ describe('create_user_for_contacts', () => { it('replaces user for contact', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); // Can log in as user - assert.include(await loginAsUser(ORIGINAL_USER), { statusCode: 302 }); + assert.include(await loginAsUser({ ...ORIGINAL_USER, password: newPassword }), { statusCode: 302 }); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); originalContact.user_for_contact = { @@ -235,7 +240,7 @@ describe('create_user_for_contacts', () => { // Transition successful assert.isTrue(transitions.create_user_for_contacts.ok); // Can no longer log in as user - assert.include(await loginAsUser(ORIGINAL_USER), { statusCode: 401 }); + assert.include(await loginAsUser({ ...ORIGINAL_USER, password: newPassword }), { statusCode: 401 }); // User's password was automatically reset. Change it to something we know. await updateUserPassword(ORIGINAL_USER.username, 'n3wPassword!'); // Can still login as original user with new password @@ -268,12 +273,14 @@ describe('create_user_for_contacts', () => { it('replaces user for a contact when the contact is associated with multiple users', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); const otherUser = { ...ORIGINAL_USER, username: 'other_user', contact: ORIGINAL_PERSON._id }; await utils.createUsers([otherUser]); + await utils.resetUserPassword([otherUser]); newUsers.push(otherUser.username); // Can log in as user - assert.include(await loginAsUser(ORIGINAL_USER), { statusCode: 302 }); + assert.include(await loginAsUser({ ...ORIGINAL_USER, password: newPassword }), { statusCode: 302 }); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); originalContact.user_for_contact = { @@ -323,18 +330,19 @@ describe('create_user_for_contacts', () => { const [otherUserSettings] = await utils.getUserSettings({ name: otherUser.username }); assert.equal(otherUserSettings.contact_id, ORIGINAL_PERSON._id); // Can still log in as other user - assert.include(await loginAsUser(otherUser), { statusCode: 302 }); + assert.include(await loginAsUser({ ...otherUser, password: newPassword }), { statusCode: 302 }); }); it('replaces multiple users for a contact', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); const otherUser = { ...ORIGINAL_USER, username: 'other_user', contact: ORIGINAL_PERSON._id }; await utils.createUsers([otherUser]); newUsers.push(otherUser.username); // Can log in as users - assert.include(await loginAsUser(ORIGINAL_USER), { statusCode: 302 }); + assert.include(await loginAsUser({ ...ORIGINAL_USER, password: newPassword }), { statusCode: 302 }); assert.include(await loginAsUser(otherUser), { statusCode: 302 }); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -403,6 +411,7 @@ describe('create_user_for_contacts', () => { { ignoreReload: 'sentinel' } ); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -428,6 +437,7 @@ describe('create_user_for_contacts', () => { const collectLogs = await utils.collectSentinelLogs(missingPersonPattern); await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); originalContact.user_for_contact = { @@ -477,6 +487,7 @@ describe('create_user_for_contacts', () => { const collectLogs = await utils.collectSentinelLogs(missingPhonePattern); await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(newPerson); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -504,6 +515,7 @@ describe('create_user_for_contacts', () => { const collectLogs = await utils.collectSentinelLogs(invalidPhonePattern); await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(newPerson); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -531,6 +543,7 @@ describe('create_user_for_contacts', () => { const collectLogs = await utils.collectSentinelLogs(missingNamePattern); await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(newPerson); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -557,6 +570,7 @@ describe('create_user_for_contacts', () => { const collectLogs = await utils.collectSentinelLogs(missingIdPattern); await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -579,6 +593,7 @@ describe('create_user_for_contacts', () => { it('does not replace user when the replace status is not READY', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(NEW_PERSON); const originalContact = await utils.getDoc(ORIGINAL_PERSON._id); @@ -607,6 +622,7 @@ describe('create_user_for_contacts', () => { it('does not replace user when the contact being replaced is not a person', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(NEW_PERSON); const clinic = await utils.getDoc(CLINIC._id); @@ -849,6 +865,7 @@ describe('create_user_for_contacts', () => { it('does not create user when the contact being added is not a person', async () => { await utils.updateSettings(getSettings(), { ignoreReload: 'sentinel' }); await utils.createUsers([ORIGINAL_USER]); + await utils.resetUserPassword([ORIGINAL_USER]); newUsers.push(ORIGINAL_USER.username); await utils.saveDoc(NEW_PERSON); const clinic = await utils.getDoc(CLINIC._id); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 766689b9588..7bc89d3580e 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -3,26 +3,46 @@ const utils = require('@utils'); const commonPage = require('@page-objects/default/common/common.wdio.page'); const loginButton = () => $('#login'); +const updatePasswordButton = () => $('#update-password'); const userField = () => $('#user'); const passwordField = () => $('#password'); +const resetPasswordField = () => $('#form[action="/medic/password-reset"] #password'); +const confirmPasswordField = () => $('#confirm-password'); +const currentPasswordField = () => $('#current-password'); const passwordToggleButton = () => $('#password-toggle'); const labelForUser = () => $('label[for="user"]'); const labelForPassword = () => $('label[for="password"]'); const errorMessageField = () => $('p.error.incorrect'); const localeByName = (locale) => $(`.locale[name="${locale}"]`); const tokenLoginError = (reason) => $(`.error.${reason}`); +const passwordResetMessageField = (errorMsg) => $(`p.error.${errorMsg}`); const getErrorMessage = async () => { await (await errorMessageField()).waitForDisplayed(); return await (await errorMessageField()).getText(); }; -const login = async ({ username, password, createUser = false, locale, loadPage = true, privacyPolicy, adminApp }) => { +const getPasswordResetErrorMessage = async (errorMsg) => { + await (await passwordResetMessageField(errorMsg)).waitForDisplayed(); + return await (await passwordResetMessageField(errorMsg)).getText(); +}; + +const NEW_PASSWORD = constants.NEW_PASSWORD; + +const login = async ({ + username, + password, + createUser = false, + locale, loadPage = true, + privacyPolicy, + adminApp, + resetPassword = true +}) => { if (utils.isMinimumChromeVersion) { await browser.url('/'); } await setPasswordValue(password); - await (await userField()).setValue(username); + await setUsernameValue(username); await changeLocale(locale); await (await loginButton()).click(); @@ -34,6 +54,10 @@ const login = async ({ username, password, createUser = false, locale, loadPage await utils.setupUserDoc(username); } + if (resetPassword) { + await passwordReset(password, NEW_PASSWORD, NEW_PASSWORD); + } + if (!loadPage) { return; } @@ -48,27 +72,37 @@ const login = async ({ username, password, createUser = false, locale, loadPage await commonPage.hideSnackbar(); }; -const cookieLogin = async (options = {}) => { - const { - username = constants.USERNAME, - password = constants.PASSWORD, - createUser = true, - locale = 'en', - } = options; +const loginRequest = async (username, password, locale) => { const opts = { path: '/medic/login', body: { user: username, password: password, locale }, method: 'POST', simple: false, }; - const resp = await utils.request(opts); - const cookieArray = utils.parseCookieResponse(resp.headers['set-cookie']); + return await utils.request(opts); +}; +const setCookiesFromResponse = async (response) => { + const cookieArray = utils.parseCookieResponse(response.headers['set-cookie']); await browser.url('/'); await browser.setCookies(cookieArray); +}; + +const cookieLogin = async (options = {}) => { + const { + username = constants.USERNAME, + password = constants.PASSWORD, + createUser = true, + locale = 'en', + } = options; + + const loginResp = await loginRequest(username, password, locale); + await setCookiesFromResponse(loginResp); + if (createUser) { await utils.setupUserDoc(username); } + await commonPage.goToBase(); }; @@ -98,6 +132,9 @@ const changeLocale = async locale => { }; const changeLanguage = async (languageCode, userTranslation) => { + if (utils.isMinimumChromeVersion) { + await browser.url('/'); + } await changeLocale(languageCode); await browser.waitUntil(async () => await (await labelForUser()).getText() === userTranslation); return { @@ -133,6 +170,29 @@ const setPasswordValue = async (password) => { await (await passwordField()).setValue(password); }; +const setConfirmPasswordValue = async (confirmPassword) => { + await (await confirmPasswordField()).waitForDisplayed(); + await (await confirmPasswordField()).setValue(confirmPassword); +}; + +const setCurrentPasswordValue = async (currentPassword) => { + await (await currentPasswordField()).waitForDisplayed(); + await (await currentPasswordField()).setValue(currentPassword); +}; + +const setUsernameValue = async (username) => { + await (await userField()).waitForDisplayed(); + await (await userField()).setValue(username); +}; + +const passwordReset = async (currentPassword, password, confirmPassword) => { + await setCurrentPasswordValue(currentPassword); + await (await resetPasswordField()).waitForDisplayed(); + await (await resetPasswordField()).setValue(password); + await setConfirmPasswordValue(confirmPassword); + await (await updatePasswordButton()).click(); +}; + module.exports = { login, cookieLogin, @@ -147,4 +207,11 @@ module.exports = { getErrorMessage, togglePassword, setPasswordValue, + setUsernameValue, + setConfirmPasswordValue, + setCurrentPasswordValue, + passwordReset, + updatePasswordButton, + getPasswordResetErrorMessage, + NEW_PASSWORD, }; diff --git a/tests/utils/index.js b/tests/utils/index.js index 89e1c5bda1c..3a8f6655335 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -800,6 +800,48 @@ const getCreatedUsers = async () => { .map((user) => ({ ...user, username: user.id.replace(COUCH_USER_ID_PREFIX, '') })); }; +const resetPassword = async (username, password) => { + const loginResponse = await request({ + path: '/medic/login', + method: 'POST', + body: { user: username, password }, + noAuth: true, + followRedirect: false, + simple: false, + headers: { 'X-Forwarded-For': randomIp() } + }); + + if (loginResponse.statusCode === 302 && loginResponse.body === '/medic/password-reset') { + const cookies = loginResponse.headers['set-cookie']; + + await request({ + path: '/medic/password-reset', + method: 'POST', + headers: { + Cookie: cookies.join('; '), + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Forwarded-For': randomIp() + }, + body: { + username: username, + currentPassword: password, + password: 'Pa33word1' + }, + followRedirect: false, + simple: false, + json: true + }); + } +}; + +const resetUserPassword = async (users) => { + for (const user of users) { + await resetPassword(user.username, user.password); + cookieJar._jar.removeAllCookiesSync(); + } +}; + /** * Creates users - optionally also creating their meta dbs * @param {Array} users - list of users to be created @@ -1639,4 +1681,5 @@ module.exports = { toggleSentinelTransitions, runSentinelTasks, runCommand, + resetUserPassword, }; diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index f0136478a51..3aafa7ae448 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -89,10 +89,17 @@ const dbInfo = getDbInfo(); const userCtx = getUserCtx(); const hasForceLoginCookie = document.cookie.includes('login=force'); + const passwordStatus = localStorage.getItem('passwordStatus'); + if (!userCtx || hasForceLoginCookie) { return redirectToLogin(dbInfo); } + if (passwordStatus === 'PASSWORD_CHANGED') { + setUiStatus('PASSWORD_CHANGE_SUCCESS'); + localStorage.removeItem('passwordStatus'); + } + if (hasFullDataAccess(userCtx)) { return Promise.resolve(); } diff --git a/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index b9d52d5a170..ee47448dd81 100644 --- a/webapp/src/js/bootstrapper/translator.js +++ b/webapp/src/js/bootstrapper/translator.js @@ -7,6 +7,7 @@ const TRANSLATIONS = { en: { FETCH_INFO: ({ count, total }) => `Fetching info (${count} of ${total} docs )…`, LOAD_APP: 'Loading app…', + PASSWORD_CHANGE_SUCCESS: 'Password changed successfully', PURGE_INIT: 'Checking data…', PURGE_INFO: ({ count }) => `Cleaned ${count} documents…`, PURGE_META: 'Cleaning metadata…', @@ -25,6 +26,7 @@ const TRANSLATIONS = { es: { FETCH_INFO: ({ count, total }) => `Obteniendo información (${count} de ${total} docs)…`, LOAD_APP: 'Cargando aplicación…', + PASSWORD_CHANGE_SUCCESS: 'Cambio de contraseña exitoso', PURGE_INIT: 'Verificación de datos…', PURGE_INFO: ({ count }) => `Limpiado ${count} documentos…`, PURGE_META: 'Limpieza de metadatos…', @@ -43,6 +45,7 @@ const TRANSLATIONS = { sw: { FETCH_INFO: ({ count, total }) => `Inachukua habari (${count} of ${total})…`, LOAD_APP: 'Inapakia programu…', + PASSWORD_CHANGE_SUCCESS: 'Umefaulu kubadilisha nenosiri', PURGE_INIT: 'Kuangalia takwimu…', PURGE_INFO: ({ count }) => `Imesafisha hati ${count}…`, PURGE_META: 'inasafisha metadata…', @@ -61,6 +64,7 @@ const TRANSLATIONS = { ne: { FETCH_INFO: ({ count, total }) => eurodigit.to_non_euro.devanagari(`${total} मध्ये ${count} डकुमेन्ट लोड हुँदै …`), LOAD_APP: 'एप लोड गर्दै…', + PASSWORD_CHANGE_SUCCESS: 'पासवर्ड सफलतापूर्वक परिवर्तन भयो', PURGE_INIT: 'डाटा जाँच गर्दै…', PURGE_INFO: ({ count }) => eurodigit.to_non_euro.devanagari(`${count} वटा डकुमेन्ट सफा गरीयो…`), PURGE_META: 'मेटा डाटा सफा गर्दै…', @@ -79,6 +83,7 @@ const TRANSLATIONS = { fr: { FETCH_INFO: ({ count, total }) => `Récupération des données (${count} sur ${total} documents)…`, LOAD_APP: 'Chargement de l’application…', + PASSWORD_CHANGE_SUCCESS: 'Mot de passe modifié avec succès', PURGE_INIT: 'Vérification des données…', PURGE_INFO: ({ count }) => `${count} document(s) nettoyé(s)…`, PURGE_META: 'Cleaning meta data…', @@ -97,6 +102,7 @@ const TRANSLATIONS = { hi: { FETCH_INFO: ({ count, total }) => `डॉक्युमेंट लोड हो रहें हैं (${total} मेंस से ${count})…`, LOAD_APP: 'एप्लीकेशन लोड हो रही है…', + PASSWORD_CHANGE_SUCCESS: 'पासवर्ड सफलतापूर्वक बदला गया', PURGE_INIT: 'डेटा की जाँच…', PURGE_INFO: ({ count }) => `${count} दस्तावेज साफ किए…`, PURGE_META: 'Nettoyage des métadonnées…', @@ -115,6 +121,7 @@ const TRANSLATIONS = { id: { FETCH_INFO: ({ count, total }) => `Mengambil informasi (${count} dari ${total} dokumen)…`, LOAD_APP: 'Memuat aplikasi…', + PASSWORD_CHANGE_SUCCESS: 'Perubahan kata sandi berhasil', PURGE_INIT: 'Mengecek data…', PURGE_INFO: ({ count }) => `Menghapus ${count} dokument…`, PURGE_META: 'Menghapus metadata…', diff --git a/webapp/tests/mocha/unit/bootstrapper.spec.js b/webapp/tests/mocha/unit/bootstrapper.spec.js index d3cf5580b54..b5ab78f7e57 100644 --- a/webapp/tests/mocha/unit/bootstrapper.spec.js +++ b/webapp/tests/mocha/unit/bootstrapper.spec.js @@ -99,6 +99,12 @@ describe('bootstrapper', () => { return promise; }); + global.localStorage = { + getItem: sinon.stub(), + setItem: sinon.stub(), + removeItem: sinon.stub(), + }; + $ = sinon.stub().returns({ text: sinon.stub(), click: sinon.stub(),