From 6d99d4fdbbb8a3617bb69f9b406b360e539e43ce Mon Sep 17 00:00:00 2001 From: Vikhyat Bhatnagar <52795644+vikhyat187@users.noreply.github.com> Date: Mon, 23 Dec 2024 21:18:15 +0530 Subject: [PATCH 1/2] Added AWS env variable in the readme file (#295) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index cb18705d..1ef5ccf0 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ DISCORD_TOKEN: The token generated for your bot while creating a discord applica DISCORD_PUBLIC_KEY: Public key of your Discord bot helps to verify the bot and apply interaction url DISCORD_APPLICATION_ID: The application id of your bot. DISCORD_GUILD_ID: Id of the guild where you want to install the slash commands. +AWS_READ_ACCESS_GROUP_ID: This we can have a random string for now, this is required to run the `/grant-aws-command` which can help to grant AWS access from discord. We pass two values the `username` and `aws-group-name` ``` To add more commands you need to modify following files: From 532e48f4001a5d811254cb7d5d21acd1e19f56e5 Mon Sep 17 00:00:00 2001 From: Pankaj Sha Date: Wed, 18 Dec 2024 02:11:50 +0530 Subject: [PATCH 2/2] feat: Added tests to support onboarding-extension-command - Added command and handler - Changed required default value - Added constant message - Changed field name and argument - Fixed function argument and types - Added super user field - Added validation for super-user and fixed reply - Changed field, expectation message and test name as it was failing - Restore all mocks in after-each hook - Fixed lint issue - Added assert statement to verify the function call signature and fixed test name - Added tests to handle edge cases and fixed existing failing tests --- src/constants/commands.ts | 31 +++++ src/constants/responses.ts | 2 + src/controllers/baseHandler.ts | 17 +++ src/controllers/onboardingExtensionCommand.ts | 45 +++++++ src/register.ts | 2 + src/typeDefinitions/rdsUser.ts | 1 + src/utils/onboardingExtension.ts | 70 +++++++++++ src/utils/sendReplyInDiscordChannel.ts | 18 +++ tests/fixtures/fixture.ts | 18 +++ .../unit/handlers/onboardingExtension.test.ts | 71 +++++++++++ tests/unit/utils/onboardingExtension.test.ts | 116 ++++++++++++++++++ .../utils/sendReplyInDiscordChannel.test.ts | 32 +++++ 12 files changed, 423 insertions(+) create mode 100644 src/controllers/onboardingExtensionCommand.ts create mode 100644 src/utils/onboardingExtension.ts create mode 100644 src/utils/sendReplyInDiscordChannel.ts create mode 100644 tests/unit/handlers/onboardingExtension.test.ts create mode 100644 tests/unit/utils/onboardingExtension.test.ts create mode 100644 tests/unit/utils/sendReplyInDiscordChannel.test.ts diff --git a/src/constants/commands.ts b/src/constants/commands.ts index 5d7d9e31..7d953936 100644 --- a/src/constants/commands.ts +++ b/src/constants/commands.ts @@ -217,3 +217,34 @@ export const NOTIFY_ONBOARDING = { }, ], }; + +export const ONBOARDING_EXTENSION = { + name: "onboarding-extension", + description: "This command helps to create an onboarding extension request.", + options: [ + { + name: "number-of-days", + description: "Number of days required for the extension request", + type: 4, + required: true, + }, + { + name: "reason", + description: "Reason for the extension request", + type: 3, + required: true, + }, + { + name: "username", + description: "Username of onboarding user", + type: 6, + required: false, + }, + { + name: "dev", + description: "Feature flag", + type: 5, + required: false, + }, + ], +}; diff --git a/src/constants/responses.ts b/src/constants/responses.ts index 91d38e67..5ffa201e 100644 --- a/src/constants/responses.ts +++ b/src/constants/responses.ts @@ -83,3 +83,5 @@ export const AUTHENTICATION_ERROR = "Invalid Authentication token"; export const TASK_UPDATE_SENT_MESSAGE = "Task update sent on Discord's tracking-updates channel."; export const NOT_IMPLEMENTED = "Feature not implemented"; +export const UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST = + "Only super user and onboarding user are authorized to create an onboarding extension request"; diff --git a/src/controllers/baseHandler.ts b/src/controllers/baseHandler.ts index 0593f3a3..645be466 100644 --- a/src/controllers/baseHandler.ts +++ b/src/controllers/baseHandler.ts @@ -30,6 +30,7 @@ import { REMOVE, GROUP_INVITE, GRANT_AWS_ACCESS, + ONBOARDING_EXTENSION, } from "../constants/commands"; import { updateNickName } from "../utils/updateNickname"; import { discordEphemeralResponse } from "../utils/discordEphemeralResponse"; @@ -46,6 +47,7 @@ import { DevFlag } from "../typeDefinitions/filterUsersByRole"; import { kickEachUser } from "./kickEachUser"; import { groupInvite } from "./groupInvite"; import { grantAWSAccessCommand } from "./grantAWSAccessCommand"; +import { onboardingExtensionCommand } from "./onboardingExtensionCommand"; export async function baseHandler( message: discordMessageRequest, @@ -187,6 +189,21 @@ export async function baseHandler( return await groupInvite(data[0].value, data[1].value, env); } + + case getCommandName(ONBOARDING_EXTENSION): { + const data = message.data?.options as Array; + const transformedArgument = { + numberOfDaysObj: data[0], + reasonObj: data[1], + userIdObj: data.find((item) => item.name === "username"), + channelId: message.channel_id, + memberObj: message.member, + devObj: data.find((item) => item.name === "dev") as unknown as DevFlag, + }; + + return await onboardingExtensionCommand(transformedArgument, env, ctx); + } + default: { return commandNotFound(); } diff --git a/src/controllers/onboardingExtensionCommand.ts b/src/controllers/onboardingExtensionCommand.ts new file mode 100644 index 00000000..e9703a7d --- /dev/null +++ b/src/controllers/onboardingExtensionCommand.ts @@ -0,0 +1,45 @@ +import { env } from "../typeDefinitions/default.types"; +import { + messageRequestDataOptions, + messageRequestMember, +} from "../typeDefinitions/discordMessage.types"; +import { DevFlag } from "../typeDefinitions/filterUsersByRole"; +import { discordTextResponse } from "../utils/discordResponse"; +import { + createOnboardingExtension, + CreateOnboardingExtensionArgs, +} from "../utils/onboardingExtension"; + +export async function onboardingExtensionCommand( + transformedArgument: { + memberObj: messageRequestMember; + userIdObj?: messageRequestDataOptions; + numberOfDaysObj: messageRequestDataOptions; + reasonObj: messageRequestDataOptions; + channelId: number; + devObj?: DevFlag; + }, + env: env, + ctx: ExecutionContext +) { + const dev = transformedArgument.devObj?.value || false; + const discordId = transformedArgument.memberObj.user.id.toString(); + + if (!dev) { + return discordTextResponse(`<@${discordId}> Feature not implemented`); + } + + const args: CreateOnboardingExtensionArgs = { + channelId: transformedArgument.channelId, + userId: transformedArgument.userIdObj?.value, + numberOfDays: Number(transformedArgument.numberOfDaysObj.value), + reason: transformedArgument.reasonObj.value, + discordId: discordId, + }; + + const initialResponse = `<@${discordId}> Processing your request for onboarding extension`; + + ctx.waitUntil(createOnboardingExtension(args, env)); + + return discordTextResponse(initialResponse); +} diff --git a/src/register.ts b/src/register.ts index 76786999..cba90029 100644 --- a/src/register.ts +++ b/src/register.ts @@ -11,6 +11,7 @@ import { REMOVE, GROUP_INVITE, GRANT_AWS_ACCESS, + ONBOARDING_EXTENSION, } from "./constants/commands"; import { config } from "dotenv"; import { DISCORD_BASE_URL } from "./constants/urls"; @@ -44,6 +45,7 @@ async function registerGuildCommands( REMOVE, GROUP_INVITE, GRANT_AWS_ACCESS, + ONBOARDING_EXTENSION, ]; try { diff --git a/src/typeDefinitions/rdsUser.ts b/src/typeDefinitions/rdsUser.ts index 56f8befa..3e05596f 100644 --- a/src/typeDefinitions/rdsUser.ts +++ b/src/typeDefinitions/rdsUser.ts @@ -7,6 +7,7 @@ export type UserType = { archived: boolean; in_discord: boolean; member?: boolean; + super_user?: boolean; }; created_at?: number; yoe?: number; diff --git a/src/utils/onboardingExtension.ts b/src/utils/onboardingExtension.ts new file mode 100644 index 00000000..3b0e7334 --- /dev/null +++ b/src/utils/onboardingExtension.ts @@ -0,0 +1,70 @@ +import config from "../../config/config"; +import { UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST } from "../constants/responses"; +import { DISCORD_BASE_URL } from "../constants/urls"; +import { env } from "../typeDefinitions/default.types"; +import { generateDiscordAuthToken } from "./authTokenGenerator"; +import { getUserDetails } from "./getUserDetails"; +import { sendReplyInDiscordChannel } from "./sendReplyInDiscordChannel"; + +export type CreateOnboardingExtensionArgs = { + userId?: string; + channelId: number; + reason: string; + numberOfDays: number; + discordId: string; +}; + +export const createOnboardingExtension = async ( + args: CreateOnboardingExtensionArgs, + env: env +) => { + const { channelId } = args; + + const authToken = await generateDiscordAuthToken( + "Cloudflare Worker", + Math.floor(Date.now() / 1000) + 2, + env.BOT_PRIVATE_KEY, + "RS256" + ); + + let content: string; + const discordReplyUrl = `${DISCORD_BASE_URL}/channels/${channelId}/messages`; + + if (args.userId && args.discordId !== args.userId) { + const userResponse = await getUserDetails(args.discordId); + if (!userResponse?.user?.roles?.super_user) { + content = `<@${args.discordId}> ${UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST}`; + return await sendReplyInDiscordChannel(discordReplyUrl, content, env); + } + } + + const userDiscordId = args.userId ? args.userId : args.discordId; + const base_url = config(env).RDS_BASE_API_URL; + const createOnboardingExtensionUrl = `${base_url}/requests?dev=true`; + + const requestBody = { + userId: userDiscordId, + type: "ONBOARDING", + numberOfDays: args.numberOfDays, + reason: args.reason, + }; + + try { + const response = await fetch(createOnboardingExtensionUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(requestBody), + }); + const jsonResponse = (await response.json()) as unknown as { + message: string; + }; + content = `<@${args.discordId}> ${jsonResponse.message}`; + return await sendReplyInDiscordChannel(discordReplyUrl, content, env); + } catch (err) { + content = `<@${args.discordId}> Error occurred while creating onboarding extension request.`; + return await sendReplyInDiscordChannel(discordReplyUrl, content, env); + } +}; diff --git a/src/utils/sendReplyInDiscordChannel.ts b/src/utils/sendReplyInDiscordChannel.ts new file mode 100644 index 00000000..20e0e473 --- /dev/null +++ b/src/utils/sendReplyInDiscordChannel.ts @@ -0,0 +1,18 @@ +import { env } from "../typeDefinitions/default.types"; + +export const sendReplyInDiscordChannel = async ( + discordReplyUrl: string, + body: any, + env: env +) => { + await fetch(discordReplyUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${env.DISCORD_TOKEN}`, + }, + body: JSON.stringify({ + content: body, + }), + }); +}; diff --git a/tests/fixtures/fixture.ts b/tests/fixtures/fixture.ts index 8602c71c..6b686dbb 100644 --- a/tests/fixtures/fixture.ts +++ b/tests/fixtures/fixture.ts @@ -383,3 +383,21 @@ export const testDataWithDevTitle = { value: true, }, }; + +export const transformedArgsForOnboardingExtension = { + memberObj: { + user: { + id: 134672111, + username: "username", + discriminator: "", + avatar: "", + }, + nick: "", + joined_at: "", + }, + userIdObj: { name: "userId", type: 6, value: "1545562672", options: [] }, + numberOfDaysObj: { value: "20", name: "numberOfDays", type: 3, options: [] }, + reasonObj: { value: "reason", name: "reason", type: 3, options: [] }, + channelId: 6754321, + devObj: { value: false, name: "dev", type: 5 }, +}; diff --git a/tests/unit/handlers/onboardingExtension.test.ts b/tests/unit/handlers/onboardingExtension.test.ts new file mode 100644 index 00000000..da8b7ffe --- /dev/null +++ b/tests/unit/handlers/onboardingExtension.test.ts @@ -0,0 +1,71 @@ +import { onboardingExtensionCommand } from "../../../src/controllers/onboardingExtensionCommand"; +import { discordTextResponse } from "../../../src/utils/discordResponse"; +import { + ctx, + guildEnv, + transformedArgsForOnboardingExtension, +} from "../../fixtures/fixture"; +import * as utils from "../../../src/utils/onboardingExtension"; + +describe("onboardingExtensionCommand", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + const discordId = + transformedArgsForOnboardingExtension.memberObj.user.id.toString(); + + it("should return Feature not implemented", async () => { + const expectedRes = await onboardingExtensionCommand( + transformedArgsForOnboardingExtension, + guildEnv, + ctx + ); + const jsonResponse = await expectedRes.json(); + const mockResponse = discordTextResponse( + `<@${discordId}> Feature not implemented` + ); + const mockJsonResponse = await mockResponse.json(); + expect(jsonResponse).toStrictEqual(mockJsonResponse); + }); + + it("should return initial response", async () => { + transformedArgsForOnboardingExtension.devObj.value = true; + const expectedRes = await onboardingExtensionCommand( + transformedArgsForOnboardingExtension, + guildEnv, + ctx + ); + const jsonResponse = await expectedRes.json(); + const mockResponse = discordTextResponse( + `<@${discordId}> Processing your request for onboarding extension` + ); + const mockJsonResponse = await mockResponse.json(); + expect(jsonResponse).toStrictEqual(mockJsonResponse); + }); + + it("should call createOnboardingExtension", async () => { + jest.spyOn(utils, "createOnboardingExtension"); + transformedArgsForOnboardingExtension.devObj.value = true; + await onboardingExtensionCommand( + transformedArgsForOnboardingExtension, + guildEnv, + ctx + ); + expect(utils.createOnboardingExtension).toHaveBeenCalledTimes(1); + expect(utils.createOnboardingExtension).toHaveBeenCalledWith( + { + channelId: transformedArgsForOnboardingExtension.channelId, + userId: transformedArgsForOnboardingExtension.userIdObj?.value, + numberOfDays: Number( + transformedArgsForOnboardingExtension.numberOfDaysObj.value + ), + reason: transformedArgsForOnboardingExtension.reasonObj.value, + discordId: discordId, + }, + guildEnv + ); + }); +}); diff --git a/tests/unit/utils/onboardingExtension.test.ts b/tests/unit/utils/onboardingExtension.test.ts new file mode 100644 index 00000000..37ac327f --- /dev/null +++ b/tests/unit/utils/onboardingExtension.test.ts @@ -0,0 +1,116 @@ +import config from "../../../config/config"; +import { UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST } from "../../../src/constants/responses"; +import { DISCORD_BASE_URL } from "../../../src/constants/urls"; +import { generateDiscordAuthToken } from "../../../src/utils/authTokenGenerator"; +import JSONResponse from "../../../src/utils/JsonResponse"; +import { + createOnboardingExtension, + CreateOnboardingExtensionArgs, +} from "../../../src/utils/onboardingExtension"; +import * as utils from "../../../src/utils/sendReplyInDiscordChannel"; +import { env, guildEnv } from "../../fixtures/fixture"; + +describe("createOnboaringExtension", () => { + const args: CreateOnboardingExtensionArgs = { + channelId: 123456789, + discordId: "4574263", + numberOfDays: 2, + reason: "This is reason", + userId: "1462465462546", + }; + const discordReplyUrl = `${DISCORD_BASE_URL}/channels/${args.channelId}/messages`; + const base_url = config(env).RDS_BASE_API_URL; + const createOnboardingExtensionUrl = `${base_url}/requests?dev=true`; + let fetchSpy: jest.SpyInstance; + let authToken: string; + + const requestData = { + userId: args.userId, + type: "ONBOARDING", + numberOfDays: args.numberOfDays, + reason: args.reason, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + fetchSpy = jest.spyOn(global, "fetch"); + jest.spyOn(utils, "sendReplyInDiscordChannel"); + authToken = await generateDiscordAuthToken( + "Cloudflare Worker", + Math.floor(Date.now() / 1000) + 2, + guildEnv.DISCORD_TOKEN, + "RS256" + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should call sendReplyInDicordChannel when user is not a super user", async () => { + fetchSpy.mockImplementation(() => new JSONResponse(null)); + await createOnboardingExtension(args, guildEnv); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(utils.sendReplyInDiscordChannel).toHaveBeenCalledTimes(1); + expect(utils.sendReplyInDiscordChannel).toHaveBeenCalledWith( + discordReplyUrl, + `<@${args.discordId}> ${UNAUTHORIZED_TO_CREATE_ONBOARDING_EXTENSION_REQUEST}`, + guildEnv + ); + }); + + it("should call sendReplyInDiscordChannel with error response for invalid request body", async () => { + const message = "numberOfDays must be positive"; + const errorContent = `<@${args.discordId}> ${message}`; + const response = new JSONResponse({ message }); + + fetchSpy.mockImplementation(() => response); + + args.numberOfDays = -1; + args.userId = undefined; + requestData.numberOfDays = args.numberOfDays; + + await createOnboardingExtension(args, guildEnv); + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(utils.sendReplyInDiscordChannel).toHaveBeenCalledTimes(1); + expect(utils.sendReplyInDiscordChannel).toHaveBeenCalledWith( + discordReplyUrl, + errorContent, + guildEnv + ); + }); + + it("should call sendReplyInDiscordChannel with error content for unexpected error", async () => { + fetchSpy.mockImplementation(() => { + throw new Error("Unexpected Error"); + }); + try { + await createOnboardingExtension(args, guildEnv); + } catch (error) { + expect(utils.sendReplyInDiscordChannel).toHaveBeenCalledTimes(1); + expect(utils.sendReplyInDiscordChannel).toHaveBeenCalledWith( + discordReplyUrl, + `<@${ + args.discordId + }> ${"Error occurred while creating onboarding extension request."}`, + guildEnv + ); + } + }); + + it("should call sendReplyInDiscordChannel with success response", async () => { + args.userId = undefined; + requestData.userId = args.discordId; + await createOnboardingExtension(args, guildEnv); + + expect(global.fetch).toHaveBeenCalledTimes(2); + expect(global.fetch).toHaveBeenCalledWith(createOnboardingExtensionUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(requestData), + }); + }); +}); diff --git a/tests/unit/utils/sendReplyInDiscordChannel.test.ts b/tests/unit/utils/sendReplyInDiscordChannel.test.ts new file mode 100644 index 00000000..999229c2 --- /dev/null +++ b/tests/unit/utils/sendReplyInDiscordChannel.test.ts @@ -0,0 +1,32 @@ +import { sendReplyInDiscordChannel } from "../../../src/utils/sendReplyInDiscordChannel"; + +describe("sendReplyInDiscordChannel", () => { + const discordReplyUrl = ""; + const content = ";"; + const mockEnv = { DISCORD_TOKEN: "mockToken" }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should call fetch", async () => { + jest.spyOn(global, "fetch").mockResolvedValue(new Response(content)); + + await sendReplyInDiscordChannel(discordReplyUrl, content, mockEnv); + + expect(global.fetch).toHaveBeenCalledWith(discordReplyUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bot ${mockEnv.DISCORD_TOKEN}`, + }, + body: JSON.stringify({ + content: content, + }), + }); + }); +});