diff --git a/src/constants/responses.ts b/src/constants/responses.ts index 8f3832bf..b6317d9a 100644 --- a/src/constants/responses.ts +++ b/src/constants/responses.ts @@ -25,3 +25,15 @@ export const RETRY_COMMAND = export const ROLE_ADDED = "Role added successfully"; export const NAME_CHANGED = "User nickname changed successfully"; + +export const ROLE_REMOVED = "Role Removed successfully"; + +export const VERIFICATION_STRING = + "Please verify your identity by clicking the link above and granting authorization to the Real Dev Squad. This will allow us to manage your Discord data."; + +export const ROLE_FETCH_FAILED = + "Oops! We are experiencing an issue fetching roles."; + +export const BAD_REQUEST = { + error: "Oops! This is not a proper request.", +}; diff --git a/src/controllers/guildRoleHandler.ts b/src/controllers/guildRoleHandler.ts index 84375a06..c52134e9 100644 --- a/src/controllers/guildRoleHandler.ts +++ b/src/controllers/guildRoleHandler.ts @@ -2,7 +2,13 @@ import * as response from "../constants/responses"; import { env } from "../typeDefinitions/default.types"; import JSONResponse from "../utils/JsonResponse"; import { IRequest } from "itty-router"; -import { addGroupRole, createGuildRole } from "../utils/guildRole"; +import { + addGroupRole, + createGuildRole, + removeGuildRole, + getGuildRoleByName, + getGuildRoles, +} from "../utils/guildRole"; import { createNewRole, memberGroupRole, @@ -39,3 +45,99 @@ export async function addGroupRoleHandler(request: IRequest, env: env) { return new JSONResponse(response.BAD_SIGNATURE); } } + +export async function removeGuildRoleHandler(request: IRequest, env: env) { + const authHeader = request.headers.get("Authorization"); + if (!authHeader) { + return new JSONResponse(response.BAD_SIGNATURE, { status: 401 }); + } + try { + await verifyAuthToken(authHeader, env); + const body: memberGroupRole = await request.json(); + const res = await removeGuildRole(body, env); + return new JSONResponse(res, { + status: 200, + headers: { + "content-type": "application/json;charset=UTF-8", + }, + }); + } catch (err) { + return new JSONResponse(response.INTERNAL_SERVER_ERROR, { + status: 500, + headers: { + "content-type": "application/json;charset=UTF-8", + }, + }); + } +} +export async function getGuildRolesHandler(request: IRequest, env: env) { + const authHeader = request.headers.get("Authorization"); + + if (!authHeader) { + return new JSONResponse(response.BAD_SIGNATURE, { status: 401 }); + } + try { + await verifyAuthToken(authHeader, env); + const roles = await getGuildRoles(env); + return new JSONResponse({ roles }); + } catch (err: any) { + const error = + err?.message === response.ROLE_FETCH_FAILED + ? response.ROLE_FETCH_FAILED + : response.INTERNAL_SERVER_ERROR; + return new JSONResponse( + { + error, + }, + { + status: 500, + headers: { + "content-type": "application/json;charset=UTF-8", + }, + } + ); + } +} + +export async function getGuildRoleByRoleNameHandler( + request: IRequest, + env: env +) { + const authHeader = request.headers.get("Authorization"); + const roleName = decodeURI(request.params?.roleName ?? ""); + + if (!authHeader) { + return new JSONResponse(response.BAD_SIGNATURE, { status: 401 }); + } + + if (!roleName) { + return new JSONResponse(response.BAD_REQUEST, { status: 404 }); + } + try { + await verifyAuthToken(authHeader, env); + const role = await getGuildRoleByName(roleName, env); + if (!role) { + return new JSONResponse(response.NOT_FOUND, { + status: 404, + headers: { + "content-type": "application/json;charset=UTF-8", + }, + }); + } + return new JSONResponse(role); + } catch (err: any) { + const error = + err?.message === response.ROLE_FETCH_FAILED + ? response.ROLE_FETCH_FAILED + : response.INTERNAL_SERVER_ERROR; + return new JSONResponse( + { error }, + { + status: 500, + headers: { + "content-type": "application/json;charset=UTF-8", + }, + } + ); + } +} diff --git a/src/controllers/verifyCommand.ts b/src/controllers/verifyCommand.ts index f5816cb0..668010f3 100644 --- a/src/controllers/verifyCommand.ts +++ b/src/controllers/verifyCommand.ts @@ -1,5 +1,5 @@ import config from "../../config/config"; -import { RETRY_COMMAND } from "../constants/responses"; +import { RETRY_COMMAND, VERIFICATION_STRING } from "../constants/responses"; import { env } from "../typeDefinitions/default.types"; import { discordEphemeralResponse } from "../utils/discordEphemeralResponse"; import { generateUniqueToken } from "../utils/generateUniqueToken"; @@ -24,7 +24,7 @@ export async function verifyCommand( ); if (response?.status === 201 || response?.status === 200) { const verificationSiteURL = config(env).VERIFICATION_SITE_URL; - const message = `${verificationSiteURL}/discord?token=${token}`; + const message = `${verificationSiteURL}/discord?token=${token}\n${VERIFICATION_STRING}`; return discordEphemeralResponse(message); } else { return discordEphemeralResponse(RETRY_COMMAND); diff --git a/src/index.ts b/src/index.ts index 43b1021e..5d6a7ca9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,9 @@ import { verifyBot } from "./utils/verifyBot"; import { addGroupRoleHandler, createGuildRoleHandler, + removeGuildRoleHandler, + getGuildRoleByRoleNameHandler, + getGuildRolesHandler, } from "./controllers/guildRoleHandler"; import { getMembersInServerHandler } from "./controllers/getMembersInServer"; import { changeNickname } from "./controllers/changeNickname"; @@ -28,6 +31,12 @@ router.put("/roles/create", createGuildRoleHandler); router.put("/roles/add", addGroupRoleHandler); +router.delete("/roles", removeGuildRoleHandler); + +router.get("/roles", getGuildRolesHandler); + +router.get("/roles/:roleName", getGuildRoleByRoleNameHandler); + router.get("/discord-members", getMembersInServerHandler); router.get("/member/:id", getGuildMemberDetailsHandler); diff --git a/src/typeDefinitions/role.types.d.ts b/src/typeDefinitions/role.types.d.ts new file mode 100644 index 00000000..5ac98f9c --- /dev/null +++ b/src/typeDefinitions/role.types.d.ts @@ -0,0 +1,12 @@ +export type GuildRole = { + id: string; + name: string; + color: string; + hoist: boolean; + icon?: string; + position?: integer; + managed?: boolean; + mentionable?: boolean; +}; + +export type Role = Pick; diff --git a/src/utils/guildRole.ts b/src/utils/guildRole.ts index 63fb36da..b0531ffc 100644 --- a/src/utils/guildRole.ts +++ b/src/utils/guildRole.ts @@ -1,4 +1,9 @@ -import { INTERNAL_SERVER_ERROR, ROLE_ADDED } from "../constants/responses"; +import { + INTERNAL_SERVER_ERROR, + ROLE_ADDED, + ROLE_FETCH_FAILED, + ROLE_REMOVED, +} from "../constants/responses"; import { DISCORD_BASE_URL } from "../constants/urls"; import { env } from "../typeDefinitions/default.types"; import { @@ -6,6 +11,7 @@ import { guildRoleResponse, memberGroupRole, } from "../typeDefinitions/discordMessage.types"; +import { GuildRole, Role } from "../typeDefinitions/role.types"; export async function createGuildRole( body: createNewRole, @@ -55,3 +61,63 @@ export async function addGroupRole(body: memberGroupRole, env: env) { return INTERNAL_SERVER_ERROR; } } + +export async function removeGuildRole(details: memberGroupRole, env: env) { + const { userid, roleid } = details; + const removeGuildRoleUrl = `${DISCORD_BASE_URL}/guilds/${env.DISCORD_GUILD_ID}/members/${userid}/roles/${roleid}`; + try { + const response = await fetch(removeGuildRoleUrl, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + }); + if (response.ok) { + return { + message: ROLE_REMOVED, + userAffected: { + userid, + roleid, + }, + }; + } + } catch (err) { + return INTERNAL_SERVER_ERROR; + } +} + +export async function getGuildRoles(env: env): Promise> { + const guildRolesUrl = `${DISCORD_BASE_URL}/guilds/${env.DISCORD_GUILD_ID}/roles`; + + try { + const response = await fetch(guildRolesUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + }); + + if (!response.ok) { + throw new Error(ROLE_FETCH_FAILED); + } + + const guildRoles: Array = await response.json(); + + return guildRoles.map((role) => ({ + id: role.id, + name: role.name, + })); + } catch (err) { + throw new Error(ROLE_FETCH_FAILED); + } +} + +export async function getGuildRoleByName( + roleName: string, + env: env +): Promise { + const roles = await getGuildRoles(env); + return roles?.find((role) => role.name === roleName); +} diff --git a/tests/fixtures/fixture.ts b/tests/fixtures/fixture.ts index 43597fbd..b051ddf6 100644 --- a/tests/fixtures/fixture.ts +++ b/tests/fixtures/fixture.ts @@ -1,3 +1,4 @@ +import { IRequest } from "itty-router"; import { createNewRole, discordMessageRequest, @@ -95,3 +96,42 @@ export const onlyRoleToBeTagged = { value: "1118201414078976192", }, }; + +export const generateDummyRequestObject = ({ + url, + method, + params, + query, + headers, // Object of key value pair +}: Partial): IRequest => { + return { + method: method ?? "GET", + url: url ?? "/roles", + params: params ?? {}, + query: query ?? {}, + headers: new Map(Object.entries(headers ?? {})), + }; +}; + +export const rolesMock = [ + { + id: "1234567889", + name: "@everyone", + permissions: "", + position: 2, + color: 2, + hoist: true, + managed: true, + mentionable: true, + }, + { + id: "12344567", + name: "bot one", + permissions: "", + position: 2, + color: 2, + hoist: true, + managed: true, + mentionable: true, + }, +]; diff --git a/tests/unit/handlers/guildRoleHandler.test.ts b/tests/unit/handlers/guildRoleHandler.test.ts new file mode 100644 index 00000000..ad579564 --- /dev/null +++ b/tests/unit/handlers/guildRoleHandler.test.ts @@ -0,0 +1,249 @@ +import { + getGuildRoleByRoleNameHandler, + getGuildRolesHandler, +} from "../../../src/controllers/guildRoleHandler"; +import { Role } from "../../../src/typeDefinitions/role.types"; +import JSONResponse from "../../../src/utils/JsonResponse"; +import { + generateDummyRequestObject, + guildEnv, + rolesMock, +} from "../../fixtures/fixture"; +import * as responseConstants from "../../../src/constants/responses"; +import * as guildRoleUtils from "../../../src/utils/guildRole"; + +jest.mock("../../../src/utils/verifyAuthToken", () => ({ + verifyAuthToken: jest.fn().mockReturnValue(true), +})); + +const getGuildRolesSpy = jest.spyOn(guildRoleUtils, "getGuildRoles"); +const getGuildRoleByNameSpy = jest.spyOn(guildRoleUtils, "getGuildRoleByName"); + +describe("get roles", () => { + it("should return a instance of JSONResponse", async () => { + const mockRequest = generateDummyRequestObject({ url: "/roles" }); + const response = await getGuildRolesHandler(mockRequest, guildEnv); + expect(response).toBeInstanceOf(JSONResponse); + }); + + it("should return Bad Signature object if no auth headers provided", async () => { + const mockRequest = generateDummyRequestObject({ url: "/roles" }); + const response: JSONResponse = await getGuildRolesHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(response.status).toBe(401); + expect(jsonResponse).toEqual(responseConstants.BAD_SIGNATURE); + }); + + it("should return role fetch failed error response if it fails to fetch roles", async () => { + getGuildRolesSpy.mockRejectedValueOnce({ + message: responseConstants.ROLE_FETCH_FAILED, + }); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + headers: { Authorization: "Bearer testtoken" }, + }); + const response: JSONResponse = await getGuildRolesHandler( + mockRequest, + guildEnv + ); + const jsonResponse = await response.json(); + expect(response.status).toBe(500); + expect(jsonResponse).toEqual({ + error: responseConstants.ROLE_FETCH_FAILED, + }); + }); + + it("should return internal server error response if it fails for any other reason", async () => { + getGuildRolesSpy.mockRejectedValueOnce({}); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + headers: { Authorization: "Bearer testtoken" }, + }); + const response: JSONResponse = await getGuildRolesHandler( + mockRequest, + guildEnv + ); + const jsonResponse = await response.json(); + expect(response.status).toBe(500); + expect(jsonResponse).toEqual({ + error: responseConstants.INTERNAL_SERVER_ERROR, + }); + }); + + it("should return empty array if there is no roles in guild", async () => { + getGuildRolesSpy.mockResolvedValue([]); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + headers: { Authorization: "Bearer testtoken" }, + }); + + const response: JSONResponse = await getGuildRolesHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { roles: Array } = await response.json(); + expect(response.status).toBe(200); + expect(Array.isArray(jsonResponse.roles)).toBeTruthy(); + expect(jsonResponse.roles.length).toBe(0); + }); + + it("should return array of id and name of roles present in guild", async () => { + getGuildRolesSpy.mockResolvedValueOnce(rolesMock); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + headers: { Authorization: "Bearer testtoken" }, + }); + + const response: JSONResponse = await getGuildRolesHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { roles: Array } = await response.json(); + expect(response.status).toBe(200); + expect(Array.isArray(jsonResponse.roles)).toBeTruthy(); + expect(jsonResponse.roles).toEqual(rolesMock); + }); +}); + +describe("get role by role name", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + + it("should return a instance of JSONResponse", async () => { + getGuildRoleByNameSpy.mockResolvedValueOnce(undefined); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: { + roleName: "everyone", + }, + headers: { Authorization: "Bearer testtoken" }, + }); + const response = await getGuildRoleByRoleNameHandler(mockRequest, guildEnv); + expect(response).toBeInstanceOf(JSONResponse); + }); + + it("should return Bad Signature object if no auth headers provided", async () => { + getGuildRoleByNameSpy.mockResolvedValueOnce(undefined); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: { + roleName: "everyone", + }, + }); + const response: JSONResponse = await getGuildRoleByRoleNameHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(response.status).toBe(401); + expect(jsonResponse).toEqual(responseConstants.BAD_SIGNATURE); + }); + + it("should return BAD REQUEST error if roleName is not provided", async () => { + getGuildRoleByNameSpy.mockResolvedValueOnce(undefined); + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: {}, + + headers: { Authorization: "Bearer testtoken" }, + }); + const response: JSONResponse = await getGuildRoleByRoleNameHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(response.status).toBe(404); + expect(jsonResponse).toEqual(responseConstants.BAD_REQUEST); + }); + + it("should return not found object if there is no roles with given name in guild", async () => { + getGuildRoleByNameSpy.mockResolvedValueOnce(undefined); + + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: { + roleName: "everyone", + }, + headers: { Authorization: "Bearer testtoken" }, + }); + + const response: JSONResponse = await getGuildRoleByRoleNameHandler( + mockRequest, + guildEnv + ); + const jsonResponse: { error: string } = await response.json(); + expect(response.status).toBe(404); + expect(jsonResponse).toEqual(responseConstants.NOT_FOUND); + }); + + it("should return role fetch failed error if there was an error while fetching roles", async () => { + getGuildRoleByNameSpy.mockRejectedValueOnce({ + message: responseConstants.ROLE_FETCH_FAILED, + }); + + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: { + roleName: "everyone", + }, + headers: { Authorization: "Bearer testtoken" }, + }); + + const response: JSONResponse = await getGuildRoleByRoleNameHandler( + mockRequest, + guildEnv + ); + const role: Role = await response.json(); + expect(response.status).toBe(500); + expect(role).toEqual({ + error: responseConstants.ROLE_FETCH_FAILED, + }); + }); + + it("should return internal server error if there was any other error", async () => { + getGuildRoleByNameSpy.mockRejectedValueOnce({}); + + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: { + roleName: "everyone", + }, + headers: { Authorization: "Bearer testtoken" }, + }); + + const response: JSONResponse = await getGuildRoleByRoleNameHandler( + mockRequest, + guildEnv + ); + const role: Role = await response.json(); + expect(response.status).toBe(500); + expect(role).toEqual({ + error: responseConstants.INTERNAL_SERVER_ERROR, + }); + }); + + it("should return object of id and name corresponding to the role name recieved", async () => { + const resultMock = { id: rolesMock[0].id, name: rolesMock[0].name }; + getGuildRoleByNameSpy.mockResolvedValueOnce(resultMock); + + const mockRequest = generateDummyRequestObject({ + url: "/roles", + params: { + roleName: "everyone", + }, + headers: { Authorization: "Bearer testtoken" }, + }); + + const response: JSONResponse = await getGuildRoleByRoleNameHandler( + mockRequest, + guildEnv + ); + const role: Role = await response.json(); + expect(response.status).toBe(200); + expect(role).toEqual(resultMock); + }); +}); diff --git a/tests/unit/utils/guildRole.test.ts b/tests/unit/utils/guildRole.test.ts index 63458077..d518ecca 100644 --- a/tests/unit/utils/guildRole.test.ts +++ b/tests/unit/utils/guildRole.test.ts @@ -1,10 +1,17 @@ import JSONResponse from "../../../src/utils/JsonResponse"; import * as response from "../../../src/constants/responses"; -import { createGuildRole, addGroupRole } from "../../../src/utils/guildRole"; +import { + createGuildRole, + addGroupRole, + removeGuildRole, + getGuildRoles, + getGuildRoleByName, +} from "../../../src/utils/guildRole"; import { dummyAddRoleBody, dummyCreateBody, guildEnv, + rolesMock, } from "../../fixtures/fixture"; describe("createGuildRole", () => { @@ -89,3 +96,127 @@ describe("addGroupRole", () => { ); }); }); + +describe("removeGuildRole", () => { + test("Should return success message on proper response", async () => { + const mockResponse = { + ok: true, + }; + jest + .spyOn(global, "fetch") + .mockImplementation(() => + Promise.resolve(new JSONResponse(mockResponse)) + ); + const result = await removeGuildRole(dummyAddRoleBody, guildEnv); + expect(global.fetch).toHaveBeenCalledWith( + `https://discord.com/api/v10/guilds/${guildEnv.DISCORD_GUILD_ID}/members/${dummyAddRoleBody.userid}/roles/${dummyAddRoleBody.roleid}`, + { + method: "DELETE", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${guildEnv.DISCORD_TOKEN}`, + }, + } + ); + expect(result).toEqual({ + message: response.ROLE_REMOVED, + userAffected: { + userid: dummyAddRoleBody.userid, + roleid: dummyAddRoleBody.roleid, + }, + }); + }); + test("Should return internal error response on api failure", async () => { + jest.spyOn(global, "fetch").mockRejectedValue("Oops some error"); + const result = await removeGuildRole(dummyAddRoleBody, guildEnv); + expect(result).toEqual(response.INTERNAL_SERVER_ERROR); + }); +}); + +describe("getGuildRoles", () => { + it("should throw role fetch failed error if status is not ok", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.resolve(new JSONResponse({}, { status: 500 })) + ); + + await expect(getGuildRoles(guildEnv)).rejects.toThrow( + response.ROLE_FETCH_FAILED + ); + }); + + it("should throw role fetch failed error if discord request fails", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.reject(new JSONResponse({}, { status: 500 })) + ); + + await expect(getGuildRoles(guildEnv)).rejects.toThrow( + response.ROLE_FETCH_FAILED + ); + }); + + it("should return array of objects containing role_id and role_name", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.resolve(new JSONResponse(rolesMock)) + ); + const roles = await getGuildRoles(guildEnv); + const expectedRoles = rolesMock.map(({ id, name }) => ({ + id, + name, + })); + expect(roles).toEqual(expectedRoles); + }); +}); + +describe("getGuildRolesByName", () => { + it("should throw role fetch failed message if status is not ok", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.resolve(new JSONResponse({}, { status: 500 })) + ); + await expect(getGuildRoles(guildEnv)).rejects.toThrow( + response.ROLE_FETCH_FAILED + ); + }); + + it("should throw role fetch failed message if discord request fails", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.reject(new JSONResponse({}, { status: 500 })) + ); + await expect(getGuildRoles(guildEnv)).rejects.toThrow( + response.ROLE_FETCH_FAILED + ); + }); + + it("should return array of objects containing role_id and role_name", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.resolve(new JSONResponse(rolesMock)) + ); + const role = await getGuildRoleByName("@everyone", guildEnv); + const expectedRoles = { + id: "1234567889", + name: "@everyone", + }; + + expect(role).toEqual(expectedRoles); + }); + it("should return undefined when role name does not match any entry", async () => { + jest + .spyOn(global, "fetch") + .mockImplementationOnce(async () => + Promise.resolve(new JSONResponse(rolesMock)) + ); + const role = await getGuildRoleByName("everyone", guildEnv); + expect(role).toBeUndefined(); + }); +});