mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-24 19:39:11 +00:00
add brute force interactions tests
This commit is contained in:
parent
0d5bc60c92
commit
f2508fbea4
6 changed files with 286 additions and 9 deletions
22
packages/api/src/routes/interactions/commands/hello-world.ts
Normal file
22
packages/api/src/routes/interactions/commands/hello-world.ts
Normal file
|
@ -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;
|
|
@ -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> | InteractionResponse) & {
|
||||
ephemeral?: boolean;
|
||||
deferred?: boolean;
|
||||
};
|
||||
|
||||
export const runAsync = async (
|
||||
handler: InteractionHandler,
|
||||
interaction: InteractionRequest,
|
||||
context: Context
|
||||
): Promise<void> => {
|
||||
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) {}
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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<InteractionData['name'], InteractionHandler> = {
|
||||
'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();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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: '<a:promareFlame:624850108667789333> 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,
|
||||
},
|
||||
});
|
||||
|
|
83
packages/api/src/routes/interactions/testHelpers.ts
Normal file
83
packages/api/src/routes/interactions/testHelpers.ts
Normal file
|
@ -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<InteractionData>,
|
||||
forceInvalid?: boolean
|
||||
): Promise<Response> => {
|
||||
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<InteractionResponse> => {
|
||||
const body = await response.json();
|
||||
return body as InteractionResponse;
|
||||
};
|
Loading…
Add table
Reference in a new issue