Skip to content

Commit 4992b8b

Browse files
marvzhaievanugarte
andauthored
Audit on user password + profile change (#1800)
* add audit on user password/profile change * Update api/main_endpoints/routes/User.js Co-authored-by: Evan Ugarte <[email protected]> * delete unused endpoint * fix User tests + lint --------- Co-authored-by: Evan Ugarte <[email protected]>
1 parent 1250d0f commit 4992b8b

File tree

2 files changed

+226
-72
lines changed

2 files changed

+226
-72
lines changed

api/main_endpoints/routes/User.js

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ const {
2424
SERVER_ERROR,
2525
} = require('../../util/constants').STATUS_CODES;
2626
const membershipState = require('../../util/constants').MEMBERSHIP_STATE;
27+
const AuditLog = require('../models/AuditLog.js');
28+
const AuditLogActions = require('../util/auditLogActions.js');
2729

2830
const logger = require('../../util/logger');
2931

@@ -32,9 +34,6 @@ const crypto = require('crypto');
3234

3335
const ROWS_PER_PAGE = 20;
3436

35-
const AuditLogActions = require('../util/auditLogActions.js');
36-
const AuditLog = require('../models/AuditLog.js');
37-
3837
// Delete a member
3938
router.post('/delete', async (req, res) => {
4039
if (!checkIfTokenSent(req)) {
@@ -198,6 +197,25 @@ router.post('/edit', async (req, res) => {
198197
const query = { _id: req.body._id };
199198
let user = req.body;
200199

200+
// keep track of user in db
201+
const existingUser = await User.findById(req.body._id);
202+
if (!existingUser) {
203+
return res.status(NOT_FOUND).send({ message: 'User not found.' });
204+
}
205+
206+
// Track field changes
207+
const fieldChanges = {};
208+
const fieldsToTrack = ['firstName', 'lastName', 'email', 'accessLevel', 'major', 'discordID', 'emailOptIn', 'membershipValidUntil'];
209+
210+
fieldsToTrack.forEach(field => {
211+
if (user[field] !== undefined && user[field] !== existingUser[field]) {
212+
fieldChanges[field] = {
213+
from: existingUser[field],
214+
to: user[field]
215+
};
216+
}
217+
});
218+
201219
if (typeof req.body.numberOfSemestersToSignUpFor !== 'undefined') {
202220
user.membershipValidUntil = getMemberExpirationDate(
203221
parseInt(req.body.numberOfSemestersToSignUpFor)
@@ -213,6 +231,14 @@ router.post('/edit', async (req, res) => {
213231
return res.sendStatus(SERVER_ERROR);
214232
}
215233
user.password = result;
234+
235+
// create audit log for password change
236+
AuditLog.create({
237+
userId: decoded._id,
238+
action: AuditLogActions.CHANGE_PW,
239+
details: { email: existingUser.email, userId: decoded._id },
240+
}).catch(logger.error);
241+
216242
} else {
217243
// omit password from the object if it is falsy
218244
// i.e. an empty string, undefined or null
@@ -236,24 +262,28 @@ router.post('/edit', async (req, res) => {
236262
if (result.nModified < 1) {
237263
return res
238264
.status(NOT_FOUND)
239-
.send({ message: `${query.email} not found.` });
265+
.send({ message: `${existingUser.email} not found.` });
240266
}
241267

242-
const sanitizedUser = {...user}; // shallow copy of the user; doesn't affect original
268+
if (Object.keys(fieldChanges).length > 0) {
269+
const sanitizedUser = {...user};
270+
if ('password' in sanitizedUser) {
271+
sanitizedUser.password = true;
272+
}
243273

244-
if ('password' in sanitizedUser) {
245-
sanitizedUser.password = true;
274+
AuditLog.create({
275+
userId: decoded._id, // person who did modification
276+
action: AuditLogActions.UPDATE_USER,
277+
documentId: user._id,
278+
details: {
279+
updatedInfo: sanitizedUser,
280+
fieldChanges: fieldChanges
281+
}
282+
}).catch(logger.error);
246283
}
247284

248-
AuditLog.create({
249-
userId: decoded._id, // the user making the update
250-
action: AuditLogActions.UPDATE_USER,
251-
documentId: user._id, // the user affected by the update
252-
details: {updatedInfo: sanitizedUser}
253-
}).catch(logger.error);
254-
255285
return res.status(OK).send({
256-
message: `${query.email} was updated.`,
286+
message: `${existingUser.email} was updated.`,
257287
membershipValidUntil: user.membershipValidUntil
258288
});
259289
});
@@ -272,8 +302,9 @@ router.post('/getPagesPrintedCount', (req, res) => {
272302
apiEndpoint: 'user/PagesPrintedCount',
273303
errorDescription: error
274304
};
305+
logger.error(info);
275306

276-
res.status(BAD_REQUEST).send({ message: 'Bad Request.' });
307+
return res.status(BAD_REQUEST).send({ message: 'Bad Request.' });
277308
}
278309

279310
if (!result) {

test/api/User.js

Lines changed: 179 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const sinon = require('sinon');
2121
const SceApiTester = require('../util/tools/SceApiTester');
2222
const {mockDayMonthAndYear, revertClock} = require('../util/mocks/Date.js');
2323

24+
const AuditLog = require('../../api/main_endpoints/models/AuditLog.js');
25+
const AuditLogActions = require('../../api/main_endpoints/util/auditLogActions.js');
2426

2527
let app = null;
2628
let test = null;
@@ -44,9 +46,6 @@ const {
4446
const { MEMBERSHIP_STATE } = require('../../api/util/constants');
4547
const { getMemberExpirationDate } = require('../../api/main_endpoints/util/userHelpers.js');
4648

47-
const AuditLogActions = require('../../api/main_endpoints/util/auditLogActions.js');
48-
const AuditLog = require('../../api/main_endpoints/models/AuditLog.js');
49-
5049
chai.should();
5150
chai.use(chaiHttp);
5251

@@ -212,51 +211,158 @@ describe('User', () => {
212211
expect(result).to.have.status(NOT_FOUND);
213212
});
214213

215-
it('Should return statusCode 200 and a message ' +
216-
'if a user was edited', async () => {
217-
const user = {
218-
_id: id,
219-
220-
token: token,
221-
firstName: 'pinkUnicorn',
222-
discordID: '0987654321',
223-
numberOfSemestersToSignUpFor: undefined
224-
};
225-
setTokenStatus(true);
226-
const result = await test.sendPostRequestWithToken(
227-
token, '/api/User/edit', user);
228-
expect(result).to.have.status(OK);
229-
result.body.should.be.a('object');
230-
result.body.should.have.property('message');
231-
});
214+
describe('create audit log on user change', async () => {
232215

233-
it('Should create an audit log when a user is updated', async () => {
234-
// ensure Audit log DB starts fresh before this test
235-
await AuditLog.deleteMany({});
236-
// update email, firstname, password, and discordID
237-
const user = {
238-
_id: id,
239-
240-
password: 'newPassword',
241-
token: token,
242-
firstName: 'Newname',
243-
discordID: '421482148',
244-
numberOfSemestersToSignUpFor: undefined
245-
};
246-
setTokenStatus(true, user);
216+
// create clean testUser before each test
217+
let testUser;
247218

248-
const result = await test.sendPostRequestWithToken(
249-
token, '/api/User/edit', user
250-
);
251-
expect(result).to.have.status(OK);
219+
beforeEach(async () => {
220+
await User.deleteMany({});
221+
testUser = await new User({
222+
223+
password: 'Passw0rd',
224+
firstName: 'first-name',
225+
lastName: 'last-name',
226+
major: 'Computer Science'
227+
}).save();
228+
229+
setTokenStatus(true, testUser);
230+
});
231+
232+
afterEach(async () => {
233+
await AuditLog.deleteMany({});
234+
});
235+
236+
it('Should create an audit log when a user is updated (no password change)' + 'not create an audit log for password change', async () => {
237+
const res = await test.sendPostRequestWithToken(token, '/api/User/edit', {
238+
_id: testUser._id.toString(),
239+
firstName: 'Newname',
240+
241+
token
242+
});
243+
244+
expect(res).to.have.status(OK);
245+
res.body.should.be.a('object');
246+
res.body.should.have.property('message');
247+
248+
const auditEntry = await AuditLog.findOne({ userId: testUser._id }).lean();
249+
expect(auditEntry).to.exist;
250+
expect(auditEntry.details.fieldChanges.firstName).to.have.deep.equal({
251+
from: 'first-name',
252+
to: 'Newname'
253+
});
254+
255+
// make sure pw change log doesn't exist
256+
const changePWlog = await AuditLog.findOne({
257+
userId: id,
258+
action: AuditLogActions.CHANGE_PW
259+
}).lean();
260+
expect(changePWlog).to.not.exist;
261+
});
252262

253-
const auditEntry = await AuditLog.findOne().lean();
263+
it('Should create an audit log when a user changes their password (no profile update)', async () => {
264+
const res = await test.sendPostRequestWithToken(token, '/api/User/edit', {
265+
_id: testUser._id.toString(),
266+
firstName: 'first-name',
267+
268+
password: 'Newpassw0rd',
269+
token
270+
});
271+
272+
expect(res).to.have.status(OK);
273+
274+
const auditEntry = await AuditLog.findOne({ userId: testUser._id }).lean();
275+
expect(auditEntry).to.exist;
276+
expect(auditEntry.action).to.equal(AuditLogActions.CHANGE_PW);
277+
expect(auditEntry).to.not.have.property('password');
278+
});
279+
280+
it('Should create both audit logs when password and profile info are updated', async () => {
281+
const res = await test.sendPostRequestWithToken(token, '/api/User/edit', {
282+
_id: testUser._id.toString(),
283+
firstName: 'Newname',
284+
285+
password: 'Newpassword1',
286+
discordID: 'anotherID',
287+
token
288+
});
289+
290+
expect(res).to.have.status(OK);
291+
292+
const changePwLog = await AuditLog.findOne({ action: AuditLogActions.CHANGE_PW }).lean();
293+
const updateUserLog = await AuditLog.findOne({ action: AuditLogActions.UPDATE_USER }).lean();
294+
295+
expect(changePwLog).to.exist;
296+
expect(updateUserLog).to.exist;
297+
});
298+
299+
it('Should track profile changes in audit log with correct from/to values', async () => {
300+
const res = await test.sendPostRequestWithToken(token, '/api/User/edit', {
301+
_id: testUser._id.toString(),
302+
firstName: 'Newname',
303+
lastName: 'Newlastname',
304+
305+
password: 'Newpassword1',
306+
discordID: 'anotherID',
307+
major: 'Software Engineering',
308+
token
309+
});
310+
311+
expect(res).to.have.status(OK);
312+
313+
const auditEntry = await AuditLog.findOne({
314+
action: AuditLogActions.UPDATE_USER,
315+
documentId: testUser._id
316+
}).lean();
317+
318+
expect(auditEntry).to.exist;
319+
expect(auditEntry.details).to.have.property('fieldChanges');
320+
321+
// track firstName change
322+
expect(auditEntry.details.fieldChanges).to.have.property('firstName');
323+
expect(auditEntry.details.fieldChanges.firstName).to.have.deep.equal({
324+
from: 'first-name',
325+
to: 'Newname'
326+
});
327+
328+
// track lastName change
329+
expect(auditEntry.details.fieldChanges).to.have.property('lastName');
330+
expect(auditEntry.details.fieldChanges.lastName).to.have.deep.equal({
331+
from: 'last-name',
332+
to: 'Newlastname'
333+
});
334+
335+
// track major change
336+
expect(auditEntry.details.fieldChanges).to.have.property('major');
337+
expect(auditEntry.details.fieldChanges.major).to.have.deep.equal({
338+
from: 'Computer Science',
339+
to: 'Software Engineering'
340+
});
341+
342+
// Should NOT track unchanged fields + password field
343+
expect(auditEntry.details.fieldChanges).to.not.have.property('password');
344+
expect(auditEntry.details.fieldChanges).to.not.have.property('email');
345+
});
254346

255-
expect(auditEntry).to.exist;
256-
expect(auditEntry).to.have.property('userId');
257-
expect(auditEntry).to.have.property('action', AuditLogActions.UPDATE_USER);
258-
expect(auditEntry.details.updatedInfo).to.have.property('password', true);
259-
await AuditLog.deleteMany({});
347+
it('Should not create audit log when no fields actually change', async () => {
348+
const res = await test.sendPostRequestWithToken(token, '/api/User/edit', {
349+
_id: testUser._id.toString(),
350+
351+
password: 'Passw0rd',
352+
firstName: 'first-name',
353+
lastName: 'last-name',
354+
major: 'Computer Science'
355+
});
356+
357+
expect(res).to.have.status(OK);
358+
359+
const auditEntry = await AuditLog.findOne({
360+
action: AuditLogActions.UPDATE_USER || AuditLogActions.CHANGE_PW,
361+
documentId: testUser._id
362+
}).lean();
363+
364+
expect(auditEntry).to.not.exist;
365+
});
260366
});
261367
});
262368

@@ -288,14 +394,27 @@ describe('User', () => {
288394
expect(result).to.have.status(NOT_FOUND);
289395
});
290396
it('Should return status code 200 if user is found', async () => {
291-
const user = {
292-
userID: id,
293-
token: token
294-
};
295-
setTokenStatus(true);
296-
const result = await test.sendPostRequestWithToken(token, '/api/User/getUserById', user);
297-
expect(result).to.have.status(OK);
298-
result.body.should.not.have.property('password');
397+
398+
const testUser = await new User({
399+
400+
password: 'Passw0rd',
401+
firstName: 'Get',
402+
lastName: 'User',
403+
accessLevel: MEMBERSHIP_STATE.ADMIN,
404+
emailVerified: true
405+
}).save();
406+
407+
// Set token mock for this user
408+
setTokenStatus(true, { _id: testUser._id, accessLevel: MEMBERSHIP_STATE.ADMIN });
409+
410+
const res = await test.sendPostRequestWithToken('', '/api/User/getUserById', {
411+
userID: testUser._id,
412+
token: ''
413+
});
414+
415+
expect(res).to.have.status(OK);
416+
res.body.should.have.property('email').eql('[email protected]');
417+
res.body.should.not.have.property('password');
299418
});
300419
});
301420

@@ -351,13 +470,17 @@ describe('User', () => {
351470

352471
it('Should return statusCode 200 and a message ' +
353472
'if a user was deleted', async () => {
354-
const user = {
473+
const user = await new User({
355474
_id : id,
475+
476+
password: 'Passw0rd',
477+
firstName: 'Delete',
478+
lastName: 'Me',
356479
token: token
357-
};
358-
setTokenStatus(true);
480+
}).save();
481+
setTokenStatus(true, { _id: user._id, accesslevel: MEMBERSHIP_STATE.ADMIN });
359482
const result = await test.sendPostRequestWithToken(
360-
token, '/api/User/delete', user);
483+
token, '/api/User/delete', { _id: user._id, token: token } );
361484
expect(result).to.have.status(OK);
362485
});
363486

0 commit comments

Comments
 (0)