diff --git a/test/api/v3/integration/members/POST-members_memberId_clear-flags.js b/test/api/v3/integration/members/POST-members_memberId_clear-flags.js new file mode 100644 index 0000000000..b6b486b6f7 --- /dev/null +++ b/test/api/v3/integration/members/POST-members_memberId_clear-flags.js @@ -0,0 +1,64 @@ +import { v4 as generateUUID } from 'uuid'; +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +describe('POST /members/:memberId/clear-flags', () => { + let reporter; + let admin; + let moderator; + + beforeEach(async () => { + reporter = await generateUser(); + admin = await generateUser({ permissions: { userSupport: true } }); + moderator = await generateUser({ permissions: { moderator: true } }); + await reporter.post(`/members/${admin._id}/flag`); + }); + + context('error cases', () => { + it('returns error when memberId is not a UUID', async () => { + await expect(moderator.post('/members/gribbly/clear-flags')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when member with UUID is not found', async () => { + const randomId = generateUUID(); + + await expect(moderator.post(`/members/${randomId}/clear-flags`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', { userId: randomId }), + }); + }); + + it('returns error when requesting user is not a moderator', async () => { + await expect(reporter.post(`/members/${admin._id}/clear-flags`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'Only a moderator may clear reports from a profile.', + }); + }); + }); + + context('valid request', () => { + it('removes a single flag from user', async () => { + await expect(moderator.post(`/members/${admin._id}/clear-flags`)).to.eventually.be.ok; + const updatedTarget = await admin.get(`/hall/heroes/${admin._id}`); + expect(updatedTarget.profile.flags).to.eql({}); + }); + + it('removes multiple flags from user', async () => { + await moderator.post(`/members/${admin._id}/flag`); + await expect(moderator.post(`/members/${admin._id}/clear-flags`)).to.eventually.be.ok; + const updatedTarget = await admin.get(`/hall/heroes/${admin._id}`); + expect(updatedTarget.profile.flags).to.eql({}); + }); + }); +}); \ No newline at end of file diff --git a/test/api/v3/integration/members/POST-members_memberId_flag.js b/test/api/v3/integration/members/POST-members_memberId_flag.js new file mode 100644 index 0000000000..3c8282df76 --- /dev/null +++ b/test/api/v3/integration/members/POST-members_memberId_flag.js @@ -0,0 +1,151 @@ +import { v4 as generateUUID } from 'uuid'; +import moment from 'moment'; +import nconf from 'nconf'; +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { IncomingWebhook } from '@slack/webhook'; + +describe('POST /members/:memberId/flag', () => { + let reporter; + let target; + + beforeEach(async () => { + reporter = await generateUser(); + target = await generateUser({ + 'profile.blurb': 'Naughty Text', + 'profile.imageUrl': 'https://evil.com/', + }); + }); + + context('error cases', () => { + it('returns error when memberId is not a UUID', async () => { + await expect(reporter.post('/members/gribbly/flag')) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns error when member with UUID is not found', async () => { + const randomId = generateUUID(); + + await expect(reporter.post(`/members/${randomId}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', { userId: randomId }), + }); + }); + + it('returns error when non-admin flags same profile twice', async () => { + await reporter.post(`/members/${target._id}/flag`); + await expect (reporter.post(`/members/${target._id}/flag`)) + .to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'A profile can not be flagged more than once by the same user.', + }); + }); + }); + + context('valid request', () => { + let admin; + const comment = 'this profile is bad'; + const source = 'Third Party Script'; + + beforeEach(async () => { + admin = await generateUser({ 'permissions.userSupport': true }); + sandbox.stub(IncomingWebhook.prototype, 'send').returns(Promise.resolve()); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('adds flags object to target user', async () => { + await reporter.post(`/members/${target._id}/flag`); + const updatedTarget = await admin.get(`/hall/heroes/${target._id}`); + expect(updatedTarget.profile.flags[reporter._id]).to.have.all.keys([ + 'comment', + 'source', + 'timestamp', + ]); + expect(moment(updatedTarget.profile.flags[reporter._id].timestamp).toDate()).to.be.a('date'); + }); + + it('allows addition of a comment and source', async () => { + await reporter.post(`/members/${target._id}/flag`, { + comment, + source, + }); + const updatedTarget = await admin.get(`/hall/heroes/${target._id}`); + expect(updatedTarget.profile.flags[reporter._id].comment).to.eql(comment); + expect(updatedTarget.profile.flags[reporter._id].source).to.eql(source); + }); + + it('allows moderator to flag twice', async () => { + const moderator = await generateUser({ 'permissions.moderator': true }); + await moderator.post(`/members/${target._id}/flag`); + await expect(moderator.post(`/members/${target._id}/flag`)).to.eventually.be.ok; + }); + + it('allows multiple non-moderators to flag individually', async () => { + await admin.post(`/members/${target._id}/flag`); + await reporter.post(`/members/${target._id}/flag`); + const updatedTarget = await admin.get(`/hall/heroes/${target._id}`); + expect(updatedTarget.profile.flags[admin._id]).to.exist; + expect(updatedTarget.profile.flags[reporter._id]).to.exist; + }); + + it('sends a flag report to moderation Slack', async () => { + const BASE_URL = nconf.get('BASE_URL'); + await reporter.post(`/members/${target._id}/flag`, { + comment, + source, + }); + + /* eslint-disable camelcase */ + expect(IncomingWebhook.prototype.send).to.be.calledWith({ + text: `@${reporter.auth.local.username} (${reporter._id}; language: ${reporter.preferences.language}) flagged @${target.auth.local.username}'s profile from ${source} and commented: ${comment}`, + attachments: [{ + fallback: 'Flag Profile', + color: 'danger', + title: 'User Profile Report', + title_link: `${BASE_URL}/profile/${target._id}`, + text: `Display Name: ${target.profile.name}\n\nImage URL: ${target.profile.imageUrl}\n\nAbout: ${target.profile.blurb}`, + mrkdwn_in: [ + 'text', + ], + }], + }); + /* eslint-enable camelcase */ + }); + + it('excludes empty fields when sending Slack message', async () => { + const BASE_URL = nconf.get('BASE_URL'); + await reporter.post(`/members/${admin._id}/flag`, { + comment, + source, + }); + + /* eslint-disable camelcase */ + expect(IncomingWebhook.prototype.send).to.be.calledWith({ + text: `@${reporter.auth.local.username} (${reporter._id}; language: ${reporter.preferences.language}) flagged @${admin.auth.local.username}'s profile from ${source} and commented: ${comment}`, + attachments: [{ + fallback: 'Flag Profile', + color: 'danger', + title: 'User Profile Report', + title_link: `${BASE_URL}/profile/${admin._id}`, + text: `Display Name: ${admin.profile.name}`, + mrkdwn_in: [ + 'text', + ], + }], + }); + /* eslint-enable camelcase */ + }); + }); +}); \ No newline at end of file diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index d8f5a086e8..e9e4f03ee1 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -146,7 +146,7 @@ api.getHeroes = { // Note, while the following routes are called getHero / updateHero // they can be used by admins to get/update any user -const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile.name purchased secret permissions'; +const heroAdminFields = 'auth balance contributor flags items lastCron party preferences profile purchased secret permissions'; const heroAdminFieldsToFetch = heroAdminFields; // these variables will make more sense when... const heroAdminFieldsToShow = heroAdminFields; // ... apiTokenObscured is added @@ -200,6 +200,7 @@ api.getHero = { if (!heroRes.contributor) heroRes.contributor = {}; heroRes.secret = hero.getSecretData(); + heroRes.profile.flags = hero.getFlagData(); res.respond(200, heroRes); }, diff --git a/website/server/controllers/api-v3/members.js b/website/server/controllers/api-v3/members.js index 19b14edde0..ac8a85815c 100644 --- a/website/server/controllers/api-v3/members.js +++ b/website/server/controllers/api-v3/members.js @@ -11,6 +11,7 @@ import { import { model as Group } from '../../models/group'; import { model as Challenge } from '../../models/challenge'; import { + BadRequest, NotFound, NotAuthorized, } from '../../libs/errors'; @@ -846,6 +847,9 @@ api.clearUserFlags = { const { memberId } = req.params; req.checkParams('memberId', res.t('memberIdRequired')).notEmpty().isUUID(); + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + if (!user.hasPermission('moderator')) { throw new BadRequest('Only a moderator may clear reports from a profile.'); } diff --git a/website/server/libs/slack.js b/website/server/libs/slack.js index 3d8afb1eef..0f2997a239 100644 --- a/website/server/libs/slack.js +++ b/website/server/libs/slack.js @@ -188,10 +188,13 @@ function sendProfileFlagNotification ({ if (userComment) { text += ` and commented: ${userComment}`; } - let profileData = `Bio: ${flaggedUser.profile.blurb}`; + let profileData = `Display Name: ${flaggedUser.profile.name}`; if (flaggedUser.profile.imageUrl) { profileData += `\n\nImage URL: ${flaggedUser.profile.imageUrl}`; } + if (flaggedUser.profile.blurb) { + profileData += `\n\nAbout: ${flaggedUser.profile.blurb}`; + } flagSlack .send({ diff --git a/website/server/models/user/hooks.js b/website/server/models/user/hooks.js index e1b00de15b..a64d49fde9 100644 --- a/website/server/models/user/hooks.js +++ b/website/server/models/user/hooks.js @@ -24,7 +24,7 @@ schema.plugin(baseModel, { // noSet is not used as updating uses a whitelist and creating only accepts // specific params (password, email, username, ...) noSet: [], - private: ['auth.local.hashed_password', 'auth.local.passwordHashMethod', 'auth.local.salt', '_cronSignature', '_ABtests', 'secret'], + private: ['auth.local.hashed_password', 'auth.local.passwordHashMethod', 'auth.local.salt', '_cronSignature', '_ABtests', 'secret', 'profile.flags'], toJSONTransform: function userToJSON (plainObj, originalDoc) { plainObj._tmp = originalDoc._tmp; // be sure to send down drop notifs diff --git a/website/server/models/user/methods.js b/website/server/models/user/methods.js index 6272c84b72..760e81ac35 100644 --- a/website/server/models/user/methods.js +++ b/website/server/models/user/methods.js @@ -548,6 +548,12 @@ schema.methods.getSecretData = function getSecretData () { return user.secret; }; +schema.methods.getFlagData = function getFlagData () { + const user = this; + + return user.profile.flags; +}; + schema.methods.updateBalance = async function updateBalance (amount, transactionType, reference,