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;