From f2508fbea4770d607047316ce0ce2f74ca3d7a8e Mon Sep 17 00:00:00 2001 From: Katalina Okano Date: Sat, 29 Jan 2022 03:49:51 -0500 Subject: [PATCH] add brute force interactions tests --- .../interactions/commands/hello-world.ts | 22 +++++ .../api/src/routes/interactions/helpers.ts | 66 ++++++++++++++- .../routes/interactions/interactions.spec.ts | 50 ++++++++++- .../src/routes/interactions/interactions.ts | 59 ++++++++++++- .../api/src/routes/interactions/responses.ts | 15 +++- .../src/routes/interactions/testHelpers.ts | 83 +++++++++++++++++++ 6 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 packages/api/src/routes/interactions/commands/hello-world.ts create mode 100644 packages/api/src/routes/interactions/testHelpers.ts diff --git a/packages/api/src/routes/interactions/commands/hello-world.ts b/packages/api/src/routes/interactions/commands/hello-world.ts new file mode 100644 index 0000000..64e75e9 --- /dev/null +++ b/packages/api/src/routes/interactions/commands/hello-world.ts @@ -0,0 +1,22 @@ +import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { + InteractionCallbackType, + InteractionRequest, + InteractionResponse, +} from '@roleypoly/types'; + +export const helloWorld: InteractionHandler = ( + interaction: InteractionRequest, + context: Context +): InteractionResponse => { + return { + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: `Hey there, ${interaction.member?.nick || interaction.user?.username}`, + }, + }; +}; + +helloWorld.ephemeral = true; +helloWorld.deferred = true; diff --git a/packages/api/src/routes/interactions/helpers.ts b/packages/api/src/routes/interactions/helpers.ts index 687ceac..d42a1b5 100644 --- a/packages/api/src/routes/interactions/helpers.ts +++ b/packages/api/src/routes/interactions/helpers.ts @@ -1,5 +1,12 @@ import { Config } from '@roleypoly/api/src/utils/config'; -import { InteractionRequest } from '@roleypoly/types'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord'; +import { + InteractionCallbackType, + InteractionFlags, + InteractionRequest, + InteractionResponse, +} from '@roleypoly/types'; export const verifyRequest = async ( config: Config, @@ -28,3 +35,60 @@ export const verifyRequest = async ( Buffer.from(timestamp + JSON.stringify(interaction)) ); }; + +export type InteractionHandler = (( + interaction: InteractionRequest, + context: Context +) => Promise | InteractionResponse) & { + ephemeral?: boolean; + deferred?: boolean; +}; + +export const runAsync = async ( + handler: InteractionHandler, + interaction: InteractionRequest, + context: Context +): Promise => { + const url = `/webhooks/${interaction.application_id}/${interaction.token}/messages/@original`; + + try { + const response = await handler(interaction, context); + await discordFetch(url, '', AuthType.None, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { + flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0, + ...response.data, + }, + }), + }); + } catch (e) { + console.error('/interations runAsync failed', { + e, + interaction: { + data: interaction.data, + user: interaction.user, + guild: interaction.guild_id, + }, + }); + try { + await discordFetch(url, '', AuthType.None, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: "I'm sorry, I'm having trouble processing this request.", + flags: InteractionFlags.EPHEMERAL, + }, + } as InteractionResponse), + }); + } catch (e) {} + } +}; diff --git a/packages/api/src/routes/interactions/interactions.spec.ts b/packages/api/src/routes/interactions/interactions.spec.ts index 24288f2..2d1439b 100644 --- a/packages/api/src/routes/interactions/interactions.spec.ts +++ b/packages/api/src/routes/interactions/interactions.spec.ts @@ -1,3 +1,49 @@ -describe('interactions validations from Discord', () => { - it('', () => {}); +jest.mock('../../utils/discord'); +import { InteractionCallbackType, InteractionFlags } from '@roleypoly/types'; +import { AuthType, discordFetch } from '../../utils/discord'; +import { configContext } from '../../utils/testHelpers'; +import { extractInteractionResponse, makeInteractionsRequest } from './testHelpers'; + +const mockDiscordFetch = discordFetch as jest.Mock; + +it('responds with a simple hello-world!', async () => { + const [config, context] = configContext(); + const response = await makeInteractionsRequest(context, { + name: 'hello-world', + }); + + expect(response.status).toBe(200); + + const interaction = await extractInteractionResponse(response); + + expect(interaction.type).toEqual( + InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE + ); + expect(interaction.data).toEqual({ + flags: InteractionFlags.EPHEMERAL, + }); + expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, { + body: JSON.stringify({ + type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { + flags: InteractionFlags.EPHEMERAL, + content: 'Hey there, test-user', + }, + }), + headers: expect.any(Object), + method: 'PATCH', + }); +}); + +it('does not allow requests that are invalid', async () => { + const [config, context] = configContext(); + const response = await makeInteractionsRequest( + context, + { + name: 'hello-world', + }, + true + ); + + expect(response.status).toBe(401); }); diff --git a/packages/api/src/routes/interactions/interactions.ts b/packages/api/src/routes/interactions/interactions.ts index f526400..2899093 100644 --- a/packages/api/src/routes/interactions/interactions.ts +++ b/packages/api/src/routes/interactions/interactions.ts @@ -1,7 +1,24 @@ -import { verifyRequest } from '@roleypoly/api/src/routes/interactions/helpers'; +import { helloWorld } from '@roleypoly/api/src/routes/interactions/commands/hello-world'; +import { + InteractionHandler, + runAsync, + verifyRequest, +} from '@roleypoly/api/src/routes/interactions/helpers'; +import { notImplemented } from '@roleypoly/api/src/routes/interactions/responses'; import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; import { invalid, json } from '@roleypoly/api/src/utils/response'; -import { InteractionRequest, InteractionType } from '@roleypoly/types'; +import { + InteractionCallbackType, + InteractionData, + InteractionFlags, + InteractionRequest, + InteractionResponse, + InteractionType, +} from '@roleypoly/types'; + +const commands: Record = { + 'hello-world': helloWorld, +}; export const handleInteraction: RoleypolyHandler = async ( request: Request, @@ -17,6 +34,10 @@ export const handleInteraction: RoleypolyHandler = async ( } if (interaction.type !== InteractionType.APPLICATION_COMMAND) { + if (interaction.type === InteractionType.PING) { + return json({ type: InteractionCallbackType.PONG }); + } + return json({ err: 'not implemented' }, { status: 400 }); } @@ -24,5 +45,37 @@ export const handleInteraction: RoleypolyHandler = async ( return json({ err: 'data missing' }, { status: 400 }); } - return json({}); + const handler = commands[interaction.data.name] || notImplemented; + + try { + if (handler.deferred) { + context.fetchContext.waitUntil(runAsync(handler, interaction, context)); + + return json({ + type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + data: { + flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0, + }, + } as InteractionResponse); + } + + const response = await handler(interaction, context); + return json({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0, + ...response.data, + }, + }); + } catch (e) { + console.error('/interactions error:', { + interaction: { + data: interaction.data, + user: interaction.user, + guild: interaction.guild_id, + }, + e, + }); + return invalid(); + } }; diff --git a/packages/api/src/routes/interactions/responses.ts b/packages/api/src/routes/interactions/responses.ts index f79dccc..deb24da 100644 --- a/packages/api/src/routes/interactions/responses.ts +++ b/packages/api/src/routes/interactions/responses.ts @@ -1,10 +1,11 @@ +import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers'; import { InteractionCallbackType, InteractionFlags, InteractionResponse, } from '@roleypoly/types'; -export const mustBeInGuild = (): InteractionResponse => ({ +export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({ type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: ':x: This command has to be used in a server.', @@ -12,7 +13,7 @@ export const mustBeInGuild = (): InteractionResponse => ({ }, }); -export const invalid = (): InteractionResponse => ({ +export const invalid: InteractionHandler = (): InteractionResponse => ({ type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: ':x: You filled that command out wrong...', @@ -20,10 +21,18 @@ export const invalid = (): InteractionResponse => ({ }, }); -export const somethingWentWrong = (): InteractionResponse => ({ +export const somethingWentWrong: InteractionHandler = (): InteractionResponse => ({ type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, data: { content: ' Something went terribly wrong.', flags: InteractionFlags.EPHEMERAL, }, }); + +export const notImplemented: InteractionHandler = (): InteractionResponse => ({ + type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE, + data: { + content: ':x: This command is not implemented yet.', + flags: InteractionFlags.EPHEMERAL, + }, +}); diff --git a/packages/api/src/routes/interactions/testHelpers.ts b/packages/api/src/routes/interactions/testHelpers.ts new file mode 100644 index 0000000..a9baeeb --- /dev/null +++ b/packages/api/src/routes/interactions/testHelpers.ts @@ -0,0 +1,83 @@ +import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions'; +import { Context } from '@roleypoly/api/src/utils/context'; +import { getID } from '@roleypoly/api/src/utils/id'; +import { + InteractionData, + InteractionRequest, + InteractionResponse, + InteractionType, +} from '@roleypoly/types'; +import nacl from 'tweetnacl'; + +const { publicKey, secretKey } = nacl.sign.keyPair(); +const hexPublicKey = Buffer.from(publicKey).toString('hex'); + +export const getSignatureHeaders = ( + context: Context, + interaction: InteractionRequest +): { + 'x-signature-ed25519': string; + 'x-signature-timestamp': string; +} => { + const timestamp = Date.now().toString(); + const body = JSON.stringify(interaction); + const signature = nacl.sign.detached(Buffer.from(timestamp + body), secretKey); + + return { + 'x-signature-ed25519': Buffer.from(signature).toString('hex'), + 'x-signature-timestamp': timestamp, + }; +}; + +export const makeInteractionsRequest = async ( + context: Context, + interactionData: Partial, + forceInvalid?: boolean +): Promise => { + context.config.publicKey = hexPublicKey; + + const interaction: InteractionRequest = { + data: { + id: getID(), + name: 'hello-world', + ...interactionData, + } as InteractionData, + id: '123', + type: InteractionType.APPLICATION_COMMAND, + application_id: context.config.botClientID, + token: getID(), + version: 1, + user: { + id: '123', + username: 'test-user', + discriminator: '1234', + bot: false, + avatar: '', + }, + member: { + nick: 'test-user', + roles: [], + }, + }; + + const request = new Request('http://localhost:3000/interactions', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...getSignatureHeaders(context, { + ...interaction, + ...(forceInvalid ? { id: 'invalid-id' } : {}), + }), + }, + body: JSON.stringify(interaction), + }); + + return handleInteraction(request, context); +}; + +export const extractInteractionResponse = async ( + response: Response +): Promise => { + const body = await response.json(); + return body as InteractionResponse; +};