From 24d91f563aff4c160b235ef5859fdabb5fba9a1b Mon Sep 17 00:00:00 2001 From: Vaibhav Singh Date: Fri, 22 Nov 2024 00:03:13 +0530 Subject: [PATCH] Feat: Super users can delete discord group roles (#2241) * added route, controller and model for deleting the group role * added route, controller and model for deleting the group role * unit tests for deleteGroupRole * updated unit tests for deleteGroupRole * integrated feature flag * chore: roleid fetch from firestore and removed console logs * integration tests for deleteGroupRole * fix: reduced db calls from 2 to 1 for roleId * feat: integrated discord service to delete role from discord * test: unit and integration tests for discord service integration * chore: refactored controller and tests to use res.boom and routes to use devFlag middleware * fix: error code --- controllers/discordactions.js | 56 +++++++ models/discordactions.js | 36 ++++- routes/discordactions.js | 11 +- services/discordService.js | 36 ++++- test/integration/discordactions.test.js | 176 ++++++++++++++++++++++ test/unit/models/discordactions.test.js | 62 +++++++- test/unit/services/discordService.test.js | 53 +++++++ 7 files changed, 423 insertions(+), 7 deletions(-) diff --git a/controllers/discordactions.js b/controllers/discordactions.js index fdca8f83b..7c71c885e 100644 --- a/controllers/discordactions.js +++ b/controllers/discordactions.js @@ -6,6 +6,7 @@ const discordRolesModel = require("../models/discordactions"); const discordServices = require("../services/discordService"); const { fetchAllUsers, fetchUser } = require("../models/users"); const { generateCloudFlareHeaders } = require("../utils/discord-actions"); +const { addLog } = require("../models/logs"); const discordDeveloperRoleId = config.get("discordDeveloperRoleId"); const discordMavenRoleId = config.get("discordMavenRoleId"); @@ -63,6 +64,60 @@ const createGroupRole = async (req, res) => { } }; +/** + * Controller function to handle the soft deletion of a group role. + * + * @param {Object} req - The request object + * @param {Object} res - The response object + * @returns {Promise} + */ +const deleteGroupRole = async (req, res) => { + const { groupId } = req.params; + + try { + const { roleExists, existingRoles } = await discordRolesModel.isGroupRoleExists({ groupId }); + + if (!roleExists) { + return res.boom.notFound("Group role not found"); + } + + const roleData = existingRoles.data(); + + const discordDeletion = await discordServices.deleteGroupRoleFromDiscord(roleData.roleid); + + if (!discordDeletion.success) { + return res.boom.badImplementation(discordDeletion.message); + } + + const { isSuccess } = await discordRolesModel.deleteGroupRole(groupId, req.userData.id); + + if (!isSuccess) { + logger.error(`Role deleted from Discord but failed to delete from database for groupId: ${groupId}`); + return res.boom.badImplementation("Group role deletion failed"); + } + + const groupDeletionLog = { + type: "group-role-deletion", + meta: { + userId: req.userData.id, + }, + body: { + groupId: groupId, + roleName: roleData.rolename, + discordRoleId: roleData.roleid, + action: "delete", + }, + }; + await addLog(groupDeletionLog.type, groupDeletionLog.meta, groupDeletionLog.body); + return res.status(200).json({ + message: "Group role deleted successfully", + }); + } catch (error) { + logger.error(`Error while deleting group role: ${error}`); + return res.boom.badImplementation("Internal server error"); + } +}; + /** * Gets all group-roles * @@ -491,4 +546,5 @@ module.exports = { setRoleToUsersWith31DaysPlusOnboarding, getUserDiscordInvite, generateInviteForUser, + deleteGroupRole, }; diff --git a/models/discordactions.js b/models/discordactions.js index 6576a9b6f..97115e952 100644 --- a/models/discordactions.js +++ b/models/discordactions.js @@ -46,6 +46,31 @@ const createNewRole = async (roleData) => { } }; +/** + * Soft deletes a group role by marking it as deleted in the database. + * This function updates the role document in Firestore, setting isDeleted to true + * and recording who deleted it and when. + * + * @param {string} groupId - The ID of the group role to be deleted + * @param {string} deletedBy - The ID of the user performing the deletion for logging purpose + * @returns {Promise} An object indicating whether the operation was successful + */ +const deleteGroupRole = async (groupId, deletedBy) => { + try { + const roleRef = admin.firestore().collection("discord-roles").doc(groupId); + await roleRef.update({ + isDeleted: true, + deletedAt: admin.firestore.Timestamp.fromDate(new Date()), + deletedBy: deletedBy, + }); + + return { isSuccess: true }; + } catch (error) { + logger.error(`Error in deleteGroupRole: ${error}`); + return { isSuccess: false }; + } +}; + const removeMemberGroup = async (roleId, discordId) => { try { const backendResponse = await deleteRoleFromDatabase(roleId, discordId); @@ -139,10 +164,13 @@ const updateGroupRole = async (roleData, docId) => { const isGroupRoleExists = async (options = {}) => { try { - const { rolename = null, roleid = null } = options; + const { groupId = null, rolename = null, roleid = null } = options; let existingRoles; - if (rolename && roleid) { + if (groupId) { + existingRoles = await discordRoleModel.doc(groupId).get(); + return { roleExists: existingRoles.exists, existingRoles }; + } else if (rolename && roleid) { existingRoles = await discordRoleModel .where("rolename", "==", rolename) .where("roleid", "==", roleid) @@ -153,9 +181,8 @@ const isGroupRoleExists = async (options = {}) => { } else if (roleid) { existingRoles = await discordRoleModel.where("roleid", "==", roleid).limit(1).get(); } else { - throw Error("Either rolename or roleId is required"); + throw Error("Either rolename, roleId, or groupId is required"); } - return { roleExists: !existingRoles.empty, existingRoles }; } catch (err) { logger.error("Error in getting all group-roles", err); @@ -1075,4 +1102,5 @@ module.exports = { getUserDiscordInvite, addInviteToInviteModel, groupUpdateLastJoinDate, + deleteGroupRole, }; diff --git a/routes/discordactions.js b/routes/discordactions.js index 745a306df..1d7621787 100644 --- a/routes/discordactions.js +++ b/routes/discordactions.js @@ -15,6 +15,7 @@ const { updateUsersNicknameStatus, syncDiscordGroupRolesInFirestore, setRoleToUsersWith31DaysPlusOnboarding, + deleteGroupRole, } = require("../controllers/discordactions"); const { validateGroupRoleBody, @@ -29,11 +30,19 @@ const ROLES = require("../constants/roles"); const { Services } = require("../constants/bot"); const { verifyCronJob } = require("../middlewares/authorizeBot"); const { authorizeAndAuthenticate } = require("../middlewares/authorizeUsersAndService"); - +const { devFlagMiddleware } = require("../middlewares/devFlag"); const router = express.Router(); router.post("/groups", authenticate, checkIsVerifiedDiscord, validateGroupRoleBody, createGroupRole); router.get("/groups", authenticate, checkIsVerifiedDiscord, getAllGroupRoles); +router.delete( + "/groups/:groupId", + authenticate, + checkIsVerifiedDiscord, + authorizeRoles([SUPERUSER]), + devFlagMiddleware, + deleteGroupRole +); router.post("/roles", authenticate, checkIsVerifiedDiscord, validateMemberRoleBody, addGroupRoleToMember); router.get("/invite", authenticate, getUserDiscordInvite); router.post("/invite", authenticate, checkCanGenerateDiscordLink, generateInviteForUser); diff --git a/services/discordService.js b/services/discordService.js index bb3517609..1244fe9d3 100644 --- a/services/discordService.js +++ b/services/discordService.js @@ -103,7 +103,40 @@ const setUserDiscordNickname = async (userName, discordId) => { }; } catch (err) { logger.error("Error in updating discord Nickname", err); - throw err; + throw new Error(err); + } +}; + +/** + * Deletes a group role from the Discord server. + * This function sends a DELETE request to the Discord API to remove the role. + * It's part of the soft delete process, where we remove the role from Discord + * but keep a record of it in our database. + * + * @param {string} roleId - The Discord ID of the role to be deleted + * @returns {Promise} The response from the Discord API + * @throws {Error} If the deletion fails or there's a network error + */ + +const deleteGroupRoleFromDiscord = async (roleId) => { + try { + const authToken = generateAuthTokenForCloudflare(); + const response = await fetch(`${DISCORD_BASE_URL}/roles/${roleId}?dev=true`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + }); + + if (response.status === 204) { + return { success: true, message: "Role deleted successfully" }; + } + + return { success: false, message: "Failed to delete role from discord" }; + } catch (err) { + logger.error("Error deleting role from Discord", err); + return { success: false, message: "Internal server error" }; } }; @@ -114,4 +147,5 @@ module.exports = { addRoleToUser, removeRoleFromUser, setUserDiscordNickname, + deleteGroupRoleFromDiscord, }; diff --git a/test/integration/discordactions.test.js b/test/integration/discordactions.test.js index 9b361990c..d943f85e6 100644 --- a/test/integration/discordactions.test.js +++ b/test/integration/discordactions.test.js @@ -213,6 +213,182 @@ describe("Discord actions", function () { }); }); + describe("DELETE /discord-actions/groups/:groupId", function () { + let groupId; + // eslint-disable-next-line mocha/no-setup-in-describe + const roleData = groupData[0]; + + beforeEach(async function () { + const docRef = await discordRoleModel.add(roleData); + groupId = docRef.id; + + superUserId = await addUser(superUser); + superUserAuthToken = authService.generateAuthToken({ userId: superUserId }); + + sinon.stub(discordRolesModel, "deleteGroupRole").resolves({ isSuccess: true }); + }); + + afterEach(async function () { + sinon.restore(); + await cleanDb(); + }); + + it("should return 404 when not in dev mode", function (done) { + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(404); + expect(res.body.error).to.equal("Not Found"); + done(err); + }); + }); + + it("should return 404 if group role not found", function (done) { + sinon.stub(discordRolesModel, "isGroupRoleExists").resolves({ + roleExists: false, + existingRoles: { data: () => ({ ...roleData, roleid: roleData.roleid }) }, + }); + + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(404); + expect(res.body.error).to.equal("Not Found"); + done(err); + }); + }); + + it("should successfully delete the group role from discord server", function (done) { + sinon.stub(discordRolesModel, "isGroupRoleExists").resolves({ + roleExists: true, + existingRoles: { data: () => ({ ...roleData, roleid: roleData.roleid }) }, + }); + + sinon.stub(discordServices, "deleteGroupRoleFromDiscord").resolves({ + success: true, + message: "Role deleted successfully", + }); + + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body.message).to.equal("Group role deleted successfully"); + done(err); + }); + }); + + it("should return 500 when discord role deletion fails", function (done) { + sinon.stub(discordRolesModel, "isGroupRoleExists").resolves({ + roleExists: true, + existingRoles: { data: () => ({ ...roleData, roleid: roleData.roleid }) }, + }); + + sinon.stub(discordServices, "deleteGroupRoleFromDiscord").resolves({ + success: false, + message: "Failed to delete role from Discord", + }); + + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(500); + expect(res.body.error).to.equal("Internal Server Error"); + done(err); + }); + }); + + it("should return 500 when discord service throws an error", function (done) { + sinon.stub(discordRolesModel, "isGroupRoleExists").resolves({ + roleExists: true, + existingRoles: { data: () => ({ ...roleData, roleid: roleData.roleid }) }, + }); + + sinon.stub(discordServices, "deleteGroupRoleFromDiscord").resolves({ + success: false, + message: "Internal server error", + }); + + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(500); + expect(res.body.error).to.equal("Internal Server Error"); + done(err); + }); + }); + + it("should successfully delete a group role from database", function (done) { + sinon.stub(discordRolesModel, "isGroupRoleExists").resolves({ + roleExists: true, + existingRoles: { data: () => ({ ...roleData, roleid: roleData.roleid }) }, + }); + + sinon.stub(discordServices, "deleteGroupRoleFromDiscord").resolves({ + success: true, + message: "Role deleted successfully", + }); + + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(200); + expect(res.body.message).to.equal("Group role deleted successfully"); + done(err); + }); + }); + + it("should return 500 when deletion fails", function (done) { + sinon.restore(); + sinon.stub(discordRolesModel, "isGroupRoleExists").resolves({ + roleExists: true, + existingRoles: { data: () => ({ ...roleData, roleid: roleData.roleid }) }, + }); + + sinon.stub(discordServices, "deleteGroupRoleFromDiscord").resolves({ + success: true, + message: "Role deleted successfully", + }); + + sinon.stub(discordRolesModel, "deleteGroupRole").resolves({ isSuccess: false }); + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(500); + expect(res.body.error).to.equal("Internal Server Error"); + done(err); + }); + }); + + it("should return 500 when an internal error occurs", function (done) { + sinon.restore(); + sinon.stub(discordRolesModel, "isGroupRoleExists").throws(new Error("Database error")); + chai + .request(app) + .delete(`/discord-actions/groups/${groupId}?dev=true`) + .set("cookie", `${cookieName}=${superUserAuthToken}`) + .end((err, res) => { + expect(res).to.have.status(500); + expect(res.body.error).to.equal("Internal Server Error"); + done(err); + }); + }); + }); + describe("POST /discord-actions/roles", function () { let roleid; diff --git a/test/unit/models/discordactions.test.js b/test/unit/models/discordactions.test.js index 5b773b13e..249e70601 100644 --- a/test/unit/models/discordactions.test.js +++ b/test/unit/models/discordactions.test.js @@ -23,6 +23,7 @@ const { isGroupRoleExists, addGroupRoleToMember, deleteRoleFromDatabase, + deleteGroupRole, updateDiscordImageForVerification, enrichGroupDataWithMembershipInfo, fetchGroupToUserMapping, @@ -172,7 +173,7 @@ describe("discordactions", function () { it("should throw an error if rolename and roleid are not passed", async function () { return isGroupRoleExists({}).catch((err) => { expect(err).to.be.an.instanceOf(Error); - expect(err.message).to.equal("Either rolename or roleId is required"); + expect(err.message).to.equal("Either rolename, roleId, or groupId is required"); }); }); @@ -242,6 +243,65 @@ describe("discordactions", function () { }); }); + describe("deleteGroupRole", function () { + const groupId = "1234"; + const deletedBy = "4321"; + let firestoreOriginal; + + beforeEach(async function () { + firestoreOriginal = admin.firestore; + + const roleRef = admin.firestore().collection("discord-roles").doc(groupId); + await roleRef.set({ + isDeleted: false, + }); + }); + + it("should mark the group role as deleted", async function () { + const result = await deleteGroupRole(groupId, deletedBy); + + const updatedDoc = await admin.firestore().collection("discord-roles").doc(groupId).get(); + + const data = updatedDoc.data(); + expect(data.isDeleted).to.equal(true); + expect(data.deletedBy).to.equal(deletedBy); + expect(data.deletedAt).to.be.an.instanceof(admin.firestore.Timestamp); + expect(result.isSuccess).to.equal(true); + }); + + it("should return isSuccess as false if Firestore update fails", async function () { + delete require.cache[require.resolve("firebase-admin")]; + + const mockFirestore = { + collection: () => ({ + doc: () => ({ + update: async () => { + throw new Error("Database error"); + }, + }), + }), + }; + + Object.defineProperty(admin, "firestore", { + configurable: true, + get: () => () => mockFirestore, + }); + + const result = await deleteGroupRole(groupId, deletedBy); + expect(result.isSuccess).to.equal(false); + }); + + afterEach(async function () { + Object.defineProperty(admin, "firestore", { + configurable: true, + value: firestoreOriginal, + }); + + const roleRef = admin.firestore().collection("discord-roles").doc(groupId); + await roleRef.delete(); + }); + }); + describe("deleteRoleFromMember", function () { let deleteStub; diff --git a/test/unit/services/discordService.test.js b/test/unit/services/discordService.test.js index 69887c58b..5f51955a0 100644 --- a/test/unit/services/discordService.test.js +++ b/test/unit/services/discordService.test.js @@ -6,12 +6,14 @@ const { getDiscordMembers, removeRoleFromUser, setUserDiscordNickname, + deleteGroupRoleFromDiscord, } = require("../../../services/discordService"); const { fetchAllUsers } = require("../../../models/users"); const Sinon = require("sinon"); const userModel = firestore.collection("users"); const userDataArray = require("../../fixtures/user/user")(); const discordMembersArray = require("../../fixtures/discordResponse/discord-response"); +// const { func } = require("joi"); let fetchStub; describe("Discord services", function () { describe("setInDiscordFalseScript", function () { @@ -158,4 +160,55 @@ describe("Discord services", function () { }); }); }); + + describe("delete group role from Discord", function () { + beforeEach(function () { + fetchStub = Sinon.stub(global, "fetch"); + }); + + afterEach(function () { + fetchStub.restore(); + }); + + it("should successfully delete role from discord and return success for 204 response", async function () { + fetchStub.returns( + Promise.resolve({ + ok: true, + status: 204, + }) + ); + + const response = await deleteGroupRoleFromDiscord("123456789"); + expect(response).to.deep.equal({ + success: true, + message: "Role deleted successfully", + }); + expect(fetchStub.calledOnce).to.be.equal(true); + }); + + it("should return failure for non-ok response", async function () { + fetchStub.returns( + Promise.resolve({ + ok: false, + status: 400, + }) + ); + + const response = await deleteGroupRoleFromDiscord("123456789"); + expect(response).to.deep.equal({ + success: false, + message: "Failed to delete role from discord", + }); + }); + + it("should handle unexpected errors", async function () { + fetchStub.rejects(new Error("Network error")); + + const response = await deleteGroupRoleFromDiscord("123456789"); + expect(response).to.deep.equal({ + success: false, + message: "Internal server error", + }); + }); + }); });