From d7173a6ba3c13f087b245ff406b00c44cd1b59cd Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 24 Oct 2024 19:07:13 +0300 Subject: [PATCH 01/48] chore: test password change routing --- api/src/controllers/login.js | 44 +++++++++++++++++++-- api/src/generate-service-worker.js | 5 +++ api/src/routing.js | 1 + api/src/templates/login/password-reset.html | 16 ++++++++ config/default/app_settings.json | 1 + 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 api/src/templates/login/password-reset.html diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index f522bcce7bf..16c33e4f7f7 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -51,6 +51,20 @@ const templates = { 'privacy.policy' ], }, + passwordReset: { + file: path.join(__dirname, '..', 'templates', 'login', 'password-reset.html'), + translationStrings: [ + 'login', + 'Password', + 'privacy.policy' + ], + } +}; + +let password_change_required = true; + +const checkPasswordChange = async (userCtx) => { + return await auth.hasAllPermissions(userCtx, 'can_change_password_first_login'); }; const getHomeUrl = userCtx => { @@ -300,15 +314,27 @@ const renderLogin = (req) => { return render('login', req); }; +const renderPasswordReset = (req) => { + return render('passwordReset', req); +} + 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 sessionCookie = getSessionCookie(sessionRes); + const options = { headers: { Cookie: sessionCookie } }; + const userCtx = await getUserCtxRetry(options); + await setCookies(req, res, sessionRes); + + const needsPasswordChange = await checkPasswordChange(userCtx); + if (needsPasswordChange && password_change_required) { + return res.status(302).send('/medic/password-reset'); + } + const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); + res.status(302).send(redirectUrl); } catch (e) { if (e.status === 401) { return res.status(401).json({ error: e.error }); @@ -320,6 +346,7 @@ const login = async (req, res) => { module.exports = { renderLogin, + renderPasswordReset, get: (req, res, next) => { return renderLogin(req) @@ -355,6 +382,17 @@ module.exports = { }); }, + passwordResetGet: (req, res, next) => { + return renderPasswordReset(req) + .then(body => { + res.setHeader( + 'Link', + '; rel=preload; as=style, ' + ); + res.send(body); + }) + .catch(next); + }, 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..2956c4c8c69 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/routing.js b/api/src/routing.js index f027f1ddb03..bc7fc598662 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -290,6 +290,7 @@ app.get(routePrefix + 'login', login.get); app.get(routePrefix + 'login/identity', login.getIdentity); app.postJson(routePrefix + 'login', login.post); app.get(routePrefix + 'login/token/:token?', login.tokenGet); +app.get(routePrefix + 'password-reset', login.passwordResetGet); app.postJson(routePrefix + 'login/token/:token?', login.tokenPost); app.get(routePrefix + 'privacy-policy', privacyPolicyController.get); diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html new file mode 100644 index 00000000000..78ba5269d2c --- /dev/null +++ b/api/src/templates/login/password-reset.html @@ -0,0 +1,16 @@ + + + + + + {{ branding.name }} + + + + +
+

Password Reset

+

You need to reset your password before continuing.

+
+ + \ No newline at end of file diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 07d76c5b722..0ae88ab28a3 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -283,6 +283,7 @@ "can_view_old_navigation": [], "can_default_facility_filter": [], "can_have_multiple_places": [], + "can_change_password_first_login": [], "can_export_devices_details": [ "national_admin" ] From 523fc3017493c6504ea908878da8dab834480782 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 29 Oct 2024 12:04:03 +0300 Subject: [PATCH 02/48] Add password reset flag to API - Add user.password_change_required flag - Add password-reset GET and POST routes - Add passwordResetGet and passwordResetPost functions - Add password-reset template with es translations - Refactor login and password reset to share translation logic --- .../translations/messages-en.properties | 5 ++ .../translations/messages-es.properties | 5 ++ api/src/controllers/login.js | 76 ++++++++++++++----- api/src/public/login/auth-utils.js | 59 ++++++++++++++ api/src/public/login/password-reset.js | 71 +++++++++++++++++ api/src/public/login/script.js | 69 +++-------------- api/src/routing.js | 1 + api/src/templates/login/index.html | 2 +- api/src/templates/login/password-reset.html | 33 +++++++- config/default/app_settings.json | 2 +- shared-libs/user-management/src/users.js | 12 ++- 11 files changed, 249 insertions(+), 86 deletions(-) create mode 100644 api/src/public/login/auth-utils.js create mode 100644 api/src/public/login/password-reset.js diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index cb04a0a6e72..bf9c6b0af57 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.title = Change your password +change.password.hint = Use uppercase letters, numbers, and special characters. +change.password.new.password = New Password +change.password.confirm.password = Confirm password +change.password.submit = Change password child_birth_date = Child birth date child_birth_outcome = Child birth outcome child_birth_weight = Child birth weight diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 2bb18b9bcf7..255bffb78c0 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.title = Cambia tu contraseña +change.password.hint = Utilice letras mayúsculas, números y caracteres especiales.. +change.password.new.password = Nueva contraseña +change.password.confirm.password = Confirmar Contraseña +change.password.submit = Cambiar la 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 diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 16c33e4f7f7..fdea15d432b 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -54,17 +54,26 @@ const templates = { passwordReset: { file: path.join(__dirname, '..', 'templates', 'login', 'password-reset.html'), translationStrings: [ - 'login', - 'Password', - 'privacy.policy' + 'login.show_password', + 'login.hide_password', + 'change.password.title', + 'change.password', + 'change.password.hint', + 'change.password.submit', + 'change.password.new.password', + 'change.password.confirm.password', + 'password.weak', + 'password.length.minimum', + 'Passwords must match' ], } }; -let password_change_required = true; - -const checkPasswordChange = async (userCtx) => { - return await auth.hasAllPermissions(userCtx, 'can_change_password_first_login'); +const skipPasswordChange = async (userCtx) => { + if (roles.isDbAdmin(userCtx)) { + return true; + } + return await auth.hasAllPermissions(userCtx, 'can_skip_password_change'); }; const getHomeUrl = userCtx => { @@ -316,7 +325,7 @@ const renderLogin = (req) => { const renderPasswordReset = (req) => { return render('passwordReset', req); -} +}; const login = async (req, res) => { try { @@ -329,10 +338,12 @@ const login = async (req, res) => { const userCtx = await getUserCtxRetry(options); await setCookies(req, res, sessionRes); - const needsPasswordChange = await checkPasswordChange(userCtx); - if (needsPasswordChange && password_change_required) { + const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); + + if (!(await skipPasswordChange(userCtx)) && user.password_change_required){ return res.status(302).send('/medic/password-reset'); } + const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); res.status(302).send(redirectUrl); } catch (e) { @@ -355,6 +366,7 @@ module.exports = { 'Link', '; rel=preload; as=style, ' + '; rel=preload; as=script, ' + + '; rel=preload; as=script, ' + '; rel=preload; as=script' ); res.send(body); @@ -384,14 +396,42 @@ module.exports = { passwordResetGet: (req, res, next) => { return renderPasswordReset(req) - .then(body => { - res.setHeader( - 'Link', - '; rel=preload; as=style, ' - ); - res.send(body); - }) - .catch(next); + .then(body => { + res.setHeader( + 'Link', + '; rel=preload; as=style, ' + + '; rel=preload; as=script, ' + + '; rel=preload; as=script' + ); + res.send(body); + }) + .catch(next); + }, + passwordResetPost: async (req, res) => { + try { + const userCtx = await auth.getUserCtx(req); + const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); + user.password = req.body.password; + user.password_change_required = false; + + await db.users.put(user); + + req.body = { + ...req.body, + user: user.name, + password: req.body.password, + locale: req.body.locale, + }; + + const sessionRes = await createSessionRetry(req); + await setCookies(req, res, sessionRes); + + const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); + res.status(302).send(redirectUrl); + } catch (err) { + logger.error('Error updating password: %o', err); + res.status(500).json({ error: 'Error updating password' }); + } }, tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next), tokenPost: async (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..cae6effab48 --- /dev/null +++ b/api/src/public/login/auth-utils.js @@ -0,0 +1,59 @@ +export const setState = function(className) { + document.getElementById('form').className = className; +}; + +export 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); +}; + +export 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(); + } + } + } +}; + +export const getLocale = function(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]; + } + return; +}; + +export const parseTranslations = function() { + const raw = document.body.getAttribute('data-translations'); + return JSON.parse(decodeURIComponent(raw)); +}; + +export const baseTranslate = (selectedLocale, translations) => { + if (!selectedLocale) { + return console.error('No enabled locales found - not translating'); + } + 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')]); +}; \ No newline at end of file diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js new file mode 100644 index 00000000000..fc32d8f89a3 --- /dev/null +++ b/api/src/public/login/password-reset.js @@ -0,0 +1,71 @@ +import { + setState, + request, + getLocale, + parseTranslations, + baseTranslate +} from './auth-utils.js'; + +let selectedLocale; +let translations; + +const PASSWORD_INPUT_ID = 'password'; +const CONFIRM_PASSWORD_INPUT_ID = 'confirm-password'; + +const translate = () => { + baseTranslate(selectedLocale, translations); +}; + +const togglePassword = () => { + const passwordInput = document.getElementById(PASSWORD_INPUT_ID); + const confirmPasswordInput = document.getElementById(CONFIRM_PASSWORD_INPUT_ID); + + const displayType = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = displayType; + confirmPasswordInput.type = displayType; + document.getElementById('password-container').classList.toggle('hidden-password'); + document.getElementById('confirm-password-container').classList.toggle('hidden-password'); +}; + +const submit = function(e) { + e.preventDefault(); + if (document.getElementById('form').className === 'loading') { + // debounce double clicks + return; + } + setState('loading'); + const url = document.getElementById('form').action; + const password = document.getElementById(PASSWORD_INPUT_ID).value; + const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID).value; + + // add password weak validation check + + const payload = JSON.stringify({ + password: password, + }); + + request('POST', url, payload, function(xmlhttp) { + if (xmlhttp.status === 302 || xmlhttp.status === 200) { + // success - redirect to app + window.location = xmlhttp.response; + } else if (xmlhttp.status === 400) { + setState('password-weak'); + } else { + setState('error'); + console.error('Error updating password', xmlhttp.response); + } + }); +}; + +document.addEventListener('DOMContentLoaded', function() { + translations = parseTranslations(); + selectedLocale = getLocale(translations); + translate(); + + document.getElementById('update-password').addEventListener('click', submit, false); + + const passwordToggle = document.getElementById('password-toggle'); + if (passwordToggle) { + passwordToggle.addEventListener('click', togglePassword, false); + } +}); diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 557fc12d213..e2295351fa1 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -1,29 +1,21 @@ +import { + setState, + request, + getCookie, + getLocale, + parseTranslations, + baseTranslate +} from './auth-utils.js'; + 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') { @@ -114,48 +106,9 @@ 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() { @@ -251,7 +204,7 @@ const togglePassword = () => { document.addEventListener('DOMContentLoaded', function() { translations = parseTranslations(); - selectedLocale = getLocale(); + selectedLocale = getLocale(translations); translate(); diff --git a/api/src/routing.js b/api/src/routing.js index bc7fc598662..6ca4f55fc28 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -291,6 +291,7 @@ app.get(routePrefix + 'login/identity', login.getIdentity); app.postJson(routePrefix + 'login', login.post); app.get(routePrefix + 'login/token/:token?', login.tokenGet); app.get(routePrefix + 'password-reset', login.passwordResetGet); +app.postJson(routePrefix + 'password-reset', login.passwordResetPost); app.postJson(routePrefix + 'login/token/:token?', login.tokenPost); app.get(routePrefix + 'privacy-policy', privacyPolicyController.get); diff --git a/api/src/templates/login/index.html b/api/src/templates/login/index.html index 204b7a7b9c0..6d01067c038 100644 --- a/api/src/templates/login/index.html +++ b/api/src/templates/login/index.html @@ -44,6 +44,6 @@ <% } %> - + diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index 78ba5269d2c..cf68bb5db14 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -7,10 +7,35 @@ - +
-

Password Reset

-

You need to reset your password before continuing.

+
+ +

+

+ + +
+ +
+ + +
+
+ + +
+ +
+ +

+

+

+ + +
+
+ - \ No newline at end of file + diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 0ae88ab28a3..86e1aa2425b 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -283,7 +283,7 @@ "can_view_old_navigation": [], "can_default_facility_filter": [], "can_have_multiple_places": [], - "can_change_password_first_login": [], + "can_skip_password_change": [], "can_export_devices_details": [ "national_admin" ] diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 8c53b6750d5..9ae0be5b58b 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -199,7 +199,7 @@ const validateNewUsername = username => { }; const createUser = (data, response) => { - const user = getUserUpdates(data.username, data); + const user = getUserUpdates(data.username, data, false); user._id = createID(data.username); return db.users.put(user).then(body => { response.user = { @@ -452,7 +452,7 @@ const getSettingsUpdates = (username, data) => { return settings; }; -const getUserUpdates = (username, data) => { +const getUserUpdates = (username, data, isUserInitiated = false) => { const ignore = ['type', 'place', 'contact']; const user = { @@ -460,6 +460,10 @@ const getUserUpdates = (username, data) => { type: 'user' }; + if (data.password && !isUserInitiated) { + user.password_change_required = true; + } + USER_EDITABLE_FIELDS.forEach(key => { if (!_.isUndefined(data[key]) && ignore.indexOf(key) === -1) { user[key] = data[key]; @@ -542,11 +546,11 @@ const missingFields = data => { return required.filter(prop => isInvalidProp(prop)); }; -const getUpdatedUserDoc = async (username, data) => getUserDoc(username, 'users') +const getUpdatedUserDoc = async (username, data, fullAccess) => getUserDoc(username, 'users') .then(doc => { return { ...doc, - ...getUserUpdates(username, data), + ...getUserUpdates(username, data, !fullAccess), _id: createID(username) }; }); From 1ef99964ed390ba2ba84b3c0d5a7998a881f2245 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 30 Oct 2024 09:25:08 +0300 Subject: [PATCH 03/48] chore: add password validation --- .../translations/messages-en.properties | 1 + .../translations/messages-es.properties | 1 + api/src/controllers/login.js | 36 +++++++++++++++++++ api/src/public/login/password-reset.js | 15 ++++++-- 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index bf9c6b0af57..41eba6012f7 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -405,6 +405,7 @@ change.password.hint = Use uppercase letters, numbers, and special characters. change.password.new.password = New Password change.password.confirm.password = Confirm password change.password.submit = Change password +change.password.required = Password and Confirm Password fields are required child_birth_date = Child birth date child_birth_outcome = Child birth outcome child_birth_weight = Child birth weight diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 255bffb78c0..3edb30e6d09 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -405,6 +405,7 @@ change.password.hint = Utilice letras mayúsculas, números y caracteres especia change.password.new.password = Nueva contraseña change.password.confirm.password = Confirmar Contraseña change.password.submit = Cambiar la contraseña +change.password.required = Los campos Contraseña y Confirmar contraseña son obligatorios 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 diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index fdea15d432b..2fe65c7db3d 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -17,6 +17,10 @@ const translations = require('../translations'); const template = require('../services/template'); const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); +const passwordTester = require('simple-password-tester'); + +const PASSWORD_MINIMUM_LENGTH = 8; +const PASSWORD_MINIMUM_SCORE = 50; const templates = { login: { @@ -62,6 +66,7 @@ const templates = { 'change.password.submit', 'change.password.new.password', 'change.password.confirm.password', + 'change.password.required', 'password.weak', 'password.length.minimum', 'Passwords must match' @@ -327,6 +332,30 @@ const renderPasswordReset = (req) => { return render('passwordReset', req); }; +const validatePassword = (password, confirmPassword) => { + if (!password || !confirmPassword) { + return { isValid: false, error: 'required'}; + } + + if (password.length < PASSWORD_MINIMUM_LENGTH) { + return { + isValid: false, + error: 'short', + params: { minimum: PASSWORD_MINIMUM_LENGTH} + }; + } + + if (password !== confirmPassword) { + return { isValid: false, error: 'mismatch' }; + } + + if (passwordTester(password) < PASSWORD_MINIMUM_SCORE) { + return { isValid: false, error: 'weak' }; + } + + return { isValid: true }; +}; + const login = async (req, res) => { try { const sessionRes = await createSession(req); @@ -409,6 +438,13 @@ module.exports = { }, passwordResetPost: async (req, res) => { try { + const validation = validatePassword(req.body.password, req.body.confirmPassword); + if (!validation.isValid) { + return res.status(400).json({ + error: `password${validation.error}`, + params: validation.params, + }); + } const userCtx = await auth.getUserCtx(req); const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); user.password = req.body.password; diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index fc32d8f89a3..d9f39932b7c 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -27,6 +27,15 @@ const togglePassword = () => { document.getElementById('confirm-password-container').classList.toggle('hidden-password'); }; +const displayPasswordValidationError = (serverResponse) => { + const { error, params } = JSON.parse(serverResponse); + setState(error); + + if (params) { + //add translate values param + } +}; + const submit = function(e) { e.preventDefault(); if (document.getElementById('form').className === 'loading') { @@ -38,10 +47,9 @@ const submit = function(e) { const password = document.getElementById(PASSWORD_INPUT_ID).value; const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID).value; - // add password weak validation check - const payload = JSON.stringify({ password: password, + confirmPassword: confirmPassword, }); request('POST', url, payload, function(xmlhttp) { @@ -49,7 +57,8 @@ const submit = function(e) { // success - redirect to app window.location = xmlhttp.response; } else if (xmlhttp.status === 400) { - setState('password-weak'); + // password validation failed + displayPasswordValidationError(xmlhttp.response); } else { setState('error'); console.error('Error updating password', xmlhttp.response); From efe3a7258e8b3e08adb33cfee989a6650ac67ff7 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 30 Oct 2024 09:25:45 +0300 Subject: [PATCH 04/48] chore: add password validation error msgs --- api/src/public/login/style.css | 6 +++++- api/src/templates/login/password-reset.html | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css index 952de1dc01f..9334b97b227 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -101,7 +101,11 @@ form { .tokenmissing .error.missing, .tokentimeout .error.timeout, .tokenexpired .error.expired, -.tokenerror .error.unknown +.tokenerror .error.unknown, +.passwordweak .error.password-weak, +.passwordshort .error.password-short, +.passwordmismatch .error.password-mismatch, +.passwordrequired .error.password-required { display: block; } diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index cf68bb5db14..5222bbddb3a 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -30,7 +30,8 @@

-

+

+

From 057782c3777ca696dfd0a89a0e32ff2c4cd30200 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 30 Oct 2024 11:34:44 +0300 Subject: [PATCH 05/48] chore: prevent unauthorized access before password change --- api/src/controllers/login.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 2fe65c7db3d..cad580d1931 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -365,14 +365,16 @@ const login = async (req, res) => { const sessionCookie = getSessionCookie(sessionRes); const options = { headers: { Cookie: sessionCookie } }; const userCtx = await getUserCtxRetry(options); - await setCookies(req, res, sessionRes); const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); if (!(await skipPasswordChange(userCtx)) && user.password_change_required){ + const selectedLocale = req.body.locale || config.get('locale'); + cookie.setLocale(res, selectedLocale); return res.status(302).send('/medic/password-reset'); } + await setCookies(req, res, sessionRes); const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); res.status(302).send(redirectUrl); } catch (e) { From 63017ca3c04126b55fa48c90594619b52340fe36 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 30 Oct 2024 17:38:57 +0300 Subject: [PATCH 06/48] chore: redirect back to login if not password reset --- api/src/auth.js | 7 ++++++- api/src/controllers/login.js | 13 ++++++++----- api/src/middleware/authorization.js | 6 ++++++ api/src/services/cookie.js | 4 ++++ webapp/src/js/bootstrapper/index.js | 4 ++++ webapp/src/js/bootstrapper/translator.js | 1 + 6 files changed, 29 insertions(+), 6 deletions(-) diff --git a/api/src/auth.js b/api/src/auth.js index 508ffc81906..349350b17f7 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -59,7 +59,12 @@ module.exports = { .then(auth => { if (auth?.userCtx?.name) { req.headers['X-Medic-User'] = auth.userCtx.name; - return auth.userCtx; + return db.users + .get(`org.couchdb.user:${auth.userCtx.name}`) + .then(user => ({ + ...auth.userCtx, + password_change_required: user.password_change_required || false + })) } throw { code: 500, message: 'Failed to authenticate' }; }); diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index cad580d1931..e8ae56bc086 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -229,7 +229,7 @@ const setCookies = (req, res, sessionRes) => { cookie.setSession(res, sessionCookie); setUserCtxCookie(res, userCtx); // Delete login=force cookie - res.clearCookie('login'); + cookie.clearCookie(res, 'login'); return Promise.resolve() .then(() => { @@ -365,16 +365,13 @@ const login = async (req, res) => { const sessionCookie = getSessionCookie(sessionRes); const options = { headers: { Cookie: sessionCookie } }; const userCtx = await getUserCtxRetry(options); - + await setCookies(req, res, sessionRes); const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); if (!(await skipPasswordChange(userCtx)) && user.password_change_required){ - const selectedLocale = req.body.locale || config.get('locale'); - cookie.setLocale(res, selectedLocale); return res.status(302).send('/medic/password-reset'); } - await setCookies(req, res, sessionRes); const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); res.status(302).send(redirectUrl); } catch (e) { @@ -439,6 +436,11 @@ module.exports = { .catch(next); }, passwordResetPost: async (req, res) => { + const limited = await rateLimitService.isLimited(req); + if (limited) { + return serverUtils.rateLimited(req, res); + } + try { const validation = validatePassword(req.body.password, req.body.confirmPassword); if (!validation.isValid) { @@ -459,6 +461,7 @@ module.exports = { user: user.name, password: req.body.password, locale: req.body.locale, + password_updated: true, }; const sessionRes = await createSessionRetry(req); diff --git a/api/src/middleware/authorization.js b/api/src/middleware/authorization.js index fe02207beca..fe232140c96 100644 --- a/api/src/middleware/authorization.js +++ b/api/src/middleware/authorization.js @@ -1,5 +1,6 @@ const auth = require('../auth'); const serverUtils = require('../server-utils'); +const cookie = require('../services/cookie'); const FIREWALL_ERROR = { code: 403, @@ -40,6 +41,11 @@ module.exports = { return serverUtils.error('Authentication error', req, res); } + if (req.userCtx.password_change_required) { + cookie.clearCookie(res, 'AuthSession'); + return res.redirect('/medic/password-reset'); + } + next(); }, 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/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index f0136478a51..46f42223209 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -28,6 +28,10 @@ try { const parsedCtx = JSON.parse(unescape(decodeURI(userCtx))); parsedCtx.locale = locale; + if (parsedCtx.password_updated) { + setUiStatus('PASSWORD_CHANGE_SUCCESS'); + delete parsedCtx.password_updated; + } return parsedCtx; } catch (e) { return; diff --git a/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index b9d52d5a170..3c1ebe4ce88 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 Change successfully', PURGE_INIT: 'Checking data…', PURGE_INFO: ({ count }) => `Cleaned ${count} documents…`, PURGE_META: 'Cleaning metadata…', From bfa94853d3cb04a9eeaca6194a48166799819d0d Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 31 Oct 2024 11:13:31 +0300 Subject: [PATCH 07/48] chore: use cookie to prevent access --- api/src/controllers/login.js | 18 ++++++++++-------- api/src/middleware/authorization.js | 5 ----- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index e8ae56bc086..55617daff24 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -229,7 +229,9 @@ const setCookies = (req, res, sessionRes) => { cookie.setSession(res, sessionCookie); setUserCtxCookie(res, userCtx); // Delete login=force cookie - cookie.clearCookie(res, 'login'); + if (!userCtx.password_change_required) { + cookie.clearCookie(res, 'login'); + } return Promise.resolve() .then(() => { @@ -241,7 +243,10 @@ const setCookies = (req, res, sessionRes) => { const selectedLocale = req.body.locale || config.get('locale'); cookie.setLocale(res, selectedLocale); - return getRedirectUrl(userCtx, req.body.redirect); + return { + userCtx, + redirectUrl: getRedirectUrl(userCtx, req.body.redirect), + }; }); }) .catch(err => { @@ -362,18 +367,14 @@ const login = async (req, res) => { if (sessionRes.statusCode !== 200) { res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); } - const sessionCookie = getSessionCookie(sessionRes); - const options = { headers: { Cookie: sessionCookie } }; - const userCtx = await getUserCtxRetry(options); - await setCookies(req, res, sessionRes); + const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); if (!(await skipPasswordChange(userCtx)) && user.password_change_required){ return res.status(302).send('/medic/password-reset'); } - const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); - res.status(302).send(redirectUrl); + return res.status(302).send(redirectUrl); } catch (e) { if (e.status === 401) { return res.status(401).json({ error: e.error }); @@ -465,6 +466,7 @@ module.exports = { }; const sessionRes = await createSessionRetry(req); + cookie.clearCookie(res, 'login'); await setCookies(req, res, sessionRes); const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); diff --git a/api/src/middleware/authorization.js b/api/src/middleware/authorization.js index fe232140c96..93267fe33cd 100644 --- a/api/src/middleware/authorization.js +++ b/api/src/middleware/authorization.js @@ -41,11 +41,6 @@ module.exports = { return serverUtils.error('Authentication error', req, res); } - if (req.userCtx.password_change_required) { - cookie.clearCookie(res, 'AuthSession'); - return res.redirect('/medic/password-reset'); - } - next(); }, From c9b570db14b3b403325006ce5f2062aa743a94f0 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 31 Oct 2024 12:59:10 +0300 Subject: [PATCH 08/48] chore: try cookie to show UI --- api/src/controllers/login.js | 4 ++-- api/src/services/cookie.js | 5 +++++ webapp/src/js/bootstrapper/index.js | 9 +++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 55617daff24..62231212c37 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -368,9 +368,8 @@ const login = async (req, res) => { res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); } const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); - const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); - if (!(await skipPasswordChange(userCtx)) && user.password_change_required){ + if (!(await skipPasswordChange(userCtx)) && userCtx.password_change_required){ return res.status(302).send('/medic/password-reset'); } @@ -467,6 +466,7 @@ module.exports = { const sessionRes = await createSessionRetry(req); cookie.clearCookie(res, 'login'); + cookie.setPasswordUpdated(res); await setCookies(req, res, sessionRes); const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); diff --git a/api/src/services/cookie.js b/api/src/services/cookie.js index d655e4f734b..d34cb22aec8 100644 --- a/api/src/services/cookie.js +++ b/api/src/services/cookie.js @@ -76,5 +76,10 @@ module.exports = { }, setForceLogin: (res) => { res.cookie('login', 'force', getCookieOptions()); + }, + setPasswordUpdated: (res) => { + const options = getCookieOptions(); + options.maxAge = ONE_YEAR_IN_MS; + res.cookie('passwordUpdated', 'true', options); } }; diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index 46f42223209..6573a4e89b0 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -15,12 +15,17 @@ let locale; document.cookie.split(';').forEach(function(c) { c = c.trim().split('=', 2); + console.log('cookies', c[0]); if (c[0] === 'userCtx') { userCtx = c[1]; } if (c[0] === 'locale') { locale = c[1]; } + // if (c[0] === 'password_updated') { + // console.log('password was updated'); + // setUiStatus('PASSWORD_CHANGE_SUCCESS'); + // } }); if (!userCtx) { return; @@ -28,10 +33,6 @@ try { const parsedCtx = JSON.parse(unescape(decodeURI(userCtx))); parsedCtx.locale = locale; - if (parsedCtx.password_updated) { - setUiStatus('PASSWORD_CHANGE_SUCCESS'); - delete parsedCtx.password_updated; - } return parsedCtx; } catch (e) { return; From f1dea9ad76c5c0e193a10f91844af764e1a00aa0 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 31 Oct 2024 14:55:10 +0300 Subject: [PATCH 09/48] chore: show password success UI --- api/src/controllers/login.js | 11 ++++++----- webapp/src/js/bootstrapper/index.js | 10 ++++++---- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 62231212c37..0061e30b534 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -365,7 +365,7 @@ const login = async (req, res) => { try { const sessionRes = await createSession(req); if (sessionRes.statusCode !== 200) { - res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); + return res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); } const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); @@ -456,6 +456,9 @@ module.exports = { await db.users.put(user); + cookie.clearCookie(res, 'login'); + cookie.clearCookie(res, 'AuthSession'); + req.body = { ...req.body, user: user.name, @@ -465,12 +468,10 @@ module.exports = { }; const sessionRes = await createSessionRetry(req); - cookie.clearCookie(res, 'login'); cookie.setPasswordUpdated(res); - await setCookies(req, res, sessionRes); - const redirectUrl = getRedirectUrl(userCtx, req.body.redirect); - res.status(302).send(redirectUrl); + const { redirectUrl } = await setCookies(req, res, sessionRes); + return res.status(302).send(redirectUrl); } catch (err) { logger.error('Error updating password: %o', err); res.status(500).json({ error: 'Error updating password' }); diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index 6573a4e89b0..40d29cca94a 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -22,10 +22,6 @@ if (c[0] === 'locale') { locale = c[1]; } - // if (c[0] === 'password_updated') { - // console.log('password was updated'); - // setUiStatus('PASSWORD_CHANGE_SUCCESS'); - // } }); if (!userCtx) { return; @@ -94,10 +90,16 @@ const dbInfo = getDbInfo(); const userCtx = getUserCtx(); const hasForceLoginCookie = document.cookie.includes('login=force'); + const showPasswordUI = document.cookie.includes('passwordUpdated'); + if (!userCtx || hasForceLoginCookie) { return redirectToLogin(dbInfo); } + if (showPasswordUI) { + setUiStatus('PASSWORD_CHANGE_SUCCESS'); + } + if (hasFullDataAccess(userCtx)) { return Promise.resolve(); } From 7e150985b86c8ad7b853ab3bf63b5b24913b5740 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 31 Oct 2024 21:15:53 +0300 Subject: [PATCH 10/48] sonar reduce cognitive --- api/src/auth.js | 10 +++++----- api/src/controllers/login.js | 30 ++++++++++++++++++++--------- api/src/generate-service-worker.js | 2 +- api/src/middleware/authorization.js | 1 - api/src/public/login/auth-utils.js | 23 ++++++++++++++-------- webapp/src/js/bootstrapper/index.js | 1 - 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/api/src/auth.js b/api/src/auth.js index 349350b17f7..b529a06c54c 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -60,11 +60,11 @@ module.exports = { if (auth?.userCtx?.name) { req.headers['X-Medic-User'] = auth.userCtx.name; return db.users - .get(`org.couchdb.user:${auth.userCtx.name}`) - .then(user => ({ - ...auth.userCtx, - password_change_required: user.password_change_required || false - })) + .get(`org.couchdb.user:${auth.userCtx.name}`) + .then(user => ({ + ...auth.userCtx, + password_change_required: user.password_change_required || false + })); } throw { code: 500, message: 'Failed to authenticate' }; }); diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 0061e30b534..e665010b0c7 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -361,12 +361,28 @@ const validatePassword = (password, confirmPassword) => { return { isValid: true }; }; +const validateSession = async (req) => { + const sessionRes = await createSession(req); + if (sessionRes.statusCode !== 200) { + throw { + status: sessionRes.statusCode, + error: 'Not logged in' + }; + } + 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) { - return res.status(sessionRes.statusCode).json({ error: 'Not logged in' }); - } + const sessionRes = await validateSession(req, res); const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); if (!(await skipPasswordChange(userCtx)) && userCtx.password_change_required){ @@ -375,11 +391,7 @@ const login = async (req, res) => { return res.status(302).send(redirectUrl); } catch (e) { - if (e.status === 401) { - return res.status(401).json({ error: e.error }); - } - logger.error('Error logging in: %o', e); - res.status(500).json({ error: 'Unexpected error logging in' }); + return sendLoginErrorResponse(e, res); } }; diff --git a/api/src/generate-service-worker.js b/api/src/generate-service-worker.js index 2956c4c8c69..9042cff62d5 100644 --- a/api/src/generate-service-worker.js +++ b/api/src/generate-service-worker.js @@ -55,7 +55,7 @@ const getLoginPageContents = async () => { const getPasswordResetPageContents = async () => { return await loginController.renderPasswordReset(); -} +}; const appendExtensionLibs = async (config) => { const libs = await extensionLibs.getAll(); diff --git a/api/src/middleware/authorization.js b/api/src/middleware/authorization.js index 93267fe33cd..fe02207beca 100644 --- a/api/src/middleware/authorization.js +++ b/api/src/middleware/authorization.js @@ -1,6 +1,5 @@ const auth = require('../auth'); const serverUtils = require('../server-utils'); -const cookie = require('../services/cookie'); const FIREWALL_ERROR = { code: 403, diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index cae6effab48..300e09c3571 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -15,16 +15,23 @@ export const request = function(method, url, payload, callback) { xmlhttp.send(payload); }; -export 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 extractCookie = function(cookies, name) { + for (const cookie of cookies) { + const [cookieName, cookieValue] = cookie.trim().split('='); + if (cookieName === name) { + return cookieValue.trim(); } } + return null; +}; + +export const getCookie = function(name) { + if (!document.cookie) { + return null; + } + + const cookies = document.cookie.split(';'); + return extractCookie(cookies, name); }; export const getLocale = function(translations) { diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index 40d29cca94a..a0b5a1a1032 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -15,7 +15,6 @@ let locale; document.cookie.split(';').forEach(function(c) { c = c.trim().split('=', 2); - console.log('cookies', c[0]); if (c[0] === 'userCtx') { userCtx = c[1]; } From d2d18e972aee37eafd5ac341f6942c151a0e0a46 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 1 Nov 2024 07:05:52 +0300 Subject: [PATCH 11/48] chore: sonar fixes --- api/src/controllers/login.js | 9 ++++----- api/src/public/login/auth-utils.js | 1 - api/src/templates/login/password-reset.html | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index e665010b0c7..27adb91e422 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -364,10 +364,9 @@ const validatePassword = (password, confirmPassword) => { const validateSession = async (req) => { const sessionRes = await createSession(req); if (sessionRes.statusCode !== 200) { - throw { - status: sessionRes.statusCode, - error: 'Not logged in' - }; + const error = new Error('Not logged in'); + error.status = sessionRes.statusCode; + throw error; } return sessionRes; }; @@ -382,7 +381,7 @@ const sendLoginErrorResponse = (e, res) => { const login = async (req, res) => { try { - const sessionRes = await validateSession(req, res); + const sessionRes = await validateSession(req); const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); if (!(await skipPasswordChange(userCtx)) && userCtx.password_change_required){ diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 300e09c3571..83333958da4 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -45,7 +45,6 @@ export const getLocale = function(translations) { if (validLocales.length) { return validLocales[0]; } - return; }; export const parseTranslations = function() { diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index 5222bbddb3a..5882c450764 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@
- +

@@ -18,8 +18,16 @@
- - + show-password-ion + hide-password-ion
From 8fee9e8826eb8f5f18a1fb3b229a543980188612 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 4 Nov 2024 10:58:42 +0300 Subject: [PATCH 12/48] chore: interpolate translations --- api/src/controllers/login.js | 2 +- api/src/public/login/auth-utils.js | 27 +++++++++++++++++++++++++- api/src/public/login/password-reset.js | 8 ++++++-- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 27adb91e422..41871f228a8 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -346,7 +346,7 @@ const validatePassword = (password, confirmPassword) => { return { isValid: false, error: 'short', - params: { minimum: PASSWORD_MINIMUM_LENGTH} + params: { minimum: PASSWORD_MINIMUM_LENGTH } }; } diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 83333958da4..55cadcdaf80 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -52,13 +52,38 @@ export const parseTranslations = function() { return JSON.parse(decodeURIComponent(raw)); }; +const replaceTranslationPlaceholders = (text, translateValues) => { + if (!text || !translateValues) { + return text; + } + + try { + const values = JSON.parse(translateValues); + console.log(values); + 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; + } +} + export const baseTranslate = (selectedLocale, translations) => { if (!selectedLocale) { return console.error('No enabled locales found - not translating'); } document .querySelectorAll('[translate]') - .forEach(elem => elem.innerText = translations[selectedLocale][elem.getAttribute('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')]); diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index d9f39932b7c..2ef6ce2e19b 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -31,8 +31,12 @@ const displayPasswordValidationError = (serverResponse) => { const { error, params } = JSON.parse(serverResponse); setState(error); - if (params) { - //add translate values param + if (params?.minimum) { + const passwordError = document.querySelector('.error.password-short'); + if (passwordError) { + passwordError.setAttribute('translate-values', JSON.stringify(params)); + baseTranslate(selectedLocale, translations); + } } }; From 0323df53e25db40d434367e2cadc0bad1ab286d0 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 4 Nov 2024 17:25:06 +0300 Subject: [PATCH 13/48] chore: first round of feedback --- .../translations/messages-en.properties | 9 +++--- .../translations/messages-es.properties | 9 +++--- .../translations/messages-fr.properties | 1 + .../translations/messages-id.properties | 1 + .../translations/messages-sw.properties | 7 +++++ api/src/auth.js | 2 +- api/src/controllers/login.js | 16 +++++------ api/src/public/login/auth-utils.js | 6 ++-- api/src/public/login/password-reset.js | 10 +++---- api/src/routing.js | 4 +-- api/src/templates/login/password-reset.html | 2 +- config/default/app_settings.json | 28 +++++++++---------- config/demo/app_settings.json | 21 +++++++------- shared-libs/user-management/src/users.js | 4 +++ webapp/src/js/bootstrapper/index.js | 1 + webapp/src/js/bootstrapper/translator.js | 3 +- 16 files changed, 69 insertions(+), 55 deletions(-) diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 41eba6012f7..a47793947d0 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -400,12 +400,12 @@ bulkdelete.confirm.title = Delete record? bulkdelete.confirm.title.plural = Delete selected records? call = Call case_id = Case ID -change.password.title = Change your password -change.password.hint = Use uppercase letters, numbers, and special characters. -change.password.new.password = New Password change.password.confirm.password = Confirm password -change.password.submit = Change password +change.password.hint = Use uppercase letters, numbers, and special characters. +change.password.new.password = New password change.password.required = Password and Confirm Password fields are required +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 @@ -972,6 +972,7 @@ partner.supporting = Supporting partners partner.tab.partners = Partners password.incorrect = Password is not correct. password.length.minimum = The password must be at least {{minimum}} characters long. +password.must.match = Passwords and confirm password must match 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 3edb30e6d09..4455b887250 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -400,12 +400,12 @@ bulkdelete.confirm.title = ¿Eliminar el registro? bulkdelete.confirm.title.plural = ¿Eliminar registros seleccionados? call = Llamar case_id = Identificación del caso -change.password.title = Cambia tu contraseña -change.password.hint = Utilice letras mayúsculas, números y caracteres especiales.. +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.confirm.password = Confirmar Contraseña -change.password.submit = Cambiar la contraseña change.password.required = Los campos Contraseña y Confirmar contraseña son obligatorios +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 @@ -972,6 +972,7 @@ partner.supporting = Socios que está apoyando partner.tab.partners = Socios 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.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 a82b00af6de..ecf1d94ea3d 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -966,6 +966,7 @@ partner.supporting = Partenaires de soutien partner.tab.partners = Partenaires password.incorrect = Mot de passe incorrect password.length.minimum = Le mot de passe doit être au moins {{minimum}} caractères. +password.must.match = Les mots de passe et le mot de passe de confirmation doivent correspondre 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 53a0b392f54..69c45b5bf87 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -884,6 +884,7 @@ partner.supporting = partner.tab.partners = 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.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-sw.properties b/api/resources/translations/messages-sw.properties index b7e0f9dae7e..15d0df9c52f 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -402,6 +402,12 @@ 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.required = Nenosiri na uthibitisho wa nenosiri zinahitajika +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 @@ -966,6 +972,7 @@ partner.supporting = Washirika wanaounga mkono partner.tab.partners = Washirika 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.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 b529a06c54c..623103a8502 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -63,7 +63,7 @@ module.exports = { .get(`org.couchdb.user:${auth.userCtx.name}`) .then(user => ({ ...auth.userCtx, - password_change_required: user.password_change_required || false + password_change_required: !!user.password_change_required })); } throw { code: 500, message: 'Failed to authenticate' }; diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 41871f228a8..da3cf53c046 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -19,8 +19,7 @@ const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); const passwordTester = require('simple-password-tester'); -const PASSWORD_MINIMUM_LENGTH = 8; -const PASSWORD_MINIMUM_SCORE = 50; +const { PASSWORD_MINIMUM_LENGTH, PASSWORD_MINIMUM_SCORE } = require('@medic/user-management/src/users'); const templates = { login: { @@ -69,7 +68,7 @@ const templates = { 'change.password.required', 'password.weak', 'password.length.minimum', - 'Passwords must match' + 'password.must.match' ], } }; @@ -240,8 +239,7 @@ const setCookies = (req, res, sessionRes) => { } }) .then(() => { - const selectedLocale = req.body.locale - || config.get('locale'); + const selectedLocale = req.body.locale || config.get('locale'); cookie.setLocale(res, selectedLocale); return { userCtx, @@ -384,7 +382,8 @@ const login = async (req, res) => { const sessionRes = await validateSession(req); const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); - if (!(await skipPasswordChange(userCtx)) && userCtx.password_change_required){ + const redirectPasswordReset = !await skipPasswordChange(userCtx); + if (redirectPasswordReset){ return res.status(302).send('/medic/password-reset'); } @@ -433,7 +432,7 @@ module.exports = { }); }, - passwordResetGet: (req, res, next) => { + getPasswordReset: (req, res, next) => { return renderPasswordReset(req) .then(body => { res.setHeader( @@ -446,7 +445,7 @@ module.exports = { }) .catch(next); }, - passwordResetPost: async (req, res) => { + resetPassword: async (req, res) => { const limited = await rateLimitService.isLimited(req); if (limited) { return serverUtils.rateLimited(req, res); @@ -475,7 +474,6 @@ module.exports = { user: user.name, password: req.body.password, locale: req.body.locale, - password_updated: true, }; const sessionRes = await createSessionRetry(req); diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 55cadcdaf80..a8b9102ea24 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -59,9 +59,9 @@ const replaceTranslationPlaceholders = (text, translateValues) => { try { const values = JSON.parse(translateValues); - console.log(values); - return Object.entries(values).reduce((result, [key, value]) => - result.replace(new RegExp(`{{${key}}}`, 'g'), value), + return Object + .entries(values) + .reduce((result, [key, value]) => result.replace(new RegExp(`{{${key}}}`, 'g'), value), text ); } catch (e) { diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 2ef6ce2e19b..ff925694c57 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -31,12 +31,10 @@ const displayPasswordValidationError = (serverResponse) => { const { error, params } = JSON.parse(serverResponse); setState(error); - if (params?.minimum) { - const passwordError = document.querySelector('.error.password-short'); - if (passwordError) { - passwordError.setAttribute('translate-values', JSON.stringify(params)); - baseTranslate(selectedLocale, translations); - } + const passwordError = document.querySelector('.error.password-short'); + if (params?.minimum && passwordError) { + passwordError.setAttribute('translate-values', JSON.stringify(params)); + baseTranslate(selectedLocale, translations); } }; diff --git a/api/src/routing.js b/api/src/routing.js index 6ca4f55fc28..aa4c08742ee 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -290,9 +290,9 @@ app.get(routePrefix + 'login', login.get); app.get(routePrefix + 'login/identity', login.getIdentity); app.postJson(routePrefix + 'login', login.post); app.get(routePrefix + 'login/token/:token?', login.tokenGet); -app.get(routePrefix + 'password-reset', login.passwordResetGet); -app.postJson(routePrefix + 'password-reset', login.passwordResetPost); 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/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index 5882c450764..c32a46d8b3a 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -36,7 +36,7 @@
-

+

diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 86e1aa2425b..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,20 +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_skip_password_change": [], - "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/users.js b/shared-libs/user-management/src/users.js index 9ae0be5b58b..71035414c40 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -1177,6 +1177,10 @@ module.exports = { */ parseCsv, + PASSWORD_MINIMUM_LENGTH, + + PASSWORD_MINIMUM_SCORE, + createMultiFacilityUser, checkPayloadFacilityCount, diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index a0b5a1a1032..d3d5faacc49 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -97,6 +97,7 @@ if (showPasswordUI) { setUiStatus('PASSWORD_CHANGE_SUCCESS'); + document.cookie = 'passwordUpdated=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; } if (hasFullDataAccess(userCtx)) { diff --git a/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index 3c1ebe4ce88..ca5fb6d9db8 100644 --- a/webapp/src/js/bootstrapper/translator.js +++ b/webapp/src/js/bootstrapper/translator.js @@ -7,7 +7,7 @@ const TRANSLATIONS = { en: { FETCH_INFO: ({ count, total }) => `Fetching info (${count} of ${total} docs )…`, LOAD_APP: 'Loading app…', - PASSWORD_CHANGE_SUCCESS: 'Password Change successfully', + PASSWORD_CHANGE_SUCCESS: 'Password change successfully', PURGE_INIT: 'Checking data…', PURGE_INFO: ({ count }) => `Cleaned ${count} documents…`, PURGE_META: 'Cleaning metadata…', @@ -26,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…', From 935c721b7aa4f037d74437ea01af24566bf2bdc1 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 4 Nov 2024 18:40:05 +0300 Subject: [PATCH 14/48] chore: additional feedback --- api/src/controllers/login.js | 3 +-- api/src/public/login/style.css | 8 ++++---- webapp/src/js/bootstrapper/index.js | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index da3cf53c046..c858d19f48c 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -60,7 +60,6 @@ const templates = { 'login.show_password', 'login.hide_password', 'change.password.title', - 'change.password', 'change.password.hint', 'change.password.submit', 'change.password.new.password', @@ -455,7 +454,7 @@ module.exports = { const validation = validatePassword(req.body.password, req.body.confirmPassword); if (!validation.isValid) { return res.status(400).json({ - error: `password${validation.error}`, + error: `password-${validation.error}`, params: validation.params, }); } diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css index 9334b97b227..ab73a44bc0b 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -102,10 +102,10 @@ form { .tokentimeout .error.timeout, .tokenexpired .error.expired, .tokenerror .error.unknown, -.passwordweak .error.password-weak, -.passwordshort .error.password-short, -.passwordmismatch .error.password-mismatch, -.passwordrequired .error.password-required +.password-weak .error.password-weak, +.password-short .error.password-short, +.password-mismatch .error.password-mismatch, +.password-required .error.password-required { display: block; } diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index d3d5faacc49..a0b5a1a1032 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -97,7 +97,6 @@ if (showPasswordUI) { setUiStatus('PASSWORD_CHANGE_SUCCESS'); - document.cookie = 'passwordUpdated=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; } if (hasFullDataAccess(userCtx)) { From f4f4de0c3ea379ea0b7874f79bf1635d7812c126 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 6 Nov 2024 11:15:46 +0300 Subject: [PATCH 15/48] chore: refactor password validation --- .../translations/messages-en.properties | 4 +- api/src/controllers/login.js | 40 +++++++++---------- shared-libs/user-management/src/users.js | 23 ++++++++--- 3 files changed, 38 insertions(+), 29 deletions(-) diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index a47793947d0..532ba49d9aa 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -403,7 +403,7 @@ 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.required = Password and Confirm Password fields are required +change.password.required = Password and confirm password fields are required change.password.submit = Change password change.password.title = Change your password child_birth_date = Child birth date @@ -972,7 +972,7 @@ partner.supporting = Supporting partners partner.tab.partners = Partners password.incorrect = Password is not correct. password.length.minimum = The password must be at least {{minimum}} characters long. -password.must.match = Passwords and confirm password must match +password.must.match = Password and confirm password must match 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/src/controllers/login.js b/api/src/controllers/login.js index c858d19f48c..4c33e93e42b 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -17,9 +17,15 @@ const translations = require('../translations'); const template = require('../services/template'); const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); -const passwordTester = require('simple-password-tester'); -const { PASSWORD_MINIMUM_LENGTH, PASSWORD_MINIMUM_SCORE } = require('@medic/user-management/src/users'); +const { validatePassword } = require('@medic/user-management/src/users'); + +const ERROR_KEY_MAPPING = { + 'password.must.match': 'password-mismatch', + 'password.weak': 'password-weak', + 'password.length.minimum': 'password-short', + 'password.required': 'password-required' +}; const templates = { login: { @@ -334,28 +340,18 @@ const renderPasswordReset = (req) => { return render('passwordReset', req); }; -const validatePassword = (password, confirmPassword) => { - if (!password || !confirmPassword) { - return { isValid: false, error: 'required'}; - } - - if (password.length < PASSWORD_MINIMUM_LENGTH) { - return { - isValid: false, - error: 'short', - params: { minimum: PASSWORD_MINIMUM_LENGTH } - }; - } +const validatePasswordReset = (password, confirmPassword) => { + const error = validatePassword(password, confirmPassword); - if (password !== confirmPassword) { - return { isValid: false, error: 'mismatch' }; + if (!error) { + return { isValid: true }; } - if (passwordTester(password) < PASSWORD_MINIMUM_SCORE) { - return { isValid: false, error: 'weak' }; + return { + isValid: false, + error: ERROR_KEY_MAPPING[error.message.translationKey], + params: error.message.translationParams } - - return { isValid: true }; }; const validateSession = async (req) => { @@ -451,10 +447,10 @@ module.exports = { } try { - const validation = validatePassword(req.body.password, req.body.confirmPassword); + const validation = validatePasswordReset(req.body.password, req.body.confirmPassword); if (!validation.isValid) { return res.status(400).json({ - error: `password-${validation.error}`, + error: validation.error, params: validation.params, }); } diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 71035414c40..c8bfe37c90f 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -494,7 +494,22 @@ const deleteUser = id => { return Promise.all([ usersDbPromise, medicDbPromise ]); }; -const validatePassword = (password) => { +const validatePassword = (password, confirmPassword = null) => { + if (confirmPassword) { + if (!password || !confirmPassword) { + return error400( + 'Password and confirm password fields are required', + 'password.required' + ); + } + + if (password !== confirmPassword) { + return error400( + 'Password and confirm password must match', + 'password.must.match' + ); + } + } if (password.length < PASSWORD_MINIMUM_LENGTH) { return error400( `The password must be at least ${PASSWORD_MINIMUM_LENGTH} characters long.`, @@ -1170,6 +1185,8 @@ module.exports = { validateNewUsername, + validatePassword, + /** * Parses a CSV of users to an array of objects. * @@ -1177,10 +1194,6 @@ module.exports = { */ parseCsv, - PASSWORD_MINIMUM_LENGTH, - - PASSWORD_MINIMUM_SCORE, - createMultiFacilityUser, checkPayloadFacilityCount, From 04b3f97f90c45da880bca530b4838c891d224931 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 6 Nov 2024 15:01:46 +0300 Subject: [PATCH 16/48] chore: refactor toggle password and legacy code --- api/src/public/login/auth-utils.js | 157 +++++++++++--------- api/src/public/login/password-reset.js | 104 ++++++------- api/src/public/login/script.js | 38 ++--- api/src/templates/login/password-reset.html | 72 ++++----- 4 files changed, 186 insertions(+), 185 deletions(-) diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index a8b9102ea24..88de9a678dc 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -1,90 +1,107 @@ -export const setState = function(className) { - document.getElementById('form').className = className; +export const setState = (className) => { + const form = document.getElementById('form'); + if(!form) return; + form.className = className; }; -export 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); +export const request = (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 extractCookie = function(cookies, name) { - for (const cookie of cookies) { - const [cookieName, cookieValue] = cookie.trim().split('='); - if (cookieName === name) { - return cookieValue.trim(); - } +const extractCookie = (cookies, name) => { + for (const cookie of cookies) { + const [cookieName, cookieValue] = cookie.trim().split('='); + if (cookieName === name) { + return cookieValue.trim(); } - return null; + } + return null; }; export const getCookie = function(name) { - if (!document.cookie) { - return null; - } + if (!document.cookie) { + return null; + } - const cookies = document.cookie.split(';'); - return extractCookie(cookies, name); + const cookies = document.cookie.split(';'); + return extractCookie(cookies, name); }; -export const getLocale = function(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]; - } +export 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]; + } }; -export const parseTranslations = function() { - const raw = document.body.getAttribute('data-translations'); - return JSON.parse(decodeURIComponent(raw)); +export const parseTranslations = () => { + const raw = document.body.getAttribute('data-translations'); + return JSON.parse(decodeURIComponent(raw)); }; const replaceTranslationPlaceholders = (text, translateValues) => { - if (!text || !translateValues) { - return text; - } + 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; - } + 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; + } } export 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')]); -}; \ No newline at end of file + 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')]); +}; + +export const togglePassword = (passwordInputId, confirmPasswordInputId = null) => { + const passwordInput = document.getElementById(passwordInputId); + if (!passwordInput) return; + + const displayType = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = displayType; + document.getElementById('password-container')?.classList.toggle('hidden-password'); + + if (confirmPasswordInputId) { + const confirmPasswordInput = document.getElementById(confirmPasswordInputId); + confirmPasswordInput.type = displayType; + document.getElementById('confirm-password-container')?.classList.toggle('hidden-password'); + } +} diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index ff925694c57..72e38336f23 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -1,9 +1,10 @@ import { - setState, - request, - getLocale, - parseTranslations, - baseTranslate + setState, + request, + getLocale, + parseTranslations, + baseTranslate, + togglePassword } from './auth-utils.js'; let selectedLocale; @@ -13,70 +14,59 @@ const PASSWORD_INPUT_ID = 'password'; const CONFIRM_PASSWORD_INPUT_ID = 'confirm-password'; const translate = () => { - baseTranslate(selectedLocale, translations); -}; - -const togglePassword = () => { - const passwordInput = document.getElementById(PASSWORD_INPUT_ID); - const confirmPasswordInput = document.getElementById(CONFIRM_PASSWORD_INPUT_ID); - - const displayType = passwordInput.type === 'password' ? 'text' : 'password'; - passwordInput.type = displayType; - confirmPasswordInput.type = displayType; - document.getElementById('password-container').classList.toggle('hidden-password'); - document.getElementById('confirm-password-container').classList.toggle('hidden-password'); + baseTranslate(selectedLocale, translations); }; const displayPasswordValidationError = (serverResponse) => { - const { error, params } = JSON.parse(serverResponse); - setState(error); + 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 passwordError = document.querySelector('.error.password-short'); + if (params?.minimum && passwordError) { + passwordError.setAttribute('translate-values', JSON.stringify(params)); + baseTranslate(selectedLocale, translations); + } }; const submit = function(e) { - e.preventDefault(); - if (document.getElementById('form').className === 'loading') { - // debounce double clicks - return; - } - setState('loading'); - const url = document.getElementById('form').action; - const password = document.getElementById(PASSWORD_INPUT_ID).value; - const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID).value; + e.preventDefault(); + if (document.getElementById('form')?.className === 'loading') { + // debounce double clicks + return; + } + setState('loading'); + const url = document.getElementById('form')?.action; + const password = document.getElementById(PASSWORD_INPUT_ID)?.value; + const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID)?.value; - const payload = JSON.stringify({ - password: password, - confirmPassword: confirmPassword, - }); + const payload = JSON.stringify({ + password: password, + confirmPassword: confirmPassword, + }); - request('POST', url, payload, function(xmlhttp) { - if (xmlhttp.status === 302 || xmlhttp.status === 200) { - // success - redirect to app - 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); - } - }); + request('POST', url, payload, function(xmlhttp) { + if (xmlhttp.status === 302 || xmlhttp.status === 200) { + // success - redirect to app + 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(); + translations = parseTranslations(); + selectedLocale = getLocale(translations); + translate(); - document.getElementById('update-password').addEventListener('click', submit, false); + document.getElementById('update-password')?.addEventListener('click', submit, false); - const passwordToggle = document.getElementById('password-toggle'); - if (passwordToggle) { - passwordToggle.addEventListener('click', togglePassword, false); - } + const passwordToggle = document.getElementById('password-toggle'); + if (passwordToggle) { + passwordToggle.addEventListener('click', () => togglePassword(PASSWORD_INPUT_ID, CONFIRM_PASSWORD_INPUT_ID), false); + } }); diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index e2295351fa1..280694f4d62 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -4,7 +4,8 @@ import { getCookie, getLocale, parseTranslations, - baseTranslate + baseTranslate, + togglePassword } from './auth-utils.js'; let selectedLocale; @@ -18,15 +19,15 @@ const setTokenState = className => { 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 }); @@ -46,7 +47,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 = {}; @@ -80,20 +81,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'; } }; @@ -112,7 +112,7 @@ const translate = () => { }; const getUsername = function() { - return document.getElementById('user').value.toLowerCase().trim(); + return document.getElementById('user')?.value.toLowerCase().trim(); }; const getRedirectUrl = function() { @@ -189,42 +189,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(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/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index c32a46d8b3a..f406f29a119 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -1,49 +1,49 @@ - - - {{ branding.name }} - - + + + {{ branding.name }} + +
- - -

-

+ + +

+

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

-

-

-

+

+

+

+

- -
- + +
+
From 1a4e971afe869039f0e95e9576da9b576c79981f Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 6 Nov 2024 18:51:02 +0300 Subject: [PATCH 17/48] chore: remove passwordUpdate cookie and set minimal cookie without Auth --- api/src/controllers/login.js | 16 ++++++++-------- api/src/public/login/password-reset.js | 1 + api/src/services/cookie.js | 5 ----- webapp/src/js/bootstrapper/index.js | 5 +++-- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 4c33e93e42b..e1a8b023adc 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -375,13 +375,17 @@ const sendLoginErrorResponse = (e, res) => { const login = async (req, res) => { try { const sessionRes = await validateSession(req); - const { userCtx, redirectUrl } = await setCookies(req, res, sessionRes); + const headers = { headers: { Cookie: getSessionCookie(sessionRes) } }; + const userCtx = await getUserCtxRetry(headers); const redirectPasswordReset = !await skipPasswordChange(userCtx); - if (redirectPasswordReset){ + if (redirectPasswordReset && userCtx.password_change_required){ + setUserCtxCookie(res, userCtx); + const selectedLocale = req.body.locale || config.get('locale'); + cookie.setLocale(res, selectedLocale); return res.status(302).send('/medic/password-reset'); } - + const { redirectUrl } = await setCookies(req, res, sessionRes); return res.status(302).send(redirectUrl); } catch (e) { return sendLoginErrorResponse(e, res); @@ -461,9 +465,6 @@ module.exports = { await db.users.put(user); - cookie.clearCookie(res, 'login'); - cookie.clearCookie(res, 'AuthSession'); - req.body = { ...req.body, user: user.name, @@ -472,9 +473,8 @@ module.exports = { }; const sessionRes = await createSessionRetry(req); - cookie.setPasswordUpdated(res); - const { redirectUrl } = await setCookies(req, res, sessionRes); + return res.status(302).send(redirectUrl); } catch (err) { logger.error('Error updating password: %o', err); diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 72e38336f23..16493f5f0bd 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -47,6 +47,7 @@ const submit = function(e) { request('POST', url, payload, function(xmlhttp) { if (xmlhttp.status === 302 || xmlhttp.status === 200) { // success - redirect to app + localStorage.setItem('passwordStatus', 'PASSWORD_CHANGED'); window.location = xmlhttp.response; } else if (xmlhttp.status === 400) { // password validation failed diff --git a/api/src/services/cookie.js b/api/src/services/cookie.js index d34cb22aec8..d655e4f734b 100644 --- a/api/src/services/cookie.js +++ b/api/src/services/cookie.js @@ -76,10 +76,5 @@ module.exports = { }, setForceLogin: (res) => { res.cookie('login', 'force', getCookieOptions()); - }, - setPasswordUpdated: (res) => { - const options = getCookieOptions(); - options.maxAge = ONE_YEAR_IN_MS; - res.cookie('passwordUpdated', 'true', options); } }; diff --git a/webapp/src/js/bootstrapper/index.js b/webapp/src/js/bootstrapper/index.js index a0b5a1a1032..3aafa7ae448 100644 --- a/webapp/src/js/bootstrapper/index.js +++ b/webapp/src/js/bootstrapper/index.js @@ -89,14 +89,15 @@ const dbInfo = getDbInfo(); const userCtx = getUserCtx(); const hasForceLoginCookie = document.cookie.includes('login=force'); - const showPasswordUI = document.cookie.includes('passwordUpdated'); + const passwordStatus = localStorage.getItem('passwordStatus'); if (!userCtx || hasForceLoginCookie) { return redirectToLogin(dbInfo); } - if (showPasswordUI) { + if (passwordStatus === 'PASSWORD_CHANGED') { setUiStatus('PASSWORD_CHANGE_SUCCESS'); + localStorage.removeItem('passwordStatus'); } if (hasFullDataAccess(userCtx)) { From f6d9949dbf99aab0967cdae4d1367d6e06efe5c8 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 7 Nov 2024 11:27:17 +0300 Subject: [PATCH 18/48] chore: refactor setting basic Cookie --- api/src/controllers/login.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index e1a8b023adc..506985912bc 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -258,6 +258,12 @@ const setCookies = (req, res, sessionRes) => { }); }; +const setBasicCookies = (res, userCtx, req) => { + setUserCtxCookie(res, userCtx); + const selectedLocale = req.body.locale || config.get('locale'); + cookie.setLocale(res, selectedLocale); +}; + const renderTokenLogin = (req, res) => { return render('tokenLogin', req, { tokenUrl: req.url }) .then(body => res.send(body)); @@ -378,11 +384,8 @@ const login = async (req, res) => { const headers = { headers: { Cookie: getSessionCookie(sessionRes) } }; const userCtx = await getUserCtxRetry(headers); - const redirectPasswordReset = !await skipPasswordChange(userCtx); - if (redirectPasswordReset && userCtx.password_change_required){ - setUserCtxCookie(res, userCtx); - const selectedLocale = req.body.locale || config.get('locale'); - cookie.setLocale(res, selectedLocale); + if (userCtx.password_change_required && !await skipPasswordChange(userCtx)){ + setBasicCookies(res, userCtx, req); return res.status(302).send('/medic/password-reset'); } const { redirectUrl } = await setCookies(req, res, sessionRes); From d693b75c84a534a7fb8dd16ff304b835ad8828b6 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 7 Nov 2024 12:07:32 +0300 Subject: [PATCH 19/48] chore: clean password reset --- api/src/controllers/login.js | 18 +++++++----------- api/src/public/login/auth-utils.js | 15 +++++++++++++-- api/src/public/login/password-reset.js | 5 ++++- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 506985912bc..fe61896d809 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -461,21 +461,17 @@ module.exports = { params: validation.params, }); } - const userCtx = await auth.getUserCtx(req); - const user = await db.users.get(`org.couchdb.user:${userCtx.name}`); + const user = await db.users.get(`org.couchdb.user:${req.body.user}`); user.password = req.body.password; user.password_change_required = false; await db.users.put(user); - - req.body = { - ...req.body, - user: user.name, - password: req.body.password, - locale: req.body.locale, - }; - - const sessionRes = await createSessionRetry(req); + const sessionRes = await createSessionRetry({ + body: { + user: user.name, + password: req.body.password, + } + }); const { redirectUrl } = await setCookies(req, res, sessionRes); return res.status(302).send(redirectUrl); diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 88de9a678dc..307b87f12d1 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -6,7 +6,7 @@ export const setState = (className) => { export const request = (method, url, payload, callback) => { const xmlhttp = new XMLHttpRequest(); - xmlhttp.onreadystatechange = function() { + xmlhttp.onreadystatechange = () => { if (xmlhttp.readyState === XMLHttpRequest.DONE) { callback(xmlhttp); } @@ -27,7 +27,7 @@ const extractCookie = (cookies, name) => { return null; }; -export const getCookie = function(name) { +export const getCookie = (name) => { if (!document.cookie) { return null; } @@ -36,6 +36,17 @@ export const getCookie = function(name) { return extractCookie(cookies, name); }; +export const getUserCtx = () => { + const cookie = getCookie('userCtx'); + if (cookie) { + try { + return JSON.parse(decodeURIComponent(cookie)); + } catch (e) { + console.error('Error parsing cookie', e); + } + } +}; + export const getLocale = (translations) => { const selectedLocale = getCookie('locale'); const defaultLocale = document.body.getAttribute('data-default-locale'); diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 16493f5f0bd..76e363a292e 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -4,7 +4,8 @@ import { getLocale, parseTranslations, baseTranslate, - togglePassword + togglePassword, + getUserCtx } from './auth-utils.js'; let selectedLocale; @@ -38,8 +39,10 @@ const submit = function(e) { const url = document.getElementById('form')?.action; const password = document.getElementById(PASSWORD_INPUT_ID)?.value; const confirmPassword = document.getElementById(CONFIRM_PASSWORD_INPUT_ID)?.value; + const userCtx = getUserCtx(); const payload = JSON.stringify({ + user: userCtx.name, password: password, confirmPassword: confirmPassword, }); From 01acf08e7297dba2fbb107ad0bf341f1c8f08ae2 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 7 Nov 2024 13:53:44 +0300 Subject: [PATCH 20/48] chore: fix userService unit test --- api/src/public/login/script.js | 14 ++------------ shared-libs/user-management/src/users.js | 6 +++--- .../user-management/test/unit/users.spec.js | 7 +++++-- webapp/src/js/bootstrapper/translator.js | 7 ++++++- webapp/tests/mocha/unit/bootstrapper.spec.js | 6 ++++++ 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 280694f4d62..98bdfc353fb 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -5,7 +5,8 @@ import { getLocale, parseTranslations, baseTranslate, - togglePassword + togglePassword, + getUserCtx } from './auth-utils.js'; let selectedLocale; @@ -124,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 diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index c8bfe37c90f..0993c9f8b1b 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -199,7 +199,7 @@ const validateNewUsername = username => { }; const createUser = (data, response) => { - const user = getUserUpdates(data.username, data, false); + const user = getUserUpdates(data.username, data); user._id = createID(data.username); return db.users.put(user).then(body => { response.user = { @@ -460,8 +460,8 @@ const getUserUpdates = (username, data, isUserInitiated = false) => { type: 'user' }; - if (data.password && !isUserInitiated) { - user.password_change_required = true; + if (data.password) { + user.password_change_required = !isUserInitiated; } USER_EDITABLE_FIELDS.forEach(key => { diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index 2e4e574d250..1a269a2941c 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -2285,7 +2285,8 @@ describe('Users service', () => { type: 'user', _id: 'org.couchdb.user:x', name: 'x', - password: 'password.123' + password: 'password.123', + password_change_required: true }]]); chai.expect(roles.hasAllPermissions.args).to.deep.equal([[['national-manager'], ['can_have_multiple_places']]]); }); @@ -2323,7 +2324,8 @@ describe('Users service', () => { type: 'user', _id: 'org.couchdb.user:x', name: 'x', - password: 'password.123' + password: 'password.123', + password_change_required: true }]]); }); }); @@ -3168,6 +3170,7 @@ describe('Users service', () => { name: 'anne', type: 'user', password: COMPLEX_PASSWORD, + password_change_required: false }); chai.expect(couchSettings.getCouchConfig.callCount).to.equal(1); chai.expect(couchSettings.getCouchConfig.args[0]).to.deep.equal(['admins']); diff --git a/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index ca5fb6d9db8..cee17e34a9f 100644 --- a/webapp/src/js/bootstrapper/translator.js +++ b/webapp/src/js/bootstrapper/translator.js @@ -7,7 +7,7 @@ const TRANSLATIONS = { en: { FETCH_INFO: ({ count, total }) => `Fetching info (${count} of ${total} docs )…`, LOAD_APP: 'Loading app…', - PASSWORD_CHANGE_SUCCESS: 'Password change successfully', + PASSWORD_CHANGE_SUCCESS: 'Password changed successfully', PURGE_INIT: 'Checking data…', PURGE_INFO: ({ count }) => `Cleaned ${count} documents…`, PURGE_META: 'Cleaning metadata…', @@ -45,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…', @@ -63,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: 'मेटा डाटा सफा गर्दै…', @@ -81,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: 'Changement de mot de passe réussi', PURGE_INIT: 'Vérification des données…', PURGE_INFO: ({ count }) => `${count} document(s) nettoyé(s)…`, PURGE_META: 'Cleaning meta data…', @@ -99,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…', @@ -117,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..21dfb0f08da 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(), From a4ea9a63fda8cced7816bfcbba31d781559ac5cf Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 7 Nov 2024 14:54:27 +0300 Subject: [PATCH 21/48] chore: add fr translations --- api/resources/translations/messages-fr.properties | 8 +++++++- webapp/src/js/bootstrapper/translator.js | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index ecf1d94ea3d..56820713c79 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -400,6 +400,12 @@ 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.required = Les champs Mot de passe et Confirmation du mot de passe sont obligatoires +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 @@ -966,7 +972,7 @@ partner.supporting = Partenaires de soutien partner.tab.partners = Partenaires password.incorrect = Mot de passe incorrect password.length.minimum = Le mot de passe doit être au moins {{minimum}} caractères. -password.must.match = Les mots de passe et le mot de passe de confirmation doivent correspondre +password.must.match = Le mot de passe et la confirmation du mot de passe doivent correspondre 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/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index cee17e34a9f..00093c0ef44 100644 --- a/webapp/src/js/bootstrapper/translator.js +++ b/webapp/src/js/bootstrapper/translator.js @@ -83,7 +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: 'Changement de mot de passe réussi', + 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…', From ecc3f821f8b9804e4226c8a6f73ae37c5b47e9ea Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 8 Nov 2024 16:45:30 +0300 Subject: [PATCH 22/48] chore: refactor to initial flow --- api/src/controllers/login.js | 107 +++++++++++++++++++++-------------- 1 file changed, 66 insertions(+), 41 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index fe61896d809..d568bd2943e 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -17,9 +17,10 @@ const translations = require('../translations'); const template = require('../services/template'); const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); - const { validatePassword } = require('@medic/user-management/src/users'); +const PASSWORD_RESET_URL = '/medic/password-reset'; + const ERROR_KEY_MAPPING = { 'password.must.match': 'password-mismatch', 'password.weak': 'password-weak', @@ -82,6 +83,9 @@ const skipPasswordChange = async (userCtx) => { if (roles.isDbAdmin(userCtx)) { return true; } + if (!userCtx.password_change_required) { + return true; + } return await auth.hasAllPermissions(userCtx, 'can_skip_password_change'); }; @@ -222,44 +226,53 @@ 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 - if (!userCtx.password_change_required) { - cookie.clearCookie(res, 'login'); - } + try { + const userCtx = await getUserCtxRetry(options); + if (await skipPasswordChange(userCtx)) { + return redirectToApp(req, res, sessionRes, sessionCookie, userCtx); + } - 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 { - userCtx, - redirectUrl: getRedirectUrl(userCtx, req.body.redirect), - }; - }); - }) - .catch(err => { - logger.error(`Error getting authCtx %o`, err); - throw { status: 401, error: 'Error getting authCtx' }; + if (userCtx.password_change_required) { + return redirectToPasswordReset(req, res, userCtx); + } + + return redirectToApp(req, res, sessionRes, sessionCookie, userCtx); + } catch (err) { + logger.error(`Error getting authCtx %o`, err); + throw { status: 401, error: 'Error getting authCtx' }; + } +}; + +const redirectToApp = async (req, res, sessionRes, sessionCookie, userCtx) => { + cookie.setSession(res, sessionCookie); + setUserCtxCookie(res, userCtx); + cookie.clearCookie(res, 'login'); + + await Promise.resolve() + .then(() => { + if (roles.isDbAdmin(userCtx)) { + return users.createAdmin(userCtx); + } }); + setUserLocale(req, res); + + return getRedirectUrl(userCtx, req.body.redirect); }; -const setBasicCookies = (res, userCtx, req) => { +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); }; @@ -380,18 +393,19 @@ const sendLoginErrorResponse = (e, res) => { const login = async (req, res) => { try { - const sessionRes = await validateSession(req); - const headers = { headers: { Cookie: getSessionCookie(sessionRes) } }; - const userCtx = await getUserCtxRetry(headers); - - if (userCtx.password_change_required && !await skipPasswordChange(userCtx)){ - setBasicCookies(res, userCtx, req); - return res.status(302).send('/medic/password-reset'); + 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 { redirectUrl } = await setCookies(req, res, sessionRes); - return res.status(302).send(redirectUrl); } catch (e) { - return sendLoginErrorResponse(e, res); + if (e.status === 401) { + return res.status(401).json({ error: e.error }); + } + logger.error('Error logging in: %o', e); + res.status(500).json({ error: 'Unexpected error logging in' }); } }; @@ -464,15 +478,26 @@ module.exports = { const user = await db.users.get(`org.couchdb.user:${req.body.user}`); user.password = req.body.password; user.password_change_required = false; - await db.users.put(user); + await new Promise(resolve => setTimeout(resolve, 1000)); + const sessionRes = await createSessionRetry({ body: { user: user.name, password: req.body.password, } }); - const { redirectUrl } = await setCookies(req, res, sessionRes); + + const sessionCookie = getSessionCookie(sessionRes); + const userCtx = await getUserCtxRetry({ headers: { Cookie: sessionCookie }}); + + const redirectUrl = await redirectToApp( + req, + res, + sessionRes, + sessionCookie, + userCtx + ); return res.status(302).send(redirectUrl); } catch (err) { From 20cde88aa302c4a275996af03e3ae32e0c4b0cf0 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 8 Nov 2024 17:05:10 +0300 Subject: [PATCH 23/48] chore: simplify check --- api/src/public/login/password-reset.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 76e363a292e..02505b7ac1c 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -48,7 +48,7 @@ const submit = function(e) { }); request('POST', url, payload, function(xmlhttp) { - if (xmlhttp.status === 302 || xmlhttp.status === 200) { + if (xmlhttp.status === 302) { // success - redirect to app localStorage.setItem('passwordStatus', 'PASSWORD_CHANGED'); window.location = xmlhttp.response; From 74ed3e28c49e48df8f99ffce4a7bf627b4b2b6d8 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Sat, 9 Nov 2024 08:29:32 +0300 Subject: [PATCH 24/48] chore: add ne translations --- api/resources/translations/messages-ne.properties | 7 +++++++ webapp/src/js/bootstrapper/translator.js | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index ab858cbc35b..5142200aebe 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -400,6 +400,12 @@ bulkdelete.confirm.title = रेकर्ड मेटाउने हो? bulkdelete.confirm.title.plural = चयन गरिएका रेकर्डहरू मेट्ने हो? call = कल case_id = केस आईडी +change.password.confirm.password = पासवर्ड पुष्टि गर्नुहोस् +change.password.hint = ठूला अक्षर, अङ्क र चिन्हहरूको मिश्रण भएको एउटा भरपर्दो पासवर्ड सिर्जना गर्नुहोस् +change.password.new.password = नयाँ पासवर्ड +change.password.required = पासवर्ड र पुष्टि पासवर्ड क्षेत्र आवश्यक छ +change.password.submit = पासवर्ड परिवर्तन गर्नुहोस् +change.password.title = आफ्नो पासवर्ड परिवर्तन गर्नुहोस् child_birth_date = बच्चाको जन्म मिति child_birth_outcome = बच्चाको जन्मावस्था child_birth_weight = बच्चाको जन्म तौल @@ -966,6 +972,7 @@ partner.supporting = सहयोगी पार्टनरहरू partner.tab.partners = पार्टनरहरू password.incorrect = पासवर्ड मिलेन। password.length.minimum = पासवर्ड कम्तीमा {{minimum}} अक्षरको हुनुपर्छ। +password.must.match = तपाईंले पासवर्ड हाल्नुहोस् र पासवर्ड पुष्टि गर्नुहोस् नामक फिल्डमा हाल्नुभएको पासवर्ड एउटै छैन। फेरि प्रयास गर्नुहोस्। password.update = अपडेट पासवर्ड password.weak = यो पासवर्ड कमजोर छ। patient\ id\ not\ found\ response = बिरामिको आईडी नपाइएमा पठाइने सन्देश diff --git a/webapp/src/js/bootstrapper/translator.js b/webapp/src/js/bootstrapper/translator.js index 00093c0ef44..ee47448dd81 100644 --- a/webapp/src/js/bootstrapper/translator.js +++ b/webapp/src/js/bootstrapper/translator.js @@ -64,7 +64,7 @@ const TRANSLATIONS = { ne: { FETCH_INFO: ({ count, total }) => eurodigit.to_non_euro.devanagari(`${total} मध्ये ${count} डकुमेन्ट लोड हुँदै …`), LOAD_APP: 'एप लोड गर्दै…', - PASSWORD_CHANGE_SUCCESS: 'पासवर्ड सफलतापूर्वक परिवर्तन', + PASSWORD_CHANGE_SUCCESS: 'पासवर्ड सफलतापूर्वक परिवर्तन भयो', PURGE_INIT: 'डाटा जाँच गर्दै…', PURGE_INFO: ({ count }) => eurodigit.to_non_euro.devanagari(`${count} वटा डकुमेन्ट सफा गरीयो…`), PURGE_META: 'मेटा डाटा सफा गर्दै…', From fcf73bdea371b7f4753a3fbe6685434ee5f01ddc Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Sat, 9 Nov 2024 08:32:55 +0300 Subject: [PATCH 25/48] chore: use getUserDoc not getUserCtx --- api/src/auth.js | 7 +---- api/src/controllers/login.js | 36 ++++++------------------ shared-libs/user-management/src/users.js | 1 + 3 files changed, 10 insertions(+), 34 deletions(-) diff --git a/api/src/auth.js b/api/src/auth.js index 623103a8502..508ffc81906 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -59,12 +59,7 @@ module.exports = { .then(auth => { if (auth?.userCtx?.name) { req.headers['X-Medic-User'] = auth.userCtx.name; - return db.users - .get(`org.couchdb.user:${auth.userCtx.name}`) - .then(user => ({ - ...auth.userCtx, - password_change_required: !!user.password_change_required - })); + return auth.userCtx; } throw { code: 500, message: 'Failed to authenticate' }; }); diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index d568bd2943e..1951b4bcc07 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -83,9 +83,6 @@ const skipPasswordChange = async (userCtx) => { if (roles.isDbAdmin(userCtx)) { return true; } - if (!userCtx.password_change_required) { - return true; - } return await auth.hasAllPermissions(userCtx, 'can_skip_password_change'); }; @@ -234,14 +231,14 @@ const setCookies = async (req, res, sessionRes) => { const options = { headers: { Cookie: sessionCookie } }; try { const userCtx = await getUserCtxRetry(options); - if (await skipPasswordChange(userCtx)) { - return redirectToApp(req, res, sessionRes, sessionCookie, userCtx); + if (roles.isDbAdmin(userCtx)) { + await users.createAdmin(userCtx); } - if (userCtx.password_change_required) { + const user = await users.getUserDoc(userCtx.name); + if (user?.password_change_required && !await skipPasswordChange(userCtx)) { return redirectToPasswordReset(req, res, userCtx); } - return redirectToApp(req, res, sessionRes, sessionCookie, userCtx); } catch (err) { logger.error(`Error getting authCtx %o`, err); @@ -253,22 +250,13 @@ const redirectToApp = async (req, res, sessionRes, sessionCookie, userCtx) => { cookie.setSession(res, sessionCookie); setUserCtxCookie(res, userCtx); cookie.clearCookie(res, 'login'); - - await Promise.resolve() - .then(() => { - if (roles.isDbAdmin(userCtx)) { - return users.createAdmin(userCtx); - } - }); setUserLocale(req, res); - return getRedirectUrl(userCtx, req.body.redirect); }; const redirectToPasswordReset = (req, res, userCtx) => { setUserCtxCookie(res, userCtx); setUserLocale(req, res); - return PASSWORD_RESET_URL; }; @@ -393,19 +381,11 @@ const sendLoginErrorResponse = (e, res) => { 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 }); - } - logger.error('Error logging in: %o', e); - res.status(500).json({ error: 'Unexpected error logging in' }); + return sendLoginErrorResponse(e, res); } }; diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 0993c9f8b1b..d9da9689bba 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -973,6 +973,7 @@ module.exports = { const facilities = await facility.list([user]); return mapUser(user, userSettings, facilities); }, + getUserDoc: (username) => getUserDoc(username, 'users'), getUserSettings, /* eslint-disable max-len */ /** From b3ec3f0a542eaecde729c2bf2fbb71f1d8b57615 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Sun, 10 Nov 2024 10:02:35 +0300 Subject: [PATCH 26/48] chore: refactor password reset, add password reset unit test --- api/src/controllers/login.js | 48 ++++++--- api/tests/mocha/controllers/login.spec.js | 118 +++++++++++++++++++++- 2 files changed, 150 insertions(+), 16 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 1951b4bcc07..364c492af04 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -389,6 +389,36 @@ const login = async (req, res) => { } }; +const updatePassword = async (user, newPassword) => { + const updatedUser = { + ...user, + password: newPassword, + password_change_required: false + } + await db.users.put(updatedUser); + // creating new session immediately after changing a password might 401 + await new Promise(resolve => setTimeout(resolve, 50)); + return updatedUser; +}; + +const createNewSession = async (username, password) => { + const sessionRes = await createSessionRetry({ + body: { + user: username, + password: password, + } + }); + + const sessionCookie = getSessionCookie(sessionRes); + const userCtx = await getUserCtxRetry({ headers: { Cookie: sessionCookie }}); + + return { + sessionRes, + sessionCookie, + userCtx + }; +} + module.exports = { renderLogin, renderPasswordReset, @@ -456,20 +486,12 @@ module.exports = { }); } const user = await db.users.get(`org.couchdb.user:${req.body.user}`); - user.password = req.body.password; - user.password_change_required = false; - await db.users.put(user); - await new Promise(resolve => setTimeout(resolve, 1000)); - - const sessionRes = await createSessionRetry({ - body: { - user: user.name, - password: req.body.password, - } - }); + await updatePassword(user, req.body.password); - const sessionCookie = getSessionCookie(sessionRes); - const userCtx = await getUserCtxRetry({ headers: { Cookie: sessionCookie }}); + const { sessionRes, sessionCookie, userCtx } = await createNewSession( + user.name, + req.body.password + ); const redirectUrl = await redirectToApp( req, diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js index a61e0657af6..f2458b3558b 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(() => { @@ -389,6 +396,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 +438,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 +505,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 +544,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 +626,98 @@ 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 when required', () => { + 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' ] }; + sinon.stub(roles, 'isDbAdmin').returns(false); + sinon.stub(auth, 'hasAllPermissions').returns(false); + 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(auth.hasAllPermissions.callCount).to.equal(1); + chai.expect(auth.hasAllPermissions.args[0][0]).to.equal(userCtx); + chai.expect(auth.hasAllPermissions.args[0][1]).to.equal('can_skip_password_change'); + 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' ] }; + sinon.stub(roles, 'isDbAdmin').returns(false); + sinon.stub(auth, 'hasAllPermissions').returns(true); + 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); @@ -649,6 +756,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 +782,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 +811,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 +838,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 +868,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); From c15cced559dde233f4bae2a0bddd868386f25ec1 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 11 Nov 2024 10:11:50 +0300 Subject: [PATCH 27/48] chore: refactor password validation --- .../translations/messages-en.properties | 1 - .../translations/messages-es.properties | 1 - .../translations/messages-fr.properties | 1 - .../translations/messages-ne.properties | 1 - .../translations/messages-sw.properties | 1 - api/src/controllers/login.js | 8 ++--- api/src/templates/login/password-reset.html | 1 - shared-libs/user-management/src/users.js | 34 +++++++++++-------- 8 files changed, 22 insertions(+), 26 deletions(-) diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 532ba49d9aa..e1f9e64bf51 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -403,7 +403,6 @@ 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.required = Password and confirm password fields are required change.password.submit = Change password change.password.title = Change your password child_birth_date = Child birth date diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 4455b887250..33810b133e5 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -403,7 +403,6 @@ 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.required = Los campos Contraseña y Confirmar contraseña son obligatorios change.password.submit = Cambiar la contraseña change.password.title = Cambiar contraseña child_birth_date = Fecha de nacimiento del niño diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 56820713c79..f490f27ac31 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -403,7 +403,6 @@ 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.required = Les champs Mot de passe et Confirmation du mot de passe sont obligatoires 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 diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index 5142200aebe..e78768b8413 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -403,7 +403,6 @@ case_id = केस आईडी change.password.confirm.password = पासवर्ड पुष्टि गर्नुहोस् change.password.hint = ठूला अक्षर, अङ्क र चिन्हहरूको मिश्रण भएको एउटा भरपर्दो पासवर्ड सिर्जना गर्नुहोस् change.password.new.password = नयाँ पासवर्ड -change.password.required = पासवर्ड र पुष्टि पासवर्ड क्षेत्र आवश्यक छ change.password.submit = पासवर्ड परिवर्तन गर्नुहोस् change.password.title = आफ्नो पासवर्ड परिवर्तन गर्नुहोस् child_birth_date = बच्चाको जन्म मिति diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index 15d0df9c52f..6af124a9735 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -405,7 +405,6 @@ 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.required = Nenosiri na uthibitisho wa nenosiri zinahitajika change.password.submit = Badilisha nenosiri change.password.title = Badilisha nenosiri lako child_birth_date = Tarehe ya kuzaliwa mtoto diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 364c492af04..51e0134d6ff 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -22,10 +22,9 @@ const { validatePassword } = require('@medic/user-management/src/users'); const PASSWORD_RESET_URL = '/medic/password-reset'; const ERROR_KEY_MAPPING = { - 'password.must.match': 'password-mismatch', - 'password.weak': 'password-weak', - 'password.length.minimum': 'password-short', - 'password.required': 'password-required' + 'password.must.match': 'password-mismatch', //NoSONAR + 'password.weak': 'password-weak', //NoSONAR + 'password.length.minimum': 'password-short' //NoSONAR }; const templates = { @@ -71,7 +70,6 @@ const templates = { 'change.password.submit', 'change.password.new.password', 'change.password.confirm.password', - 'change.password.required', 'password.weak', 'password.length.minimum', 'password.must.match' diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index f406f29a119..6eabdf204fb 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -39,7 +39,6 @@

-

diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index d9da9689bba..ee4320b5446 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -494,22 +494,16 @@ const deleteUser = id => { return Promise.all([ usersDbPromise, medicDbPromise ]); }; -const validatePassword = (password, confirmPassword = null) => { - if (confirmPassword) { - if (!password || !confirmPassword) { - return error400( - 'Password and confirm password fields are required', - 'password.required' - ); - } - - if (password !== confirmPassword) { - return error400( - 'Password and confirm password must match', - 'password.must.match' - ); - } +const validateConfirmPassword = (password, confirmPassword) => { + if (password !== confirmPassword) { + return error400( + 'Password and confirm password must match', + 'password.must.match' + ); } +} + +const validatePasswordStrength = (password) => { if (password.length < PASSWORD_MINIMUM_LENGTH) { return error400( `The password must be at least ${PASSWORD_MINIMUM_LENGTH} characters long.`, @@ -517,12 +511,22 @@ const validatePassword = (password, confirmPassword = null) => { { 'minimum': PASSWORD_MINIMUM_LENGTH } ); } + if (passwordTester(password) < PASSWORD_MINIMUM_SCORE) { return error400( 'The password is too easy to guess. Include a range of types of characters to increase the score.', 'password.weak' ); } +} + +const validatePassword = (password, confirmPassword = null) => { + const strengthError = validatePasswordStrength(password); + if (strengthError) return strengthError; + + if (confirmPassword != null) { + return validateConfirmPassword(password, confirmPassword); + } }; const getDataRoles = (data) => data.roles || (data.type && getRoles(data.type)); From 3c0f2fae2f9ea013bf0a6ce747c19f6c6aa639cf Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 11 Nov 2024 13:27:16 +0300 Subject: [PATCH 28/48] chore: set password_change_required on password update --- shared-libs/user-management/src/users.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index ee4320b5446..bd5691fd846 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -461,7 +461,7 @@ const getUserUpdates = (username, data, isUserInitiated = false) => { }; if (data.password) { - user.password_change_required = !isUserInitiated; + user.password_change_required = isUserInitiated; } USER_EDITABLE_FIELDS.forEach(key => { @@ -569,7 +569,7 @@ const getUpdatedUserDoc = async (username, data, fullAccess) => getUserDoc(usern .then(doc => { return { ...doc, - ...getUserUpdates(username, data, !fullAccess), + ...getUserUpdates(username, data, fullAccess), _id: createID(username) }; }); @@ -1156,7 +1156,7 @@ module.exports = { hydratePayload(data); const [user, userSettings] = await Promise.all([ - getUpdatedUserDoc(username, data), + getUpdatedUserDoc(username, data, fullAccess), getUpdatedSettingsDoc(username, data), ]); From 5fcb64d6271afb1d662e19a7371bca6c00d87bc8 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 11 Nov 2024 14:44:59 +0300 Subject: [PATCH 29/48] chore: update e2e to do password reset --- shared-libs/user-management/src/users.js | 1 + .../default/login/login.wdio.page.js | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index bd5691fd846..65fac3d4fbc 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -200,6 +200,7 @@ const validateNewUsername = username => { const createUser = (data, response) => { const user = getUserUpdates(data.username, data); + user.password_change_required = true; user._id = createID(data.username); return db.users.put(user).then(body => { response.user = { diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 766689b9588..2965f830f59 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -3,8 +3,10 @@ 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 confirmPasswordField = () => $('#confirm-password'); const passwordToggleButton = () => $('#password-toggle'); const labelForUser = () => $('label[for="user"]'); const labelForPassword = () => $('label[for="password"]'); @@ -17,7 +19,7 @@ const getErrorMessage = async () => { return await (await errorMessageField()).getText(); }; -const login = async ({ username, password, createUser = false, locale, loadPage = true, privacyPolicy, adminApp }) => { +const login = async ({ username, password, createUser = false, locale, loadPage = true, privacyPolicy, adminApp, resetPassword = true }) => { if (utils.isMinimumChromeVersion) { await browser.url('/'); } @@ -34,6 +36,10 @@ const login = async ({ username, password, createUser = false, locale, loadPage await utils.setupUserDoc(username); } + if(resetPassword) { + await passwordReset(password, password); + } + if (!loadPage) { return; } @@ -133,6 +139,14 @@ const setPasswordValue = async (password) => { await (await passwordField()).setValue(password); }; +const passwordReset = async (password, confirmPassword) => { + await (await passwordField()).waitForDisplayed(); + await (await passwordField()).setValue(password); + await (await confirmPasswordField()).waitForDisplayed(); + await (await confirmPasswordField()).setValue(confirmPassword); + await (await updatePasswordButton()).click(); +} + module.exports = { login, cookieLogin, From 1f223f8f4c3d2a869eb742998fe395f6f0aea740 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 10:56:44 +0300 Subject: [PATCH 30/48] Add password reset e2e --- .eslintrc | 3 +- api/src/controllers/login.js | 18 ++-- shared-libs/user-management/src/users.js | 12 ++- .../default/login/login-logout.wdio-spec.js | 93 +++++++++++++++++-- .../default/login/login.wdio.page.js | 51 ++++++++-- webapp/tests/mocha/unit/bootstrapper.spec.js | 2 +- 6 files changed, 146 insertions(+), 33 deletions(-) diff --git a/.eslintrc b/.eslintrc index 93363b9d6e2..4c07282ab0b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,7 +1,8 @@ { "extends": "@medic", "parserOptions": { - "ecmaVersion": 6 + "ecmaVersion": 6, + "sourceType": "module" }, "ignorePatterns": [ "**/node_modules/**", diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 51e0134d6ff..62a5902623f 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -237,14 +237,14 @@ const setCookies = async (req, res, sessionRes) => { if (user?.password_change_required && !await skipPasswordChange(userCtx)) { return redirectToPasswordReset(req, res, userCtx); } - return redirectToApp(req, res, sessionRes, sessionCookie, 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, sessionRes, sessionCookie, userCtx) => { +const redirectToApp = async ({ req, res, sessionCookie, userCtx }) => { cookie.setSession(res, sessionCookie); setUserCtxCookie(res, userCtx); cookie.clearCookie(res, 'login'); @@ -356,7 +356,7 @@ const validatePasswordReset = (password, confirmPassword) => { isValid: false, error: ERROR_KEY_MAPPING[error.message.translationKey], params: error.message.translationParams - } + }; }; const validateSession = async (req) => { @@ -392,7 +392,7 @@ const updatePassword = async (user, newPassword) => { ...user, password: newPassword, password_change_required: false - } + }; await db.users.put(updatedUser); // creating new session immediately after changing a password might 401 await new Promise(resolve => setTimeout(resolve, 50)); @@ -415,7 +415,7 @@ const createNewSession = async (username, password) => { sessionCookie, userCtx }; -} +}; module.exports = { renderLogin, @@ -491,13 +491,7 @@ module.exports = { req.body.password ); - const redirectUrl = await redirectToApp( - req, - res, - sessionRes, - sessionCookie, - userCtx - ); + const redirectUrl = await redirectToApp({ req, res, sessionCookie, userCtx }); return res.status(302).send(redirectUrl); } catch (err) { diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 65fac3d4fbc..46e2a4c942c 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -424,7 +424,7 @@ const getCommonFieldsUpdates = (userDoc, data) => { if (data.roles) { userDoc.roles = data.roles; } - + if (!_.isUndefined(data.place)) { userDoc.facility_id = data.facility_id; } @@ -502,7 +502,7 @@ const validateConfirmPassword = (password, confirmPassword) => { 'password.must.match' ); } -} +}; const validatePasswordStrength = (password) => { if (password.length < PASSWORD_MINIMUM_LENGTH) { @@ -519,13 +519,15 @@ const validatePasswordStrength = (password) => { 'password.weak' ); } -} +}; const validatePassword = (password, confirmPassword = null) => { const strengthError = validatePasswordStrength(password); - if (strengthError) return strengthError; + if (strengthError) { + return strengthError; + } - if (confirmPassword != null) { + if (confirmPassword !== null) { return validateConfirmPassword(password, confirmPassword); } }; diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index cae71c50e44..4d490c3d621 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 @@ -70,13 +72,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.analyticsTab()).waitForDisplayed(); await (await commonPage.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.analyticsTab()).waitForDisplayed(); const cookies = await browser.getCookies(); @@ -115,7 +117,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.messagesTab()).waitForDisplayed(); // Delete cookies and trigger a request to the server await browser.deleteCookies('AuthSession'); @@ -133,17 +135,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); }); @@ -166,4 +173,76 @@ describe('Login page funcionality tests', () => { await (await commonPage.messagesTab()).waitForDisplayed(); }); }); + + describe('Password Reset', () => { + 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 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 browser.url('/medic/password-reset'); + await loginPage.getPasswordResetTranslations(); + await loginPage.setPasswordValue(''); + await loginPage.setConfirmPasswordValue(user.password); + 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 browser.url('/medic/password-reset'); + await loginPage.getPasswordResetTranslations(); + 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 browser.url('/medic/password-reset'); + await loginPage.getPasswordResetTranslations(); + 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 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 browser.url('/medic/password-reset'); + await loginPage.getPasswordResetTranslations(); + await loginPage.setPasswordValue(user.password); + await loginPage.setConfirmPasswordValue(user.password); + await (await loginPage.updatePasswordButton()).click(); + await commonPage.waitForPageLoaded(); + await (await commonPage.messagesTab()).waitForDisplayed(); + expect(await commonPage.isMessagesListPresent()); + }); + }); }); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 2965f830f59..26664b43674 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -13,18 +13,34 @@ const labelForPassword = () => $('label[for="password"]'); const errorMessageField = () => $('p.error.incorrect'); const localeByName = (locale) => $(`.locale[name="${locale}"]`); const tokenLoginError = (reason) => $(`.error.${reason}`); +const passwordResetTitle = () => $('p.title'); +const passwordResetHint = () => $('p.help-text'); +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, resetPassword = true }) => { +const getPasswordResetErrorMessage = async (errorMsg) => { + await (await passwordResetMessageField(errorMsg)).waitForDisplayed(); + return await (await passwordResetMessageField(errorMsg)).getText(); +}; + +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(); @@ -36,7 +52,7 @@ const login = async ({ username, password, createUser = false, locale, loadPage await utils.setupUserDoc(username); } - if(resetPassword) { + if (resetPassword) { await passwordReset(password, password); } @@ -139,13 +155,28 @@ const setPasswordValue = async (password) => { await (await passwordField()).setValue(password); }; -const passwordReset = async (password, confirmPassword) => { - await (await passwordField()).waitForDisplayed(); - await (await passwordField()).setValue(password); +const setConfirmPasswordValue = async (confirmPassword) => { await (await confirmPasswordField()).waitForDisplayed(); await (await confirmPasswordField()).setValue(confirmPassword); +}; + +const setUsernameValue = async (username) => { + await (await userField()).waitForDisplayed(); + await (await userField()).setValue(username); +}; + +const passwordReset = async (password, confirmPassword) => { + await setPasswordValue(password); + await setConfirmPasswordValue(confirmPassword); await (await updatePasswordButton()).click(); -} +}; + +const getPasswordResetTranslations = async () => { + return { + passwordResetTitle: await (await passwordResetTitle()).getText(), + passwordResetHint: await (await passwordResetHint()).getText(), + }; +}; module.exports = { login, @@ -161,4 +192,10 @@ module.exports = { getErrorMessage, togglePassword, setPasswordValue, + setUsernameValue, + setConfirmPasswordValue, + passwordReset, + updatePasswordButton, + getPasswordResetTranslations, + getPasswordResetErrorMessage, }; diff --git a/webapp/tests/mocha/unit/bootstrapper.spec.js b/webapp/tests/mocha/unit/bootstrapper.spec.js index 21dfb0f08da..b5ab78f7e57 100644 --- a/webapp/tests/mocha/unit/bootstrapper.spec.js +++ b/webapp/tests/mocha/unit/bootstrapper.spec.js @@ -103,7 +103,7 @@ describe('bootstrapper', () => { getItem: sinon.stub(), setItem: sinon.stub(), removeItem: sinon.stub(), - } + }; $ = sinon.stub().returns({ text: sinon.stub(), From 5a24839b0dc7928edfbe93e8ac47f84e146150b9 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 12:36:03 +0300 Subject: [PATCH 31/48] disable eslint to run tests in ci --- .eslintrc | 3 +- api/src/controllers/login.js | 3 +- api/src/public/login/auth-utils.js | 1 + api/src/public/login/password-reset.js | 1 + api/src/public/login/script.js | 1 + tests/e2e/default/about/about.wdio-spec.js | 2 +- .../default/admin/admin-access.wdio-spec.js | 4 +- .../default/login/login-logout.wdio-spec.js | 2 +- .../default/login/login.wdio.page.js | 47 +++++++++++++++---- 9 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.eslintrc b/.eslintrc index 4c07282ab0b..93363b9d6e2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,8 +1,7 @@ { "extends": "@medic", "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" + "ecmaVersion": 6 }, "ignorePatterns": [ "**/node_modules/**", diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 62a5902623f..fe60fdf332f 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -411,7 +411,6 @@ const createNewSession = async (username, password) => { const userCtx = await getUserCtxRetry({ headers: { Cookie: sessionCookie }}); return { - sessionRes, sessionCookie, userCtx }; @@ -486,7 +485,7 @@ module.exports = { const user = await db.users.get(`org.couchdb.user:${req.body.user}`); await updatePassword(user, req.body.password); - const { sessionRes, sessionCookie, userCtx } = await createNewSession( + const { sessionCookie, userCtx } = await createNewSession( user.name, req.body.password ); diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 307b87f12d1..713fcb7fc79 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -1,3 +1,4 @@ +/* eslint-disable */ export const setState = (className) => { const form = document.getElementById('form'); if(!form) return; diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 02505b7ac1c..017620d2ca2 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -1,3 +1,4 @@ +/* eslint-disable */ import { setState, request, diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 98bdfc353fb..d12f096ff1a 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -1,3 +1,4 @@ +/* eslint-disable */ import { setState, request, diff --git a/tests/e2e/default/about/about.wdio-spec.js b/tests/e2e/default/about/about.wdio-spec.js index b83703ee2f5..44b7edca453 100644 --- a/tests/e2e/default/about/about.wdio-spec.js +++ b/tests/e2e/default/about/about.wdio-spec.js @@ -6,7 +6,7 @@ const partnersFactory = require('@factories/cht/config/partners'); describe('About page', () => { beforeEach(async () => { - await loginPage.cookieLogin(); + await loginPage.cookieLogin({ resetPassword: false }); }); afterEach(async () => { diff --git a/tests/e2e/default/admin/admin-access.wdio-spec.js b/tests/e2e/default/admin/admin-access.wdio-spec.js index b84359f54d5..941b034631c 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' }); @@ -53,7 +53,7 @@ describe('Acessing the admin app', () => { }); it('should allow admins to access the page', async () => { - await loginPage.cookieLogin({ createUser: false }); + await loginPage.cookieLogin({ createUser: false, resetPassword: false }); await browser.url('/admin'); await (await adminPage.adminNavbarLogo()).waitForDisplayed(); diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index 4d490c3d621..45f204232e3 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -177,7 +177,7 @@ describe('Login page functionality tests', () => { describe('Password Reset', () => { 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_MISMATCH = 'Password and confirm password must match'; const places = placeFactory.generateHierarchy(); const districtHospital = places.get('district_hospital'); const user = userFactory.build({ place: districtHospital._id, roles: ['chw'] }); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 26664b43674..6fc616ee99a 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -70,27 +70,56 @@ const login = async ({ 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 passwordResetRequest = async (username, password) => { + const opts = { + path: '/medic/password-reset', + body: { + user: username, + password: password, + confirmPassword: password + }, + method: 'POST', + simple: false, + }; + 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', + resetPassword = true, + } = options; + + const loginResp = await loginRequest(username, password, locale); + await setCookiesFromResponse(loginResp); + if (createUser) { await utils.setupUserDoc(username); } + + if (resetPassword) { + const resetResp = await passwordResetRequest(username, password); + await setCookiesFromResponse(resetResp); + } await commonPage.goToBase(); }; From ff6192c06d8e3948991483c5b292384cfed96062 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 13:26:31 +0300 Subject: [PATCH 32/48] try api eslint --- api/.eslintrc | 3 +++ tests/e2e/default/about/about.wdio-spec.js | 2 +- tests/e2e/default/admin/admin-access.wdio-spec.js | 4 ++-- tests/e2e/default/login/login-logout.wdio-spec.js | 1 - tests/page-objects/default/login/login.wdio.page.js | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/api/.eslintrc b/api/.eslintrc index 82c665ac87f..5ebbafb9d13 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -20,6 +20,9 @@ "files": [ "src/public/**/*.js" ], + "parserOptions": { + "sourceType": "module" + }, "rules": { "no-console": "off", "compat/compat": "error" diff --git a/tests/e2e/default/about/about.wdio-spec.js b/tests/e2e/default/about/about.wdio-spec.js index 44b7edca453..b83703ee2f5 100644 --- a/tests/e2e/default/about/about.wdio-spec.js +++ b/tests/e2e/default/about/about.wdio-spec.js @@ -6,7 +6,7 @@ const partnersFactory = require('@factories/cht/config/partners'); describe('About page', () => { beforeEach(async () => { - await loginPage.cookieLogin({ resetPassword: false }); + await loginPage.cookieLogin(); }); afterEach(async () => { diff --git a/tests/e2e/default/admin/admin-access.wdio-spec.js b/tests/e2e/default/admin/admin-access.wdio-spec.js index 941b034631c..b264b29351e 100644 --- a/tests/e2e/default/admin/admin-access.wdio-spec.js +++ b/tests/e2e/default/admin/admin-access.wdio-spec.js @@ -32,7 +32,7 @@ describe('Accessing 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.cookieLogin({ ...offlineUser, createUser: false, resetPassword: true }); await common.waitForLoaders(); await browser.url('/admin/#/forms'); @@ -53,7 +53,7 @@ describe('Accessing the admin app', () => { }); it('should allow admins to access the page', async () => { - await loginPage.cookieLogin({ createUser: false, resetPassword: false }); + await loginPage.cookieLogin({ createUser: false }); await browser.url('/admin'); await (await adminPage.adminNavbarLogo()).waitForDisplayed(); diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index 45f204232e3..cb50749f769 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -242,7 +242,6 @@ describe('Login page functionality tests', () => { await (await loginPage.updatePasswordButton()).click(); await commonPage.waitForPageLoaded(); await (await commonPage.messagesTab()).waitForDisplayed(); - expect(await commonPage.isMessagesListPresent()); }); }); }); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 6fc616ee99a..f89e182723a 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -106,7 +106,7 @@ const cookieLogin = async (options = {}) => { password = constants.PASSWORD, createUser = true, locale = 'en', - resetPassword = true, + resetPassword = false, } = options; const loginResp = await loginRequest(username, password, locale); From 4817bb1314d87337977fadcb35c8f0f326904410 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 18:05:20 +0300 Subject: [PATCH 33/48] chore: add unit tests --- shared-libs/user-management/src/users.js | 57 +++++++---- .../user-management/test/unit/users.spec.js | 99 +++++++++++++++++-- 2 files changed, 129 insertions(+), 27 deletions(-) diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 46e2a4c942c..48170bf6019 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -200,14 +200,19 @@ const validateNewUsername = username => { const createUser = (data, response) => { const user = getUserUpdates(data.username, data); - user.password_change_required = true; user._id = createID(data.username); - return db.users.put(user).then(body => { - response.user = { - id: body.id, - rev: body.rev - }; - }); + + return isTargetAdminUser(data.username) + .then(isAdmin => { + user.password_change_required = !isAdmin; + return db.users.put(user); + }) + .then(body => { + response.user = { + id: body.id, + rev: body.rev + }; + }); }; const hasUserCreateFlag = doc => doc?.user_for_contact?.create; @@ -453,7 +458,20 @@ const getSettingsUpdates = (username, data) => { return settings; }; -const getUserUpdates = (username, data, isUserInitiated = false) => { +const isTargetAdminUser = async (username) => { + const admins = await couchSettings.getCouchConfig('admins'); + return admins && !!admins[username]; +}; + +const requirePasswordChange = async (username, fullAccess) => { + if (!fullAccess) { + return false; + } + + return !(await isTargetAdminUser(username)); +}; + +const getUserUpdates = (username, data, requirePasswordChange = false) => { const ignore = ['type', 'place', 'contact']; const user = { @@ -462,7 +480,7 @@ const getUserUpdates = (username, data, isUserInitiated = false) => { }; if (data.password) { - user.password_change_required = isUserInitiated; + user.password_change_required = requirePasswordChange; } USER_EDITABLE_FIELDS.forEach(key => { @@ -568,14 +586,17 @@ const missingFields = data => { return required.filter(prop => isInvalidProp(prop)); }; -const getUpdatedUserDoc = async (username, data, fullAccess) => getUserDoc(username, 'users') - .then(doc => { - return { - ...doc, - ...getUserUpdates(username, data, fullAccess), - _id: createID(username) - }; - }); +const getUpdatedUserDoc = async (username, data, fullAccess) => { + const passwordChange = await requirePasswordChange(username, fullAccess); + return getUserDoc(username, 'users') + .then(doc => { + return { + ...doc, + ...getUserUpdates(username, data, passwordChange), + _id: createID(username) + }; + }); +}; const getUpdatedSettingsDoc = (username, data) => getUserDoc(username, 'medic') @@ -1186,7 +1207,7 @@ 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; }, diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index 1a269a2941c..023a79ef45d 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -1302,6 +1302,41 @@ 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()); + + 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 @@ -3110,7 +3145,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']); } @@ -3142,7 +3177,7 @@ describe('Users service', () => { name: 'admin2', type: 'user', }); - chai.expect(couchSettings.getCouchConfig.callCount).to.equal(0); + chai.expect(couchSettings.getCouchConfig.callCount).to.equal(1); }); it('should not update the password in CouchDB config if user is not admin', async () => { @@ -3170,11 +3205,57 @@ describe('Users service', () => { name: 'anne', type: 'user', password: COMPLEX_PASSWORD, - password_change_required: false + 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 }; + 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', () => { @@ -3251,7 +3332,7 @@ 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', password_change_required: true } ]); }); }); @@ -3269,7 +3350,7 @@ 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', password_change_required: true } ]); chai.expect(db.medic.put.callCount).to.equal(1); chai.expect(db.medic.put.args[0]).to.deep.equal([ @@ -3302,7 +3383,7 @@ 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', password_change_required: true } ]); chai.expect(db.medic.put.callCount).to.equal(1); chai.expect(db.medic.put.args[0]).to.deep.equal([ @@ -3745,7 +3826,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 () => { @@ -3771,7 +3852,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']); } From 47d990b806033c3eef5a0c421f130cfff4447318 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 18:26:33 +0300 Subject: [PATCH 34/48] chore: skip password reset for admin --- tests/e2e/default/logging/logging.wdio-spec.js | 2 +- tests/e2e/default/login/login-logout.wdio-spec.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/default/logging/logging.wdio-spec.js b/tests/e2e/default/logging/logging.wdio-spec.js index b5a309c62c5..6c0374200cf 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({ auth, 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 cb50749f769..b3073dcbc91 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -56,7 +56,7 @@ describe('Login page functionality tests', () => { describe('Log out', () => { it('should show a warning before log out', async () => { - await loginPage.cookieLogin(auth); + await loginPage.cookieLogin({ auth, resetPassword: false }); expect(await commonPage.getLogoutMessage()).to.equal('You will need an internet connection to log back in.'); }); }); @@ -169,7 +169,7 @@ describe('Login page functionality tests', () => { expect(revealedPassword.type).to.equal('text'); expect(revealedPassword.value).to.equal('pass-456'); - await loginPage.login(auth); + await loginPage.login({ auth, resetPassword: false }); await (await commonPage.messagesTab()).waitForDisplayed(); }); }); From 0a814c621e0b99985c6fd38b1a143eef4dca812c Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 19:04:03 +0300 Subject: [PATCH 35/48] chore: self review --- api/src/public/login/auth-utils.js | 16 +++++++------ api/tests/mocha/controllers/login.spec.js | 2 +- .../user-management/test/unit/users.spec.js | 24 ++++++++++++++++--- .../default/navigation/bfcache.wdio-spec.js | 1 + tests/e2e/upgrade/admin-user.wdio-spec.js | 3 ++- tests/e2e/upgrade/upgrade.wdio-spec.js | 21 +++++++++++++--- tests/e2e/upgrade/webapp.wdio-spec.js | 7 +++++- 7 files changed, 58 insertions(+), 16 deletions(-) diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 713fcb7fc79..a885bb0436c 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -1,7 +1,9 @@ /* eslint-disable */ export const setState = (className) => { const form = document.getElementById('form'); - if(!form) return; + if (!form) { + return; + } form.className = className; }; @@ -75,14 +77,12 @@ const replaceTranslationPlaceholders = (text, translateValues) => { const values = JSON.parse(translateValues); return Object .entries(values) - .reduce((result, [key, value]) => result.replace(new RegExp(`{{${key}}}`, 'g'), value), - text - ); + .reduce((result, [key, value]) => result.replace(new RegExp(`{{${key}}}`, 'g'), value), text); } catch (e) { console.error('Error parsing translation placeholders', e); return text; } -} +}; export const baseTranslate = (selectedLocale, translations) => { if (!selectedLocale) { @@ -105,7 +105,9 @@ export const baseTranslate = (selectedLocale, translations) => { export const togglePassword = (passwordInputId, confirmPasswordInputId = null) => { const passwordInput = document.getElementById(passwordInputId); - if (!passwordInput) return; + if (!passwordInput) { + return; + } const displayType = passwordInput.type === 'password' ? 'text' : 'password'; passwordInput.type = displayType; @@ -116,4 +118,4 @@ export const togglePassword = (passwordInputId, confirmPasswordInputId = null) = confirmPasswordInput.type = displayType; document.getElementById('confirm-password-container')?.classList.toggle('hidden-password'); } -} +}; diff --git a/api/tests/mocha/controllers/login.spec.js b/api/tests/mocha/controllers/login.spec.js index f2458b3558b..4529229a8ea 100644 --- a/api/tests/mocha/controllers/login.spec.js +++ b/api/tests/mocha/controllers/login.spec.js @@ -655,7 +655,7 @@ describe('login controller', () => { }); }); - it('logs in successfully and redirects to password-reset when required', () => { + it('logs in successfully and redirects to password-reset for new users', () => { req.body = { user: 'sharon', password: 'p4ss', locale: 'es' }; const postResponse = { statusCode: 200, diff --git a/shared-libs/user-management/test/unit/users.spec.js b/shared-libs/user-management/test/unit/users.spec.js index 023a79ef45d..1dde32fbbbf 100644 --- a/shared-libs/user-management/test/unit/users.spec.js +++ b/shared-libs/user-management/test/unit/users.spec.js @@ -3332,7 +3332,13 @@ 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', password_change_required: true } + { + name: 'agatha', + type: 'user', + roles: ['admin'], _id: + 'org.couchdb.user:agatha', + password_change_required: true, + } ]); }); }); @@ -3350,7 +3356,13 @@ 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', password_change_required: true } + { + name: 'agatha', + type: 'user', + roles: ['admin'], + _id: 'org.couchdb.user:agatha', + password_change_required: true, + } ]); chai.expect(db.medic.put.callCount).to.equal(1); chai.expect(db.medic.put.args[0]).to.deep.equal([ @@ -3383,7 +3395,13 @@ 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', password_change_required: true } + { + name: 'perseus', + type: 'user', + roles: ['admin'], + _id: 'org.couchdb.user:perseus', + password_change_required: true, + } ]); chai.expect(db.medic.put.callCount).to.equal(1); chai.expect(db.medic.put.args[0]).to.deep.equal([ 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/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 6891d0204fb..9dba923bed6 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(); }); @@ -131,7 +141,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 () => { From 2bc16037fe08ee620ef648e55838f8ee2dfea082 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Tue, 12 Nov 2024 19:23:53 +0300 Subject: [PATCH 36/48] chore: update service worker unit test --- api/tests/mocha/generate-service-worker.spec.js | 3 +++ 1 file changed, 3 insertions(+) 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' ], From 3e6fd03ef9692c521e69962ab3e0cf93bd94f333 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 13 Nov 2024 14:46:19 +0300 Subject: [PATCH 37/48] chore: update integration tests and fix login e2e --- api/src/controllers/login.js | 4 +- api/src/templates/login/token-login.html | 2 +- shared-libs/user-management/src/users.js | 9 ++-- .../default/login/login-logout.wdio-spec.js | 2 +- .../integration/api/controllers/login.spec.js | 31 +++++++++++++- .../integration/api/controllers/users.spec.js | 41 ++++++++++++------- 6 files changed, 64 insertions(+), 25 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index fe60fdf332f..ff1ac5e7e5b 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -362,9 +362,7 @@ const validatePasswordReset = (password, confirmPassword) => { const validateSession = async (req) => { const sessionRes = await createSession(req); if (sessionRes.statusCode !== 200) { - const error = new Error('Not logged in'); - error.status = sessionRes.statusCode; - throw error; + throw { status: sessionRes.statusCode, error: 'Not logged in' }; } return sessionRes; }; diff --git a/api/src/templates/login/token-login.html b/api/src/templates/login/token-login.html index 9b26748fc3d..8c56970d65e 100644 --- a/api/src/templates/login/token-login.html +++ b/api/src/templates/login/token-login.html @@ -40,6 +40,6 @@
- + diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 48170bf6019..734187425e0 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -201,10 +201,11 @@ const validateNewUsername = username => { const createUser = (data, response) => { const user = getUserUpdates(data.username, data); user._id = createID(data.username); - return isTargetAdminUser(data.username) .then(isAdmin => { - user.password_change_required = !isAdmin; + if (!data.token_login) { + user.password_change_required = !isAdmin; + } return db.users.put(user); }) .then(body => { @@ -479,7 +480,7 @@ const getUserUpdates = (username, data, requirePasswordChange = false) => { type: 'user' }; - if (data.password) { + if (data.password && !tokenLogin.shouldEnableTokenLogin(data)) { user.password_change_required = requirePasswordChange; } @@ -1207,7 +1208,7 @@ module.exports = { */ resetPassword: async (username) => { const password = passwords.generate(); - const user = await getUpdatedUserDoc(username, { password }, true); + const user = await getUpdatedUserDoc(username, { password }); await saveUserUpdates(user); return password; }, diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index b3073dcbc91..3131fbf9c6a 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -169,7 +169,7 @@ describe('Login page functionality tests', () => { expect(revealedPassword.type).to.equal('text'); expect(revealedPassword.value).to.equal('pass-456'); - await loginPage.login({ auth, resetPassword: false }); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); await (await commonPage.messagesTab()).waitForDisplayed(); }); }); 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/users.spec.js b/tests/integration/api/controllers/users.spec.js index 68910df19c6..eac30a9e0a8 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -25,19 +25,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, @@ -621,10 +636,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)}` }); }; From dd73991ca62e514a59addbf5c2333f603fc89417 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 13 Nov 2024 15:48:03 +0300 Subject: [PATCH 38/48] chore: sonar --- api/src/controllers/login.js | 7 +++++-- shared-libs/user-management/src/users.js | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index ff1ac5e7e5b..482027b1c5b 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -234,7 +234,7 @@ const setCookies = async (req, res, sessionRes) => { } const user = await users.getUserDoc(userCtx.name); - if (user?.password_change_required && !await skipPasswordChange(userCtx)) { + if (user?.password_change_required && !user?.token_login?.active && !await skipPasswordChange(userCtx)) { return redirectToPasswordReset(req, res, userCtx); } return redirectToApp({ req, res, sessionCookie, userCtx }); @@ -362,7 +362,10 @@ const validatePasswordReset = (password, confirmPassword) => { const validateSession = async (req) => { const sessionRes = await createSession(req); if (sessionRes.statusCode !== 200) { - throw { status: sessionRes.statusCode, error: 'Not logged in' }; + const error = new Error('Not logged in'); + error.status = sessionRes.statusCode; + error.error = 'Not logged in'; + throw error; } return sessionRes; }; diff --git a/shared-libs/user-management/src/users.js b/shared-libs/user-management/src/users.js index 734187425e0..cdade9c3ef2 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -1208,7 +1208,7 @@ 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; }, From f6498767ac8c8c568010a386987e3c9ebf28ae88 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Wed, 13 Nov 2024 19:30:17 +0300 Subject: [PATCH 39/48] chore: add selector specificity --- tests/e2e/default/db/initial-replication.wdio-spec.js | 2 +- .../default/privacy-policy/privacy-policy.wdio-spec.js | 10 ++++++++-- tests/page-objects/default/login/login.wdio.page.js | 6 ++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/e2e/default/db/initial-replication.wdio-spec.js b/tests/e2e/default/db/initial-replication.wdio-spec.js index 92a907973d7..6ab6a977815 100644 --- a/tests/e2e/default/db/initial-replication.wdio-spec.js +++ b/tests/e2e/default/db/initial-replication.wdio-spec.js @@ -150,7 +150,7 @@ describe('initial-replication', () => { }); it('should support "disconnects"', async () => { - await loginPage.login({ ...userAllowedDocs.user, loadPage: false }); + await loginPage.login({ ...userAllowedDocs.user, loadPage: false, resetPassword: false }); setTimeout(() => browser.refresh(), 1000); setTimeout(() => browser.refresh(), 3000); setTimeout(() => browser.refresh(), 5000); 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 f1bc8fa5344..e3a5de48fde 100644 --- a/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js +++ b/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js @@ -53,7 +53,7 @@ describe('Privacy policy', () => { it('should not show on subsequent login', async () => { await browser.reloadSession(); await browser.url('/'); - await loginPage.login({ username: user.username, password: user.password }); + await loginPage.login({ username: user.username, password: user.password, resetPassword: false }); await (await commonElements.messagesTab()).waitForDisplayed(); expect(await (await privacyPage.privacyWrapper()).isDisplayed()).to.not.be.true; }); @@ -61,7 +61,13 @@ describe('Privacy policy', () => { it('should show french policy on secondary login', async () => { await browser.reloadSession(); await browser.url('/'); - await loginPage.login({ username: user.username, password: user.password, locale: 'fr', privacyPolicy: true }); + await loginPage.login({ + username: user.username, + password: user.password, + locale: 'fr', + privacyPolicy: true, + resetPassword: false + }); await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), frenchTexts); expect(await (await commonElements.messagesTab()).isDisplayed()).to.be.true; }); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index f89e182723a..9b653cef155 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -5,7 +5,8 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const loginButton = () => $('#login'); const updatePasswordButton = () => $('#update-password'); const userField = () => $('#user'); -const passwordField = () => $('#password'); +const passwordField = () => $('#form[action="/medic/login"] #password'); +const resetPasswordField = () => $('#form[action="/medic/password-reset"] #password'); const confirmPasswordField = () => $('#confirm-password'); const passwordToggleButton = () => $('#password-toggle'); const labelForUser = () => $('label[for="user"]'); @@ -195,7 +196,8 @@ const setUsernameValue = async (username) => { }; const passwordReset = async (password, confirmPassword) => { - await setPasswordValue(password); + await (await resetPasswordField()).waitForDisplayed(); + await (await resetPasswordField()).setValue(password); await setConfirmPasswordValue(confirmPassword); await (await updatePasswordButton()).click(); }; From 8511c674588e65d2b35736b994edeef679b4b69c Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 14 Nov 2024 13:21:54 +0300 Subject: [PATCH 40/48] address feedback --- .../translations/messages-en.properties | 1 + api/src/controllers/login.js | 82 +++++++++++++------ api/src/public/login/auth-utils.js | 8 +- api/src/public/login/password-reset.js | 31 +++++-- api/src/public/login/style.css | 3 +- api/src/templates/login/password-reset.html | 5 ++ shared-libs/user-management/src/index.js | 3 +- shared-libs/user-management/src/users.js | 23 +----- 8 files changed, 100 insertions(+), 56 deletions(-) diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index e1f9e64bf51..d3cceae2749 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -970,6 +970,7 @@ partner.name.field = Partner name partner.supporting = Supporting partners partner.tab.partners = Partners password.incorrect = Password is not correct. +password.current.incorrect = Current 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.update = Update password diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 482027b1c5b..0dedc5a5375 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'); @@ -17,14 +17,13 @@ const translations = require('../translations'); const template = require('../services/template'); const rateLimitService = require('../services/rate-limit'); const serverUtils = require('../server-utils'); -const { validatePassword } = require('@medic/user-management/src/users'); const PASSWORD_RESET_URL = '/medic/password-reset'; const ERROR_KEY_MAPPING = { - 'password.must.match': 'password-mismatch', //NoSONAR 'password.weak': 'password-weak', //NoSONAR - 'password.length.minimum': 'password-short' //NoSONAR + 'password.length.minimum': 'password-short', //NoSONAR + 'password.current.incorrect': 'current-password-incorrect' //NoSONAR }; const templates = { @@ -72,16 +71,18 @@ const templates = { 'change.password.confirm.password', 'password.weak', 'password.length.minimum', - 'password.must.match' + 'password.must.match', + 'user.password.current', + 'password.current.incorrect' ], } }; -const skipPasswordChange = async (userCtx) => { - if (roles.isDbAdmin(userCtx)) { - return true; - } - return await auth.hasAllPermissions(userCtx, 'can_skip_password_change'); +const skipPasswordChange = async (user, userCtx) => { + return !user?.password_change_required || + user?.token_login?.active || + roles.isDbAdmin(userCtx) || + await auth.hasAllPermissions(userCtx, 'can_skip_password_change'); }; const getHomeUrl = userCtx => { @@ -234,7 +235,7 @@ const setCookies = async (req, res, sessionRes) => { } const user = await users.getUserDoc(userCtx.name); - if (user?.password_change_required && !user?.token_login?.active && !await skipPasswordChange(userCtx)) { + if (!await skipPasswordChange(user, userCtx)) { return redirectToPasswordReset(req, res, userCtx); } return redirectToApp({ req, res, sessionCookie, userCtx }); @@ -345,8 +346,8 @@ const renderPasswordReset = (req) => { return render('passwordReset', req); }; -const validatePasswordReset = (password, confirmPassword) => { - const error = validatePassword(password, confirmPassword); +const validatePasswordReset = (password) => { + const error = validatePassword(password); if (!error) { return { isValid: true }; @@ -388,16 +389,22 @@ const login = async (req, res) => { } }; -const updatePassword = async (user, newPassword) => { +const updatePassword = async (user, newPassword, retry = 10) => { const updatedUser = { ...user, password: newPassword, password_change_required: false }; - await db.users.put(updatedUser); - // creating new session immediately after changing a password might 401 - await new Promise(resolve => setTimeout(resolve, 50)); - return updatedUser; + 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 createNewSession = async (username, password) => { @@ -417,6 +424,23 @@ const createNewSession = async (username, password) => { }; }; +const validateCurrentPassword = async (username, currentPassword) => { + try { + await request.get({ + url: new URL('/_session', environment.serverUrlNoAuth).toString(), + json: true, + resolveWithFullResponse: true, + auth: { user: username, pass: currentPassword }, + }); + return { isValid: true }; + } catch (err) { + return { + isValid: false, + error: ERROR_KEY_MAPPING['password.current.incorrect'], + }; + } +}; + module.exports = { renderLogin, renderPasswordReset, @@ -476,27 +500,33 @@ module.exports = { } try { - const validation = validatePasswordReset(req.body.password, req.body.confirmPassword); + const { username, currentPassword, password } = req.body; + const validation = validatePasswordReset(password); if (!validation.isValid) { return res.status(400).json({ error: validation.error, params: validation.params, }); } - const user = await db.users.get(`org.couchdb.user:${req.body.user}`); - await updatePassword(user, req.body.password); + const currentPasswordValidation = await validateCurrentPassword(username, currentPassword); + if (!currentPasswordValidation.isValid) { + return res.status(400).json({ + error: currentPasswordValidation.error + }); + } + const userDoc = await db.users.get(`org.couchdb.user:${username}`); + await updatePassword(userDoc, password); - const { sessionCookie, userCtx } = await createNewSession( - user.name, - req.body.password - ); + const { sessionCookie, userCtx } = await createNewSession(username, password); const redirectUrl = await redirectToApp({ req, res, sessionCookie, userCtx }); return res.status(302).send(redirectUrl); } catch (err) { logger.error('Error updating password: %o', err); - res.status(500).json({ error: 'Error updating password' }); + return res.status(err.status || 500).json({ + error: err.message || 'Error updating password' + }); } }, tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next), diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index a885bb0436c..23bdf40e290 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -103,7 +103,7 @@ export const baseTranslate = (selectedLocale, translations) => { .forEach(elem => elem.title = translations[selectedLocale][elem.getAttribute('translate-title')]); }; -export const togglePassword = (passwordInputId, confirmPasswordInputId = null) => { +export const togglePassword = (passwordInputId, confirmPasswordInputId = null, currentPasswordInputId = null) => { const passwordInput = document.getElementById(passwordInputId); if (!passwordInput) { return; @@ -118,4 +118,10 @@ export const togglePassword = (passwordInputId, confirmPasswordInputId = null) = confirmPasswordInput.type = displayType; document.getElementById('confirm-password-container')?.classList.toggle('hidden-password'); } + + if (currentPasswordInputId) { + const currentPasswordInput = document.getElementById(currentPasswordInputId); + currentPasswordInput.type = displayType; + document.getElementById('current-password-container')?.classList.toggle('hidden-password'); + } }; diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 017620d2ca2..37881f8f853 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -14,6 +14,7 @@ let translations; const PASSWORD_INPUT_ID = 'password'; const CONFIRM_PASSWORD_INPUT_ID = 'confirm-password'; +const CURRENT_PASSWORD_INPUT_ID = 'current-password'; const translate = () => { baseTranslate(selectedLocale, translations); @@ -30,22 +31,39 @@ const displayPasswordValidationError = (serverResponse) => { } }; +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; } - setState('loading'); - const url = document.getElementById('form')?.action; + 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({ - user: userCtx.name, + username: userCtx.name, password: password, - confirmPassword: confirmPassword, + currentPassword: currentPassword, }); request('POST', url, payload, function(xmlhttp) { @@ -72,6 +90,9 @@ document.addEventListener('DOMContentLoaded', function() { const passwordToggle = document.getElementById('password-toggle'); if (passwordToggle) { - passwordToggle.addEventListener('click', () => togglePassword(PASSWORD_INPUT_ID, CONFIRM_PASSWORD_INPUT_ID), false); + passwordToggle.addEventListener('click', () => togglePassword( + PASSWORD_INPUT_ID, + CONFIRM_PASSWORD_INPUT_ID, + CURRENT_PASSWORD_INPUT_ID), false); } }); diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css index ab73a44bc0b..d618e2488c8 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -105,7 +105,8 @@ form { .password-weak .error.password-weak, .password-short .error.password-short, .password-mismatch .error.password-mismatch, -.password-required .error.password-required +.password-required .error.password-required, +.current-password-incorrect .error.current-password-incorrect { display: block; } diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index 6eabdf204fb..5dc6c43ff2b 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -13,6 +13,10 @@

+ +
+ +
@@ -36,6 +40,7 @@
+

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 cdade9c3ef2..38373023a14 100644 --- a/shared-libs/user-management/src/users.js +++ b/shared-libs/user-management/src/users.js @@ -514,16 +514,7 @@ const deleteUser = id => { return Promise.all([ usersDbPromise, medicDbPromise ]); }; -const validateConfirmPassword = (password, confirmPassword) => { - if (password !== confirmPassword) { - return error400( - 'Password and confirm password must match', - 'password.must.match' - ); - } -}; - -const validatePasswordStrength = (password) => { +const validatePassword = (password) => { if (password.length < PASSWORD_MINIMUM_LENGTH) { return error400( `The password must be at least ${PASSWORD_MINIMUM_LENGTH} characters long.`, @@ -531,7 +522,6 @@ const validatePasswordStrength = (password) => { { 'minimum': PASSWORD_MINIMUM_LENGTH } ); } - if (passwordTester(password) < PASSWORD_MINIMUM_SCORE) { return error400( 'The password is too easy to guess. Include a range of types of characters to increase the score.', @@ -540,17 +530,6 @@ const validatePasswordStrength = (password) => { } }; -const validatePassword = (password, confirmPassword = null) => { - const strengthError = validatePasswordStrength(password); - if (strengthError) { - return strengthError; - } - - if (confirmPassword !== null) { - return validateConfirmPassword(password, confirmPassword); - } -}; - const getDataRoles = (data) => data.roles || (data.type && getRoles(data.type)); const missingFields = data => { const required = ['username']; From cbde20f970981a9297a02ae5e876ac3ec54023ca Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 15 Nov 2024 07:02:18 +0300 Subject: [PATCH 41/48] validate user changing password --- .../translations/messages-en.properties | 3 +- .../translations/messages-es.properties | 2 + .../translations/messages-fr.properties | 2 + .../translations/messages-ne.properties | 2 + .../translations/messages-sw.properties | 2 + api/src/controllers/login.js | 22 +++++++---- api/src/public/login/auth-utils.js | 1 - api/src/public/login/password-reset.js | 1 - api/src/public/login/script.js | 1 - api/src/public/login/style.css | 1 + api/src/templates/login/password-reset.html | 1 + .../default/login/login-logout.wdio-spec.js | 38 ++++++++++++++++--- .../default/login/login.wdio.page.js | 16 ++++++-- 13 files changed, 72 insertions(+), 20 deletions(-) diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index d3cceae2749..29f446168f7 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -969,10 +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.current.incorrect = Current 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 33810b133e5..ac62ed5cbd1 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -969,9 +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 f490f27ac31..b8ab34c1c1c 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -969,9 +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-ne.properties b/api/resources/translations/messages-ne.properties index e78768b8413..1b10c50dc4d 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -969,9 +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 6af124a9735..1668fd26b43 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -969,9 +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/controllers/login.js b/api/src/controllers/login.js index 0dedc5a5375..5b64a7ca164 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -23,7 +23,8 @@ const PASSWORD_RESET_URL = '/medic/password-reset'; const ERROR_KEY_MAPPING = { 'password.weak': 'password-weak', //NoSONAR 'password.length.minimum': 'password-short', //NoSONAR - 'password.current.incorrect': 'current-password-incorrect' //NoSONAR + 'password.current.incorrect': 'current-password-incorrect', //NoSONAR + 'password.same': 'password-same' //NoSonar }; const templates = { @@ -73,7 +74,8 @@ const templates = { 'password.length.minimum', 'password.must.match', 'user.password.current', - 'password.current.incorrect' + 'password.current.incorrect', + 'password.same' ], } }; @@ -424,7 +426,7 @@ const createNewSession = async (username, password) => { }; }; -const validateCurrentPassword = async (username, currentPassword) => { +const validateCurrentPassword = async (username, currentPassword, newPassword) => { try { await request.get({ url: new URL('/_session', environment.serverUrlNoAuth).toString(), @@ -432,6 +434,13 @@ const validateCurrentPassword = async (username, currentPassword) => { resolveWithFullResponse: true, auth: { user: username, pass: currentPassword }, }); + + if (currentPassword === newPassword) { + return { + isValid: false, + error: ERROR_KEY_MAPPING['password.same'], + }; + } return { isValid: true }; } catch (err) { return { @@ -508,7 +517,7 @@ module.exports = { params: validation.params, }); } - const currentPasswordValidation = await validateCurrentPassword(username, currentPassword); + const currentPasswordValidation = await validateCurrentPassword(username, currentPassword, password); if (!currentPasswordValidation.isValid) { return res.status(400).json({ error: currentPasswordValidation.error @@ -524,9 +533,8 @@ module.exports = { return res.status(302).send(redirectUrl); } catch (err) { logger.error('Error updating password: %o', err); - return res.status(err.status || 500).json({ - error: err.message || 'Error updating password' - }); + const status = err.status || 500; + res.status(status).json({ error: err.error || 'Error updating password' }); } }, tokenGet: (req, res, next) => renderTokenLogin(req, res).catch(next), diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 23bdf40e290..b7647cf9229 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -1,4 +1,3 @@ -/* eslint-disable */ export const setState = (className) => { const form = document.getElementById('form'); if (!form) { diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 37881f8f853..1483255aff9 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -1,4 +1,3 @@ -/* eslint-disable */ import { setState, request, diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index d12f096ff1a..98bdfc353fb 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -1,4 +1,3 @@ -/* eslint-disable */ import { setState, request, diff --git a/api/src/public/login/style.css b/api/src/public/login/style.css index d618e2488c8..f47dc543fe6 100644 --- a/api/src/public/login/style.css +++ b/api/src/public/login/style.css @@ -106,6 +106,7 @@ form { .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/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index 5dc6c43ff2b..e347ece7a53 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -41,6 +41,7 @@

+

diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index 3131fbf9c6a..f64df3aac04 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -175,9 +175,12 @@ describe('Login page functionality tests', () => { }); 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'] }); @@ -196,10 +199,9 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await browser.url('/medic/password-reset'); await loginPage.getPasswordResetTranslations(); await loginPage.setPasswordValue(''); - await loginPage.setConfirmPasswordValue(user.password); + await loginPage.setConfirmPasswordValue(''); await (await loginPage.updatePasswordButton()).click(); expect(await loginPage.getPasswordResetErrorMessage('password-short')).to.equal(PASSWORD_MISSING); }); @@ -209,7 +211,6 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await browser.url('/medic/password-reset'); await loginPage.getPasswordResetTranslations(); await loginPage.setPasswordValue(user.password); await loginPage.setConfirmPasswordValue(''); @@ -222,7 +223,6 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await browser.url('/medic/password-reset'); await loginPage.getPasswordResetTranslations(); await loginPage.setPasswordValue('12345678'); await loginPage.setConfirmPasswordValue('12345678'); @@ -230,16 +230,42 @@ describe('Login page functionality tests', () => { expect(await loginPage.getPasswordResetErrorMessage('password-weak')).to.equal(PASSWORD_WEAK); }); - it('should reset password successfully and redirect to webapp', async () => { + it('should try to reset password and verify current password is correct', async () => { + await browser.url('/'); + await loginPage.setPasswordValue(user.password); + await loginPage.setUsernameValue(user.username); + await (await loginPage.loginButton()).click(); + await loginPage.getPasswordResetTranslations(); + 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 browser.url('/medic/password-reset'); await loginPage.getPasswordResetTranslations(); + 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.getPasswordResetTranslations(); + await loginPage.passwordReset(user.password, NEW_PASSWORD, NEW_PASSWORD); + await (await loginPage.updatePasswordButton()).click(); await commonPage.waitForPageLoaded(); await (await commonPage.messagesTab()).waitForDisplayed(); }); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 9b653cef155..2cb9b5d50a4 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -5,9 +5,10 @@ const commonPage = require('@page-objects/default/common/common.wdio.page'); const loginButton = () => $('#login'); const updatePasswordButton = () => $('#update-password'); const userField = () => $('#user'); -const passwordField = () => $('#form[action="/medic/login"] #password'); +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"]'); @@ -28,6 +29,8 @@ const getPasswordResetErrorMessage = async (errorMsg) => { return await (await passwordResetMessageField(errorMsg)).getText(); }; +const NEW_PASSWORD = 'Pa33word1'; + const login = async ({ username, password, @@ -54,7 +57,7 @@ const login = async ({ } if (resetPassword) { - await passwordReset(password, password); + await passwordReset(password, NEW_PASSWORD, NEW_PASSWORD); } if (!loadPage) { @@ -190,12 +193,18 @@ const setConfirmPasswordValue = async (confirmPassword) => { 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 (password, confirmPassword) => { +const passwordReset = async (currentPassword, password, confirmPassword) => { + await setCurrentPasswordValue(currentPassword); await (await resetPasswordField()).waitForDisplayed(); await (await resetPasswordField()).setValue(password); await setConfirmPasswordValue(confirmPassword); @@ -225,6 +234,7 @@ module.exports = { setPasswordValue, setUsernameValue, setConfirmPasswordValue, + setCurrentPasswordValue, passwordReset, updatePasswordButton, getPasswordResetTranslations, From 71ccda5b982a809ab6bf785f84edd19a3938c98e Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 15 Nov 2024 08:04:35 +0300 Subject: [PATCH 42/48] chore: try iife pattern --- api/.eslintrc | 3 - api/src/public/login/auth-utils.js | 248 +++++++++++--------- api/src/public/login/password-reset.js | 7 +- api/src/public/login/script.js | 4 +- api/src/templates/login/index.html | 3 +- api/src/templates/login/password-reset.html | 5 +- 6 files changed, 142 insertions(+), 128 deletions(-) diff --git a/api/.eslintrc b/api/.eslintrc index 5ebbafb9d13..82c665ac87f 100644 --- a/api/.eslintrc +++ b/api/.eslintrc @@ -20,9 +20,6 @@ "files": [ "src/public/**/*.js" ], - "parserOptions": { - "sourceType": "module" - }, "rules": { "no-console": "off", "compat/compat": "error" diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index b7647cf9229..8ea536e527e 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -1,126 +1,140 @@ -export const setState = (className) => { - const form = document.getElementById('form'); - if (!form) { - return; - } - form.className = className; -}; - -export const request = (method, url, payload, callback) => { - const xmlhttp = new XMLHttpRequest(); - xmlhttp.onreadystatechange = () => { - if (xmlhttp.readyState === XMLHttpRequest.DONE) { - callback(xmlhttp); +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); }; - 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; -}; -export const getCookie = (name) => { - if (!document.cookie) { + 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 cookies = document.cookie.split(';'); - return extractCookie(cookies, name); -}; + const parseTranslations = () => { + const raw = document.body.getAttribute('data-translations'); + return JSON.parse(decodeURIComponent(raw)); + }; + + const replaceTranslationPlaceholders = (text, translateValues) => { + if (!text || !translateValues) { + return text; + } -export const getUserCtx = () => { - const cookie = getCookie('userCtx'); - if (cookie) { try { - return JSON.parse(decodeURIComponent(cookie)); + 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 cookie', e); + console.error('Error parsing translation placeholders', e); + return text; } - } -}; - -export 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]; - } -}; - -export 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; - } -}; - -export 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')]); -}; - -export const togglePassword = (passwordInputId, confirmPasswordInputId = null, currentPasswordInputId = null) => { - const passwordInput = document.getElementById(passwordInputId); - if (!passwordInput) { - return; - } - - const displayType = passwordInput.type === 'password' ? 'text' : 'password'; - passwordInput.type = displayType; - document.getElementById('password-container')?.classList.toggle('hidden-password'); - - if (confirmPasswordInputId) { - const confirmPasswordInput = document.getElementById(confirmPasswordInputId); - confirmPasswordInput.type = displayType; - document.getElementById('confirm-password-container')?.classList.toggle('hidden-password'); - } - - if (currentPasswordInputId) { - const currentPasswordInput = document.getElementById(currentPasswordInputId); - currentPasswordInput.type = displayType; - document.getElementById('current-password-container')?.classList.toggle('hidden-password'); - } -}; + }; + + 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, confirmPasswordInputId = null, currentPasswordInputId = null) => { + const passwordInput = document.getElementById(passwordInputId); + if (!passwordInput) { + return; + } + + const displayType = passwordInput.type === 'password' ? 'text' : 'password'; + passwordInput.type = displayType; + document.getElementById('password-container')?.classList.toggle('hidden-password'); + + if (confirmPasswordInputId) { + const confirmPasswordInput = document.getElementById(confirmPasswordInputId); + confirmPasswordInput.type = displayType; + document.getElementById('confirm-password-container')?.classList.toggle('hidden-password'); + } + + if (currentPasswordInputId) { + const currentPasswordInput = document.getElementById(currentPasswordInputId); + currentPasswordInput.type = displayType; + document.getElementById('current-password-container')?.classList.toggle('hidden-password'); + } + }; + + return { + setState, + request, + getCookie, + getUserCtx, + getLocale, + parseTranslations, + baseTranslate, + togglePassword, + }; +})(); \ No newline at end of file diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index 1483255aff9..f67dcc7043c 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -1,4 +1,4 @@ -import { +const { setState, request, getLocale, @@ -6,7 +6,7 @@ import { baseTranslate, togglePassword, getUserCtx -} from './auth-utils.js'; +} = window.AuthUtils; let selectedLocale; let translations; @@ -92,6 +92,7 @@ document.addEventListener('DOMContentLoaded', function() { passwordToggle.addEventListener('click', () => togglePassword( PASSWORD_INPUT_ID, CONFIRM_PASSWORD_INPUT_ID, - CURRENT_PASSWORD_INPUT_ID), false); + CURRENT_PASSWORD_INPUT_ID), false + ); } }); diff --git a/api/src/public/login/script.js b/api/src/public/login/script.js index 98bdfc353fb..4e6c3a300b2 100644 --- a/api/src/public/login/script.js +++ b/api/src/public/login/script.js @@ -1,4 +1,4 @@ -import { +const { setState, request, getCookie, @@ -7,7 +7,7 @@ import { baseTranslate, togglePassword, getUserCtx -} from './auth-utils.js'; +} = window.AuthUtils; let selectedLocale; let translations; diff --git a/api/src/templates/login/index.html b/api/src/templates/login/index.html index 6d01067c038..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 index e347ece7a53..d595db85147 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -13,8 +13,8 @@

-
+
@@ -50,6 +50,7 @@
- + + From cf77f9ff55aa7cc6384614c335c74289c9a0942a Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 15 Nov 2024 08:14:34 +0300 Subject: [PATCH 43/48] chore: sonar --- api/src/public/login/auth-utils.js | 2 +- api/src/public/login/password-reset.js | 4 ++-- api/src/templates/login/password-reset.html | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/api/src/public/login/auth-utils.js b/api/src/public/login/auth-utils.js index 8ea536e527e..c4bc481aa3b 100644 --- a/api/src/public/login/auth-utils.js +++ b/api/src/public/login/auth-utils.js @@ -137,4 +137,4 @@ window.AuthUtils = (function() { baseTranslate, togglePassword, }; -})(); \ No newline at end of file +})(); diff --git a/api/src/public/login/password-reset.js b/api/src/public/login/password-reset.js index f67dcc7043c..d021f82eccc 100644 --- a/api/src/public/login/password-reset.js +++ b/api/src/public/login/password-reset.js @@ -92,7 +92,7 @@ document.addEventListener('DOMContentLoaded', function() { passwordToggle.addEventListener('click', () => togglePassword( PASSWORD_INPUT_ID, CONFIRM_PASSWORD_INPUT_ID, - CURRENT_PASSWORD_INPUT_ID), false - ); + CURRENT_PASSWORD_INPUT_ID + ), false); } }); diff --git a/api/src/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index d595db85147..0e44519a310 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -18,8 +18,8 @@ -
+
-
+
From 6c7d976f11706102a3b463e546e8d9651d8b4f7e Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Fri, 15 Nov 2024 14:18:38 +0300 Subject: [PATCH 44/48] validate password reset in auth middleware update tests --- api/src/auth.js | 20 ++++++++++ api/src/middleware/authorization.js | 4 +- api/src/templates/login/password-reset.html | 2 +- api/src/templates/login/token-login.html | 3 +- .../mocha/middleware/authorization.spec.js | 39 ++++++++++++------- .../default/admin/admin-access.wdio-spec.js | 3 +- .../default/login/login-logout.wdio-spec.js | 8 ++-- .../default/login/login.wdio.page.js | 19 --------- 8 files changed, 58 insertions(+), 40 deletions(-) diff --git a/api/src/auth.js b/api/src/auth.js index 508ffc81906..59ab14a5f5d 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -76,6 +76,26 @@ module.exports = { }); }, + checkPasswordChange: async (req) => { + if (!req.userCtx || !req.userCtx.name) { + throw { code: 401, message: 'Not logged in' }; + } + + if (req.url.includes('/password-reset')) { + return true; + } + + if (environment.isTesting) { + return true; + } + + const user = await users.getUserDoc((req.userCtx.name)); + if (user.password_change_required) { + throw { code: 403, message: 'Password change required' }; + } + return true; + }, + /** * Extract Basic Auth credentials from a request * 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/templates/login/password-reset.html b/api/src/templates/login/password-reset.html index 0e44519a310..876449161fd 100644 --- a/api/src/templates/login/password-reset.html +++ b/api/src/templates/login/password-reset.html @@ -18,8 +18,8 @@
+
-
- + + diff --git a/api/tests/mocha/middleware/authorization.spec.js b/api/tests/mocha/middleware/authorization.spec.js index 1a3a5af0629..f3477dc3883 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,57 +73,69 @@ 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', () => { - it('should allow authorized', () => { + it('should allow authorized', () => { testReq.authorized = true; middleware.handleAuthErrorsAllowingAuthorized(testReq, testRes, next); next.callCount.should.equal(1); 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/tests/e2e/default/admin/admin-access.wdio-spec.js b/tests/e2e/default/admin/admin-access.wdio-spec.js index b264b29351e..f2d237731be 100644 --- a/tests/e2e/default/admin/admin-access.wdio-spec.js +++ b/tests/e2e/default/admin/admin-access.wdio-spec.js @@ -32,7 +32,8 @@ describe('Accessing 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, resetPassword: true }); + + await loginPage.login({ username: offlineUser.username, password: offlineUser.password }); await common.waitForLoaders(); await browser.url('/admin/#/forms'); diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index f64df3aac04..1472148167f 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -56,7 +56,7 @@ describe('Login page functionality tests', () => { describe('Log out', () => { it('should show a warning before log out', async () => { - await loginPage.cookieLogin({ auth, resetPassword: false }); + await loginPage.cookieLogin(auth); expect(await commonPage.getLogoutMessage()).to.equal('You will need an internet connection to log back in.'); }); }); @@ -72,13 +72,13 @@ describe('Login page functionality tests', () => { }); it('should log in using username and password fields', async () => { - await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); + await loginPage.login(auth); await (await commonPage.analyticsTab()).waitForDisplayed(); await (await commonPage.messagesTab()).waitForDisplayed(); }); it('should set correct cookies', async () => { - await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); + await loginPage.login(auth); await (await commonPage.analyticsTab()).waitForDisplayed(); const cookies = await browser.getCookies(); @@ -117,7 +117,7 @@ describe('Login page functionality 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({ username: auth.username, password: auth.password, resetPassword: false }); + await loginPage.login(auth); await (await commonPage.messagesTab()).waitForDisplayed(); // Delete cookies and trigger a request to the server await browser.deleteCookies('AuthSession'); diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 2cb9b5d50a4..67a5120cf3b 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -84,20 +84,6 @@ const loginRequest = async (username, password, locale) => { return await utils.request(opts); }; -const passwordResetRequest = async (username, password) => { - const opts = { - path: '/medic/password-reset', - body: { - user: username, - password: password, - confirmPassword: password - }, - method: 'POST', - simple: false, - }; - return await utils.request(opts); -}; - const setCookiesFromResponse = async (response) => { const cookieArray = utils.parseCookieResponse(response.headers['set-cookie']); await browser.url('/'); @@ -110,7 +96,6 @@ const cookieLogin = async (options = {}) => { password = constants.PASSWORD, createUser = true, locale = 'en', - resetPassword = false, } = options; const loginResp = await loginRequest(username, password, locale); @@ -120,10 +105,6 @@ const cookieLogin = async (options = {}) => { await utils.setupUserDoc(username); } - if (resetPassword) { - const resetResp = await passwordResetRequest(username, password); - await setCookiesFromResponse(resetResp); - } await commonPage.goToBase(); }; From e0806e0a63b09dfb5d168d71e91eb1d1040f6fb7 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 18 Nov 2024 06:42:08 +0300 Subject: [PATCH 45/48] update e2e --- .github/workflows/build.yml | 2 +- api/Dockerfile | 4 +- api/package.json | 4 +- api/server.js | 12 +- api/src/auth.js | 10 +- api/src/controllers/login.js | 43 +- .../mocha/services/async-storage.spec.js | 5 - package.json | 4 +- scripts/deploy/package-lock.json | 4 +- scripts/deploy/package.json | 4 +- sentinel/Dockerfile | 4 +- sentinel/package-lock.json | 4 +- sentinel/package.json | 4 +- .../navigation/more-options-menu.wdio-spec.js | 32 +- .../old-navigation.wdio-spec.js | 5 +- .../reports/send-message.wdio-spec.js | 8 +- tests/e2e/default-mobile/wdio.conf.js | 1 + .../default/admin/admin-access.wdio-spec.js | 8 +- .../default/analytics/analytics.wdio-spec.js | 2 +- .../delete-assigned-place.wdio-spec.js | 2 +- .../e2e/default/contacts/export.wdio-spec.js | 9 +- tests/e2e/default/db/db-sync.wdio-spec.js | 4 +- .../default/enketo/add-family.wdio-spec.js | 2 +- .../default/enketo/death-report.wdio-spec.js | 2 +- .../edit-report-with-attachment.wdio-spec.js | 8 +- .../default/enketo/phone-widget.wdio-spec.js | 3 +- ...pregnancy-complete-a-delivery.wdio-spec.js | 6 +- ...egnancy-danger-sign-follow-up.wdio-spec.js | 4 +- .../submit-photo-upload-form.wdio-spec.js | 4 +- .../enketo/submit-z-score-form.wdio-spec.js | 2 +- .../e2e/default/logging/logging.wdio-spec.js | 2 +- .../default/login/login-logout.wdio-spec.js | 18 +- .../offline-user/all-permissions.wdio-spec.js | 50 +- .../delete-permission-disabled.wdio-spec.js | 16 +- .../edit-permission-disabled.wdio-spec.js | 6 +- .../permissions-disabled.wdio-spec.js | 46 +- .../permissions-enabled.wdio-spec.js | 44 +- .../privacy-policy.wdio-spec.js | 25 +- tests/e2e/default/reports/delete.wdio-spec.js | 2 +- tests/e2e/default/reports/export.wdio-spec.js | 8 +- .../service-worker.wdio-spec.js | 4 +- .../tasks/config/tasks-breadcrumbs-config.js | 33 +- .../e2e/default/tasks/due-dates.wdio-spec.js | 2 +- tests/e2e/default/tasks/tasks.wdio-spec.js | 18 + .../incorrect-locale.wdio-spec.js | 5 +- .../translations/message-format.wdio-spec.js | 7 +- .../nepali-dates-and-numbers.wdio-spec.js | 2 +- .../translations/new-language.wdio-spec.js | 2 +- tests/integration/api/server.spec.js | 24 + .../haproxy/keep-alive-script/Dockerfile | 2 +- .../default/about/about.wdio.page.js | 2 - .../default/admin/admin.wdio.page.js | 2 +- .../default/analytics/analytics.wdio.page.js | 14 - .../default/common/common.wdio.page.js | 545 +++++++++--------- .../default/contacts/contacts.wdio.page.js | 29 +- .../default/enketo/custom-doc.wdio.page.js | 3 - .../default/login/login.wdio.page.js | 1 + .../old-navigation.wdio.page.js | 8 + .../privacy-policy.wdio.page.js | 2 +- .../default/reports/reports.wdio.page.js | 33 +- .../default/users/user-settings.wdio.page.js | 4 +- .../default/users/user.wdio.page.js | 3 +- 62 files changed, 588 insertions(+), 580 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 334d51143ea..2b983ac1a35 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,7 +16,7 @@ env: TAG: ${{ (github.ref_type == 'tag' && github.ref_name) || '' }} BRANCH: ${{ github.head_ref || github.ref_name }} BUILD_NUMBER: ${{ github.run_id }} - NODE_VERSION: '20.11' + NODE_VERSION: '22.11' jobs: diff --git a/api/Dockerfile b/api/Dockerfile index fd35f5b327a..46f417dfb59 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,10 +1,8 @@ -FROM alpine:3.19 AS base_build +FROM node:22-alpine AS base_build RUN apk add --update --no-cache \ build-base \ curl \ - nodejs~=20 \ - npm~=10 \ tzdata \ libxslt \ bash \ diff --git a/api/package.json b/api/package.json index 1c9ff46e6f3..9b6e5e2a949 100644 --- a/api/package.json +++ b/api/package.json @@ -8,8 +8,8 @@ "url": "git://github.com/medic/cht-core.git" }, "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" }, "scripts": { "toc": "doctoc --github --maxlevel 2 README.md", diff --git a/api/server.js b/api/server.js index fb8d799d8ef..6364630fda2 100644 --- a/api/server.js +++ b/api/server.js @@ -82,7 +82,6 @@ process await migrations.run(); logger.info('Database migrations completed successfully'); - startupLog.start('forms'); logger.info('Generating manifest'); await manifest.generate(); logger.info('Manifest generated successfully'); @@ -90,15 +89,20 @@ process logger.info('Generating service worker'); await generateServiceWorker.run(true); logger.info('Service worker generated successfully'); + } catch (err) { + logger.error('Fatal error initialising API'); + logger.error('%o', err); + process.exit(1); + } + try { + startupLog.start('forms'); logger.info('Updating xforms…'); await generateXform.updateAll(); logger.info('xforms updated successfully'); - } catch (err) { - logger.error('Fatal error initialising API'); + logger.error('Error initialising API'); logger.error('%o', err); - process.exit(1); } startupLog.complete(); diff --git a/api/src/auth.js b/api/src/auth.js index 59ab14a5f5d..9c539341e23 100644 --- a/api/src/auth.js +++ b/api/src/auth.js @@ -78,7 +78,10 @@ module.exports = { checkPasswordChange: async (req) => { if (!req.userCtx || !req.userCtx.name) { - throw { code: 401, message: 'Not logged in' }; + const error = new Error('Not logged in'); + error.code = 401; + error.error = 'Not logged in'; + throw error; } if (req.url.includes('/password-reset')) { @@ -91,7 +94,10 @@ module.exports = { const user = await users.getUserDoc((req.userCtx.name)); if (user.password_change_required) { - throw { code: 403, message: 'Password change required' }; + const error = new Error('Password change required'); + error.code = 403; + error.error = 'Password change required'; + throw error; } return true; }, diff --git a/api/src/controllers/login.js b/api/src/controllers/login.js index 5b64a7ca164..53ccc5f12b7 100644 --- a/api/src/controllers/login.js +++ b/api/src/controllers/login.js @@ -450,6 +450,30 @@ const validateCurrentPassword = async (username, currentPassword, newPassword) = } }; +const passwordResetValidation = async (username, currentPassword, password) => { + const validation = validatePasswordReset(password); + if (!validation.isValid) { + return { + isValid: false, + status: 400, + error: validation.error, + params: validation.params + }; + } + + const currentPasswordValidation = await validateCurrentPassword(username, currentPassword, password); + if (!currentPasswordValidation.isValid) { + return { + isValid: false, + status: 400, + error: currentPasswordValidation.error + }; + } + + return { isValid: true }; +}; + + module.exports = { renderLogin, renderPasswordReset, @@ -510,26 +534,19 @@ module.exports = { try { const { username, currentPassword, password } = req.body; - const validation = validatePasswordReset(password); - if (!validation.isValid) { - return res.status(400).json({ - error: validation.error, - params: validation.params, - }); - } - const currentPasswordValidation = await validateCurrentPassword(username, currentPassword, password); - if (!currentPasswordValidation.isValid) { - return res.status(400).json({ - error: currentPasswordValidation.error + 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 db.users.get(`org.couchdb.user:${username}`); await updatePassword(userDoc, password); const { sessionCookie, userCtx } = await createNewSession(username, password); - const redirectUrl = await redirectToApp({ req, res, sessionCookie, userCtx }); - return res.status(302).send(redirectUrl); } catch (err) { logger.error('Error updating password: %o', err); diff --git a/api/tests/mocha/services/async-storage.spec.js b/api/tests/mocha/services/async-storage.spec.js index cc77549ed17..e7b7e17b55a 100644 --- a/api/tests/mocha/services/async-storage.spec.js +++ b/api/tests/mocha/services/async-storage.spec.js @@ -1,16 +1,13 @@ const sinon = require('sinon'); const rewire = require('rewire'); const { expect } = require('chai'); -const asyncHooks = require('node:async_hooks'); const request = require('@medic/couch-request'); const serverUtils = require('../../../src/server-utils'); describe('async-storage', () => { let service; - let asyncLocalStorage; beforeEach(() => { - asyncLocalStorage = sinon.spy(asyncHooks, 'AsyncLocalStorage'); sinon.stub(request, 'initialize'); }); @@ -20,8 +17,6 @@ describe('async-storage', () => { it('should initialize async storage and initialize couch-request', async () => { service = rewire('../../../src/services/async-storage'); - - expect(asyncLocalStorage.callCount).to.equal(1); expect(request.initialize.args).to.deep.equal([[ service, serverUtils.REQUEST_ID_HEADER diff --git a/package.json b/package.json index 2064e3b99c2..43e9019ccce 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "url": "git://github.com/medic/cht-core.git" }, "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" }, "workspaces": [ "./shared-libs/*" diff --git a/scripts/deploy/package-lock.json b/scripts/deploy/package-lock.json index 8d80e83826c..89d84f2d027 100644 --- a/scripts/deploy/package-lock.json +++ b/scripts/deploy/package-lock.json @@ -32,8 +32,8 @@ "sinon": "^17.0.1" }, "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" } }, "node_modules/@isaacs/cliui": { diff --git a/scripts/deploy/package.json b/scripts/deploy/package.json index 02fb47b32bf..9776cbcb8e0 100644 --- a/scripts/deploy/package.json +++ b/scripts/deploy/package.json @@ -7,8 +7,8 @@ "test": "tests" }, "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" }, "bin": { "cht-deploy": "./cht-deploy", diff --git a/sentinel/Dockerfile b/sentinel/Dockerfile index 834828ac503..7c7cb950cfc 100644 --- a/sentinel/Dockerfile +++ b/sentinel/Dockerfile @@ -1,10 +1,8 @@ -FROM alpine:3.19 AS base_build +FROM node:22-alpine AS base_build RUN apk add --update --no-cache \ build-base \ curl \ - nodejs~=20 \ - npm~=10 \ tzdata \ bash \ jq diff --git a/sentinel/package-lock.json b/sentinel/package-lock.json index 674251a9427..52c73db440e 100644 --- a/sentinel/package-lock.json +++ b/sentinel/package-lock.json @@ -8,8 +8,8 @@ "name": "medic-sentinel", "version": "0.1.0", "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" } }, "../shared-libs/cht-datasource": { diff --git a/sentinel/package.json b/sentinel/package.json index cbeb28a96e2..1365be2601d 100644 --- a/sentinel/package.json +++ b/sentinel/package.json @@ -4,8 +4,8 @@ "private": true, "version": "0.1.0", "engines": { - "node": ">=20.11.0", - "npm": ">=10.2.4" + "node": ">=22.11.0", + "npm": ">=10.9.0" }, "scripts": { "run-watch": "TZ=UTC nodemon --inspect=0.0.0.0:9228 --watch server.js --watch 'src/**' --watch '../shared-libs/**/src/**' --watch '../shared-libs/**/dist/**' server.js" diff --git a/tests/e2e/default-mobile/navigation/more-options-menu.wdio-spec.js b/tests/e2e/default-mobile/navigation/more-options-menu.wdio-spec.js index 72214a2272d..a13bd85876d 100644 --- a/tests/e2e/default-mobile/navigation/more-options-menu.wdio-spec.js +++ b/tests/e2e/default-mobile/navigation/more-options-menu.wdio-spec.js @@ -58,7 +58,7 @@ describe('More Options Menu - Offline User', () => { describe('Message tab', () => { it('should hide the kebab menu.', async () => { await sms.sendSms('testing', contact.phone); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); }); @@ -73,9 +73,9 @@ describe('More Options Menu - Offline User', () => { await commonPage.goToPeople(); await contactsPage.selectLHSRowByText(patient.name); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; }); it('should hide the \'export\' and \'edit\' options and ' + @@ -83,9 +83,9 @@ describe('More Options Menu - Offline User', () => { await commonPage.goToPeople(); await contactsPage.selectLHSRowByText(health_center.name); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.false; }); }); @@ -93,24 +93,24 @@ describe('More Options Menu - Offline User', () => { it('should hide the \'export\' and \'edit\' options and ' + 'enable the \'delete\' and \'review\' options when the sms report is opened', async () => { await commonPage.goToReports(); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await reportPage.goToReportById(smsReportId); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit',)).to.be.false; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; }); it('should hide the \'export\' option and ' + 'enable the \'edit\', \'delete\' and \'review\' options when the xml report is opened', async () => { await reportPage.goToReportById(xmlReportId); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; }); }); }); diff --git a/tests/e2e/default-mobile/old-navigation/old-navigation.wdio-spec.js b/tests/e2e/default-mobile/old-navigation/old-navigation.wdio-spec.js index ffb4f25a667..fa637bb23e8 100644 --- a/tests/e2e/default-mobile/old-navigation/old-navigation.wdio-spec.js +++ b/tests/e2e/default-mobile/old-navigation/old-navigation.wdio-spec.js @@ -6,7 +6,6 @@ const pregnancyFactory = require('@factories/cht/reports/pregnancy'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const oldNavigationPage = require('@page-objects/default/old-navigation/old-navigation.wdio.page'); const messagesPage = require('@page-objects/default/sms/messages.wdio.page'); -const commonPage = require('@page-objects/default/common/common.wdio.page'); const taskPage = require('@page-objects/default/tasks/tasks.wdio.page'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); @@ -56,7 +55,7 @@ describe('Old Navigation', () => { await messagesPage.sendMessageOnMobile(message, person.name, person.phone ); await messagesPage.openMessage(person._id); - const { name } = await commonPage.getHeaderTitleOnMobile(); + const { name } = await oldNavigationPage.getHeaderTitleOnMobile(); expect(name).to.equal(person.name); const messages = await messagesPage.getAmountOfMessagesByPhone(); @@ -71,7 +70,7 @@ describe('Old Navigation', () => { pregnancyReport._id, '~pregnancy-danger-sign-follow-up~anc.pregnancy_danger_sign_followup' ); - const { name } = await commonPage.getHeaderTitleOnMobile(); + const { name } = await oldNavigationPage.getHeaderTitleOnMobile(); expect(name).to.equal('Pregnancy danger sign follow-up'); }); diff --git a/tests/e2e/default-mobile/reports/send-message.wdio-spec.js b/tests/e2e/default-mobile/reports/send-message.wdio-spec.js index a973be680fd..11acd90734f 100644 --- a/tests/e2e/default-mobile/reports/send-message.wdio-spec.js +++ b/tests/e2e/default-mobile/reports/send-message.wdio-spec.js @@ -3,7 +3,7 @@ const placeFactory = require('@factories/cht/contacts/place'); const userFactory = require('@factories/cht/users/users'); const personFactory = require('@factories/cht/contacts/person'); const loginPage = require('@page-objects/default/login/login.wdio.page'); -const commonElements = require('@page-objects/default/common/common.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const pregnancyFactory = require('@factories/cht/reports/pregnancy'); @@ -39,12 +39,12 @@ describe('Report - Send message action', () => { }); it('should display option to send message', async () => { - await commonElements.goToReports(); + await commonPage.goToReports(); const firstReport = await reportsPage.leftPanelSelectors.firstReport(); await reportsPage.openSelectedReport(firstReport); - expect(await commonElements.isReportActionDisplayed()).to.equal(true); - expect(await commonElements.reportsFastActionFAB().getAttribute('class')) + expect(await commonPage.isReportActionDisplayed()).to.equal(true); + expect(await commonPage.fabSelectors.reportsFastActionFAB().getAttribute('class')) .to.include('fa-envelope'); }); }); diff --git a/tests/e2e/default-mobile/wdio.conf.js b/tests/e2e/default-mobile/wdio.conf.js index e05b097ed5f..6b1fd0e8282 100644 --- a/tests/e2e/default-mobile/wdio.conf.js +++ b/tests/e2e/default-mobile/wdio.conf.js @@ -12,6 +12,7 @@ exports.config = Object.assign(wdioBaseConfig.config, { '../default/navigation/navigation.wdio-spec.js', '../default/reports/delete.wdio-spec.js', '../default/enketo/training-cards.wdio-spec.js', + './**/more-options-menu.wdio-spec.js', ] }, beforeSuite: async () => { diff --git a/tests/e2e/default/admin/admin-access.wdio-spec.js b/tests/e2e/default/admin/admin-access.wdio-spec.js index f2d237731be..388d4f10a1d 100644 --- a/tests/e2e/default/admin/admin-access.wdio-spec.js +++ b/tests/e2e/default/admin/admin-access.wdio-spec.js @@ -38,19 +38,19 @@ describe('Accessing the admin app', () => { await common.waitForLoaders(); await browser.url('/admin/#/forms'); expect(await (await adminPage.adminNavbarLogo()).isDisplayed()).to.equal(false); - expect(await common.jsonError()).to.equal(error); + expect(await common.getJsonErrorText()).to.equal(error); await browser.url('/admin'); expect(await (await adminPage.adminNavbarLogo()).isDisplayed()).to.equal(false); - expect(await common.jsonError()).to.equal(error); + expect(await common.getJsonErrorText()).to.equal(error); await browser.url('/medic/_design/medic-admin/_rewrite/'); expect(await (await adminPage.adminNavbarLogo()).isDisplayed()).to.equal(false); - expect(await common.jsonError()).to.equal(error); + expect(await common.getJsonErrorText()).to.equal(error); await browser.url('/medic/_design/medic-admin/_rewrite/#/authorization/permissions'); expect(await (await adminPage.adminNavbarLogo()).isDisplayed()).to.equal(false); - expect(await common.jsonError()).to.equal(error); + expect(await common.getJsonErrorText()).to.equal(error); }); it('should allow admins to access the page', async () => { diff --git a/tests/e2e/default/analytics/analytics.wdio-spec.js b/tests/e2e/default/analytics/analytics.wdio-spec.js index 19542236fbc..80d94c18a68 100644 --- a/tests/e2e/default/analytics/analytics.wdio-spec.js +++ b/tests/e2e/default/analytics/analytics.wdio-spec.js @@ -43,7 +43,7 @@ describe('Targets', () => { await sentinelUtils.waitForSentinel(); await loginPage.login(chw); - await (await commonPage.analyticsTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); }); afterEach(async () => { diff --git a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js index 2f6fedb6c83..d6f00e7df47 100644 --- a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js +++ b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js @@ -65,6 +65,6 @@ describe('User Test Cases -> Creating Users ->', () => { await contactPage.selectLHSRowByText(districtHospital2.name); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.false; }); }); diff --git a/tests/e2e/default/contacts/export.wdio-spec.js b/tests/e2e/default/contacts/export.wdio-spec.js index 32d0e50a87a..648587076d1 100644 --- a/tests/e2e/default/contacts/export.wdio-spec.js +++ b/tests/e2e/default/contacts/export.wdio-spec.js @@ -1,9 +1,8 @@ const moment = require('moment'); -const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); const userFactory = require('@factories/cht/users/users'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); -const commonElements = require('@page-objects/default/common/common.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const fileDownloadUtils = require('@utils/file-download'); const utils = require('@utils'); @@ -23,12 +22,12 @@ describe('Export Contacts ', () => { contactDocs.forEach(savedContact => savedContactIds.push(savedContact.id)); await utils.createUsers([ onlineUser ]); await loginPage.login(onlineUser); - await commonElements.waitForPageLoaded(); - await commonElements.goToPeople(); + await commonPage.waitForPageLoaded(); + await commonPage.goToPeople(); }); it('should download export file', async () => { - await contactPage.exportContacts(); + await commonPage.accessExportOption(); const files = await fileDownloadUtils.waitForDownload(`contacts-${today.format('YYYYMMDD')}`); expect(files).to.not.be.undefined; diff --git a/tests/e2e/default/db/db-sync.wdio-spec.js b/tests/e2e/default/db/db-sync.wdio-spec.js index 24c327738c7..ffc7b4a3a2d 100644 --- a/tests/e2e/default/db/db-sync.wdio-spec.js +++ b/tests/e2e/default/db/db-sync.wdio-spec.js @@ -98,8 +98,8 @@ describe('db-sync', () => { body: restrictedUser }); await sentinelUtils.waitForSentinel(); - await loginPage.login({ username: restrictedUserName, password: restrictedPass }); - await (await commonElements.analyticsTab()).waitForDisplayed(); + await loginPage.login({ username: restrictedUserName, password: restrictedPass, resetPassword: false }); + await (await commonElements.tabsSelector.analyticsTab()).waitForDisplayed(); }); after(async () => { diff --git a/tests/e2e/default/enketo/add-family.wdio-spec.js b/tests/e2e/default/enketo/add-family.wdio-spec.js index 5d03750c5a1..1cdc770359b 100644 --- a/tests/e2e/default/enketo/add-family.wdio-spec.js +++ b/tests/e2e/default/enketo/add-family.wdio-spec.js @@ -18,7 +18,7 @@ describe('Family form', () => { await commonPage.openFastActionReport('add-family-multiple-repeats', false); await familyForm.submitFamilyForm(); await familyForm.reportCheck('test Family', 'boreholes', 'true', 'true', 'ucid'); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await commonEnketoPage.setInputValue('Names', 'modified'); await genericForm.nextPage(7); await commonEnketoPage.selectCheckBox('What is the family\'s source of drinking water?', 'Spring'); diff --git a/tests/e2e/default/enketo/death-report.wdio-spec.js b/tests/e2e/default/enketo/death-report.wdio-spec.js index b4829321ce6..49ba3f2c1a4 100644 --- a/tests/e2e/default/enketo/death-report.wdio-spec.js +++ b/tests/e2e/default/enketo/death-report.wdio-spec.js @@ -65,7 +65,7 @@ describe('Submit a death report', () => { expect((await reportsPage.getDetailReportRowContent('place_of_death')).rowValues[0]).to.equal('health_facility'); // Edit the report created - await reportsPage.editReport(); + await commonPage.accessEditOption(); await commonEnketoPage.selectRadioButton('Place of death', 'Home'); await genericForm.nextPage(); await genericForm.submitForm(); diff --git a/tests/e2e/default/enketo/edit-report-with-attachment.wdio-spec.js b/tests/e2e/default/enketo/edit-report-with-attachment.wdio-spec.js index b92f4949313..13df92e8112 100644 --- a/tests/e2e/default/enketo/edit-report-with-attachment.wdio-spec.js +++ b/tests/e2e/default/enketo/edit-report-with-attachment.wdio-spec.js @@ -27,7 +27,7 @@ describe('Edit report with attachment', () => { await commonPage.goToReports(); await reportsPage.openReport(editReportWithAttachmentDoc._id); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.submitForm(); const editedReport = await utils.getDoc(editReportWithAttachmentDoc._id); @@ -35,7 +35,7 @@ describe('Edit report with attachment', () => { expect(editedReport.fields).excludingEvery('meta').to.deep.equal({ intro: 'initial text' }); await reportsPage.openReport(editReportWithAttachmentDoc._id); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.submitForm(); const twiceEditedReport = await utils.getDoc(editReportWithAttachmentDoc._id); @@ -50,7 +50,7 @@ describe('Edit report with attachment', () => { await commonPage.goToReports(); await reportsPage.openReport(editReportWithAttachmentDoc._id); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await commonEnketoPage.setInputValue('Enter text', 'initial text updated'); await genericForm.submitForm(); @@ -59,7 +59,7 @@ describe('Edit report with attachment', () => { expect(editedReport.fields).excludingEvery('meta').to.deep.equal({ intro: 'initial text updated' }); await reportsPage.openReport(editReportWithAttachmentDoc._id); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await commonEnketoPage.setInputValue('Enter text', 'initial text updated twice'); await genericForm.submitForm(); diff --git a/tests/e2e/default/enketo/phone-widget.wdio-spec.js b/tests/e2e/default/enketo/phone-widget.wdio-spec.js index c7bbdba53fd..0c5056d8af1 100644 --- a/tests/e2e/default/enketo/phone-widget.wdio-spec.js +++ b/tests/e2e/default/enketo/phone-widget.wdio-spec.js @@ -3,7 +3,6 @@ const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const utils = require('@utils'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page'); -const contactsPage = require('@page-objects/default/contacts/contacts.wdio.page'); const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page'); const personFactory = require('@factories/cht/contacts/person'); const { expect } = require('chai'); @@ -57,7 +56,7 @@ describe('Phone widget', () => { it('can use duplicate phone number when editing contact with same number', async () => { await commonPage.goToPeople(person1._id); - await contactsPage.openEditContactForm(); + await commonPage.accessEditOption(); await (await genericForm.nextPage()); // Try setting phone to number of the other person diff --git a/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js b/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js index f09bb6a4fbe..b3884a729ff 100644 --- a/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js +++ b/tests/e2e/default/enketo/pregnancy-complete-a-delivery.wdio-spec.js @@ -213,7 +213,7 @@ describe('Contact Delivery Form', () => { expect(_.uniq(aliveBabyUUIds).length).to.deep.equal(noOfAliveBabies); await reportsPage.openReport(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.nextPage(); await genericForm.nextPage(); await genericForm.nextPage(); @@ -321,7 +321,7 @@ describe('Contact Delivery Form', () => { // edit one field await reportsPage.openReport(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.nextPage(); await genericForm.nextPage(); await commonEnketoPage.selectRadioButton('How did she deliver?', 'Cesarean'); @@ -392,7 +392,7 @@ describe('Contact Delivery Form', () => { expect(initialReport._attachments).to.equal(undefined); await browser.refresh(); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.nextPage(); await dangerSignPage.selectAllDangerSignsDelivery(); await genericForm.nextPage(); diff --git a/tests/e2e/default/enketo/pregnancy-danger-sign-follow-up.wdio-spec.js b/tests/e2e/default/enketo/pregnancy-danger-sign-follow-up.wdio-spec.js index 8009c06c66e..bc85774a410 100644 --- a/tests/e2e/default/enketo/pregnancy-danger-sign-follow-up.wdio-spec.js +++ b/tests/e2e/default/enketo/pregnancy-danger-sign-follow-up.wdio-spec.js @@ -44,7 +44,7 @@ describe('Pregnancy danger sign follow-up form', () => { expect(initialReport._attachments).to.equal(undefined); await reportsPage.openReport(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.nextPage(); await genericForm.submitForm(); @@ -63,7 +63,7 @@ describe('Pregnancy danger sign follow-up form', () => { expect(initialReport._attachments).to.equal(undefined); await reportsPage.openReport(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await fillPregnancyDangerSignFollowUpForm('No', 'Yes'); await dangerSignPage.selectAllDangerSignsPregnancy(); await genericForm.submitForm(); diff --git a/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js b/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js index 548aacee22d..5b3a83909f9 100644 --- a/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js +++ b/tests/e2e/default/enketo/submit-photo-upload-form.wdio-spec.js @@ -31,7 +31,7 @@ describe('Submit Photo Upload form', () => { expect(attachmentNames[0]).to.match(/^user-file-photo-for-upload-form-\d\d?_\d\d?_\d\d?\.png$/); await reportsPage.openReport(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed(); await genericForm.submitForm(); @@ -50,7 +50,7 @@ describe('Submit Photo Upload form', () => { expect(attachmentNames[0]).to.match(/^user-file-photo-for-upload-form-\d\d?_\d\d?_\d\d?\.png$/); await reportsPage.openReport(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed(); await enketoWidgetsPage.selectImage('photo-upload', path.join(__dirname, '../../../../webapp/src/img/layers.png')); await (enketoWidgetsPage.imagePreview('photo-upload')).waitForDisplayed(); diff --git a/tests/e2e/default/enketo/submit-z-score-form.wdio-spec.js b/tests/e2e/default/enketo/submit-z-score-form.wdio-spec.js index 44addd0b249..83d9c925fb9 100644 --- a/tests/e2e/default/enketo/submit-z-score-form.wdio-spec.js +++ b/tests/e2e/default/enketo/submit-z-score-form.wdio-spec.js @@ -119,7 +119,7 @@ describe('Submit Z-Score form', () => { const reportId = await reportsPage.getCurrentReportId(); const initialReport = await utils.getDoc(reportId); - await reportsPage.editReport(); + await commonPage.accessEditOption(); await genericForm.submitForm(); const updatedReport = await utils.getDoc(reportId); diff --git a/tests/e2e/default/logging/logging.wdio-spec.js b/tests/e2e/default/logging/logging.wdio-spec.js index 6c0374200cf..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, resetPassword: false }); + 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 1472148167f..c02977c9a51 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -72,14 +72,14 @@ describe('Login page functionality tests', () => { }); it('should log in using username and password fields', async () => { - await loginPage.login(auth); - await (await commonPage.analyticsTab()).waitForDisplayed(); - await (await commonPage.messagesTab()).waitForDisplayed(); + 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 (await commonPage.analyticsTab()).waitForDisplayed(); + await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); + await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); const cookies = await browser.getCookies(); expect(cookies.length).to.equal(3); @@ -117,8 +117,8 @@ describe('Login page functionality 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 (await commonPage.messagesTab()).waitForDisplayed(); + 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'); await commonPage.goToReports(); @@ -170,7 +170,7 @@ describe('Login page functionality tests', () => { expect(revealedPassword.value).to.equal('pass-456'); await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); - await (await commonPage.messagesTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); }); }); @@ -267,7 +267,7 @@ describe('Login page functionality tests', () => { await loginPage.passwordReset(user.password, NEW_PASSWORD, NEW_PASSWORD); await (await loginPage.updatePasswordButton()).click(); await commonPage.waitForPageLoaded(); - await (await commonPage.messagesTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); }); }); }); diff --git a/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js b/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js index 79519bfb9c4..cf843f1306c 100644 --- a/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/offline-user/all-permissions.wdio-spec.js @@ -63,7 +63,7 @@ describe('More Options Menu - Offline User', () => { it('should hide the kebab menu.', async () => { await commonPage.goToMessages(); await sms.sendSms('testing', contact.phone); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); }); @@ -72,27 +72,27 @@ describe('More Options Menu - Offline User', () => { 'disable the \'delete\' option when no contact is opened', async () => { await commonPage.goToPeople(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.false; }); it('should hide the \'export\' option and ' + 'enable the \'edit\' and \'delete\' options when a contact is opened', async () => { await commonPage.goToPeople(patient._id); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; }); it('should hide the \'export\' and \'edit\' options and ' + 'disable the \'delete\' option when the offline user\'s place is selected', async () => { await commonPage.goToPeople(offlineUser.place); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.false; }); }); @@ -100,24 +100,24 @@ describe('More Options Menu - Offline User', () => { it('should hide the \'export\' and \'edit\' options and ' + 'enable the \'delete\' and \'review\' options when the sms report is opened', async () => { await commonPage.goToReports(); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await reportPage.goToReportById(smsReportId); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; }); it('should hide the \'export\' option and ' + 'enable the \'edit\', \'delete\' and \'review\' options when the xml report is opened', async () => { await reportPage.goToReportById(xmlReportId); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; }); }); }); @@ -136,20 +136,20 @@ describe('More Options Menu - Offline User', () => { it('should hide the kebab menu in Messages, People and Reports tabs', async () => { await commonPage.goToMessages(); await sms.sendSms('testing', contact.phone); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await commonPage.goToPeople(); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await contactPage.selectLHSRowByText(contact.name); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await commonPage.goToReports(); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await reportPage.goToReportById(smsReportId); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; await reportPage.goToReportById(xmlReportId); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); }); }); diff --git a/tests/e2e/default/more-options-menu/offline-user/delete-permission-disabled.wdio-spec.js b/tests/e2e/default/more-options-menu/offline-user/delete-permission-disabled.wdio-spec.js index 445b55363bf..0cffc5de941 100644 --- a/tests/e2e/default/more-options-menu/offline-user/delete-permission-disabled.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/offline-user/delete-permission-disabled.wdio-spec.js @@ -63,26 +63,26 @@ describe('More Options Menu - Offline User - Delete permissions disabled', () => await commonPage.goToPeople(contact._id); await commonPage.closeReloadModal(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('edit', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; }); }); describe('Report tab', () => { it('should hide the kebab menu when the sms report is opened', async () => { await reportPage.goToReportById(smsReportId); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); it('should enable the \'edit\' and \'review\' option and ' + 'hide the \'export\' and \'delete\' options when the xml report is opened', async () => { await reportPage.goToReportById(xmlReportId); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('edit', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; - expect(await commonPage.isMenuOptionVisible('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'reports')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; }); }); }); diff --git a/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js b/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js index 9b4716e27f8..5ce3ef9bdb8 100644 --- a/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/offline-user/edit-permission-disabled.wdio-spec.js @@ -62,19 +62,19 @@ describe('More Options Menu - Offline User - Edit permissions disabled', () => { it('should hide the kebab menu.', async () => { await commonPage.goToPeople(patient._id); await commonPage.closeReloadModal(); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); }); describe('Report tab', () => { it('should hide the kebab menu when the sms report is opened.', async () => { await reportPage.goToReportById(smsReportId); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); it('should hide the kebab menu when the xml report is opened.', async () => { await reportPage.goToReportById(xmlReportId); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); }); }); diff --git a/tests/e2e/default/more-options-menu/online-user/permissions-disabled.wdio-spec.js b/tests/e2e/default/more-options-menu/online-user/permissions-disabled.wdio-spec.js index d950f98be94..7461ce309fa 100644 --- a/tests/e2e/default/more-options-menu/online-user/permissions-disabled.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/online-user/permissions-disabled.wdio-spec.js @@ -58,28 +58,28 @@ describe('More Options Menu - Online User - Permissions disabled', () => { 'enable the \'edit\' and \'delete\' options when a contact is opened', async () => { await commonPage.goToPeople(contact._id); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; }); it('should hide the \'export\' option and ' + 'enable the \'edit\', \'delete\' and \'review\' options when a report is opened', async () => { await commonPage.goToReports(); - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; (await reportPage.leftPanelSelectors.firstReport()).click(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; + expect(await commonPage.isMenuOptionVisible('export')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; }); it('should hide the kebab menu on the Message tab', async () => { await commonPage.goToMessages(); await commonPage.waitForLoaderToDisappear(); expect(await commonPage.isMessagesListPresent()).to.be.true; - expect(await (await commonPage.moreOptionsMenu()).isExisting()).to.be.false; + expect(await commonPage.isMoreOptionsMenuPresent()).to.be.false; }); }); @@ -95,9 +95,9 @@ describe('More Options Menu - Online User - Permissions disabled', () => { 'enable the \'edit\' and \'export\' options when a contact is opened', async () => { await commonPage.goToPeople(contact._id); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('delete', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('edit', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('export', 'contacts')).to.be.true; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; }); it('should hide the \'delete\' option and ' + @@ -105,10 +105,10 @@ describe('More Options Menu - Online User - Permissions disabled', () => { await commonPage.goToReports(); (await reportPage.leftPanelSelectors.firstReport()).click(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('delete', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('export', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('edit', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; }); }); @@ -124,9 +124,9 @@ describe('More Options Menu - Online User - Permissions disabled', () => { 'enable the \'export\' option when a contact is opened', async () => { await commonPage.goToPeople(contact._id); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('edit', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('export', 'contacts')).to.be.true; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; }); it('should hide the \'edit\', \'delete\' and \'review\' options and ' + @@ -134,10 +134,10 @@ describe('More Options Menu - Online User - Permissions disabled', () => { await commonPage.goToReports(); (await reportPage.leftPanelSelectors.firstReport()).click(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionVisible('edit', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('review', 'report')).to.be.false; - expect(await commonPage.isMenuOptionEnabled('export', 'reports')).to.be.true; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; + expect(await commonPage.isMenuOptionVisible('review')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; }); }); }); diff --git a/tests/e2e/default/more-options-menu/online-user/permissions-enabled.wdio-spec.js b/tests/e2e/default/more-options-menu/online-user/permissions-enabled.wdio-spec.js index 3655eb80ece..bc2c4c23b9a 100644 --- a/tests/e2e/default/more-options-menu/online-user/permissions-enabled.wdio-spec.js +++ b/tests/e2e/default/more-options-menu/online-user/permissions-enabled.wdio-spec.js @@ -25,23 +25,23 @@ describe('More Options Menu - Online User - Permissions enabled', () => { async () => { await commonPage.goToMessages(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'messages')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.false; }); it('should disable the \'export\' option and hide the \'edit\' and \'delete\' options in Contact tab', async () => { await commonPage.goToPeople(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; }); it('should disable the \'export\' option and hide the \'edit\' and \'delete\' options in Report tab', async () => { await commonPage.goToReports(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('edit', 'reports')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'reports')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.false; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.false; }); }); @@ -103,7 +103,7 @@ describe('More Options Menu - Online User - Permissions enabled', () => { await commonPage.waitForLoaderToDisappear(); await commonPage.waitForPageLoaded(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'messages')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; }); it('should enable the \'export\', \'review\' and \'delete\' options and hide the \'edit\' option in Report tab ' + @@ -111,10 +111,10 @@ describe('More Options Menu - Online User - Permissions enabled', () => { await reportPage.goToReportById(smsReportId); await (await reportPage.rightPanelSelectors.reportBodyDetails()).waitForDisplayed(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionVisible('edit', 'reports')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; }); it('should enable the \'export\', \'review\', \'edit\' and \'delete\' options in Report tab ' + @@ -122,19 +122,19 @@ describe('More Options Menu - Online User - Permissions enabled', () => { await reportPage.goToReportById(xmlReportId); await reportPage.rightPanelSelectors.reportBodyDetails().waitForDisplayed(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('review', 'report')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('edit', 'reports')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'reports')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('review')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; }); it('should enable the \'export\', \'edit\' and \'delete\' options in Contact tab ' + 'when a contact is opened.', async () => { await commonPage.goToPeople(contact._id); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('edit', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('edit')).to.be.true; + expect(await commonPage.isMenuOptionEnabled('delete')).to.be.true; }); it('should enable the \'export\' option and hide the \'edit\' and \'delete\' options in Contact tab ' + @@ -144,9 +144,9 @@ describe('More Options Menu - Online User - Permissions enabled', () => { await commonPage.waitForPageLoaded(); await commonPage.goToPeople(); await commonPage.openMoreOptionsMenu(); - expect(await commonPage.isMenuOptionEnabled('export', 'contacts')).to.be.true; - expect(await commonPage.isMenuOptionVisible('edit', 'contacts')).to.be.false; - expect(await commonPage.isMenuOptionVisible('delete', 'contacts')).to.be.false; + expect(await commonPage.isMenuOptionEnabled('export')).to.be.true; + expect(await commonPage.isMenuOptionVisible('edit')).to.be.false; + expect(await commonPage.isMenuOptionVisible('delete')).to.be.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 e3a5de48fde..0c0b42c9153 100644 --- a/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js +++ b/tests/e2e/default/privacy-policy/privacy-policy.wdio-spec.js @@ -1,4 +1,4 @@ -const commonElements = require('@page-objects/default/common/common.wdio.page.js'); +const commonPage = require('@page-objects/default/common/common.wdio.page.js'); const utils = require('@utils'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const privacyPage = require('@page-objects/default/privacy-policy/privacy-policy.wdio.page'); @@ -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 }), @@ -36,12 +37,12 @@ describe('Privacy policy', () => { it('should show the correct privacy policy on login', async () => { await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), englishTexts, user.isOffline); - expect(await (await commonElements.messagesTab()).isDisplayed()).to.be.true; + expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; }); it('should not show on refresh', async () => { await browser.url('/'); - await (await commonElements.messagesTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); expect(await (await privacyPage.privacyWrapper()).isDisplayed()).to.not.be.true; }); @@ -53,8 +54,8 @@ describe('Privacy policy', () => { it('should not show on subsequent login', async () => { await browser.reloadSession(); await browser.url('/'); - await loginPage.login({ username: user.username, password: user.password, resetPassword: false }); - await (await commonElements.messagesTab()).waitForDisplayed(); + 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; }); @@ -63,20 +64,20 @@ describe('Privacy policy', () => { await browser.url('/'); await loginPage.login({ username: user.username, - password: user.password, + password: newPassword, locale: 'fr', privacyPolicy: true, resetPassword: false }); await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), frenchTexts); - expect(await (await commonElements.messagesTab()).isDisplayed()).to.be.true; + expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; }); it('should show if the user changes their language', async () => { await browser.setCookies({ name: 'locale', value: 'es' }); await browser.refresh(); await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), spanishTexts); - expect(await (await commonElements.messagesTab()).isDisplayed()).to.be.true; + expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; }); it('should show if the user policy changes', async () => { @@ -100,13 +101,13 @@ describe('Privacy policy', () => { await privacyPage.updatePrivacyPolicy(updatedPolicy); if (user.isOffline) { - await commonElements.sync(); + await commonPage.sync(); } await browser.refresh(); await privacyPage.waitAndAcceptPolicy(await privacyPage.privacyWrapper(), text); - expect(await (await commonElements.messagesTab()).isDisplayed()).to.be.true; + expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; }); }); }); @@ -139,8 +140,8 @@ describe('Privacy policy', () => { it('should not fail due to document conflict for new offline user', async () => { await privacyPage.waitForPolicy(await privacyPage.privacyWrapper(), englishTexts); await privacyPage.acceptPrivacyPolicy(); - await commonElements.sync(); - expect(await (await commonElements.messagesTab()).isDisplayed()).to.be.true; + await commonPage.sync(); + expect(await (await commonPage.tabsSelector.messagesTab()).isDisplayed()).to.be.true; passed = true; }); }); diff --git a/tests/e2e/default/reports/delete.wdio-spec.js b/tests/e2e/default/reports/delete.wdio-spec.js index 45200a37ec9..385b3eca094 100644 --- a/tests/e2e/default/reports/delete.wdio-spec.js +++ b/tests/e2e/default/reports/delete.wdio-spec.js @@ -53,7 +53,7 @@ describe('Delete Reports', () => { expect(await (await reportsPage.leftPanelSelectors.reportByUUID(savedReportIds[1])).isDisplayed()).to.be.true; await reportsPage.openReport(savedReportIds[1]); - await reportsPage.deleteReport(); + await commonElements.accessDeleteOption(); await commonElements.goToReports(); expect(await (await reportsPage.leftPanelSelectors.reportByUUID(savedReportIds[0])).isDisplayed()).to.be.true; diff --git a/tests/e2e/default/reports/export.wdio-spec.js b/tests/e2e/default/reports/export.wdio-spec.js index ca6613bbf90..0b7f2ec4afc 100644 --- a/tests/e2e/default/reports/export.wdio-spec.js +++ b/tests/e2e/default/reports/export.wdio-spec.js @@ -3,7 +3,7 @@ const userFactory = require('@factories/cht/users/users'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); const reportFactory = require('@factories/cht/reports/generic-report'); -const commonElements = require('@page-objects/default/common/common.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); const reportsPage = require('@page-objects/default/reports/reports.wdio.page'); const loginPage = require('@page-objects/default/login/login.wdio.page'); const fileDownloadUtils = require('@utils/file-download'); @@ -35,8 +35,8 @@ describe('Export Reports', () => { (await utils.saveDocs(reports)).forEach(savedReport => savedReportIds.push(savedReport.id)); await utils.createUsers([ onlineUser ]); await loginPage.login(onlineUser); - await commonElements.waitForPageLoaded(); - await commonElements.goToReports(); + await commonPage.waitForPageLoaded(); + await commonPage.goToReports(); }); after(async () => { @@ -46,7 +46,7 @@ describe('Export Reports', () => { it('should download export file', async () => { await (await reportsPage.leftPanelSelectors.firstReport()).waitForDisplayed(); - await reportsPage.exportReports(); + await commonPage.accessExportOption(); const files = await fileDownloadUtils.waitForDownload(`reports-${today.format('YYYYMMDD')}`); expect(files).to.not.be.undefined; 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 812f64bb782..3fe432351a0 100644 --- a/tests/e2e/default/service-worker/service-worker.wdio-spec.js +++ b/tests/e2e/default/service-worker/service-worker.wdio-spec.js @@ -73,7 +73,7 @@ describe('Service worker cache', () => { }; const isLoggedIn = async () => { - const tab = await commonPage.messagesTab(); + const tab = await commonPage.tabsSelector.messagesTab(); return await tab.isExisting(); }; @@ -219,7 +219,7 @@ describe('Service worker cache', () => { it('should load the page while offline', async () => { await browser.throttle('offline'); await browser.refresh(); - await (await commonPage.analyticsTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); await browser.throttle('online'); }); diff --git a/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js b/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js index 2c6687693e9..d6a4a79f934 100644 --- a/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js +++ b/tests/e2e/default/tasks/config/tasks-breadcrumbs-config.js @@ -38,7 +38,7 @@ module.exports = [ ], events: [ { - id: 'person-creation-follow-up', + id: 'person-creation', start: 3, end: 7, dueDate: function (event, contact) { @@ -47,4 +47,35 @@ module.exports = [ } ] }, + + { + name: 'person_create_follow_up', + icon: 'icon-person', + title: 'person_create_follow_up', + appliesTo: 'reports', + appliesToType: ['home_visit'], + appliesIf: function () { + return true; + }, + resolvedIf: function (contact) { + return isFormArraySubmittedInWindow(contact.reports, ['home_visit'], contact.contact.reported_date); + }, + actions: [ + { + type: 'report', + form: 'home_visit' + } + ], + events: [ + { + id: 'person-creation-follow-up', + start: 3, + end: 1, + dueDate: function (event, contact) { + return contact.contact.reported_date; + } + } + ] + }, + ]; diff --git a/tests/e2e/default/tasks/due-dates.wdio-spec.js b/tests/e2e/default/tasks/due-dates.wdio-spec.js index 2faeff96317..353510ced11 100644 --- a/tests/e2e/default/tasks/due-dates.wdio-spec.js +++ b/tests/e2e/default/tasks/due-dates.wdio-spec.js @@ -28,7 +28,7 @@ describe('Task list due dates', () => { await sentinelUtils.waitForSentinel(); await loginPage.login(chw); - await (await commonPage.analyticsTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); }); after(async () => { diff --git a/tests/e2e/default/tasks/tasks.wdio-spec.js b/tests/e2e/default/tasks/tasks.wdio-spec.js index e2a1ed6ba26..f0e770a1de0 100644 --- a/tests/e2e/default/tasks/tasks.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks.wdio-spec.js @@ -92,6 +92,24 @@ describe('Tasks', () => { expect(list).to.have.length(2); }); + it('should add a task when CHW completes a task successfully, and that task creates another task', async () => { + await tasksPage.compileTasks('tasks-breadcrumbs-config.js', false); + + await commonPage.goToTasks(); + let list = await tasksPage.getTasks(); + expect(list).to.have.length(2); + let task = await tasksPage.getTaskByContactAndForm('Megan Spice', 'person_create'); + await task.click(); + await tasksPage.waitForTaskContentLoaded('Home Visit'); + const taskElement = await tasksPage.getOpenTaskElement(); + await genericForm.submitForm(); + await taskElement.waitForDisplayed(); + await commonPage.sync(true); + task = await tasksPage.getTaskByContactAndForm('Megan Spice', 'person_create_follow_up'); + list = await tasksPage.getTasks(); + expect(list).to.have.length(3); + }); + it('should load multiple pages of tasks on infinite scrolling', async () => { await tasksPage.compileTasks('tasks-multiple-config.js', true); diff --git a/tests/e2e/default/translations/incorrect-locale.wdio-spec.js b/tests/e2e/default/translations/incorrect-locale.wdio-spec.js index c107f185e7b..b930e192255 100644 --- a/tests/e2e/default/translations/incorrect-locale.wdio-spec.js +++ b/tests/e2e/default/translations/incorrect-locale.wdio-spec.js @@ -5,6 +5,7 @@ const contactElements = require('@page-objects/default/contacts/contacts.wdio.pa const loginPage = require('@page-objects/default/login/login.wdio.page'); const placeFactory = require('@factories/cht/contacts/place'); const sentinelUtils = require('@utils/sentinel'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); describe('Testing Incorrect locale', () => { const LANGUAGE_CODE = 'hil'; @@ -44,8 +45,8 @@ describe('Testing Incorrect locale', () => { await userSettingsElements.setLanguage(LANGUAGE_CODE); - const text = await commonElements.getReportsButtonLabel().getText(); - expect(text).to.equal('HilReports'); + const tabsButtonLabelsNames = await commonPage.getAllButtonLabelsNames(); + expect(tabsButtonLabelsNames).to.include('HilReports'); await commonElements.goToPeople(); await commonElements.waitForPageLoaded(); diff --git a/tests/e2e/default/translations/message-format.wdio-spec.js b/tests/e2e/default/translations/message-format.wdio-spec.js index 16bcb4e170f..c04079211cd 100644 --- a/tests/e2e/default/translations/message-format.wdio-spec.js +++ b/tests/e2e/default/translations/message-format.wdio-spec.js @@ -34,14 +34,13 @@ describe('MessageFormat', () => { // wait for language to load await browser.waitUntil(async () => { - return await (await commonPage.getReportsButtonLabel()).getText() === 'Reports {{thing}}'; + return await commonPage.isElementPresent('div*=Reports {{thing}}'); }, { timeout: 2000, timeoutMsg: 'Timed out waiting for translations to update' }); - expect(await (await commonPage.getReportsButtonLabel()).getText()).to.equal('Reports {{thing}}'); - expect(await (await commonPage.getTasksButtonLabel()).getText()).to.equal('Tasks {thing'); - expect(await (await commonPage.getMessagesButtonLabel()).getText()).to.equal('Messages {thing}'); + const tabsButtonLabelsNames = await commonPage.getAllButtonLabelsNames(); + expect(tabsButtonLabelsNames).to.include.members(['Reports {{thing}}', 'Tasks {thing', 'Messages {thing}']); }); }); diff --git a/tests/e2e/default/translations/nepali-dates-and-numbers.wdio-spec.js b/tests/e2e/default/translations/nepali-dates-and-numbers.wdio-spec.js index 9c356f7096c..2f87ff9da9b 100644 --- a/tests/e2e/default/translations/nepali-dates-and-numbers.wdio-spec.js +++ b/tests/e2e/default/translations/nepali-dates-and-numbers.wdio-spec.js @@ -95,7 +95,7 @@ describe('Bikram Sambat date display', () => { await chtConfUtils.compileAndUploadAppForms(formsPath); await loginPage.cookieLogin(); - await (await commonPage.analyticsTab()).waitForDisplayed(); + await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); }); after(async () => { diff --git a/tests/e2e/default/translations/new-language.wdio-spec.js b/tests/e2e/default/translations/new-language.wdio-spec.js index 16ee6e53765..81d59f65999 100644 --- a/tests/e2e/default/translations/new-language.wdio-spec.js +++ b/tests/e2e/default/translations/new-language.wdio-spec.js @@ -57,7 +57,7 @@ describe('Adding new language', () => { await addTranslations(NEW_LANG_CODE, NEW_TRANSLATIONS); await commonPage.goToBase(); await userSettingsElements.setLanguage(NEW_LANG_CODE); - await browser.waitUntil(async () => await (await commonPage.analyticsTab()).getText() === 'Analytiks'); + await browser.waitUntil(async () => await (await commonPage.tabsSelector.analyticsTab()).getText() === 'Analytiks'); // Check for translations in the UI await commonPage.goToMessages(); diff --git a/tests/integration/api/server.spec.js b/tests/integration/api/server.spec.js index 16c529221c6..2b50dec116c 100644 --- a/tests/integration/api/server.spec.js +++ b/tests/integration/api/server.spec.js @@ -430,4 +430,28 @@ describe('server', () => { }); }); + + describe('api startup', () => { + it('should start up with broken forms', async () => { + const waitForLogs = await utils.waitForApiLogs(/Failed to update xform/); + + const formName = 'broken'; + const formDoc = { + _id: `form:${formName}`, + title: formName, + type: 'form', + _attachments: { + xml: { + content_type: 'application/octet-stream', + data: btoa('this is totally not an xml'), + }, + }, + }; + await utils.db.put(formDoc); // don't use utils.saveDoc because that actually waits for good forms + await waitForLogs.promise; + + await utils.stopApi(); + await utils.startApi(); + }); + }); }); diff --git a/tests/integration/haproxy/keep-alive-script/Dockerfile b/tests/integration/haproxy/keep-alive-script/Dockerfile index a3a88e64e1f..1d9a42c33eb 100644 --- a/tests/integration/haproxy/keep-alive-script/Dockerfile +++ b/tests/integration/haproxy/keep-alive-script/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.19 +FROM alpine:3.20 RUN apk add --update --no-cache curl diff --git a/tests/page-objects/default/about/about.wdio.page.js b/tests/page-objects/default/about/about.wdio.page.js index f2baf0a89b0..1fef86546e3 100644 --- a/tests/page-objects/default/about/about.wdio.page.js +++ b/tests/page-objects/default/about/about.wdio.page.js @@ -2,7 +2,6 @@ const userName = () => $('label=User name'); const partners = () => $('.partners'); const version = () => $('[test-id="about-version"]'); const aboutCard = () => $('mat-card-title*=About'); -const RELOAD_BUTTON = '.about.page .mat-primary'; const getPartnerImage = async (name) => { await (await partners()).waitForDisplayed(); @@ -23,5 +22,4 @@ module.exports = { getPartnerImage, getVersion, aboutCard, - RELOAD_BUTTON, }; diff --git a/tests/page-objects/default/admin/admin.wdio.page.js b/tests/page-objects/default/admin/admin.wdio.page.js index d4bb0cc8d5a..fcd8e4f30f1 100644 --- a/tests/page-objects/default/admin/admin.wdio.page.js +++ b/tests/page-objects/default/admin/admin.wdio.page.js @@ -1,4 +1,4 @@ -const utils = require('../../../utils'); +const utils = require('@utils'); const adminNavbarLogo = () => $('.navbar-header .navbar-brand'); const languagesPanel = () => $('.tab-content > #language-accordion > .panel'); diff --git a/tests/page-objects/default/analytics/analytics.wdio.page.js b/tests/page-objects/default/analytics/analytics.wdio.page.js index e9c4fd284a2..3fdf68b8e17 100644 --- a/tests/page-objects/default/analytics/analytics.wdio.page.js +++ b/tests/page-objects/default/analytics/analytics.wdio.page.js @@ -1,35 +1,21 @@ const TARGET_MET_COLOR = '#76b0b0'; - const TARGET_UNMET_COLOR = '#000000'; const goToTargets = () => browser.url('/#/analytics/targets'); - const noSelectedTarget = () => $('.empty-selection'); - const targets = () => $$('.target'); - const targetWrap = () => $('.page .targets'); - const targetTitle = (targetElement) => targetElement.$('.heading .title h2'); - const targetGoal = (targetElement) => targetElement.$('.body .count .goal'); - const targetCountNumber = (targetElement) => targetElement.$('.body .count .number'); - const targetCountNumberColor = (targetElement) => targetElement.$('.body .count .number:not(.goal-met)'); - const targetProgressNumber = (targetElement) => targetElement.$('.body .target-progress .number'); - const targetNumberPercent = (targetElement) => targetElement.$('.body .target-progress .number .value'); - const targetNumberPercentCount = (targetElement) => targetElement.$('.body .target-progress .number span:nth-child(2)'); - const targetGoalValue = (targetElement) => targetElement.$('.body .count .goal'); const EMPTY_SELECTION = '.content-pane .item-content.empty-selection'; - const emptySelectionError = () => $(`${EMPTY_SELECTION}.selection-error`); - const emptySelectionNoError = () => $(`${EMPTY_SELECTION}:not(.selection-error)`); const getTargetInfo = async (targetElement) => { diff --git a/tests/page-objects/default/common/common.wdio.page.js b/tests/page-objects/default/common/common.wdio.page.js index 938b8e20c1a..4c196d6f2f1 100644 --- a/tests/page-objects/default/common/common.wdio.page.js +++ b/tests/page-objects/default/common/common.wdio.page.js @@ -1,84 +1,170 @@ -const modalPage = require('./modal.wdio.page'); -const constants = require('@constants'); -const aboutPage = require('@page-objects/default/about/about.wdio.page'); const fs = require('fs'); +const modalPage = require('@page-objects/default/common/modal.wdio.page'); +const constants = require('@constants'); + +const ELEMENT_DISPLAY_PAUSE = 500; // 500ms + +const tabsSelector = { + getAllButtonLabels: async () => await $$('.header .tabs .button-label'), + messagesTab: () => $('#messages-tab'), + taskTab: () => $('#tasks-tab'), + analyticsTab: () => $('#analytics-tab'), +}; + +const hamburgerMenuSelectors = { + hamburgerMenu: () => $('aria/Application menu'), + closeSideBarMenu: () => $('.panel-header-close'), + sideBarMenuTitle: () => $('aria/Menu'), + appManagementButton: () => $('aria/App Management'), + syncButton: () => $('aria/Sync now'), + syncSuccess: () => $('aria/All reports synced'), + syncInProgress: () => $('mat-sidenav-content').$('*="Currently syncing"'), + aboutButton: () => $('aria/About'), + userSettingsButton: () => $('aria/User settings'), + feedbackMenuOption: () => $('aria/Report bug'), + logoutButton: () => $('aria/Log out'), +}; + +const kebabMenuSelectors = { + moreOptionsMenu: () => $('aria/Actions menu'), + edit: () => $('aria/Edit'), + delete: () => $('aria/Delete'), + export: () => $('aria/Export'), + review: () => $('aria/Review'), +}; -const hamburgerMenu = () => $('aria/Application menu'); -const closeSideBarMenu = () => $('.panel-header-close'); const FAST_ACTION_TRIGGER = '.fast-action-trigger'; -const fastActionFAB = () => $$(`${FAST_ACTION_TRIGGER} .fast-action-fab-button`); -const fastActionFlat = () => $(`${FAST_ACTION_TRIGGER} .fast-action-flat-button`); -const multipleActions = () => $(`${FAST_ACTION_TRIGGER}[test-id="multiple-actions-menu"]`); const FAST_ACTION_LIST_CONTAINER = '.fast-action-content-wrapper'; -const fastActionListContainer = () => $(FAST_ACTION_LIST_CONTAINER); -const fastActionListCloseButton = () => $(`${FAST_ACTION_LIST_CONTAINER} .panel-header .panel-header-close`); -const fastActionById = (id) => $(`${FAST_ACTION_LIST_CONTAINER} .fast-action-item[test-id="${id}"]`); -const fastActionItems = () => $$(`${FAST_ACTION_LIST_CONTAINER} .fast-action-item`); -const moreOptionsMenu = () => $('aria/Actions menu'); -const hamburgerMenuItemSelector = 'mat-sidenav-content'; -const logoutButton = () => $('aria/Log out'); -const syncButton = () => $('aria/Sync now'); -const messagesTab = () => $('#messages-tab'); -const analyticsTab = () => $('#analytics-tab'); -const taskTab = () => $('#tasks-tab'); -const getReportsButtonLabel = () => $('#reports-tab .button-label'); -const getMessagesButtonLabel = () => $('#messages-tab .button-label'); -const getTasksButtonLabel = () => $('#tasks-tab .button-label'); -const getAllButtonLabels = async () => await $$('.header .tabs .button-label'); -const loaders = () => $$('.container-fluid .loader'); -const syncSuccess = () => $('aria/All reports synced'); -const syncInProgress = () => $(hamburgerMenuItemSelector).$('*="Currently syncing"'); -const syncRequired = () => $(`${hamburgerMenuItemSelector} .sync-status .required`); -const jsonError = async () => (await $('pre')).getText(); -const REPORTS_CONTENT_SELECTOR = '#reports-content'; -const reportsFastActionFAB = () => $(`${REPORTS_CONTENT_SELECTOR} .fast-action-fab-button mat-icon`); - -//languages -const activeSnackbar = () => $('#snackbar.active'); -const inactiveSnackbar = () => $('#snackbar:not(.active)'); -const snackbar = () => $('#snackbar.active .snackbar-message'); -const snackbarMessage = async () => (await $('#snackbar.active .snackbar-message')).getText(); -const snackbarAction = () => $('#snackbar.active .snackbar-action'); - -// Mobile -const mobileTopBarTitle = () => $('mm-navigation .ellipsis-title'); - -//User settings -const USER_SETTINGS = 'aria/User settings'; -const EDIT_PROFILE = '.user .configuration.page i.fa-user'; -// Feedback or Report bug -const feedbackMenuOption = () => $('aria/Report bug'); -const FEEDBACK = '#feedback'; -//About menu -const ABOUT_MENU = 'aria/About'; -//Configuration App -const ELEMENT_DISPLAY_PAUSE = 500; // 500ms +const fabSelectors = { + fastActionFAB: () => $$(`${FAST_ACTION_TRIGGER} .fast-action-fab-button`), + fastActionFlat: () => $(`${FAST_ACTION_TRIGGER} .fast-action-flat-button`), + multipleActions: () => $(`${FAST_ACTION_TRIGGER}[test-id="multiple-actions-menu"]`), + fastActionListContainer: () => $(FAST_ACTION_LIST_CONTAINER), + fastActionListCloseButton: () => $(`${FAST_ACTION_LIST_CONTAINER} .panel-header .panel-header-close`), + fastActionById: (id) => $(`${FAST_ACTION_LIST_CONTAINER} .fast-action-item[test-id="${id}"]`), + fastActionItems: () => $$(`${FAST_ACTION_LIST_CONTAINER} .fast-action-item`), + reportsFastActionFAB: () => $('#reports-content .fast-action-fab-button mat-icon'), +}; + +const userSettingsSelectors = { + editProfileButton: () => $('.user .configuration.page i.fa-user'), +}; -const configurationAppMenuOption = () => $('aria/App Management'); -const errorLog = () => $(`error-log`); -const sideBarMenuTitle = () => $('aria/Menu'); +const getJsonErrorText = async () => await $('pre').getText(); const isHamburgerMenuOpen = async () => { return await (await $('mat-sidenav-container.mat-drawer-container-has-open')).isExisting(); }; +const openHamburgerMenu = async () => { + if (!(await isHamburgerMenuOpen())) { + await (await hamburgerMenuSelectors.hamburgerMenu()).waitForClickable(); + await (await hamburgerMenuSelectors.hamburgerMenu()).click(); + // Adding pause here as we have to wait for sidebar nav menu animation to load + await browser.pause(ELEMENT_DISPLAY_PAUSE); + } + await (await hamburgerMenuSelectors.sideBarMenuTitle()).waitForDisplayed(); +}; + +const closeHamburgerMenu = async () => { + if (await isHamburgerMenuOpen()) { + await (await hamburgerMenuSelectors.closeSideBarMenu()).waitForClickable(); + await (await hamburgerMenuSelectors.closeSideBarMenu()).click(); + } + + await (await hamburgerMenuSelectors.sideBarMenuTitle()).waitForDisplayed({ reverse: true }); +}; + const openMoreOptionsMenu = async () => { - await (await moreOptionsMenu()).waitForClickable(); - await (await moreOptionsMenu()).click(); + await (await kebabMenuSelectors.moreOptionsMenu()).waitForClickable(); + await (await kebabMenuSelectors.moreOptionsMenu()).click(); +}; + +const performMenuAction = async (actionSelector) => { + await openMoreOptionsMenu(); + const actionElement = await actionSelector(); + await actionElement.waitForClickable(); + await actionElement.click(); +}; + +const accessEditOption = async () => { + await performMenuAction(kebabMenuSelectors.edit); +}; + +const accessDeleteOption = async () => { + await performMenuAction(kebabMenuSelectors.delete); +}; + +const accessExportOption = async () => { + await performMenuAction(kebabMenuSelectors.export); +}; + +const accessReviewOption = async () => { + await performMenuAction(kebabMenuSelectors.review); }; const waitForSnackbarToClose = async () => { - if (await (await snackbar()).isExisting()) { - await (await snackbar()).waitForDisplayed({ reverse: true }); + const snackbar = await $('#snackbar.active .snackbar-message'); + if (await snackbar.isExisting()) { + await snackbar.waitForDisplayed({ reverse: true }); + } +}; + +const hideSnackbar = () => { + // snackbar appears in the bottom of the page for 5 seconds when certain actions are made + // for example when filling a form, or creating a contact and intercepts all clicks in the FAB and Flat buttons + // this action is temporary, and will be undone with a refresh + return browser.execute(() => { + // eslint-disable-next-line no-undef + window.jQuery('.snackbar-content').hide(); + }); +}; + +const getVisibleLoaders = async () => { + const visible = []; + for (const loader of await $$('.container-fluid .loader')) { + if (await loader.isDisplayedInViewport()) { + visible.push(loader); + } } + + return visible; +}; + +const waitForLoaderToDisappear = async (element) => { + const loaderSelector = '.loader'; + const loader = await (element ? element.$(loaderSelector) : $(loaderSelector)); + await loader.waitForDisplayed({ reverse: true }); +}; + +const waitForLoaders = async () => { + await browser.waitUntil(async () => { + const visibleLoaders = await getVisibleLoaders(); + return !visibleLoaders.length; + }, { timeoutMsg: 'Waiting for Loading spinners to hide timed out.' }); +}; + +const waitForAngularLoaded = async (timeout = 40000) => { + await (await hamburgerMenuSelectors.hamburgerMenu()).waitForDisplayed({ timeout }); +}; + +const waitForPageLoaded = async () => { + // if we immediately check for app loaders, we might bypass the initial page load (the bootstrap loader) + // so waiting for the main page to load. + await waitForAngularLoaded(); + // ideally we would somehow target all loaders that we expect (like LHS + RHS loaders), but not all pages + // get all loaders. + do { + await waitForLoaders(); + } while ((await getVisibleLoaders()).length > 0); }; const clickFastActionById = async (id) => { // Wait for the Angular Material's animation to complete. await browser.pause(ELEMENT_DISPLAY_PAUSE); - await (await fastActionListContainer()).waitForDisplayed(); - await (await fastActionById(id)).waitForClickable(); - await (await fastActionById(id)).click(); + await (await fabSelectors.fastActionListContainer()).waitForDisplayed(); + await (await fabSelectors.fastActionById(id)).waitForClickable(); + await (await fabSelectors.fastActionById(id)).click(); }; /** @@ -86,7 +172,7 @@ const clickFastActionById = async (id) => { * @returns {Promise} */ const findVisibleFAB = async () => { - for (const button of await fastActionFAB()) { + for (const button of await fabSelectors.fastActionFAB()) { if (await button.isDisplayed()) { return button; } @@ -97,7 +183,7 @@ const clickFastActionFAB = async ({ actionId, waitForList }) => { await closeHamburgerMenu(); const fab = await findVisibleFAB(); await fab.waitForClickable(); - waitForList = waitForList === undefined ? await (await multipleActions()).isExisting() : waitForList; + waitForList = waitForList === undefined ? await (await fabSelectors.multipleActions()).isExisting() : waitForList; await fab.click(); if (waitForList) { await clickFastActionById(actionId); @@ -111,17 +197,17 @@ const getFastActionItemsLabels = async () => { await fab.click(); await browser.pause(ELEMENT_DISPLAY_PAUSE); - await (await fastActionListContainer()).waitForDisplayed(); + await (await fabSelectors.fastActionListContainer()).waitForDisplayed(); - const items = await fastActionItems(); + const items = await fabSelectors.fastActionItems(); return await items.map(item => item.getText()); }; const clickFastActionFlat = async ({ actionId, waitForList }) => { - await (await fastActionFlat()).waitForDisplayed(); - await (await fastActionFlat()).waitForClickable(); - waitForList = waitForList === undefined ? await (await multipleActions()).isExisting() : waitForList; - await (await fastActionFlat()).click(); + await (await fabSelectors.fastActionFlat()).waitForDisplayed(); + await (await fabSelectors.fastActionFlat()).waitForClickable(); + waitForList = waitForList === undefined ? await (await fabSelectors.multipleActions()).isExisting() : waitForList; + await (await fabSelectors.fastActionFlat()).click(); if (waitForList) { await clickFastActionById(actionId); } @@ -140,90 +226,55 @@ const openFastActionReport = async (formId, rightSideAction = true) => { const getFastActionFABTextById = async (actionId) => { await clickFastActionFAB({ actionId, waitForList: false }); - await (await fastActionListContainer()).waitForDisplayed(); - return await (await fastActionById(actionId)).getText(); + await (await fabSelectors.fastActionListContainer()).waitForDisplayed(); + return await (await fabSelectors.fastActionById(actionId)).getText(); }; const getFastActionFlatText = async () => { await waitForSnackbarToClose(); - await (await fastActionFlat()).waitForDisplayed(); - return await (await fastActionFlat()).getText(); + await (await fabSelectors.fastActionFlat()).waitForDisplayed(); + return await (await fabSelectors.fastActionFlat()).getText(); }; const closeFastActionList = async () => { - await (await fastActionListContainer()).waitForDisplayed(); - await (await fastActionListCloseButton()).waitForClickable(); - await (await fastActionListCloseButton()).click(); + await (await fabSelectors.fastActionListContainer()).waitForDisplayed(); + await (await fabSelectors.fastActionListCloseButton()).waitForClickable(); + await (await fabSelectors.fastActionListCloseButton()).click(); }; const isReportActionDisplayed = async () => { return await browser.waitUntil(async () => { - const exists = await (await reportsFastActionFAB()).isExisting(); + const exists = await (await fabSelectors.reportsFastActionFAB()).isExisting(); if (exists) { - await (await reportsFastActionFAB()).waitForDisplayed(); + await (await fabSelectors.reportsFastActionFAB()).waitForDisplayed(); } return exists; }); }; -const isMessagesListPresent = () => { - return isElementByIdPresent('message-list'); -}; - -const isTasksListPresent = () => { - return isElementByIdPresent('tasks-list'); -}; - -const isReportsListPresent = () => { - return isElementByIdPresent('reports-list'); -}; - -const isPeopleListPresent = () => { - return isElementByIdPresent('contacts-list'); +const isElementPresent = async (selector) => { + return await (await $(selector)).isExisting(); }; -const isTargetMenuItemPresent = async () => { - return await (await $(`=Target`)).isExisting(); -}; +const isMessagesListPresent = () => isElementPresent('#message-list'); -const isTargetAggregatesMenuItemPresent = async () => { - return await (await $(`=Target aggregates`)).isExisting(); -}; +const isTasksListPresent = () => isElementPresent('#tasks-list'); -const isElementByIdPresent = async (elementId) => { - return await (await $(`#${elementId}`)).isExisting(); -}; +const isReportsListPresent = () => isElementPresent('#reports-list'); -const getHeaderTitleOnMobile = async () => { - return { - name: await mobileTopBarTitle().getText(), - }; -}; +const isPeopleListPresent = () => isElementPresent('#contacts-list'); -const openHamburgerMenu = async () => { - if (!(await isHamburgerMenuOpen())) { - await (await hamburgerMenu()).waitForClickable(); - await (await hamburgerMenu()).click(); - // Adding pause here as we have to wait for sidebar nav menu animation to load - await browser.pause(ELEMENT_DISPLAY_PAUSE); - } - await (await sideBarMenuTitle()).waitForDisplayed(); -}; +const isTargetMenuItemPresent = () => isElementPresent('=Target'); -const closeHamburgerMenu = async () => { - if (await isHamburgerMenuOpen()) { - await (await closeSideBarMenu()).waitForClickable(); - await (await closeSideBarMenu()).click(); - } +const isTargetAggregatesMenuItemPresent = () => isElementPresent('=Target aggregates'); - await (await sideBarMenuTitle()).waitForDisplayed({ reverse: true }); -}; +const isMoreOptionsMenuPresent = async () => await (await kebabMenuSelectors.moreOptionsMenu()).isExisting(); const navigateToLogoutModal = async () => { await openHamburgerMenu(); - await (await logoutButton()).waitForClickable(); - await (await logoutButton()).click(); + await (await hamburgerMenuSelectors.logoutButton()).waitForClickable(); + await (await hamburgerMenuSelectors.logoutButton()).click(); await (await modalPage.body()).waitForDisplayed(); }; @@ -259,92 +310,54 @@ const goToBase = async () => { await waitForPageLoaded(); }; -const goToReports = async (reportId = '') => { - await goToUrl(`/#/reports/${reportId}`); - await waitForPageLoaded(); -}; - -const goToPeople = async (contactId = '', shouldLoad = true) => { - await goToUrl(`/#/contacts/${contactId}`); - if (shouldLoad) { - await waitForPageLoaded(); - } +const goToAboutPage = async () => { + await goToUrl(`/#/about`); + await waitForLoaders(); }; const goToMessages = async () => { await goToUrl(`/#/messages`); - await (await messagesTab()).waitForDisplayed(); + await (await tabsSelector.messagesTab()).waitForDisplayed(); }; const goToTasks = async () => { await goToUrl(`/#/tasks`); - await (await taskTab()).waitForDisplayed(); + await (await tabsSelector.taskTab()).waitForDisplayed(); await waitForPageLoaded(); }; -const goToAnalytics = async () => { - await goToUrl(`/#/analytics`); - await (await analyticsTab()).waitForDisplayed(); +const goToReports = async (reportId = '') => { + await goToUrl(`/#/reports/${reportId}`); await waitForPageLoaded(); }; -const goToAboutPage = async () => { - await goToUrl(`/#/about`); - await waitForLoaders(); -}; - -const waitForLoaderToDisappear = async (element) => { - const loaderSelector = '.loader'; - const loader = await (element ? element.$(loaderSelector) : $(loaderSelector)); - await loader.waitForDisplayed({ reverse: true }); -}; - -const hideSnackbar = () => { - // snackbar appears in the bottom of the page for 5 seconds when certain actions are made - // for example when filling a form, or creating a contact and intercepts all clicks in the FAB and Flat buttons - // this action is temporary, and will be undone with a refresh - return browser.execute(() => { - // eslint-disable-next-line no-undef - window.jQuery('.snackbar-content').hide(); - }); -}; - -const getVisibleLoaders = async () => { - const visible = []; - for (const loader of await loaders()) { - if (await loader.isDisplayedInViewport()) { - visible.push(loader); - } +const goToPeople = async (contactId = '', shouldLoad = true) => { + await goToUrl(`/#/contacts/${contactId}`); + if (shouldLoad) { + await waitForPageLoaded(); } - - return visible; -}; - -const waitForLoaders = async () => { - await browser.waitUntil(async () => { - const visibleLoaders = await getVisibleLoaders(); - return !visibleLoaders.length; - }, { timeoutMsg: 'Waiting for Loading spinners to hide timed out.' }); }; -const waitForAngularLoaded = async (timeout = 40000) => { - await (await hamburgerMenu()).waitForDisplayed({ timeout }); +const goToAnalytics = async () => { + await goToUrl(`/#/analytics`); + await (await tabsSelector.analyticsTab()).waitForDisplayed(); + await waitForPageLoaded(); }; -const waitForPageLoaded = async () => { - // if we immediately check for app loaders, we might bypass the initial page load (the bootstrap loader) - // so waiting for the main page to load. - await waitForAngularLoaded(); - // ideally we would somehow target all loaders that we expect (like LHS + RHS loaders), but not all pages - // get all loaders. - do { - await waitForLoaders(); - } while ((await getVisibleLoaders()).length > 0); +const closeReloadModal = async (shouldUpdate = false, timeout = 5000) => { + try { + shouldUpdate ? await modalPage.submit(timeout) : await modalPage.cancel(timeout); + shouldUpdate && await waitForAngularLoaded(); + return true; + } catch (err) { + timeout && console.error('Reload modal has not showed up'); + return false; + } }; const syncAndNotWaitForSuccess = async () => { await openHamburgerMenu(); - await (await syncButton()).click(); + await (await hamburgerMenuSelectors.syncButton()).click(); }; const syncAndWaitForSuccess = async (timeout = 20000, retry = 10) => { @@ -355,13 +368,13 @@ const syncAndWaitForSuccess = async (timeout = 20000, retry = 10) => { try { await openHamburgerMenu(); - if (!await (await syncInProgress()).isExisting()) { - await (await syncButton()).click(); + if (!await (await hamburgerMenuSelectors.syncInProgress()).isExisting()) { + await (await hamburgerMenuSelectors.syncButton()).click(); await openHamburgerMenu(); } - await (await syncInProgress()).waitForDisplayed({ timeout, reverse: true }); - await (await syncSuccess()).waitForDisplayed({ timeout }); + await (await hamburgerMenuSelectors.syncInProgress()).waitForDisplayed({ timeout, reverse: true }); + await (await hamburgerMenuSelectors.syncSuccess()).waitForDisplayed({ timeout }); } catch (err) { console.error(err); await syncAndWaitForSuccess(timeout, retry - 1); @@ -393,33 +406,13 @@ const sync = async (expectReload, timeout) => { await closeHamburgerMenu(); }; -const syncAndWaitForFailure = async () => { - await openHamburgerMenu(); - await (await syncButton()).click(); - await openHamburgerMenu(); - await (await syncRequired()).waitForDisplayed({ timeout: 20000 }); -}; - -const closeReloadModal = async (shouldUpdate = false, timeout = 5000) => { - try { - shouldUpdate ? await modalPage.submit(timeout) : await modalPage.cancel(timeout); - shouldUpdate && await waitForAngularLoaded(); - return true; - } catch (err) { - timeout && console.error('Reload modal has not showed up'); - return false; - } -}; - const openReportBugAndFetchProperties = async () => { - await (await feedbackMenuOption()).waitForClickable(); - await (await feedbackMenuOption()).click(); + await (await hamburgerMenuSelectors.feedbackMenuOption()).waitForClickable(); + await (await hamburgerMenuSelectors.feedbackMenuOption()).click(); return await modalPage.getModalDetails(); }; -const isReportBugOpen = async () => { - return await (await $(FEEDBACK)).isExisting(); -}; +const isReportBugOpen = () => isElementPresent('#feedback'); const closeReportBug = async () => { if (await isReportBugOpen()) { @@ -428,29 +421,29 @@ const closeReportBug = async () => { }; const openAboutMenu = async () => { - await (await $(ABOUT_MENU)).waitForClickable(); - await (await $(ABOUT_MENU)).click(); - await (await $(aboutPage.RELOAD_BUTTON)).waitForDisplayed(); + await (await hamburgerMenuSelectors.aboutButton()).waitForClickable(); + await (await hamburgerMenuSelectors.aboutButton()).click(); + await (await $('.about.page .mat-primary')).waitForDisplayed(); }; const openUserSettings = async () => { - await (await $(USER_SETTINGS)).waitForClickable(); - await (await $(USER_SETTINGS)).click(); + await (await hamburgerMenuSelectors.userSettingsButton()).waitForClickable(); + await (await hamburgerMenuSelectors.userSettingsButton()).click(); }; const openUserSettingsAndFetchProperties = async () => { - await (await $(USER_SETTINGS)).waitForClickable(); - await (await $(USER_SETTINGS)).click(); - await (await $(EDIT_PROFILE)).waitForDisplayed(); + await openUserSettings(); + await (await userSettingsSelectors.editProfileButton()).waitForDisplayed(); }; const openEditProfile = async () => { - await (await $(EDIT_PROFILE)).click(); + await (await userSettingsSelectors.editProfileButton()).waitForClickable(); + await (await userSettingsSelectors.editProfileButton()).click(); }; const openAppManagement = async () => { - await (await configurationAppMenuOption()).waitForClickable(); - await (await configurationAppMenuOption()).click(); + await (await hamburgerMenuSelectors.appManagementButton()).waitForClickable(); + await (await hamburgerMenuSelectors.appManagementButton()).click(); await (await $('.navbar-brand')).waitForDisplayed(); }; @@ -460,18 +453,16 @@ const getTextForElements = async (elements) => { }; const getAllButtonLabelsNames = async () => { - return await getTextForElements(getAllButtonLabels); + return await getTextForElements(tabsSelector.getAllButtonLabels); }; -//more options menu -const optionSelector = (action, item) => $(`[test-id="${action}-${item}"]`); - -const isMenuOptionEnabled = async (action, item) => { - return await (await optionSelector(action, item)).isEnabled(); +const isMenuOptionEnabled = async (action) => { + const parent = await (await kebabMenuSelectors[action]()).parentElement().parentElement(); + return await parent.getAttribute('aria-disabled') === 'false'; }; -const isMenuOptionVisible = async (action, item) => { - return await (await optionSelector(action, item)).isDisplayed(); +const isMenuOptionVisible = async (action) => { + return await (await kebabMenuSelectors[action]()).isDisplayed(); }; const loadNextInfiniteScrollPage = async () => { @@ -482,7 +473,7 @@ const loadNextInfiniteScrollPage = async () => { }; const getErrorLog = async () => { - await errorLog().waitForDisplayed(); + await (await $('error-log')).waitForDisplayed(); const errorMessage = await (await $('.error-details span')).getText(); const userDetails = await (await $$('.error-details dl dd')); @@ -494,9 +485,9 @@ const getErrorLog = async () => { }; const createFormDoc = (path, formId) => { - const id = formId ? formId : path.split('/').pop(); + const id = formId || path.split('/').pop(); const formXML = fs.readFileSync(`${path}.xml`, 'utf8'); - const formDoc = { + return { _id: `form:${id}`, internalId: id, title: id, @@ -512,74 +503,66 @@ const createFormDoc = (path, formId) => { }, }, }; - return formDoc; }; module.exports = { + tabsSelector, + fabSelectors, + getJsonErrorText, + openHamburgerMenu, + closeHamburgerMenu, openMoreOptionsMenu, - closeFastActionList, + accessEditOption, + accessDeleteOption, + accessExportOption, + accessReviewOption, + hideSnackbar, + waitForLoaderToDisappear, + waitForLoaders, + waitForAngularLoaded, + waitForPageLoaded, clickFastActionFAB, + getFastActionItemsLabels, clickFastActionFlat, openFastActionReport, getFastActionFABTextById, getFastActionFlatText, - logout, - logoutButton, - getLogoutMessage, - messagesTab, - analyticsTab, - goToReports, - goToPeople, - getReportsButtonLabel, - getMessagesButtonLabel, - getTasksButtonLabel, - goToBase, - hideSnackbar, - waitForLoaders, - sync, - syncAndNotWaitForSuccess, - syncButton, - closeReloadModal, - goToMessages, - goToTasks, - goToAnalytics, + closeFastActionList, + isReportActionDisplayed, + isElementPresent, isMessagesListPresent, isTasksListPresent, isPeopleListPresent, isReportsListPresent, isTargetMenuItemPresent, isTargetAggregatesMenuItemPresent, - openHamburgerMenu, - closeHamburgerMenu, + isMoreOptionsMenuPresent, + logout, + getLogoutMessage, + goToUrl, + refresh, + goToBase, + goToAboutPage, + goToMessages, + goToTasks, + goToReports, + goToPeople, + goToAnalytics, + closeReloadModal, + syncAndNotWaitForSuccess, + sync, + openReportBugAndFetchProperties, + closeReportBug, openAboutMenu, - openUserSettingsAndFetchProperties, openUserSettings, + openUserSettingsAndFetchProperties, openEditProfile, - openReportBugAndFetchProperties, openAppManagement, - waitForLoaderToDisappear, - goToAboutPage, - waitForPageLoaded, - activeSnackbar, - inactiveSnackbar, - snackbarMessage, - snackbarAction, getTextForElements, - jsonError, + getAllButtonLabelsNames, isMenuOptionEnabled, isMenuOptionVisible, - moreOptionsMenu, - refresh, - syncAndWaitForFailure, - waitForAngularLoaded, - closeReportBug, - getAllButtonLabelsNames, loadNextInfiniteScrollPage, - goToUrl, - getFastActionItemsLabels, getErrorLog, - reportsFastActionFAB, - isReportActionDisplayed, - getHeaderTitleOnMobile, createFormDoc, }; diff --git a/tests/page-objects/default/contacts/contacts.wdio.page.js b/tests/page-objects/default/contacts/contacts.wdio.page.js index 1cd4d8533c8..c55384273ea 100644 --- a/tests/page-objects/default/contacts/contacts.wdio.page.js +++ b/tests/page-objects/default/contacts/contacts.wdio.page.js @@ -11,12 +11,6 @@ const searchSelectors = { searchBox: () => $('.mm-search-bar-container input#freetext'), }; -const menuSelectors = { - exportButton: () => $(`.mat-mdc-menu-content .mat-mdc-menu-item[test-id="export-contacts"]`), - editContactButton: () => $(`.mat-mdc-menu-content .mat-mdc-menu-item[test-id="edit-contacts"]`), - deleteContactButton: () => $(`.mat-mdc-menu-content .mat-mdc-menu-item[test-id="delete-contacts"]`), -}; - const CONTACT_LIST_SELECTOR = '#contacts-list'; const CONTENT_ROW_SELECTOR = `${CONTACT_LIST_SELECTOR} .content-row`; const leftPanelSelectors = { @@ -206,16 +200,9 @@ const addPerson = async ({ return (await contactCardSelectors.contactCardName()).getText(); }; -const openEditContactForm = async () => { - await waitForContactLoaded(); - await commonPage.openMoreOptionsMenu(); - await (await menuSelectors.editContactButton()).waitForClickable(); - await (await menuSelectors.editContactButton()).click(); -}; - const editPerson = async (currentName, { name, phone, dob }) => { await selectLHSRowByText(currentName); - await openEditContactForm(); + await commonPage.accessEditOption(); await (await genericForm.nextPage()); if (name !== undefined) { @@ -237,9 +224,7 @@ const editPersonName = async (name, updatedName) => { }; const deletePerson = async () => { - await commonPage.openMoreOptionsMenu(); - await (await menuSelectors.deleteContactButton()).waitForClickable(); - await (await menuSelectors.deleteContactButton()).click(); + await commonPage.accessDeleteOption(); await modalPage.submit(); }; @@ -284,7 +269,7 @@ const allContactsList = async () => { const editPlace = async (currentName, editedName) => { await selectLHSRowByText(currentName, true); - await openEditContactForm(); + await commonPage.accessEditOption(); await commonEnketoPage.setInputValue('Name of this', editedName); await genericForm.submitForm(); @@ -339,12 +324,6 @@ const getDeathCardInfo = async () => { }; }; -const exportContacts = async () => { - await commonPage.openMoreOptionsMenu(); - await (await menuSelectors.exportButton()).waitForClickable(); - await (await menuSelectors.exportButton()).click(); -}; - const getCurrentContactId = async () => { const currentUrl = await browser.getUrl(); const contactBaseUrl = utils.getBaseUrl() + 'contacts/'; @@ -407,11 +386,9 @@ module.exports = { getAllRHSPeopleNames, waitForContactLoaded, waitForContactUnloaded, - openEditContactForm, editPerson, editPersonName, editPlace, - exportContacts, getContactSummaryField, getAllRHSReportsNames, getAllRHSTaskNames, diff --git a/tests/page-objects/default/enketo/custom-doc.wdio.page.js b/tests/page-objects/default/enketo/custom-doc.wdio.page.js index 2a72ee5577c..b6471e9f070 100644 --- a/tests/page-objects/default/enketo/custom-doc.wdio.page.js +++ b/tests/page-objects/default/enketo/custom-doc.wdio.page.js @@ -46,7 +46,6 @@ const reportModelXml = ` `; - const editReportWithAttachmentDoc ={ _id: uuid(), form: 'one-text-form', @@ -71,8 +70,6 @@ const editReportWithAttachmentDoc ={ } }; - - module.exports = { extensionLibDoc, editReportWithAttachmentDoc, diff --git a/tests/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 67a5120cf3b..9aa5a65d8f1 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -220,4 +220,5 @@ module.exports = { updatePasswordButton, getPasswordResetTranslations, getPasswordResetErrorMessage, + NEW_PASSWORD, }; diff --git a/tests/page-objects/default/old-navigation/old-navigation.wdio.page.js b/tests/page-objects/default/old-navigation/old-navigation.wdio.page.js index f7ac9e15eca..6df5e55da5b 100644 --- a/tests/page-objects/default/old-navigation/old-navigation.wdio.page.js +++ b/tests/page-objects/default/old-navigation/old-navigation.wdio.page.js @@ -137,6 +137,13 @@ const logout = async () => { await browser.pause(100); // wait for login page js to execute }; +const mobileTopBarTitle = () => $('mm-navigation .ellipsis-title'); +const getHeaderTitleOnMobile = async () => { + return { + name: await mobileTopBarTitle().getText(), + }; +}; + module.exports = { goToBase, goToMessages, @@ -147,4 +154,5 @@ module.exports = { sync, waitForPageLoaded, logout, + getHeaderTitleOnMobile, }; diff --git a/tests/page-objects/default/privacy-policy/privacy-policy.wdio.page.js b/tests/page-objects/default/privacy-policy/privacy-policy.wdio.page.js index ddb3b815315..b089763bcbc 100644 --- a/tests/page-objects/default/privacy-policy/privacy-policy.wdio.page.js +++ b/tests/page-objects/default/privacy-policy/privacy-policy.wdio.page.js @@ -37,7 +37,7 @@ const waitAndAcceptPolicy = async (elm, { header, paragraph, language }, sync = timeout: 15 * 1000, timeoutMsg: `Timed out waiting for messages tag to be displayed` }; - await browser.waitUntil(async () => (await commonElements.messagesTab()).isDisplayed(), timeoutOpts); + await browser.waitUntil(async () => (await commonElements.tabsSelector.messagesTab()).isDisplayed(), timeoutOpts); if (sync) { await commonElements.sync(); } diff --git a/tests/page-objects/default/reports/reports.wdio.page.js b/tests/page-objects/default/reports/reports.wdio.page.js index 82577aa1dd9..5ff38721d29 100644 --- a/tests/page-objects/default/reports/reports.wdio.page.js +++ b/tests/page-objects/default/reports/reports.wdio.page.js @@ -52,13 +52,6 @@ const rightPanelSelectors = { sentTask: () => $(`${REPORT_BODY_DETAILS} ul .task-list .task-state .state`), }; -const kebabMenuSelectors = { - edit: () => $('.mat-mdc-menu-content .mat-mdc-menu-item[test-id="edit-reports"]'), - delete: () => $('.mat-mdc-menu-content .mat-mdc-menu-item[test-id="delete-reports"]'), - export: () => $('.mat-mdc-menu-content .mat-mdc-menu-item[test-id="export-reports"]'), - review: () => $('.mat-mdc-menu-content .mat-mdc-menu-item[test-id="review-report"]'), -}; - const REVIEW_REPORT_CONTAINER = '.verify-report-options-wrapper'; const reviewDialogSelectors = { container: () => $(REVIEW_REPORT_CONTAINER), @@ -403,29 +396,8 @@ const fieldByIndex = async (index) => { return await (await $(`${REPORT_BODY_DETAILS} li:nth-child(${index}) p`)).getText(); }; -const performMenuAction = async (actionSelector) => { - await commonElements.openMoreOptionsMenu(); - const actionElement = await actionSelector(); - await actionElement.waitForClickable(); - await actionElement.click(); -}; - -const editReport = async () => { - await performMenuAction(kebabMenuSelectors.edit); -}; - -const exportReports = async () => { - await performMenuAction(kebabMenuSelectors.export); -}; - -const deleteReport = async () => { - await performMenuAction(kebabMenuSelectors.delete); -}; - const openReview = async () => { - await commonElements.openMoreOptionsMenu(); - await (await kebabMenuSelectors.review()).waitForClickable(); - await (await kebabMenuSelectors.review()).click(); + await commonElements.accessReviewOption(); await (await reviewDialogSelectors.container()).waitForDisplayed(); }; @@ -524,9 +496,6 @@ module.exports = { getOpenReportInfo, getListReportInfo, openReport, - editReport, - deleteReport, - exportReports, fieldByIndex, clickOnCaseId, getReportListLoadingStatus, diff --git a/tests/page-objects/default/users/user-settings.wdio.page.js b/tests/page-objects/default/users/user-settings.wdio.page.js index 031db284a3b..4ecb2e3d5e8 100644 --- a/tests/page-objects/default/users/user-settings.wdio.page.js +++ b/tests/page-objects/default/users/user-settings.wdio.page.js @@ -1,5 +1,5 @@ -const commonPage = require('../common/common.wdio.page'); -const modalPage = require('../common/modal.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const modalPage = require('@page-objects/default/common/modal.wdio.page'); const languageDropDown = () => $('#language'); diff --git a/tests/page-objects/default/users/user.wdio.page.js b/tests/page-objects/default/users/user.wdio.page.js index 54b0566ab3b..8da7f95267d 100644 --- a/tests/page-objects/default/users/user.wdio.page.js +++ b/tests/page-objects/default/users/user.wdio.page.js @@ -1,5 +1,5 @@ const _ = require('lodash'); -const commonElements = require('../common/common.wdio.page'); +const commonElements = require('@page-objects/default/common/common.wdio.page'); const addUserButton = () => $('a#add-user'); const cancelButton = () => $('a[test-id="modal-cancel-btn"]'); @@ -223,7 +223,6 @@ const closeAddUserDialog = async () => { await (await addUserDialog()).waitForDisplayed({ reverse: true }); }; - module.exports = { addUserDialog, goToAdminUser, From 40bee0aa81d546c5898f3cddbf3a0ebf5020251a Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 18 Nov 2024 10:04:25 +0300 Subject: [PATCH 46/48] chore: update breaking e2e --- tests/e2e/default/contacts/edit.wdio-spec.js | 5 +++-- tests/e2e/default/login/login-logout.wdio-spec.js | 12 ------------ tests/e2e/default/reports/breadcrumbs.wdio-spec.js | 2 +- .../e2e/default/reports/sidebar-filter.wdio-spec.js | 7 ++++--- .../e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js | 5 +++-- 5 files changed, 11 insertions(+), 20 deletions(-) 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/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index 41d48c40da6..c02977c9a51 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -72,21 +72,13 @@ describe('Login page functionality tests', () => { }); it('should log in using username and password fields', async () => { -<<<<<<< HEAD await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); -======= - await loginPage.login(auth); ->>>>>>> 58dcfb6d746fee5c5640e35ccf16e95b53f3057f await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); }); it('should set correct cookies', async () => { -<<<<<<< HEAD await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); -======= - await loginPage.login(auth); ->>>>>>> 58dcfb6d746fee5c5640e35ccf16e95b53f3057f await (await commonPage.tabsSelector.analyticsTab()).waitForDisplayed(); const cookies = await browser.getCookies(); @@ -125,11 +117,7 @@ describe('Login page functionality tests', () => { it('should display the "session expired" modal and redirect to login page', async () => { // Login and ensure it's redirected to webapp -<<<<<<< HEAD await loginPage.login({ username: auth.username, password: auth.password, resetPassword: false }); -======= - await loginPage.login(auth); ->>>>>>> 58dcfb6d746fee5c5640e35ccf16e95b53f3057f await (await commonPage.tabsSelector.messagesTab()).waitForDisplayed(); // Delete cookies and trigger a request to the server await browser.deleteCookies('AuthSession'); diff --git a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js index 2ce7e5ccf8a..4280f60e9f4 100644 --- a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js +++ b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js @@ -115,7 +115,7 @@ 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..939564ccc10 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/tasks/tasks-breadcrumbs.wdio-spec.js b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js index 7e4c883a9ad..698ce865601 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,7 @@ 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 +162,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'); From 87170ff3c120dbd91f9ddaf868e699b458bffe11 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 18 Nov 2024 10:09:32 +0300 Subject: [PATCH 47/48] chore: sonar --- tests/e2e/default/reports/breadcrumbs.wdio-spec.js | 6 +++++- tests/e2e/default/reports/sidebar-filter.wdio-spec.js | 6 +++--- tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/e2e/default/reports/breadcrumbs.wdio-spec.js b/tests/e2e/default/reports/breadcrumbs.wdio-spec.js index 4280f60e9f4..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, resetPassword: false }); + 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 939564ccc10..d3d120b80e4 100644 --- a/tests/e2e/default/reports/sidebar-filter.wdio-spec.js +++ b/tests/e2e/default/reports/sidebar-filter.wdio-spec.js @@ -114,7 +114,7 @@ describe('Reports Sidebar Filter', () => { }); it('should filter by form', async () => { - await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword:false }); + await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); @@ -132,7 +132,7 @@ describe('Reports Sidebar Filter', () => { }); it('should filter by place', async () => { - await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword:false }); + await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); @@ -150,7 +150,7 @@ describe('Reports Sidebar Filter', () => { }); it('should filter by status', async () => { - await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword:false }); + await loginPage.login({ username: districtHospitalUser.username, password: newPassword, resetPassword: false }); await commonPage.waitForPageLoaded(); await commonPage.goToReports(); diff --git a/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js index 698ce865601..cf88c99d659 100644 --- a/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js +++ b/tests/e2e/default/tasks/tasks-breadcrumbs.wdio-spec.js @@ -100,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, resetPassword: false }); + await loginPage.login({ + password: userWithManyPlacesPass, + username: userWithManyPlaces.name, + resetPassword: false + }); await commonPage.waitForPageLoaded(); await commonPage.goToTasks(); const infos = await tasksPage.getTasksListInfos(await tasksPage.getTasks()); From c23d3e2bb08dca5721405c842c9d71627625ef7f Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Mon, 18 Nov 2024 17:22:34 +0300 Subject: [PATCH 48/48] chore: update e2e tests --- tests/constants.js | 3 ++ .../db/initial-replication.wdio-spec.js | 8 ++++- tests/e2e/default/enketo/repeat.wdio-spec.js | 29 ++++++++++++++++--- .../default/login/login-logout.wdio-spec.js | 8 +---- .../service-worker.wdio-spec.js | 13 +++++++-- .../sms/messages-sender-data.wdio-spec.js | 10 +++++-- .../targets/target-aggregates.wdio-spec.js | 18 ++++++++---- tests/e2e/default/tasks/tasks.wdio-spec.js | 15 ++++++---- .../default/telemetry/telemetry.wdio-spec.js | 6 ++-- .../default/users/create-meta-db.wdio-spec.js | 4 ++- .../default/login/login.wdio.page.js | 12 +------- 11 files changed, 84 insertions(+), 42 deletions(-) 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/db/initial-replication.wdio-spec.js b/tests/e2e/default/db/initial-replication.wdio-spec.js index 6ab6a977815..0acd35ff6e1 100644 --- a/tests/e2e/default/db/initial-replication.wdio-spec.js +++ b/tests/e2e/default/db/initial-replication.wdio-spec.js @@ -150,7 +150,13 @@ describe('initial-replication', () => { }); it('should support "disconnects"', async () => { - await loginPage.login({ ...userAllowedDocs.user, loadPage: false, resetPassword: 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..164b03fea72 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); @@ -64,7 +65,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-count'); expect(await commonEnketoPage.isElementDisplayed('span', 'Select a state:')).to.be.true; @@ -91,7 +97,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 +120,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 +147,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; diff --git a/tests/e2e/default/login/login-logout.wdio-spec.js b/tests/e2e/default/login/login-logout.wdio-spec.js index c02977c9a51..3f80e96cd0c 100644 --- a/tests/e2e/default/login/login-logout.wdio-spec.js +++ b/tests/e2e/default/login/login-logout.wdio-spec.js @@ -199,7 +199,6 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await loginPage.getPasswordResetTranslations(); await loginPage.setPasswordValue(''); await loginPage.setConfirmPasswordValue(''); await (await loginPage.updatePasswordButton()).click(); @@ -211,7 +210,6 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await loginPage.getPasswordResetTranslations(); await loginPage.setPasswordValue(user.password); await loginPage.setConfirmPasswordValue(''); await (await loginPage.updatePasswordButton()).click(); @@ -223,19 +221,17 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await loginPage.getPasswordResetTranslations(); 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 correct', async () => { + 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.getPasswordResetTranslations(); await loginPage.setCurrentPasswordValue('12'); await loginPage.setPasswordValue(user.password); await loginPage.setConfirmPasswordValue(user.password); @@ -250,7 +246,6 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await loginPage.getPasswordResetTranslations(); await loginPage.setCurrentPasswordValue(user.password); await loginPage.setPasswordValue(user.password); await loginPage.setConfirmPasswordValue(user.password); @@ -263,7 +258,6 @@ describe('Login page functionality tests', () => { await loginPage.setPasswordValue(user.password); await loginPage.setUsernameValue(user.username); await (await loginPage.loginButton()).click(); - await loginPage.getPasswordResetTranslations(); await loginPage.passwordReset(user.password, NEW_PASSWORD, NEW_PASSWORD); await (await loginPage.updatePasswordButton()).click(); await commonPage.waitForPageLoaded(); 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 3fe432351a0..953234bf908 100644 --- a/tests/e2e/default/service-worker/service-worker.wdio-spec.js +++ b/tests/e2e/default/service-worker/service-worker.wdio-spec.js @@ -65,10 +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 browser.throttle('online'); - await loginPage.login(chw); + const loginParams = passwordChangeRequired + ? chw + : { ...chw, password: newPassword, resetPassword: false }; + + passwordChangeRequired = false; + await loginPage.login(loginParams); await commonPage.waitForPageLoaded(); }; @@ -100,7 +107,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([ '/', @@ -123,15 +129,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 159f1aea028..6a63eeff40b 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.wdio-spec.js b/tests/e2e/default/tasks/tasks.wdio-spec.js index f0e770a1de0..6888f3036a2 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,11 +59,6 @@ describe('Tasks', () => { await chtConfUtils.compileAndUploadAppForms(formsPath); }); - beforeEach(async () => { - await loginPage.login(chw); - await commonPage.waitForPageLoaded(); - }); - afterEach(async () => { await commonPage.logout(); await utils.revertSettings(true); @@ -77,6 +72,8 @@ describe('Tasks', () => { }); 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(); @@ -93,6 +90,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(); @@ -111,6 +110,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(); @@ -140,6 +141,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/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/page-objects/default/login/login.wdio.page.js b/tests/page-objects/default/login/login.wdio.page.js index 9aa5a65d8f1..28569e2e5ab 100644 --- a/tests/page-objects/default/login/login.wdio.page.js +++ b/tests/page-objects/default/login/login.wdio.page.js @@ -15,8 +15,6 @@ const labelForPassword = () => $('label[for="password"]'); const errorMessageField = () => $('p.error.incorrect'); const localeByName = (locale) => $(`.locale[name="${locale}"]`); const tokenLoginError = (reason) => $(`.error.${reason}`); -const passwordResetTitle = () => $('p.title'); -const passwordResetHint = () => $('p.help-text'); const passwordResetMessageField = (errorMsg) => $(`p.error.${errorMsg}`); const getErrorMessage = async () => { @@ -29,7 +27,7 @@ const getPasswordResetErrorMessage = async (errorMsg) => { return await (await passwordResetMessageField(errorMsg)).getText(); }; -const NEW_PASSWORD = 'Pa33word1'; +const NEW_PASSWORD = constants.NEW_PASSWORD; const login = async ({ username, @@ -192,13 +190,6 @@ const passwordReset = async (currentPassword, password, confirmPassword) => { await (await updatePasswordButton()).click(); }; -const getPasswordResetTranslations = async () => { - return { - passwordResetTitle: await (await passwordResetTitle()).getText(), - passwordResetHint: await (await passwordResetHint()).getText(), - }; -}; - module.exports = { login, cookieLogin, @@ -218,7 +209,6 @@ module.exports = { setCurrentPasswordValue, passwordReset, updatePasswordButton, - getPasswordResetTranslations, getPasswordResetErrorMessage, NEW_PASSWORD, };