diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 649adf8c94..f4cfaa5272 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -16,6 +16,7 @@ "onlySocialAttachLocal": "Local auth can only be added to a social account.", "invalidReqParams": "Invalid request parameters.", "memberIdRequired": "\"member\" must be a valid UUID.", + "heroIdRequired": "\"heroId\" must be a valid UUID.", "taskIdRequired": "\"taskId\" must be a valid UUID.", "taskNotFound": "Task not found.", "invalidTaskType": "Task type must be one of \"habit\", \"daily\", \"todo\", \"reward\".", @@ -85,5 +86,7 @@ "onlyLeaderCancelQuest": "Only the group or quest leader can cancel the quest.", "questInvitationDoesNotExist": "No quest invitation has been sent out yet.", "questNotPending": "There is no quest to start.", - "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest" + "questOrGroupLeaderOnlyStartQuest": "Only the quest leader or group leader can force start the quest", + "noAdminAccess": "You don't have admin access.", + "pageMustBeNumber": "req.query.page must be a number" } diff --git a/test/api/v3/integration/hall/GET-hall_heroes.test.js b/test/api/v3/integration/hall/GET-hall_heroes.test.js new file mode 100644 index 0000000000..745bc7739c --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_heroes.test.js @@ -0,0 +1,29 @@ +import { + generateUser, +} from '../../../../helpers/api-v3-integration.helper'; + +describe('GET /hall/heroes', () => { + it('returns all heroes sorted by -contributor.level and with correct fields', async () => { + let nonHero = await generateUser(); + let hero1 = await generateUser({ + contributor: {level: 1}, + }); + let hero2 = await generateUser({ + contributor: {level: 3}, + }); + + let heroes = await nonHero.get('/hall/heroes'); + expect(heroes.length).to.equal(2); + expect(heroes[0]._id).to.equal(hero2._id); + expect(heroes[1]._id).to.equal(hero1._id); + + expect(heroes[0]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + expect(heroes[1]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + + expect(heroes[0].profile).to.have.all.keys(['name']); + expect(heroes[1].profile).to.have.all.keys(['name']); + + expect(heroes[0].profile.name).to.equal(hero2.profile.name); + expect(heroes[1].profile.name).to.equal(hero1.profile.name); + }); +}); diff --git a/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js new file mode 100644 index 0000000000..ba2bec1783 --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_heroes_heroId.test.js @@ -0,0 +1,56 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('GET /heroes/:heroId', () => { + let user; + + before(async () => { + user = await generateUser({ + contributor: {admin: true}, + }); + }); + + it('requires the caller to be an admin', async () => { + let nonAdmin = await generateUser(); + + await expect(nonAdmin.get(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noAdminAccess'), + }); + }); + + it('validates req.params.heroId', async () => { + await expect(user.get(`/hall/heroes/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('handles non-existing heroes', async () => { + let dummyId = generateUUID(); + await expect(user.get(`/hall/heroes/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); + + it('returns only necessary hero data', async () => { + let hero = await generateUser({ + contributor: {tier: 23}, + }); + let heroRes = await user.get(`/hall/heroes/${hero._id}`); + + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + }); +}); diff --git a/test/api/v3/integration/hall/GET-hall_patrons.test.js b/test/api/v3/integration/hall/GET-hall_patrons.test.js new file mode 100644 index 0000000000..17fa3fd58d --- /dev/null +++ b/test/api/v3/integration/hall/GET-hall_patrons.test.js @@ -0,0 +1,60 @@ +import { + generateUser, + translate as t, + resetHabiticaDB, +} from '../../../../helpers/api-v3-integration.helper'; +import { times } from 'lodash'; + +describe('GET /hall/patrons', () => { + let user; + + beforeEach(async () => { + await resetHabiticaDB(); + user = await generateUser(); + }); + + it('fails if req.query.page is not numeric', async () => { + await expect(user.get(`/hall/patrons?page=notNumber`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('returns all patrons sorted by -backer.tier and with correct fields', async () => { + let patron1 = await generateUser({ + backer: {tier: 1}, + }); + let patron2 = await generateUser({ + backer: {tier: 3}, + }); + + let patrons = await user.get('/hall/patrons'); + expect(patrons.length).to.equal(2); + expect(patrons[0]._id).to.equal(patron2._id); + expect(patrons[1]._id).to.equal(patron1._id); + + expect(patrons[0]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + expect(patrons[1]).to.have.all.keys(['_id', 'contributor', 'backer', 'profile']); + + expect(patrons[0].profile).to.have.all.keys(['name']); + expect(patrons[1].profile).to.have.all.keys(['name']); + + expect(patrons[0].profile.name).to.equal(patron2.profile.name); + expect(patrons[1].profile.name).to.equal(patron1.profile.name); + }); + + it('returns only first 50 patrons per request, more if req.query.page is passed', async () => { + await Promise.all(times(53, n => { + return generateUser({backer: {tier: n}}); + })); + + let patrons = await user.get('/hall/patrons'); + expect(patrons.length).to.equal(50); + + let morePatrons = await user.get('/hall/patrons?page=1'); + expect(morePatrons.length).to.equal(2); + expect(morePatrons[0].backer.tier).to.equal(2); + expect(morePatrons[1].backer.tier).to.equal(1); + }); +}); diff --git a/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js new file mode 100644 index 0000000000..391d5cdeee --- /dev/null +++ b/test/api/v3/integration/hall/PUT-hall_heores_heroId.test.js @@ -0,0 +1,148 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-v3-integration.helper'; +import { v4 as generateUUID } from 'uuid'; + +describe('PUT /heroes/:heroId', () => { + let user; + + before(async () => { + user = await generateUser({ + contributor: {admin: true}, + }); + }); + + it('requires the caller to be an admin', async () => { + let nonAdmin = await generateUser(); + + await expect(nonAdmin.put(`/hall/heroes/${user._id}`)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('noAdminAccess'), + }); + }); + + it('validates req.params.heroId', async () => { + await expect(user.put(`/hall/heroes/invalidUUID`)).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: t('invalidReqParams'), + }); + }); + + it('handles non-existing heroes', async () => { + let dummyId = generateUUID(); + await expect(user.put(`/hall/heroes/${dummyId}`)).to.eventually.be.rejected.and.eql({ + code: 404, + error: 'NotFound', + message: t('userWithIDNotFound', {userId: dummyId}), + }); + }); + + it('updates contributor level, balance, ads, blocked', async () => { + let hero = await generateUser(); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + balance: 3, + contributor: {level: 1}, + purchased: {ads: true}, + auth: {blocked: true}, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level + expect(heroRes.contributor.level).to.equal(1); + expect(heroRes.purchased.ads).to.equal(true); + expect(heroRes.auth.blocked).to.equal(true); + // test hero values + await hero.sync(); + expect(hero.balance).to.equal(3 + 0.75); // 3+0.75 for first contrib level + expect(hero.contributor.level).to.equal(1); + expect(hero.flags.contributor).to.equal(true); + expect(hero.purchased.ads).to.equal(true); + expect(hero.auth.blocked).to.equal(true); + }); + + it('updates contributor level', async () => { + let hero = await generateUser({ + contributor: {level: 5}, + }); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + contributor: {level: 6}, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.balance).to.equal(1); // 0+1 for sixth contrib level + expect(heroRes.contributor.level).to.equal(6); + expect(heroRes.items.pets['Dragon-Hydra']).to.equal(5); + // test hero values + await hero.sync(); + expect(hero.balance).to.equal(1); // 0+1 for sixth contrib level + expect(hero.contributor.level).to.equal(6); + expect(hero.flags.contributor).to.equal(true); + expect(hero.items.pets['Dragon-Hydra']).to.equal(5); + }); + + it('updates contributor data', async () => { + let hero = await generateUser({ + contributor: {level: 5}, + }); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + contributor: {text: 'Astronaut'}, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.contributor.level).to.equal(5); // doesn't modify previous values + expect(heroRes.contributor.text).to.equal('Astronaut'); + // test hero values + await hero.sync(); + expect(hero.contributor.level).to.equal(5); // doesn't modify previous values + expect(hero.contributor.text).to.equal('Astronaut'); + }); + + it('updates items', async () => { + let hero = await generateUser(); + let heroRes = await user.put(`/hall/heroes/${hero._id}`, { + itemPath: 'items.special.snowball', + itemVal: 5, + }); + + // test response + expect(heroRes).to.have.all.keys([ // works as: object has all and only these keys + '_id', 'balance', 'profile', 'purchased', + 'contributor', 'auth', 'items', + ]); + expect(heroRes.auth.local).not.to.have.keys(['salt', 'hashed_password']); + expect(heroRes.profile).to.have.all.keys(['name']); + + // test response values + expect(heroRes.items.special.snowball).to.equal(5); + // test hero values + await hero.sync(); + expect(hero.items.special.snowball).to.equal(5); + }); +}); diff --git a/website/src/controllers/api-v3/hall.js b/website/src/controllers/api-v3/hall.js new file mode 100644 index 0000000000..57454c038d --- /dev/null +++ b/website/src/controllers/api-v3/hall.js @@ -0,0 +1,192 @@ +import { authWithHeaders } from '../../middlewares/api-v3/auth'; +import cron from '../../middlewares/api-v3/cron'; +import { model as User } from '../../models/user'; +import { + NotFound, + NotAuthorized, +} from '../../libs/api-v3/errors'; +import _ from 'lodash'; + +let api = {}; + +/** + * @api {get} /hall/patrons Get all Patrons. Only the first 50 patrons are returned. More can be accessed passing ?page=n. + * @apiVersion 3.0.0 + * @apiName GetPatrons + * @apiGroup Hall + * + * @apiParam {Number} page The result page. Default is 0 + * + * @apiSuccess {Array} patron An array of patrons + */ +api.getPatrons = { + method: 'GET', + url: '/hall/patrons', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + req.checkQuery('page', res.t('pageMustBeNumber')).optional().isNumeric(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + let page = req.query.page ? Number(req.query.page) : 0; + const perPage = 50; + + let patrons = await User + .find({ + 'backer.tier': {$gt: 0}, + }) + .select('contributor backer profile.name') + .sort('-backer.tier') + .skip(page * perPage) + .limit(perPage) + .lean() + .exec(); + + res.respond(200, patrons); + }, +}; + +/** + * @api {get} /hall/heroes Get all Heroes + * @apiVersion 3.0.0 + * @apiName GetHeroes + * @apiGroup Hall + * + * @apiSuccess {Array} hero An array of heroes + */ +api.getHeroes = { + method: 'GET', + url: '/hall/heroes', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let heroes = await User + .find({ + 'contributor.level': {$gt: 0}, + }) + .select('contributor backer profile.name') + .sort('-contributor.level') + .lean() + .exec(); + + res.respond(200, heroes); + }, +}; + +// Note, while the following routes are called getHero / updateHero +// they can be used by admins to get/update any user +// TODO rename? + +const heroAdminFields = 'contributor balance profile.name purchased items auth'; + +/** + * @api {get} /hall/heroes/:heroId Get an hero given his _id. Must be an admin to make this request + * @apiVersion 3.0.0 + * @apiName GetHero + * @apiGroup Hall + * + * @apiSuccess {Object} hero The hero object + */ +api.getHero = { + method: 'GET', + url: '/hall/heroes/:heroId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let heroId = req.params.heroId; + + req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('noAdminAccess')); + } + + let hero = await User + .findById(heroId) + .select(heroAdminFields) + .exec(); + + if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); + let heroRes = hero.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, heroRes); + }, +}; + +// e.g., tier 5 gives 4 gems. Tier 8 = moderator. Tier 9 = staff +const gemsPerTier = {1: 3, 2: 3, 3: 3, 4: 4, 5: 4, 6: 4, 7: 4, 8: 0, 9: 0}; + +/** + * @api {put} /hall/heroes/:heroId Update an hero. Must be an admin to make this request + * @apiVersion 3.0.0 + * @apiName UpdateHero + * @apiGroup Hall + * + * @apiSuccess {Object} hero The updated hero object + */ +api.updateHero = { + method: 'PUT', + url: '/hall/heroes/:heroId', + middlewares: [authWithHeaders(), cron], + async handler (req, res) { + let user = res.locals.user; + let heroId = req.params.heroId; + let updateData = req.body; + + req.checkParams('heroId', res.t('heroIdRequired')).notEmpty().isUUID(); + + let validationErrors = req.validationErrors(); + if (validationErrors) throw validationErrors; + + if (!user.contributor.admin) { + throw new NotAuthorized(res.t('noAdminAccess')); + } + + let hero = await User.findById(heroId).exec(); + if (!hero) throw new NotFound(res.t('userWithIDNotFound', {userId: heroId})); + + if (updateData.balance) hero.balance = updateData.balance; + + // give them gems if they got an higher level + let newTier = updateData.contributor && updateData.contributor.level; // tier = level in this context + let oldTier = hero.contributor && hero.contributor.level || 0; + if (newTier > oldTier) { + hero.flags.contributor = true; + let tierDiff = newTier - oldTier; // can be 2+ tier increases at once + while (tierDiff) { + hero.balance += gemsPerTier[newTier] / 4; // balance is in $ + tierDiff--; + newTier--; // give them gems for the next tier down if they weren't aready that tier + } + } + + if (updateData.contributor) _.assign(hero.contributor, updateData.contributor); + if (updateData.purchased && updateData.purchased.ads) hero.purchased.ads = updateData.purchased.ads; + + // give them the Dragon Hydra pet if they're above level 6 + if (hero.contributor.level >= 6) hero.items.pets['Dragon-Hydra'] = 5; + if (updateData.itemPath && updateData.itemVal && + updateData.itemPath.indexOf('items.') === 0 && + User.schema.paths[updateData.itemPath]) { + _.set(hero, updateData.itemPath, updateData.itemVal); // Sanitization at 5c30944 (deemed unnecessary) TODO review + } + + if (updateData.auth && _.isBoolean(updateData.auth.blocked)) hero.auth.blocked = updateData.auth.blocked; + + let savedHero = await hero.save(); + let heroJSON = savedHero.toJSON(); + let responseHero = {_id: heroJSON._id}; // only respond with important fields + heroAdminFields.split(' ').forEach(field => { + _.set(responseHero, field, _.get(heroJSON, field)); + }); + + res.respond(200, responseHero); + }, +}; + +export default api;