diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index 8880939f58..a9c0a0fb2d 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -127,5 +127,6 @@ "privateMessageGiftGemsMessage": "<%= gemAmount %> gems! ", "privateMessageGiftSubscriptionMessage": "<%= numberOfMonths %> months of subscription! ", "cannotSendGemsToYourself": "Cannot send gems to yourself. Try a subscription instead.", - "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems." + "notEnoughGemsToSend": "Amount must be within 0 and your current number of gems.", + "mustPurchaseToSet": "Must purchase <%= val %> to set it on <%= key %>." } diff --git a/test/api/v3/integration/user/PUT-user.test.js b/test/api/v3/integration/user/PUT-user.test.js new file mode 100644 index 0000000000..7ea5b44cbf --- /dev/null +++ b/test/api/v3/integration/user/PUT-user.test.js @@ -0,0 +1,200 @@ +import { + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; + +import { each, get } from 'lodash'; + +describe('PUT /user', () => { + let user; + + beforeEach(async () => { + user = await generateUser(); + }); + + context('Allowed Operations', () => { + it('updates the user', async () => { + await user.put('/user', { + 'profile.name': 'Frodo', + 'preferences.costume': true, + 'stats.hp': 14, + }); + + await user.sync(); + + expect(user.profile.name).to.eql('Frodo'); + expect(user.preferences.costume).to.eql(true); + expect(user.stats.hp).to.eql(14); + }); + }); + + context('Top Level Protected Operations', () => { + let protectedOperations = { + 'gem balance': {balance: 100}, + auth: {'auth.blocked': true, 'auth.timestamps.created': new Date()}, + contributor: {'contributor.level': 9, 'contributor.admin': true, 'contributor.text': 'some text'}, + backer: {'backer.tier': 10, 'backer.npc': 'Bilbo'}, + subscriptions: {'purchased.plan.extraMonths': 500, 'purchased.plan.consecutive.trinkets': 1000}, + 'customization gem purchases': {'purchased.background.tavern': true, 'purchased.skin.bear': true}, + }; + + each(protectedOperations, (data, testName) => { + it(`does not allow updating ${testName}`, async () => { + let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] }); + + await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: errorText, + }); + }); + }); + }); + + context('Sub-Level Protected Operations', () => { + let protectedOperations = { + 'class stat': {'stats.class': 'wizard'}, + 'flags unless whitelisted': {'flags.dropsEnabled': true}, + webhooks: {'preferences.webhooks': [1, 2, 3]}, + sleep: {'preferences.sleep': true}, + }; + + each(protectedOperations, (data, testName) => { + it(`does not allow updating ${testName}`, async () => { + let errorText = t('messageUserOperationProtected', { operation: Object.keys(data)[0] }); + + await expect(user.put('/user', data)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: errorText, + }); + }); + }); + }); + + context('Default Appearance Preferences', () => { + let testCases = { + shirt: 'yellow', + skin: 'ddc994', + 'hair.color': 'blond', + 'hair.bangs': 2, + 'hair.base': 1, + 'hair.flower': 4, + size: 'broad', + }; + + each(testCases, (item, type) => { + const update = {}; + update[`preferences.${type}`] = item; + + it(`updates user with ${type} that is a default`, async () => { + let dbUpdate = {}; + dbUpdate[`purchased.${type}.${item}`] = true; + await user.update(dbUpdate); + + // Sanity checks to make sure user is not already equipped with item + expect(get(user.preferences, type)).to.not.eql(item); + + let updatedUser = await user.put('/user', update); + + expect(get(updatedUser.preferences, type)).to.eql(item); + }); + }); + + it('returns an error if user tries to update body size with invalid type', async () => { + await expect(user.put('/user', { + 'preferences.size': 'round', + })).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t(`mustPurchaseToSet`, { val: 'round', key: 'preferences.size' }), + }); + }); + + it('can set beard to default', async () => { + await user.update({ + 'purchased.hair.beard': 3, + 'preferences.hair.beard': 3, + }); + + let updatedUser = await user.put('/user', { + 'preferences.hair.beard': 0, + }); + + expect(updatedUser.preferences.hair.beard).to.eql(0); + }); + + it('can set mustache to default', async () => { + await user.update({ + 'purchased.hair.mustache': 2, + 'preferences.hair.mustache': 2, + }); + + let updatedUser = await user.put('/user', { + 'preferences.hair.mustache': 0, + }); + + expect(updatedUser.preferences.hair.mustache).to.eql(0); + }); + }); + + context('Purchasable Appearance Preferences', () => { + let testCases = { + background: 'volcano', + shirt: 'convict', + skin: 'cactus', + 'hair.base': 7, + 'hair.beard': 2, + 'hair.color': 'rainbow', + 'hair.mustache': 2, + }; + + each(testCases, (item, type) => { + const update = {}; + update[`preferences.${type}`] = item; + + it(`returns an error if user tries to update ${type} with ${type} the user does not own`, async () => { + await expect(user.put('/user', update)).to.eventually.be.rejected.and.eql({ + code: 401, + error: 'NotAuthorized', + message: t('mustPurchaseToSet', {val: item, key: `preferences.${type}`}), + }); + }); + + it(`updates user with ${type} user does own`, async () => { + let dbUpdate = {}; + dbUpdate[`purchased.${type}.${item}`] = true; + await user.update(dbUpdate); + + // Sanity check to make sure user is not already equipped with item + expect(get(user.preferences, type)).to.not.eql(item); + + let updatedUser = await user.put('/user', update); + + expect(get(updatedUser.preferences, type)).to.eql(item); + }); + }); + }); + + context('Improvement Categories', () => { + it('sets valid categories', async () => { + await user.put('/user', { + 'preferences.improvementCategories': ['work', 'school'], + }); + + await user.sync(); + + expect(user.preferences.improvementCategories).to.eql(['work', 'school']); + }); + + it('discards invalid categories', async () => { + await expect(user.put('/user', { + 'preferences.improvementCategories': ['work', 'procrastination', 'school'], + })).to.eventually.be.rejected.and.eql({ + code: 400, + error: 'BadRequest', + message: 'User validation failed', + }); + }); + }); +}); diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index d7fff0bd6f..147f1552c9 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -72,12 +72,116 @@ api.getBuyList = { }, }; +let updatablePaths = [ + 'flags.customizationsNotification', + 'flags.showTour', + 'flags.tour', + 'flags.tutorial', + 'flags.communityGuidelinesAccepted', + 'flags.welcomed', + 'flags.cardReceived', + 'flags.warnedLowHealth', + + 'achievements', + + 'party.order', + 'party.orderAscending', + 'party.quest.completed', + 'party.quest.RSVPNeeded', + + 'preferences', + 'profile', + 'stats', + 'inbox.optOut', +]; + +// This tells us for which paths users can call `PUT /user`. +// The trick here is to only accept leaf paths, not root/intermediate paths (see http://goo.gl/OEzkAs) +let acceptablePUTPaths = _.reduce(require('./../../models/user').schema.paths, (accumulator, val, leaf) => { + let found = _.find(updatablePaths, (rootPath) => { + return leaf.indexOf(rootPath) === 0; + }); + + if (found) accumulator[leaf] = true; + + return accumulator; +}, {}); + +let restrictedPUTSubPaths = [ + 'stats.class', + + 'preferences.sleep', + 'preferences.webhooks', +]; + +_.each(restrictedPUTSubPaths, (removePath) => { + delete acceptablePUTPaths[removePath]; +}); + +let requiresPurchase = { + 'preferences.background': 'background', + 'preferences.shirt': 'shirt', + 'preferences.size': 'size', + 'preferences.skin': 'skin', + 'preferences.hair.bangs': 'hair.bangs', + 'preferences.hair.base': 'hair.base', + 'preferences.hair.beard': 'hair.beard', + 'preferences.hair.color': 'hair.color', + 'preferences.hair.flower': 'hair.flower', + 'preferences.hair.mustache': 'hair.mustache', +}; + +let checkPreferencePurchase = (user, path, item) => { + let itemPath = `${path}.${item}`; + let appearance = _.get(common.content.appearances, itemPath); + if (!appearance) return false; + if (appearance.price === 0) return true; + + return _.get(user.purchased, itemPath); +}; + +/** + * @api {put} /user Update the user. Example body: {'stats.hp':50, 'preferences.background': 'beach'} + * @apiVersion 3.0.0 + * @apiName UserUpdate + * @apiGroup User + * + * @apiSuccess user object The updated user object + */ +api.updateUser = { + method: 'PUT', + middlewares: [authWithHeaders(), cron], + url: '/user', + async handler (req, res) { + let user = res.locals.user; + + _.each(req.body, (val, key) => { + let purchasable = requiresPurchase[key]; + + if (purchasable && !checkPreferencePurchase(user, purchasable, val)) { + throw new NotAuthorized(res.t(`mustPurchaseToSet`, { val, key })); + } + + if (acceptablePUTPaths[key]) { + _.set(user, key, val); + } else { + throw new NotAuthorized(res.t('messageUserOperationProtected', { operation: key })); + } + }); + + await user.save(); + return res.respond(200, user); + }, +}; + /** * @api {delete} /user DELETE an authenticated user's profile * @apiVersion 3.0.0 * @apiName UserDelete * @apiGroup User * + * @apiParam {string} password The user's password unless it's a Facebook account + * * @apiSuccess {} object An empty object */ api.deleteUser = { @@ -105,7 +209,6 @@ api.deleteUser = { } let types = ['party', 'publicGuilds', 'privateGuilds']; - // @TODO: The group leave route doesn't work unless it has these fields. We should probably force the group to get these let groupFields = basicGroupFields.concat(' leader memberCount'); let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields}); diff --git a/website/src/middlewares/api-v3/errorHandler.js b/website/src/middlewares/api-v3/errorHandler.js index 95a592514d..401da18627 100644 --- a/website/src/middlewares/api-v3/errorHandler.js +++ b/website/src/middlewares/api-v3/errorHandler.js @@ -72,7 +72,9 @@ module.exports = function errorHandler (err, req, res, next) { // eslint-disable message: responseErr.message, }; - if (responseErr.errors) jsonRes.errors = responseErr.errors; + if (responseErr.errors) { + jsonRes.errors = responseErr.errors; + } // In some occasions like when invalid JSON is supplied `res.respond` might be not yet avalaible, // in this case we use the standard res.status(...).json(...) diff --git a/website/src/models/user.js b/website/src/models/user.js index 49caaeff81..3d26234960 100644 --- a/website/src/models/user.js +++ b/website/src/models/user.js @@ -526,6 +526,7 @@ export let schema = new Schema({ schema.plugin(baseModel, { // TODO revisit a lot of things are missing. Given how many attributes we do have here we should white-list the ones that can be updated + // TODO this is a only used for creating an user, on update we use a whitelist noSet: ['_id', 'apiToken', 'auth.blocked', 'auth.timestamps', 'lastCron', 'auth.local.hashed_password', 'auth.local.salt', 'tasksOrder', 'tags', 'stats', 'challenges', 'guilds', 'party._id', 'party.quest', 'invitations', 'balance', 'backer', 'contributor'],