mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-04-25 03:49: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 { 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 (
|
export const verifyRequest = async (
|
||||||
config: Config,
|
config: Config,
|
||||||
|
@ -28,3 +35,60 @@ export const verifyRequest = async (
|
||||||
Buffer.from(timestamp + JSON.stringify(interaction))
|
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', () => {
|
jest.mock('../../utils/discord');
|
||||||
it('', () => {});
|
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 { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||||
import { invalid, json } from '@roleypoly/api/src/utils/response';
|
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 (
|
export const handleInteraction: RoleypolyHandler = async (
|
||||||
request: Request,
|
request: Request,
|
||||||
|
@ -17,6 +34,10 @@ export const handleInteraction: RoleypolyHandler = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
|
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
|
||||||
|
if (interaction.type === InteractionType.PING) {
|
||||||
|
return json({ type: InteractionCallbackType.PONG });
|
||||||
|
}
|
||||||
|
|
||||||
return json({ err: 'not implemented' }, { status: 400 });
|
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({ 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 {
|
import {
|
||||||
InteractionCallbackType,
|
InteractionCallbackType,
|
||||||
InteractionFlags,
|
InteractionFlags,
|
||||||
InteractionResponse,
|
InteractionResponse,
|
||||||
} from '@roleypoly/types';
|
} from '@roleypoly/types';
|
||||||
|
|
||||||
export const mustBeInGuild = (): InteractionResponse => ({
|
export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
|
||||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
data: {
|
data: {
|
||||||
content: ':x: This command has to be used in a server.',
|
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,
|
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
data: {
|
data: {
|
||||||
content: ':x: You filled that command out wrong...',
|
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,
|
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||||
data: {
|
data: {
|
||||||
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
|
content: '<a:promareFlame:624850108667789333> Something went terribly wrong.',
|
||||||
flags: InteractionFlags.EPHEMERAL,
|
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