From 7f65707ee4d3afe2b06b31fb413fa5a941b9fc8a Mon Sep 17 00:00:00 2001 From: Keith Holliday Date: Sun, 20 Mar 2016 14:08:08 -0500 Subject: [PATCH] Ported user delete route and added initial tests --- common/locales/en/api-v3.json | 3 +- .../v3/integration/user/DELETE-user.test.js | 130 ++++++++++++++++++ website/src/controllers/api-v3/groups.js | 38 +---- website/src/controllers/api-v3/user.js | 47 ++++++- website/src/models/group.js | 44 ++++++ 5 files changed, 226 insertions(+), 36 deletions(-) create mode 100644 test/api/v3/integration/user/DELETE-user.test.js diff --git a/common/locales/en/api-v3.json b/common/locales/en/api-v3.json index e9dcd2d86d..788672975c 100644 --- a/common/locales/en/api-v3.json +++ b/common/locales/en/api-v3.json @@ -117,5 +117,6 @@ "missingPetFoodFeed": "\"pet\" and \"food\" are required parameters.", "invalidPetName": "Invalid pet name supplied.", "missingEggHatchingPotionHatch": "\"egg\" and \"hatchingPotion\" are required parameters.", - "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'." + "invalidTypeEquip": "\"type\" must be one of 'equipped', 'pet', 'mount', 'costume'.", + "cannotDeleteActiveAccount": "You have an active subscription, cancel your plan before deleting your account." } diff --git a/test/api/v3/integration/user/DELETE-user.test.js b/test/api/v3/integration/user/DELETE-user.test.js new file mode 100644 index 0000000000..3536bd8a7a --- /dev/null +++ b/test/api/v3/integration/user/DELETE-user.test.js @@ -0,0 +1,130 @@ +import { + checkExistence, + createAndPopulateGroup, + generateGroup, + generateUser, + translate as t, +} from '../../../../helpers/api-integration/v3'; +import { find } from 'lodash'; + +describe('DELETE /user', () => { + let user; + + beforeEach(async () => { + user = await generateUser({balance: 10}); + }); + + it('user has active subscription', async () => { + let userWithSubscription = await generateUser({'purchased.plan.customerId': 'fake-customer-id'}); + + await expect(userWithSubscription.del('/user')).to.be.rejected.and.to.eventually.eql({ + code: 401, + error: 'NotAuthorized', + message: t('cannotDeleteActiveAccount'), + }); + }); + + it('deletes the user', async () => { + await user.del('/user'); + await expect(checkExistence('users', user._id)).to.eventually.eql(false); + }); + + context('last member of a party', () => { + let party; + + beforeEach(async () => { + party = await generateGroup(user, { + type: 'party', + privacy: 'private', + }); + }); + + it('deletes party when user is the only member', async () => { + await user.del('/user'); + await expect(checkExistence('party', party._id)).to.eventually.eql(false); + }); + }); + + context('last member of a private guild', () => { + let privateGuild; + + beforeEach(async () => { + privateGuild = await generateGroup(user, { + type: 'guild', + privacy: 'private', + }); + }); + + it('deletes guild when user is the only member', async () => { + await user.del('/user'); + await expect(checkExistence('groups', privateGuild._id)).to.eventually.eql(false); + }); + }); + + context('groups user is leader of', () => { + let guild, oldLeader, newLeader; + + beforeEach(async () => { + let { group, groupLeader, members } = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 1, + }); + + guild = group; + newLeader = members[0]; + oldLeader = groupLeader; + }); + + it('chooses new group leader for any group user was the leader of', async () => { + await oldLeader.del('/user'); + + let updatedGuild = await newLeader.get(`/groups/${guild._id}`); + + expect(updatedGuild.leader).to.exist; + expect(updatedGuild.leader._id).to.not.eql(oldLeader._id); + }); + }); + + context('groups user is a part of', () => { + let group1, group2, userToDelete, otherUser; + + beforeEach(async () => { + userToDelete = await generateUser({balance: 10}); + + group1 = await generateGroup(userToDelete, { + type: 'guild', + privacy: 'public', + }); + + let {group, members} = await createAndPopulateGroup({ + groupDetails: { + type: 'guild', + privacy: 'public', + }, + members: 3, + }); + + group2 = group; + otherUser = members[0]; + + await userToDelete.post(`/groups/${group2._id}/join`); + }); + + it('removes user from all groups user was a part of', async () => { + await userToDelete.del('/user'); + + let updatedGroup1Members = await otherUser.get(`/groups/${group1._id}/members`); + let updatedGroup2Members = await otherUser.get(`/groups/${group2._id}/members`); + let userInGroup = find(updatedGroup2Members, (member) => { + return member._id === userToDelete._id; + }); + + expect(updatedGroup1Members).to.be.empty; + expect(updatedGroup2Members).to.not.be.empty; + expect(userInGroup).to.not.exist; + }); + }); +}); diff --git a/website/src/controllers/api-v3/groups.js b/website/src/controllers/api-v3/groups.js index 7850236ead..1430f1773f 100644 --- a/website/src/controllers/api-v3/groups.js +++ b/website/src/controllers/api-v3/groups.js @@ -102,42 +102,11 @@ api.getGroups = { let types = req.query.type.split(','); let groupFields = basicGroupFields.concat('description memberCount balance'); let sort = '-memberCount'; - let queries = []; - types.forEach(type => { - switch (type) { - case 'party': - queries.push(Group.getGroup({user, groupId: 'party', fields: groupFields})); - break; - case 'privateGuilds': - queries.push(Group.find({ - type: 'guild', - privacy: 'private', - _id: {$in: user.guilds}, - }).select(groupFields).sort(sort).exec()); - break; - case 'publicGuilds': - queries.push(Group.find({ - type: 'guild', - privacy: 'public', - }).select(groupFields).sort(sort).exec()); // TODO use lean? - break; - case 'tavern': - if (types.indexOf('publicGuilds') === -1) { - queries.push(Group.getGroup({user, groupId: 'habitrpg', fields: groupFields})); - } - break; - } - }); + let results = await Group.getGroups({user, types, groupFields, sort}); // If no valid value for type was supplied, return an error - if (queries.length === 0) throw new BadRequest(res.t('groupTypesRequired')); - - // TODO we would like not to return a single big array but Q doesn't support the funtionality https://github.com/kriskowal/q/issues/328 - let results = _.reduce(await Q.all(queries), (previousValue, currentValue) => { - if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array - return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue - }, []); + if (results.length === 0) throw new BadRequest(res.t('groupTypesRequired')); res.respond(200, results); }, @@ -170,7 +139,8 @@ api.getGroup = { group = Group.toJSONCleanChat(group, user); // TODO Instead of populate we make a find call manually because of https://github.com/Automattic/mongoose/issues/3833 - group.leader = (await User.findById(group.leader).select(nameFields).exec()).toJSON({minimize: true}); + let leader = await User.findById(group.leader).select(nameFields).exec(); + if (leader) group.leader = leader.toJSON({minimize: true}); res.respond(200, group); }, diff --git a/website/src/controllers/api-v3/user.js b/website/src/controllers/api-v3/user.js index e0b5b44312..a0b6262368 100644 --- a/website/src/controllers/api-v3/user.js +++ b/website/src/controllers/api-v3/user.js @@ -7,10 +7,14 @@ import { NotAuthorized, } from '../../libs/api-v3/errors'; import * as Tasks from '../../models/task'; -import { model as Group } from '../../models/group'; +import { + basicFields as basicGroupFields, + model as Group, +} from '../../models/group'; import { model as User } from '../../models/user'; import Q from 'q'; import _ from 'lodash'; +import * as firebase from '../../libs/api-v3/firebase'; let api = {}; @@ -41,6 +45,47 @@ api.getUser = { }, }; +/** + * @api {delete} /user DELETE an authenticated user's profile + * @apiVersion 3.0.0 + * @apiName UserDelete + * @apiGroup User + * + * @apiSuccess {} object An empty object + */ +api.deleteUser = { + method: 'DELETE', + middlewares: [authWithHeaders(), cron], + url: '/user', + async handler (req, res) { + let user = res.locals.user; + let plan = user.purchased.plan; + + if (plan && plan.customerId && !plan.dateTerminated) { + throw new NotAuthorized(res.t('cannotDeleteActiveAccount')); + } + + 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 populateLeader = true; + + let groupsUserIsMemberOf = await Group.getGroups({user, types, groupFields, populateLeader}); + + let groupLeavePromises = groupsUserIsMemberOf.map((group) => { + return group.leave(user, 'remove-all'); + }); + + await Q.all(groupLeavePromises); + + await user.remove(); + + res.respond(200, {}); + + firebase.deleteUser(user._id); + }, +}; + const partyMembersFields = 'profile.name stats achievements items.special'; /** diff --git a/website/src/models/group.js b/website/src/models/group.js index 9cf30e582e..57c0d7405d 100644 --- a/website/src/models/group.js +++ b/website/src/models/group.js @@ -151,6 +151,50 @@ schema.statics.getGroup = async function getGroup (options = {}) { return group; }; +schema.statics.getGroups = async function getGroups (options = {}) { + let {user, types, groupFields = basicFields, sort = '-memberCount', populateLeader = false} = options; + let queries = []; + + types.forEach(type => { + switch (type) { + case 'party': + queries.push(this.getGroup({user, groupId: 'party', fields: groupFields, populateLeader})); + break; + case 'privateGuilds': + let privateGroupQuery = this.find({ + type: 'guild', + privacy: 'private', + _id: {$in: user.guilds}, + }).select(groupFields); + if (populateLeader === true) privateGroupQuery.populate('leader', nameFields); + privateGroupQuery.sort(sort).exec(); + queries.push(privateGroupQuery); + break; + case 'publicGuilds': + let publicGroupQuery = this.find({ + type: 'guild', + privacy: 'public', + }).select(groupFields); + if (populateLeader === true) publicGroupQuery.populate('leader', nameFields); + publicGroupQuery.sort(sort).exec(); + queries.push(publicGroupQuery); // TODO use lean? + break; + case 'tavern': + if (types.indexOf('publicGuilds') === -1) { + queries.push(this.getGroup({user, groupId: 'habitrpg', fields: groupFields})); + } + break; + } + }); + + let groupsArray = _.reduce(await Q.all(queries), (previousValue, currentValue) => { + if (_.isEmpty(currentValue)) return previousValue; // don't add anything to the results if the query returned null or an empty array + return previousValue.concat(Array.isArray(currentValue) ? currentValue : [currentValue]); // otherwise concat the new results to the previousValue + }, []); + + return groupsArray; +}; + // When converting to json remove chat messages with more than 1 flag and remove all flags info // unless the user is an admin // Not putting into toJSON because there we can't access user