From 64e03ab7a41b11c06cef35eb104d08ec9103cf46 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Wed, 6 Dec 2023 19:37:33 +0100 Subject: [PATCH] feat: Add friends list --- .../__tests__/api/controllers/user.spec.ts | 271 +++++++++++++++--- backend/__tests__/dal/user.spec.ts | 102 +++++++ backend/src/api/controllers/user.ts | 38 +++ backend/src/api/routes/users.ts | 41 +++ backend/src/dal/user.ts | 44 +++ backend/src/middlewares/rate-limit.ts | 19 ++ backend/src/types/types.d.ts | 6 + 7 files changed, 486 insertions(+), 35 deletions(-) diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 18e05ab38..97711e51c 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -1,11 +1,52 @@ import request from "supertest"; import app from "../../../src/app"; +import _ from "lodash"; import * as Configuration from "../../../src/init/configuration"; +import * as UserDal from "../../../src/dal/user"; +import * as AuthUtils from "../../../src/utils/auth"; +import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier"; +import MonkeyError from "../../../src/utils/error"; const mockApp = request(app); +const configuration = Configuration.getCachedConfiguration(); -describe("user controller test", () => { +const uid = "123456"; + +const mockDecodedToken: DecodedIdToken = { + uid, + email: "newuser@mail.com", + iat: 0, +} as DecodedIdToken; + +jest.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken); + +function dummyUser(uid): MonkeyTypes.User { + return { + uid, + addedAt: 0, + email: "test@example.com", + name: "Bob", + personalBests: { + time: {}, + words: {}, + quote: {}, + custom: {}, + zen: {}, + }, + }; +} + +const userGetMock = jest.spyOn(UserDal, "getUser"); +const userGetFriendsListMock = jest.spyOn(UserDal, "getFriendsList"); +const userAddFriendMock = jest.spyOn(UserDal, "addFriend"); +const userRemoveFriendMock = jest.spyOn(UserDal, "removeFriend"); + +describe("UserController", () => { describe("user creation flow", () => { + beforeEach(() => { + enableSignUpFeatures(true); + }); + it("should be able to check name, sign up, and get user data", async () => { await mockApp .get("/users/checkName/NewUser") @@ -21,39 +62,6 @@ describe("user controller test", () => { captcha: "captcha", }; - jest.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue({ - //if stuff breaks this might be the reason - users: { - signUp: true, - discordIntegration: { - enabled: false, - }, - autoBan: { - enabled: false, - maxCount: 5, - maxHours: 1, - }, - profiles: { - enabled: false, - }, - xp: { - enabled: false, - gainMultiplier: 0, - maxDailyBonus: 0, - minDailyBonus: 0, - streak: { - enabled: false, - maxStreakDays: 0, - maxStreakMultiplier: 0, - }, - }, - inbox: { - enabled: false, - maxMail: 0, - }, - }, - } as any); - await mockApp .post("/users/signup") .set("authorization", "Uid 123456789|newuser@mail.com") @@ -86,8 +94,201 @@ describe("user controller test", () => { Accept: "application/json", }) .expect(409); + }); + }); - jest.restoreAllMocks(); + describe("friends", () => { + beforeEach(async () => { + await enablePremiumFeatures(true); + }); + + describe("getFriends", () => { + afterEach(() => { + [userGetMock, userGetFriendsListMock].forEach((it) => it.mockReset()); + }); + + it("should get get friends list", async () => { + //GIVEN + userGetFriendsListMock.mockResolvedValue([]); + + //WHEN + const { + body: { data: friendsList }, + } = await mockApp + .get("/users/friends") + .set("Authorization", "Bearer 123456789") + .send() + .expect(200); + + //THEN + expect(friendsList).toEqual([]); + + expect(userGetFriendsListMock).toBeCalledWith(uid); + }); + + describe("validations", () => { + it("should fail if premium feature is disabled", async () => { + //GIVEN + await enablePremiumFeatures(false); + + //WHEN + await mockApp + .get("/users/friends") + .set("Authorization", "Bearer 123456789") + .send() + .expect(503) + .expect(expectErrorMessage("Premium is temporarily disabled.")); + }); + + it("should fail without authorization", async () => { + await mockApp.get("/users/friends").send().expect(401); + }); + }); + }); + describe("addFriend", () => { + afterEach(() => { + [userGetMock, userAddFriendMock].forEach((it) => it.mockReset()); + }); + it("should add friend if exist", async () => { + //GIVEN + userGetMock.mockResolvedValue(dummyUser("any")); + + //WHEN + await mockApp + .post("/users/friends") + .set("Authorization", "Bearer 123456789") + .send({ uid: "123" }) + .expect(200); + + //THEN + expect(userGetMock).toHaveBeenCalledWith("123", "addFriend"); + expect(userGetMock).toHaveBeenCalledWith(uid, "addFriend"); + expect(userAddFriendMock).toHaveBeenCalledWith(uid, "123"); + }); + + it("should fail adding a friend that does not exists", async () => { + //GIVEN + userGetMock.mockImplementation(async (uid, stack) => { + if (uid === "unknown") throw new MonkeyError(404); + return dummyUser(uid); + }); + + //WHEN + await mockApp + .post("/users/friends") + .set("Authorization", "Bearer 123456789") + .send({ uid: "unknown" }) + .expect(404); + + //THEN + expect(userAddFriendMock).not.toHaveBeenCalled(); + }); + + it("should fail exceeding max friends limit", async () => { + //GIVEN + const user = dummyUser(uid); + user.friends = [...Array(32).keys()].map((it) => "uid" + it); + userGetMock.mockResolvedValue(user); + + //WHEN + await mockApp + .post("/users/friends") + .set("Authorization", "Bearer 123456789") + .send({ uid: "123" }) + .expect(400) + .expect(expectErrorMessage("You can only have up to 25 friends")); + + //THEN + expect(userAddFriendMock).not.toHaveBeenCalled(); + }); + + describe("validations", () => { + it("should fail without body", async () => { + //WHEN + await mockApp + .post("/users/friends") + .set("Authorization", "Bearer 123456789") + .send() + .expect(422) + .expect(expectErrorMessage('"uid" is required (undefined)')); + }); + + it("should fail if premium feature is disabled", async () => { + //GIVEN + await enablePremiumFeatures(false); + + //WHEN + await mockApp + .post("/users/friends") + .set("Authorization", "Bearer 123456789") + .send() + .expect(503) + .expect(expectErrorMessage("Premium is temporarily disabled.")); + }); + + it("should fail without authorization", async () => { + await mockApp.post("/users/friends").send().expect(401); + }); + }); + }); + + describe("removeFriend", () => { + afterEach(() => { + [userGetMock, userRemoveFriendMock].forEach((it) => it.mockReset()); + }); + it("should remove friend if exist", async () => { + //WHEN + await mockApp + .delete("/users/friends/123") + .set("Authorization", "Bearer 123456789") + .send() + .expect(200); + + //THEN + expect(userRemoveFriendMock).toHaveBeenCalledWith(uid, "123"); + }); + describe("validations", () => { + it("should fail if premium feature is disabled", async () => { + //GIVEN + await enablePremiumFeatures(false); + + //WHEN + await mockApp + .delete("/users/friends/123") + .set("Authorization", "Bearer 123456789") + .send() + .expect(503) + .expect(expectErrorMessage("Premium is temporarily disabled.")); + }); + + it("should fail without authorization", async () => { + await mockApp.delete("/users/friends/123").send().expect(401); + }); + }); }); }); }); + +async function enablePremiumFeatures(premium: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { premium: { enabled: premium }, signup: true }, + }); + + jest + .spyOn(Configuration, "getCachedConfiguration") + .mockResolvedValue(mockConfig); +} + +async function enableSignUpFeatures(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { signUp: enabled }, + }); + + jest + .spyOn(Configuration, "getCachedConfiguration") + .mockResolvedValue(mockConfig); +} + +function expectErrorMessage(message: string): (res: request.Response) => void { + return (res) => expect(res.body).toHaveProperty("message", message); +} diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index afb012e55..0bf8d49ea 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -781,4 +781,106 @@ describe("UserDal", () => { await expect(streak).toBe(expectedStreak); } }); + + describe("deleteUser", () => { + it("should remove deleted user from other users friends list", async () => { + //GIVEN + const user = new ObjectId().toHexString(); + const otherUser = new ObjectId().toHexString(); + await UserDAL.addUser("u1", "u1@axample.com", user); + await UserDAL.addUser("u2", "test email", otherUser); + await UserDAL.addFriend(otherUser, user); + await expect(UserDAL.getUser(otherUser, "test")).resolves.toHaveProperty( + "friends", + [user] + ); + + //WHEN + await UserDAL.deleteUser(user); + + //THEN + await expect(UserDAL.getUser(user, "test")).rejects.toThrow( + "User not found\nStack: test" + ); + await expect(UserDAL.getUser(otherUser, "test")).resolves.toHaveProperty( + "friends", + [] + ); + }); + + describe("friends", () => { + it("should get friend list", async () => { + //GIVEN + const user = new ObjectId().toHexString(); + const friend = new ObjectId().toHexString(); + const friend2 = new ObjectId().toHexString(); + await UserDAL.addUser("u1", "u1@axample.com", user); + await UserDAL.addUser("u2", "test email", friend); + await UserDAL.addUser("u3", "test email", friend2); + await UserDAL.addFriend(user, friend); + await UserDAL.addFriend(user, friend2); + await UserDAL.addFriend(user, "unknown"); + + //WHEN + await expect(UserDAL.getFriendsList(user)).resolves.toEqual([ + { uid: friend, name: "u2" }, + { uid: friend2, name: "u3" }, + ]); + }); + + it("should add friend ", async () => { + //GIVEN + const user = new ObjectId().toHexString(); + const friend = new ObjectId().toHexString(); + await UserDAL.addUser("u1", "u1@axample.com", user); + await UserDAL.addUser("u2", "test email", friend); + + //WHEN + await UserDAL.addFriend(user, friend); + + //THEN + await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty( + "friends", + [friend] + ); + + //WHEN + //adding the same friend twice + await UserDAL.addFriend(user, friend); + + //THEN + await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty( + "friends", + [friend] + ); + }); + it("should remove friend ", async () => { + //GIVEN + const user = new ObjectId().toHexString(); + const friend = new ObjectId().toHexString(); + await UserDAL.addUser("u1", "u1@axample.com", user); + await UserDAL.addUser("u2", "test email", friend); + await UserDAL.addFriend(user, friend); + + //WHEN + await UserDAL.removeFriend(user, friend); + + //THEN + await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty( + "friends", + [] + ); + + //WHEN + //remove an unknown friend + await expect(UserDAL.removeFriend(user, "unknown")).resolves; + + //THEN + await expect(UserDAL.getUser(user, "test")).resolves.toHaveProperty( + "friends", + [] + ); + }); + }); + }); }); diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index ccf60cf6c..7ff5cabed 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -915,3 +915,41 @@ export async function revokeAllTokens( removeTokensFromCacheByUid(uid); return new MonkeyResponse("All tokens revoked"); } + +export async function getFriends( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + + const friends = await UserDAL.getFriendsList(uid); + return new MonkeyResponse("Friends retrieved", friends ?? []); +} +export async function addFriend( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const newFriend = req.body.uid; + + const user = await UserDAL.getUser(uid, "addFriend"); + //TODO move max friends to config + if (user.friends?.length || 0 > 25) { + throw new MonkeyError(400, "You can only have up to 25 friends"); + } + + // To make sure that the friend exists + await UserDAL.getUser(newFriend, "addFriend"); + + await UserDAL.addFriend(uid, newFriend); + return new MonkeyResponse("Friend added"); +} + +export async function removeFriend( + req: MonkeyTypes.Request +): Promise { + const { uid } = req.ctx.decodedToken; + const { friendUid } = req.params; + + await UserDAL.removeFriend(uid, friendUid); + + return new MonkeyResponse("Friend removed"); +} diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index fcc1c4e09..a7709ed0b 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -94,6 +94,13 @@ const languageSchema = joi .required(); const quoteIdSchema = joi.string().min(1).max(10).regex(/\d+/).required(); +const validatePremiumFeatureIsEnabled = validateConfiguration({ + criteria: (configuration) => { + return configuration.users.premium.enabled; + }, + invalidMessage: "Premium is temporarily disabled.", +}); + router.get( "/", authenticateRequest(), @@ -659,4 +666,38 @@ router.post( asyncHandler(UserController.revokeAllTokens) ); +router.get( + "/friends", + validatePremiumFeatureIsEnabled, + authenticateRequest(), + RateLimit.userFriendsGet, + asyncHandler(UserController.getFriends) +); + +router.post( + "/friends", + validatePremiumFeatureIsEnabled, + authenticateRequest(), + RateLimit.userFriendsAdd, + validateRequest({ + body: { + uid: joi.string().required(), + }, + }), + asyncHandler(UserController.addFriend) +); + +router.delete( + "/friends/:friendUid", + validatePremiumFeatureIsEnabled, + authenticateRequest(), + RateLimit.userFriendsDelete, + validateRequest({ + params: { + friendUid: joi.string().required(), + }, + }), + asyncHandler(UserController.removeFriend) +); + export default router; diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 37c542ff5..edad0430f 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -46,6 +46,12 @@ export async function addUser( export async function deleteUser(uid: string): Promise { await getUsersCollection().deleteOne({ uid }); + + //remove deleted user from other users friends lists + await getUsersCollection().updateMany( + { friends: uid }, + { $pull: { friends: uid } } + ); } export async function resetUser(uid: string): Promise { @@ -1068,3 +1074,41 @@ export async function logIpAddress( } await getUsersCollection().updateOne({ uid }, { $set: { ips: currentIps } }); } + +export async function getFriendsList( + uid: string +): Promise { + const query = getUsersCollection().aggregate([ + { $match: { uid } }, + { + $lookup: { + from: "users", + localField: "friends", + foreignField: "uid", + as: "friends", + }, + }, + { + $project: { "friends.uid": 1, "friends.name": 1, _id: 0 }, + }, + ]); + const result = await query.toArray(); + return result ? result[0].friends : {}; +} + +export async function addFriend(uid: string, newFriend: string): Promise { + await getUsersCollection().updateOne( + { uid }, + { $addToSet: { friends: newFriend } } + ); +} + +export async function removeFriend( + uid: string, + oldFriend: string +): Promise { + await getUsersCollection().updateOne( + { uid }, + { $pull: { friends: oldFriend } } + ); +} diff --git a/backend/src/middlewares/rate-limit.ts b/backend/src/middlewares/rate-limit.ts index f8dede206..8fcba8596 100644 --- a/backend/src/middlewares/rate-limit.ts +++ b/backend/src/middlewares/rate-limit.ts @@ -542,6 +542,25 @@ export const webhookLimit = rateLimit({ handler: customHandler, }); +export const userFriendsGet = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); +export const userFriendsAdd = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); +export const userFriendsDelete = rateLimit({ + windowMs: ONE_HOUR_MS, + max: 60 * REQUEST_MULTIPLIER, + keyGenerator: getKeyWithUid, + handler: customHandler, +}); + export const apeKeysUpdate = apeKeysGenerate; export const apeKeysDelete = apeKeysGenerate; diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 7af5568b6..2b7039967 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -101,6 +101,7 @@ declare namespace MonkeyTypes { lbOptOut?: boolean; premium?: PremiumInfo; ips?: UserIpHistory; + friends?: string[]; //List of user uids } interface UserStreak { @@ -399,4 +400,9 @@ declare namespace MonkeyTypes { startTimestamp: number; expirationTimestamp: number; } + + interface Friend { + uid: string; + name: string; + } }