Skip to content

Commit

Permalink
Warn offline users before replicating too many docs (#5793)
Browse files Browse the repository at this point in the history
Adds /api/v1/users-info endpoint which

returns the number of docs a user will replicate and a warn flag if this number exceeds the recommended limit.
when queried as an offline user, returns the requester doc count.
when queried as an online user, requires facility_id and role params, plus an optional contact_id param (either GET or POST).
is queried as a new step in webapp bootstrapper. When resulting warn flag is truthy, the user has to confirm to actually continue bootstrapping and start replication. Cancelling redirects to login.
is queried as a new step in admin user creation/updating. When resulting warn flag is truthy, a warning message is displayed. The admin would need to click on "Submit" the 2nd time to actually create or update the user document.
Once an hour, API will log warnings of users that have replicated more than 10 000 docs.
Updates webapp bootstrapper to display correct doc count progression, even when interrupted.

#5362
  • Loading branch information
dianabarsan authored and ngaruko committed Sep 3, 2019
1 parent af98a8e commit e9809c6
Show file tree
Hide file tree
Showing 22 changed files with 1,189 additions and 205 deletions.
101 changes: 70 additions & 31 deletions admin/src/js/controllers/edit-user.js
Expand Up @@ -7,6 +7,7 @@ var passwordTester = require('simple-password-tester'),
angular
.module('controllers')
.controller('EditUserCtrl', function(
$http,
$log,
$q,
$rootScope,
Expand Down Expand Up @@ -279,6 +280,38 @@ angular
});
};

let previousQuery;
const validateReplicationLimit = () => {
const role = $scope.roles && $scope.roles[$scope.editUserModel.role];
if (!role || !role.offline) {
return $q.resolve();
}

const query = {
role: $scope.editUserModel.role,
facility_id: $scope.editUserModel.place,
contact_id: $scope.editUserModel.contact
};

if (previousQuery && JSON.stringify(query) === previousQuery) {
return $q.resolve();
}

previousQuery = JSON.stringify(query);
return $http
.get('/api/v1/users-info', { params: query })
.then(resp => {
if (resp.data.warn) {
return $q.reject({
key: 'configuration.user.replication.limit.exceeded',
params: { total_docs: resp.data.total_docs, limit: resp.data.limit }
});
}

previousQuery = null;
});
};

var computeFields = function() {
$scope.editUserModel.place = $(
'#edit-user-profile [name=facilitySelect]'
Expand Down Expand Up @@ -329,40 +362,46 @@ angular
$scope.setError();
return;
}
changedUpdates($scope.editUserModel).then(function(updates) {
$q.resolve()
.then(function() {
if (!haveUpdates(updates)) {
return;
} else if ($scope.editUserModel.id) {
return UpdateUser($scope.editUserModel.username, updates);
} else {
return CreateUser(updates);
}
})
.then(function() {
$scope.setFinished();
// TODO: change this from a broadcast to a changes watcher
// https://github.com/medic/medic/issues/4094
$rootScope.$broadcast(
'UsersUpdated',
$scope.editUserModel.id
);
$uibModalInstance.close();
})
.catch(function(err) {
if (err && err.data && err.data.error && err.data.error.translationKey) {
$translate(err.data.error.translationKey, err.data.error.translationParams).then(function(value) {
$scope.setError(err, value);
});
} else {
$scope.setError(err, 'Error updating user');
}
});
return validateReplicationLimit().then(() => {
changedUpdates($scope.editUserModel).then(function(updates) {
$q.resolve()
.then(function() {
if (!haveUpdates(updates)) {
return;
} else if ($scope.editUserModel.id) {
return UpdateUser($scope.editUserModel.username, updates);
} else {
return CreateUser(updates);
}
})
.then(function() {
$scope.setFinished();
// TODO: change this from a broadcast to a changes watcher
// https://github.com/medic/medic/issues/4094
$rootScope.$broadcast(
'UsersUpdated',
$scope.editUserModel.id
);
$uibModalInstance.close();
})
.catch(function(err) {
if (err && err.data && err.data.error && err.data.error.translationKey) {
$translate(err.data.error.translationKey, err.data.error.translationParams).then(function(value) {
$scope.setError(err, value);
});
} else {
$scope.setError(err, 'Error updating user');
}
});
});
});
})
.catch(function(err) {
$scope.setError(err, 'Error validating user');
if (err.key) {
$translate(err.key, err.params).then(value => $scope.setError(err, value));
} else {
$scope.setError(err, 'Error validating user');
}
});
} else {
$scope.setError();
Expand Down
200 changes: 157 additions & 43 deletions admin/tests/unit/controllers/edit-user.js
@@ -1,45 +1,39 @@
describe('EditUserCtrl controller', () => {
'use strict';

let jQuery,
mockCreateNewUser,
mockEditAUser,
mockEditCurrentUser,
scope,
translationsDbQuery,
dbGet,
UpdateUser,
CreateUser,
UserSettings,
translate,
Translate,
Settings,
userToEdit;
let jQuery;
let mockCreateNewUser;
let mockEditAUser;
let mockEditCurrentUser;
let scope;
let translationsDbQuery;
let dbGet;
let UpdateUser;
let CreateUser;
let UserSettings;
let translate;
let Translate;
let Settings;
let userToEdit;
let http;

beforeEach(() => {
module('adminApp');

dbGet = sinon.stub();
translationsDbQuery = sinon.stub();
translationsDbQuery.returns(
Promise.resolve({
rows: [{ value: { code: 'en' } }, { value: { code: 'fr' } }],
})
);
UpdateUser = sinon.stub();
UpdateUser.returns(Promise.resolve());
CreateUser = sinon.stub();
CreateUser.returns(Promise.resolve());
translationsDbQuery.resolves({ rows: [{ value: { code: 'en' } }, { value: { code: 'fr' } }]});
UpdateUser = sinon.stub().resolves();
CreateUser = sinon.stub().resolves();
UserSettings = sinon.stub();
Settings = sinon.stub().returns(
Promise.resolve({
roles: {
'district-manager': { name: 'xyz', offline: true },
'data-entry': { name: 'abc' },
supervisor: { name: 'qrt', offline: true },
},
})
);
Settings = sinon.stub().resolves({
roles: {
'district-manager': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' },
supervisor: { name: 'qrt', offline: true },
'national-manager': { name: 'national-manager', offline: false }
}
});
http = { get: sinon.stub() };
userToEdit = {
_id: 'user.id',
name: 'user.name',
Expand All @@ -64,9 +58,7 @@ describe('EditUserCtrl controller', () => {
close: () => {},
};
});
$provide.factory('processingFunction', () => {
return null;
});
$provide.factory('processingFunction', () => {});
$provide.factory(
'DB',
KarmaUtils.mockDB({
Expand All @@ -80,6 +72,7 @@ describe('EditUserCtrl controller', () => {
$provide.value('translate', translate);
$provide.value('Translate', Translate);
$provide.value('Settings', Settings);
$provide.value('$http', http);
});

inject((translate, $rootScope, $controller) => {
Expand Down Expand Up @@ -112,7 +105,7 @@ describe('EditUserCtrl controller', () => {
});
};
mockEditCurrentUser = user => {
UserSettings.returns(Promise.resolve(user));
UserSettings.resolves(user);
createController();
};

Expand Down Expand Up @@ -330,7 +323,7 @@ describe('EditUserCtrl controller', () => {
mockContact(null);
Translate.fieldIsRequired.withArgs('associated.contact').returns(Promise.resolve('An associated contact is required'));
Translate.fieldIsRequired.withArgs('Facility').returns(Promise.resolve('Facility field is required'));

// when
scope.editUser();

Expand Down Expand Up @@ -389,6 +382,7 @@ describe('EditUserCtrl controller', () => {
mockContact(userToEdit.contact_id);
mockFacility(userToEdit.facility_id);
mockContactGet(userToEdit.contact_id);
http.get.withArgs('/api/v1/users-info').resolves({ data: { total_docs: 1000, warn: false, limit: 10000 }});

setTimeout(() => {
scope.editUserModel.fullname = 'fullname';
Expand All @@ -415,13 +409,14 @@ describe('EditUserCtrl controller', () => {
chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
chai.expect(updates.place).to.equal(scope.editUserModel.facility_id);
chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id);
chai
.expect(updates.language)
.to.equal(scope.editUserModel.language.code);
chai.expect(updates.language).to.equal(scope.editUserModel.language.code);
chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role);
chai
.expect(updates.password)
.to.deep.equal(scope.editUserModel.password);
chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);
chai.expect(http.get.callCount).to.equal(1);
chai.expect(http.get.args[0]).to.deep.equal([
'/api/v1/users-info',
{ params: { role: 'supervisor', facility_id: scope.editUserModel.place, contact_id: scope.editUserModel.contact }}
]);
done();
});
});
Expand All @@ -446,5 +441,124 @@ describe('EditUserCtrl controller', () => {
});
});
});

it('should not query users-info when user role is not offline', done => {
mockEditAUser(userToEdit);
mockContact(userToEdit.contact_id);
mockFacility(userToEdit.facility_id);
mockContactGet(userToEdit.contact_id);

setTimeout(() => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
scope.editUserModel.facilitySelect = 'facility_id';
scope.editUserModel.contactSelect = 'contact_id';
scope.editUserModel.language.code = 'language-code';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
scope.editUserModel.role = 'national-manager';

scope.editUser();

setTimeout(() => {
chai.expect(UpdateUser.called).to.equal(true);
chai.expect(http.get.callCount).to.equal(0);
chai.expect(UpdateUser.args[0]).to.deep.equal([
'user.name',
{
fullname: 'fullname',
email: 'email@email.com',
phone: 'phone',
roles: ['national-manager'],
language: 'language-code',
password: 'medic.1234'
}
]);
done();
});
});
});

it('should not save user if offline and is warned by users-info', done => {
mockEditAUser(userToEdit);
mockContact('new_contact_id');
mockFacility('new_facility_id');
mockContactGet(userToEdit.contact_id);
http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: true, total_docs: 10200, limit: 10000 } });

setTimeout(() => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
scope.editUserModel.facilitySelect = 'new_facility';
scope.editUserModel.contactSelect = 'new_contact';
scope.editUserModel.language.code = 'language-code';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
scope.editUserModel.role = 'supervisor';

scope.editUser();

setTimeout(() => {
chai.expect(UpdateUser.callCount).to.equal(0);
chai.expect(http.get.callCount).to.equal(1);
chai.expect(http.get.args[0]).to.deep.equal([
'/api/v1/users-info',
{ params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
]);
done();
});
});
});

it('should save user if offline and warned when user clicks on submit the 2nd time', done => {
mockEditAUser(userToEdit);
mockContact('new_contact_id');
mockFacility('new_facility_id');
mockContactGet(userToEdit.contact_id);
http.get.withArgs('/api/v1/users-info').resolves({ data: { warn: true, total_docs: 10200, limit: 10000 } });

setTimeout(() => {
scope.editUserModel.fullname = 'fullname';
scope.editUserModel.email = 'email@email.com';
scope.editUserModel.phone = 'phone';
scope.editUserModel.facilitySelect = 'new_facility';
scope.editUserModel.contactSelect = 'new_contact';
scope.editUserModel.language.code = 'language-code';
scope.editUserModel.password = 'medic.1234';
scope.editUserModel.passwordConfirm = 'medic.1234';
scope.editUserModel.role = 'supervisor';

scope.editUser();

setTimeout(() => {
chai.expect(UpdateUser.callCount).to.equal(0);
chai.expect(http.get.callCount).to.equal(1);
chai.expect(http.get.args[0]).to.deep.equal([
'/api/v1/users-info',
{ params: { role: 'supervisor', facility_id: 'new_facility_id', contact_id: 'new_contact_id' }}
]);

scope.editUser();
setTimeout(() => {
chai.expect(UpdateUser.callCount).to.equal(1);
chai.expect(http.get.callCount).to.equal(1);

const updateUserArgs = UpdateUser.args[0];
chai.expect(updateUserArgs[0]).to.equal('user.name');
const updates = updateUserArgs[1];
chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname);
chai.expect(updates.email).to.equal(scope.editUserModel.email);
chai.expect(updates.phone).to.equal(scope.editUserModel.phone);
chai.expect(updates.language).to.equal(scope.editUserModel.language.code);
chai.expect(updates.roles[0]).to.equal(scope.editUserModel.role);
chai.expect(updates.password).to.deep.equal(scope.editUserModel.password);

done();
});
});
});
});
});
});

0 comments on commit e9809c6

Please sign in to comment.