diff --git a/src/routes/projectMemberInvites/create.js b/src/routes/projectMemberInvites/create.js index 336ab12f..a06e1bed 100644 --- a/src/routes/projectMemberInvites/create.js +++ b/src/routes/projectMemberInvites/create.js @@ -395,7 +395,7 @@ module.exports = [ }) )) .then((values) => { - const response = _.assign({}, { success: values }); + const response = _.assign({}, { success: util.postProcessInvites('$[*]', values, req) }); if (failed.length) { res.status(403).json(_.assign({}, response, { failed })); } else { diff --git a/src/routes/projectMemberInvites/create.spec.js b/src/routes/projectMemberInvites/create.spec.js index cfb4f44b..17f4422d 100644 --- a/src/routes/projectMemberInvites/create.spec.js +++ b/src/routes/projectMemberInvites/create.spec.js @@ -446,8 +446,8 @@ describe('Project Member Invite create', () => { resJson.role.should.equal('customer'); resJson.projectId.should.equal(project2.id); resJson.userId.should.equal(12345); - resJson.email.should.equal('hello@world.com'); - resJson.hashEmail.should.equal(md5('hello@world.com')); + should.not.exist(resJson.email); + should.not.exist(resJson.hashEmail); server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; done(); } @@ -495,6 +495,8 @@ describe('Project Member Invite create', () => { resJson.role.should.equal('customer'); resJson.projectId.should.equal(project2.id); resJson.userId.should.equal(40051331); + should.not.exist(resJson.email); + should.not.exist(resJson.hashEmail); server.services.pubsub.publish.calledWith('project.member.invite.created').should.be.true; done(); } @@ -828,7 +830,7 @@ describe('Project Member Invite create', () => { } else { const resJson = res.body.failed; should.exist(resJson); - resJson[0].email.should.equal('DUPLICATE_UPPERCASE@test.com'); // email is masked + resJson[0].email.should.equal('DUPLICATE_UPPERCASE@test.com'); resJson[0].message.should.equal('User with such email is already invited to this project.'); resJson.length.should.equal(1); done(); diff --git a/src/routes/projectMemberInvites/get.js b/src/routes/projectMemberInvites/get.js index 205e3113..05552f82 100644 --- a/src/routes/projectMemberInvites/get.js +++ b/src/routes/projectMemberInvites/get.js @@ -114,7 +114,7 @@ module.exports = [ return invite; }) )) - .then(invite => res.json(util.maskInviteEmails('$[*].email', invite, req))) + .then(invite => res.json(util.postProcessInvites('$.email', invite, req))) .catch(next); }, ]; diff --git a/src/routes/projectMemberInvites/get.spec.js b/src/routes/projectMemberInvites/get.spec.js index 532221c6..6bcb88ec 100644 --- a/src/routes/projectMemberInvites/get.spec.js +++ b/src/routes/projectMemberInvites/get.spec.js @@ -55,7 +55,7 @@ describe('GET Project Member Invite', () => { const invite2 = models.ProjectMemberInvite.create({ id: 2, userId: testUtil.userIds.copilot, - email: null, + email: 'test@topcoder.com', projectId: project1.id, role: 'copilot', createdBy: 1, @@ -206,6 +206,8 @@ describe('GET Project Member Invite', () => { const resJson = res.body; should.exist(resJson); should.exist(resJson.projectId); + should.not.exist(resJson.email); + should.not.exist(resJson.hashEmail); resJson.id.should.be.eql(2); resJson.userId.should.be.eql(testUtil.userIds.copilot); resJson.status.should.be.eql(INVITE_STATUS.PENDING); @@ -230,7 +232,7 @@ describe('GET Project Member Invite', () => { should.exist(resJson); should.exist(resJson.projectId); resJson.id.should.be.eql(3); - resJson.email.should.be.eql('test@topcoder.com'); + resJson.email.should.be.eql('t***t@t***r.com'); // masked resJson.hashEmail.should.be.eql(md5('test@topcoder.com')); resJson.status.should.be.eql(INVITE_STATUS.PENDING); done(); diff --git a/src/routes/projectMemberInvites/list.js b/src/routes/projectMemberInvites/list.js index a8a6dcfd..7c531930 100644 --- a/src/routes/projectMemberInvites/list.js +++ b/src/routes/projectMemberInvites/list.js @@ -103,7 +103,7 @@ module.exports = [ return invites; }) )) - .then(invites => res.json(util.maskInviteEmails('$[*].email', invites, req))) + .then(invites => res.json(util.postProcessInvites('$[*]', invites, req))) .catch(next); }, ]; diff --git a/src/routes/projectMemberInvites/list.spec.js b/src/routes/projectMemberInvites/list.spec.js index 1ea32bef..10631634 100644 --- a/src/routes/projectMemberInvites/list.spec.js +++ b/src/routes/projectMemberInvites/list.spec.js @@ -209,6 +209,7 @@ describe('GET Project Member Invites', () => { resJson.length.should.be.eql(1); // check invitations _.filter(resJson, inv => inv.id === 2).length.should.be.eql(1); + should.not.exist(resJson[0].email); done(); } }); @@ -253,6 +254,7 @@ describe('GET Project Member Invites', () => { resJson.length.should.be.eql(1); // check invitations _.filter(resJson, inv => inv.id === 3).length.should.be.eql(1); + resJson[0].email.should.be.eql('t***t@t***r.com'); // masked done(); } }); diff --git a/src/routes/projectMemberInvites/update.js b/src/routes/projectMemberInvites/update.js index 20184c60..490b61ae 100644 --- a/src/routes/projectMemberInvites/update.js +++ b/src/routes/projectMemberInvites/update.js @@ -122,11 +122,11 @@ module.exports = [ }; return util .addUserToProject(req, member) - .then(() => res.json(util.maskInviteEmails('$.email', updatedInvite, req))) + .then(() => res.json(util.postProcessInvites('$.email', updatedInvite, req))) .catch(err => next(err)); }); } - return res.json(util.maskInviteEmails('$.email', updatedInvite, req)); + return res.json(util.postProcessInvites('$.email', updatedInvite, req)); }); }) .catch(next); diff --git a/src/routes/projects/get.js b/src/routes/projects/get.js index a8aee1a4..fbb18481 100644 --- a/src/routes/projects/get.js +++ b/src/routes/projects/get.js @@ -186,7 +186,7 @@ module.exports = [ req.log.debug('Project found in ES'); return result; }).then((project) => { - res.status(200).json(util.maskInviteEmails('$.invites[?(@.email)]', project, req)); + res.status(200).json(util.postProcessInvites('$.invites[?(@.email)]', project, req)); }) .catch(err => next(err)); }, diff --git a/src/routes/projects/get.spec.js b/src/routes/projects/get.spec.js index 1a3ab773..8f220e3a 100644 --- a/src/routes/projects/get.spec.js +++ b/src/routes/projects/get.spec.js @@ -526,6 +526,28 @@ describe('GET Project', () => { }); }); + it('should not return "email" for any invite which has userId field', (done) => { + request(server) + .get(`/v5/projects/${project1.id}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .expect('Content-Type', /json/) + .expect(200) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body; + should.exist(resJson); + resJson.invites.length.should.be.eql(1); + resJson.invites[0].should.have.property('userId'); + should.not.exist(resJson.invites[0].email); + done(); + } + }); + }); + it('should only return "members.role" field, when it\'s the only field listed in "fields" query param', (done) => { request(server) .get(`/v5/projects/${project1.id}?fields=members.role`) diff --git a/src/routes/projects/list.js b/src/routes/projects/list.js index 135ab145..4dcafca8 100755 --- a/src/routes/projects/list.js +++ b/src/routes/projects/list.js @@ -629,15 +629,18 @@ module.exports = [ // so we don't want DB to return unrelated data, ref issue #450 if (_.intersection(_.keys(filters), SUPPORTED_FILTERS).length > 0) { req.log.debug('Don\'t fallback to DB because some filters are defined.'); - return util.setPaginationHeaders(req, res, util.maskInviteEmails('$[*].invites[?(@.email)]', result, req)); + return util.setPaginationHeaders(req, res, + util.postProcessInvites('$.rows[*].invites[?(@.email)]', result, req)); } return retrieveProjectsFromDB(req, criteria, sort, req.query.fields) - .then(r => util.setPaginationHeaders(req, res, util.maskInviteEmails('$[*].invites[?(@.email)]', r, req))); + .then(r => util.setPaginationHeaders(req, res, + util.postProcessInvites('$.rows[*].invites[?(@.email)]', r, req))); } req.log.debug('Projects found in ES'); // set header - return util.setPaginationHeaders(req, res, util.maskInviteEmails('$[*].invites[?(@.email)]', result, req)); + return util.setPaginationHeaders(req, res, + util.postProcessInvites('$.rows[*].invites[?(@.email)]', result, req)); }) .catch(err => next(err)); } @@ -655,14 +658,17 @@ module.exports = [ // so we don't want DB to return unrelated data, ref issue #450 if (_.intersection(_.keys(filters), SUPPORTED_FILTERS).length > 0) { req.log.debug('Don\'t fallback to DB because some filters are defined.'); - return util.setPaginationHeaders(req, res, util.maskInviteEmails('$[*].invites[?(@.email)]', result, req)); + return util.setPaginationHeaders(req, res, + util.postProcessInvites('$.rows[*].invites[?(@.email)]', result, req)); } return retrieveProjectsFromDB(req, criteria, sort, req.query.fields) - .then(r => util.setPaginationHeaders(req, res, util.maskInviteEmails('$[*].invites[?(@.email)]', r, req))); + .then(r => util.setPaginationHeaders(req, res, + util.postProcessInvites('$.rows[*].invites[?(@.email)]', r, req))); } req.log.debug('Projects found in ES'); - return util.setPaginationHeaders(req, res, util.maskInviteEmails('$[*].invites[?(@.email)]', result, req)); + return util.setPaginationHeaders(req, res, + util.postProcessInvites('$.rows[*].invites[?(@.email)]', result, req)); }) .catch(err => next(err)); }, diff --git a/src/routes/projects/list.spec.js b/src/routes/projects/list.spec.js index a23995a8..60bf3701 100644 --- a/src/routes/projects/list.spec.js +++ b/src/routes/projects/list.spec.js @@ -64,6 +64,12 @@ const data = [ email: 'test@topcoder.com', status: 'pending', }, + { + id: 2, + email: 'hello@world.com', + status: 'pending', + createdBy: 1, + }, ], phases: [ @@ -376,6 +382,10 @@ describe('LIST Project', () => { const resJson = res.body; should.exist(resJson); resJson.should.have.lengthOf(2); + resJson[0].invites[0].should.have.property('userId'); + should.not.exist(resJson[0].invites[0].email); + resJson[1].invites[0].should.have.property('userId'); + should.not.exist(resJson[1].invites[0].email); done(); } }); @@ -1070,6 +1080,10 @@ describe('LIST Project', () => { should.exist(resJson); resJson.should.have.lengthOf(1); resJson[0].name.should.equal('test1'); + resJson[0].invites.should.have.lengthOf(2); + resJson[0].invites[0].should.have.property('userId'); + should.not.exist(resJson[0].invites[0].email); + resJson[0].invites[1].email.should.equal('h***o@w***d.com'); done(); } }); diff --git a/src/util.js b/src/util.js index 2accedbc..42c72913 100644 --- a/src/util.js +++ b/src/util.js @@ -639,7 +639,11 @@ _.assignIn(util, { } }, /** - * Mask email in the fields defined by `jsonPath` in the `data`. + * Post-process given invite(s) with following constraints: + * - email field will be omitted from invite if the invite has defined userId + * - email field (if existed) will be masked UNLESS current user has admin permissions OR current user created this invite + * + * Email to be masked is found in the fields defined by `jsonPath` in the `data`. * Immutable - doesn't modify data, but creates a clone. * * @param {String} jsonPath jsonpath string @@ -648,24 +652,45 @@ _.assignIn(util, { * * @return {Object} data has been processed */ - maskInviteEmails: (jsonPath, data, req) => { + postProcessInvites: (jsonPath, data, req) => { // clone data to avoid mutations const dataClone = _.cloneDeep(data); const isAdmin = util.hasPermission({ topcoderRoles: [USER_ROLE.TOPCODER_ADMIN] }, req.authUser); + const currentUserId = req.authUser.userId; if (isAdmin) { // even though we didn't make any changes to the data, return a clone here for consistency return dataClone; } + const postProcessInvite = (invite) => { + if (!_.has(invite, 'email')) { + return invite; + } + let email; + if (!invite.userId) { + // mask email if non-admin or not own invite + email = isAdmin || invite.createdBy === currentUserId ? invite.email : util.maskEmail(invite.email); + } else { + // userId is defined, no email field returned + email = null; + } + _.assign(invite, { email }); + if (!invite.email && _.has(invite, 'hashEmail')) { + _.assign(invite, { hashEmail: null }); + } + return invite; + }; + jp.apply(dataClone, jsonPath, (value) => { if (_.isObject(value)) { - _.assign(value, { email: util.maskEmail(value.email) }); - return value; + // data contains nested invite object + return postProcessInvite(value); } - // isString or null - return util.maskEmail(value); + // data is single invite object + // value is string or null + return postProcessInvite(dataClone).email; }); return dataClone; diff --git a/src/util.spec.js b/src/util.spec.js index 80dc973a..03a0693b 100644 --- a/src/util.spec.js +++ b/src/util.spec.js @@ -43,7 +43,7 @@ describe('Util method', () => { }); }); - describe('maskInviteEmails', () => { + describe('postProcessInvites', () => { it('should mask emails when passing data like for a project list endpoint for non-admin user', () => { const list = [ { @@ -68,7 +68,7 @@ describe('Util method', () => { const res = { authUser: { userId: 2 }, }; - util.maskInviteEmails('$..invites[?(@.email)]', list, res).should.deep.equal(list2); + util.postProcessInvites('$..invites[?(@.email)]', list, res).should.deep.equal(list2); }); it('should mask emails when passing data like for a project details endpoint for non-admin user', () => { @@ -91,7 +91,7 @@ describe('Util method', () => { const res = { authUser: { userId: 2 }, }; - util.maskInviteEmails('$..invites[?(@.email)]', detail, res).should.deep.equal(detail2); + util.postProcessInvites('$..invites[?(@.email)]', detail, res).should.deep.equal(detail2); }); it('should mask emails when passing data like for a single invite endpoint for non-admin user', () => { @@ -114,7 +114,7 @@ describe('Util method', () => { const res = { authUser: { userId: 2 }, }; - util.maskInviteEmails('$.success[?(@.email)]', detail, res).should.deep.equal(detail2); + util.postProcessInvites('$.success[?(@.email)]', detail, res).should.deep.equal(detail2); }); it('should NOT mask emails when passing data like for a single invite endpoint for admin user', () => { @@ -137,7 +137,82 @@ describe('Util method', () => { const res = { authUser: { userId: 2, roles: ['administrator'] }, }; - util.maskInviteEmails('$..email', detail, res).should.deep.equal(detail2); + util.postProcessInvites('$.success[?(@.email)]', detail, res).should.deep.equal(detail2); + }); + + it('should NOT mask emails when passing data like for a single invite endpoint for user\'s own invite', () => { + const detail = { + success: [ + { + id: 1, + email: 'abcd@aaaa.com', + createdBy: 2, + }, + ], + }; + const detail2 = { + success: [ + { + id: 1, + email: 'abcd@aaaa.com', + createdBy: 2, + }, + ], + }; + const res = { + authUser: { userId: 2, email: 'abcd@aaaa.com' }, + }; + util.postProcessInvites('$.success[?(@.email)]', detail, res).should.deep.equal(detail2); + }); + + it('should NOT mask emails when passing data like for a project details endpoint for user\'s own invite', () => { + const detail = { + id: 1, + invites: [{ + id: 2, + email: 'abcd@aaaa.com', + createdBy: 2, + }, + ], + }; + const detail2 = { + id: 1, + invites: [{ + id: 2, + email: 'abcd@aaaa.com', + createdBy: 2, + }, + ], + }; + const res = { + authUser: { userId: 2, email: 'abcd@aaaa.com' }, + }; + util.postProcessInvites('$.invites[?(@.email)]', detail, res).should.deep.equal(detail2); + }); + + it('should not return emails for invite with defined userId', () => { + const detail = { + id: 1, + invites: [{ + id: 2, + email: 'abcd@aaaa.com', + userId: 33, + }, + ], + }; + const detail2 = { + id: 1, + invites: [{ + id: 2, + email: null, + userId: 33, + }, + ], + }; + const res = { + authUser: { userId: 2 }, + }; + util.postProcessInvites('$..invites[?(@.email)]', detail, res).should.deep.equal(detail2); }); });