From 0d667323d3c1bf653f3d26918e5ae940f67eaa0a Mon Sep 17 00:00:00 2001 From: Alys Date: Sat, 18 Apr 2020 18:44:37 +1000 Subject: [PATCH] create GetHeroParty API v3 route to fetch a given user's party data Only some data from the party will be loaded (e.g., not private data such as name, description). Includes tests for the route. See the next commit for front-end changes that use this. --- .../GET-hall_heroes_party_groupId.test.js | 64 +++++++++++++++++++ website/client/src/store/actions/hall.js | 6 ++ .../common/script/errors/apiErrorMessages.js | 1 + website/server/controllers/api-v3/hall.js | 53 +++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js diff --git a/test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js new file mode 100644 index 0000000000..c112461e86 --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_heroes_party_groupId.test.js @@ -0,0 +1,64 @@ +import { v4 as generateUUID } from 'uuid'; +import { + generateUser, + generateGroup, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import apiError from '../../../../../website/server/libs/apiError'; + +describe('GET /heroes/party/:groupId', () => { + let user; // admin user + + before(async () => { + user = await generateUser({ + contributor: { admin: true }, + }); + }); + + it('requires the caller to be an admin', async () => { + const nonAdmin = await generateUser(); + const party = await generateGroup(nonAdmin, { type: 'party', privacy: 'private' }); + await expect(nonAdmin.get(`/hall/heroes/party/${party._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noAdminAccess'), + }); + }); + + it('validates req.params.groupId', async () => { + await expect(user.get('/hall/heroes/party/invalidUUID')).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + // message: apiError('groupIdRequired'), // XXX why doesn't this work? + }); + }); + + it('handles non-existing party', async () => { + const dummyId = generateUUID(); + await expect(user.get(`/hall/heroes/party/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: apiError('groupWithIDNotFound', { groupId: dummyId }), + }); + }); + + it('returns only necessary party data given group id', async () => { + const nonAdmin = await generateUser(); + const party = await generateGroup(nonAdmin, { type: 'party', privacy: 'private' }); + + const partyRes = await user.get(`/hall/heroes/party/${party._id}`); + + expect(partyRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'id', 'balance', 'challengeCount', 'leader', 'leaderOnly', 'memberCount', + 'purchased', 'quest', 'summary', + ]); + expect(partyRes.summary).to.eq(' '); + // NB: 'summary' is NOT a field that the API route retrieves! + // It must not be retrieved for privacy reasons. + // However the group model automatically adds a summary for reasons given here: + // https://github.com/HabitRPG/habitica/blob/8da36bf27c62ba0397a6af260c20d35a17f3d911/website/server/models/group.js#L161-L170 + + // XXX need something like this? expect(heroRes.profile).to.have.all.keys(['name']); + }); +}); diff --git a/website/client/src/store/actions/hall.js b/website/client/src/store/actions/hall.js index c3139e07cb..52fde94b26 100644 --- a/website/client/src/store/actions/hall.js +++ b/website/client/src/store/actions/hall.js @@ -26,3 +26,9 @@ export async function getPatrons (store, payload) { const response = await axios.get(url); return response.data.data; } + +export async function getHeroParty (store, payload) { + const url = `/api/v4/hall/heroes/party/${payload.groupId}`; + const response = await axios.get(url); + return response.data.data; +} diff --git a/website/common/script/errors/apiErrorMessages.js b/website/common/script/errors/apiErrorMessages.js index 77991b3f9b..c316179e8c 100644 --- a/website/common/script/errors/apiErrorMessages.js +++ b/website/common/script/errors/apiErrorMessages.js @@ -14,6 +14,7 @@ export default { guildsOnlyPaginate: 'Only public guilds support pagination.', guildsPaginateBooleanString: 'req.query.paginate must be a boolean string.', groupIdRequired: 'req.params.groupId must contain a groupId.', + groupWithIDNotFound: 'Group with id "<%= groupId %>" not found.', groupRemainOrLeaveChallenges: 'req.query.keep must be either "remain-in-challenges" or "leave-challenges"', managerIdRequired: 'req.body.managerId must contain a User ID.', noSudoAccess: 'You don\'t have sudo access.', diff --git a/website/server/controllers/api-v3/hall.js b/website/server/controllers/api-v3/hall.js index 95f59b6c00..bb5a42022d 100644 --- a/website/server/controllers/api-v3/hall.js +++ b/website/server/controllers/api-v3/hall.js @@ -3,6 +3,7 @@ import validator from 'validator'; import { authWithHeaders } from '../../middlewares/auth'; import { ensureAdmin } from '../../middlewares/ensureAccessRight'; import { model as User } from '../../models/user'; +import { model as Group } from '../../models/group'; import { NotFound, } from '../../libs/errors'; @@ -147,6 +148,9 @@ api.getHeroes = { const heroAdminFields = 'contributor secret balance profile.name purchased items preferences auth lastCron flags.chatRevoked flags.chatShadowMuted party'; +const heroPartyAdminFields = 'balance challengeCount leader leaderOnly memberCount purchased quest'; +// must never include Party name, description, summary, leaderMessage + /** * @api {get} /api/v3/hall/heroes/:heroId Get any user ("hero") given the UUID or Username * @apiParam (Path) {UUID} heroId user ID @@ -323,4 +327,53 @@ api.updateHero = { }, }; +/** + * @api {get} /api/v3/hall/heroes/party/:groupId Get any Party given its ID + * @apiParam (Path) {UUID} groupId party's group ID + * @apiName GetHeroParty + * @apiGroup Hall + * @apiPermission Admin + * + * @apiDescription Returns some basic information about a given Party, + # to assist admins with user support. + * + * @apiSuccess {Object} data The party object (contains computed fields + * that are not in the Group model) + * + * @apiUse NoAuthHeaders + * @apiUse NoAccount + * @apiUse NoUser + * @apiUse NotAdmin + * // XXX add others missing from here + * @apiUse groupIdRequired + * @apiUse GroupNotFound + */ +api.getHeroParty = { // XXX tests + method: 'GET', + url: '/hall/heroes/party/:groupId', + middlewares: [authWithHeaders(), ensureAdmin], + async handler (req, res) { + req.checkParams('groupId', apiError('groupIdRequired')).notEmpty().isUUID(); + + const validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + const { groupId } = req.params; + + const query = { _id: groupId }; + + const party = await Group + .findOne(query) + .select(heroPartyAdminFields) + .exec(); + + if (!party) throw new NotFound(apiError('groupWithIDNotFound', { groupId })); // XXX check that's handled nicely // groupWithIDNotFound: 'Group with id \"<%= groupId %>\" not found.', + const partyRes = party.toJSON({ minimize: true }); + // //// // supply to the possible absence of hero.contributor + // //// // if we didn't pass minimize: true it would have returned all fields as empty + // //// if (!heroRes.contributor) heroRes.contributor = {}; + res.respond(200, partyRes); + }, +}; + export default api;