mirror of
https://github.com/roleypoly/roleypoly.git
synced 2025-06-17 01:59:08 +00:00
big overhaul (#474)
* miniflare init * feat(api): add tests * chore: more tests, almost 100% * add sessions/state spec * add majority of routes and datapaths, start on interactions * nevermind, no interactions * nevermind x2, tweetnacl is bad but SubtleCrypto has what we need apparently * simplify interactions verify * add brute force interactions tests * every primary path API route is refactored! * automatically import from legacy, or die trying. * check that we only fetch legacy once, ever * remove old-src, same some historic pieces * remove interactions & worker-utils package, update misc/types * update some packages we don't need specific pinning for anymore * update web references to API routes since they all changed * fix all linting issues, upgrade most packages * fix tests, divorce enzyme where-ever possible * update web, fix integration issues * pre-build api * fix tests * move api pretest to api package.json instead of CI * remove interactions from terraform, fix deploy side configs * update to tf 1.1.4 * prevent double writes to worker in GCS, port to newer GCP auth workflow * fix api.tf var refs, upgrade node action * change to curl-based script upload for worker script due to terraform provider limitations * oh no, cloudflare freaked out :(
This commit is contained in:
parent
b644a38aa7
commit
3291f9aacc
183 changed files with 9853 additions and 9924 deletions
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
25
packages/api/src/routes/auth/bot.spec.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/bot', () => {
|
||||
it('redirects to a Discord OAuth bot flow url', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bot', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456'
|
||||
);
|
||||
});
|
||||
|
||||
it('redirects to a Discord OAuth bot flow url, forcing a guild when set', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bot?guild=123456', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&scope=bot%20applications.commands&permissions=268435456&guild_id=123456&disable_guild_select=true'
|
||||
);
|
||||
});
|
||||
});
|
43
packages/api/src/routes/auth/bot.ts
Normal file
43
packages/api/src/routes/auth/bot.ts
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
const validGuildID = /^[0-9]+$/;
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
permissions: number;
|
||||
guildID?: string;
|
||||
scopes: string[];
|
||||
};
|
||||
|
||||
const buildURL = (params: URLParams) => {
|
||||
let url = `https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&scope=${params.scopes.join('%20')}&permissions=${params.permissions}`;
|
||||
|
||||
if (params.guildID) {
|
||||
url += `&guild_id=${params.guildID}&disable_guild_select=true`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export const authBot: RoleypolyHandler = (
|
||||
request: Request,
|
||||
{ config }: Context
|
||||
): Response => {
|
||||
let guildID = new URL(request.url).searchParams.get('guild') || '';
|
||||
|
||||
if (guildID && !validGuildID.test(guildID)) {
|
||||
guildID = '';
|
||||
}
|
||||
|
||||
return seeOther(
|
||||
buildURL({
|
||||
clientID: config.botClientID,
|
||||
permissions: 268435456, // Send messages + manage roles
|
||||
guildID,
|
||||
scopes: ['bot', 'applications.commands'],
|
||||
})
|
||||
);
|
||||
};
|
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
54
packages/api/src/routes/auth/bounce.spec.ts
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { StateSession } from '@roleypoly/types';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/bounce', () => {
|
||||
it('should return a redirect to Discord OAuth', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bounce', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'https://discord.com/api/oauth2/authorize?client_id=test123&response_type=code&scope=identify%20guilds&prompt=none&redirect_uri=http%3A%2F%2Ftest.local%2Fauth%2Fcallback&state='
|
||||
);
|
||||
});
|
||||
|
||||
it('should store a state-session', async () => {
|
||||
const response = await makeRequest('GET', '/auth/bounce', undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
const url = new URL(response.headers.get('Location') || '');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
const environment = getBindings();
|
||||
const session = await environment.KV_SESSIONS.get(`state_${state}`, 'json');
|
||||
expect(session).not.toBeUndefined();
|
||||
});
|
||||
|
||||
test.each([
|
||||
['http://web.test.local', 'http://web.test.local', 'http://web.test.local'],
|
||||
['http://*.test.local', 'http://web.test.local', 'http://web.test.local'],
|
||||
['http://other.test.local', 'http://web.test.local', undefined],
|
||||
])(
|
||||
'should process callback hosts when set to %s',
|
||||
async (allowlist, input, expected) => {
|
||||
const response = await makeRequest('GET', `/auth/bounce?cbh=${input}`, undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
API_PUBLIC_URI: 'http://api.test.local',
|
||||
ALLOWED_CALLBACK_HOSTS: allowlist,
|
||||
});
|
||||
expect(response.status).toBe(303);
|
||||
const url = new URL(response.headers.get('Location') || '');
|
||||
const state = url.searchParams.get('state');
|
||||
|
||||
const environment = getBindings();
|
||||
const session = (await environment.KV_SESSIONS.get(`state_${state}`, 'json')) as {
|
||||
data: StateSession;
|
||||
};
|
||||
expect(session).not.toBeUndefined();
|
||||
expect(session?.data.callbackHost).toBe(expected);
|
||||
}
|
||||
);
|
||||
});
|
64
packages/api/src/routes/auth/bounce.ts
Normal file
64
packages/api/src/routes/auth/bounce.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
|
||||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
import { StateSession } from '@roleypoly/types';
|
||||
|
||||
type URLParams = {
|
||||
clientID: string;
|
||||
redirectURI: string;
|
||||
state: string;
|
||||
};
|
||||
|
||||
export const buildURL = (params: URLParams) =>
|
||||
`https://discord.com/api/oauth2/authorize?client_id=${
|
||||
params.clientID
|
||||
}&response_type=code&scope=identify%20guilds&prompt=none&redirect_uri=${encodeURIComponent(
|
||||
params.redirectURI
|
||||
)}&state=${params.state}`;
|
||||
|
||||
const hostMatch = (a: string, b: string): boolean => {
|
||||
const aURL = new URL(a);
|
||||
const bURL = new URL(b);
|
||||
|
||||
return aURL.host === bURL.host && aURL.protocol === bURL.protocol;
|
||||
};
|
||||
|
||||
const wildcardMatch = (wildcard: string, host: string): boolean => {
|
||||
const aURL = new URL(wildcard);
|
||||
const bURL = new URL(host);
|
||||
|
||||
const regex = new RegExp(aURL.hostname.replace('*', '[a-z0-9-]+'));
|
||||
return regex.test(bURL.hostname);
|
||||
};
|
||||
|
||||
export const isAllowedCallbackHost = (config: Config, host: string): boolean => {
|
||||
return (
|
||||
hostMatch(host, config.apiPublicURI) ||
|
||||
config.allowedCallbackHosts.some((allowedHost) =>
|
||||
allowedHost.includes('*')
|
||||
? wildcardMatch(allowedHost, host)
|
||||
: hostMatch(allowedHost, host)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const authBounce: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
{ config }: Context
|
||||
) => {
|
||||
const stateSessionData: StateSession = {};
|
||||
|
||||
const { cbh: callbackHost } = getQuery(request);
|
||||
if (callbackHost && isAllowedCallbackHost(config, callbackHost)) {
|
||||
stateSessionData.callbackHost = callbackHost;
|
||||
}
|
||||
|
||||
const state = await setupStateSession(config, stateSessionData);
|
||||
|
||||
const redirectURI = `${config.apiPublicURI}/auth/callback`;
|
||||
const clientID = config.botClientID;
|
||||
|
||||
return seeOther(buildURL({ state, redirectURI, clientID }));
|
||||
};
|
93
packages/api/src/routes/auth/callback.spec.ts
Normal file
93
packages/api/src/routes/auth/callback.spec.ts
Normal file
|
@ -0,0 +1,93 @@
|
|||
jest.mock('../../utils/discord');
|
||||
jest.mock('../../sessions/create');
|
||||
|
||||
import { createSession } from '../../sessions/create';
|
||||
import { setupStateSession } from '../../sessions/state';
|
||||
import { parseEnvironment } from '../../utils/config';
|
||||
import { discordFetch } from '../../utils/discord';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
const mockCreateSession = createSession as jest.Mock;
|
||||
|
||||
describe('GET /auth/callback', () => {
|
||||
it('should ask Discord to trade code for tokens', async () => {
|
||||
const env = getBindings();
|
||||
const config = parseEnvironment(env);
|
||||
const stateID = await setupStateSession(config, {});
|
||||
|
||||
const tokens = {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
mockDiscordFetch.mockReturnValueOnce(tokens);
|
||||
|
||||
mockCreateSession.mockReturnValueOnce({
|
||||
sessionID: 'test-session-id',
|
||||
tokens,
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
});
|
||||
|
||||
const response = await makeRequest(
|
||||
'GET',
|
||||
`/auth/callback?state=${stateID}&code=1234`,
|
||||
undefined,
|
||||
{
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(mockDiscordFetch).toBeCalledTimes(1);
|
||||
expect(mockCreateSession).toBeCalledWith(expect.any(Object), tokens);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/new-session/test-session-id'
|
||||
);
|
||||
});
|
||||
|
||||
it('will fail if state is invalid', async () => {
|
||||
const response = await makeRequest(
|
||||
'GET',
|
||||
`/auth/callback?state=invalid-state&code=1234`,
|
||||
undefined,
|
||||
{
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
|
||||
);
|
||||
});
|
||||
|
||||
it('will fail if state is missing', async () => {
|
||||
const response = await makeRequest('GET', `/auth/callback?code=1234`, undefined, {
|
||||
BOT_CLIENT_ID: 'test123',
|
||||
BOT_CLIENT_SECRET: 'test456',
|
||||
API_PUBLIC_URI: 'http://test.local/',
|
||||
UI_PUBLIC_URI: 'http://web.test.local/',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(303);
|
||||
expect(response.headers.get('Location')).toContain(
|
||||
'http://web.test.local/machinery/error?error_code=authFailure&extra=state invalid'
|
||||
);
|
||||
});
|
||||
});
|
76
packages/api/src/routes/auth/callback.ts
Normal file
76
packages/api/src/routes/auth/callback.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { isAllowedCallbackHost } from '@roleypoly/api/src/routes/auth/bounce';
|
||||
import { createSession } from '@roleypoly/api/src/sessions/create';
|
||||
import { getStateSession } from '@roleypoly/api/src/sessions/state';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { dateFromID } from '@roleypoly/api/src/utils/id';
|
||||
import { formDataRequest, getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { seeOther } from '@roleypoly/api/src/utils/response';
|
||||
import { AuthTokenResponse, StateSession } from '@roleypoly/types';
|
||||
|
||||
const authFailure = (uiPublicURI: string, extra?: string) =>
|
||||
seeOther(
|
||||
uiPublicURI +
|
||||
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
|
||||
);
|
||||
|
||||
export const authCallback: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
{ config }: Context
|
||||
) => {
|
||||
let bounceBaseUrl = config.uiPublicURI;
|
||||
|
||||
const { state: stateValue, code } = getQuery(request);
|
||||
|
||||
if (stateValue === null) {
|
||||
return authFailure('state missing');
|
||||
}
|
||||
|
||||
try {
|
||||
const stateTime = dateFromID(stateValue);
|
||||
const stateExpiry = stateTime + 1000 * config.retention.session;
|
||||
const currentTime = Date.now();
|
||||
|
||||
if (currentTime > stateExpiry) {
|
||||
return authFailure('state expired');
|
||||
}
|
||||
|
||||
const stateSession = await getStateSession<StateSession>(config, stateValue);
|
||||
if (
|
||||
stateSession?.callbackHost &&
|
||||
isAllowedCallbackHost(config, stateSession.callbackHost)
|
||||
) {
|
||||
bounceBaseUrl = stateSession.callbackHost;
|
||||
}
|
||||
} catch (e) {
|
||||
return authFailure(config.uiPublicURI, 'state invalid');
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return authFailure(config.uiPublicURI, 'code missing');
|
||||
}
|
||||
|
||||
const response = await discordFetch<AuthTokenResponse>(
|
||||
`/oauth2/token`,
|
||||
'',
|
||||
AuthType.None,
|
||||
formDataRequest({
|
||||
client_id: config.botClientID,
|
||||
client_secret: config.botClientSecret,
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.apiPublicURI + '/auth/callback',
|
||||
})
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return authFailure(config.uiPublicURI, 'code auth failure');
|
||||
}
|
||||
|
||||
const session = await createSession(config, response);
|
||||
if (!session) {
|
||||
return authFailure(config.uiPublicURI, 'session setup failure');
|
||||
}
|
||||
|
||||
return seeOther(bounceBaseUrl + '/machinery/new-session/' + session.sessionID);
|
||||
};
|
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal file
63
packages/api/src/routes/auth/delete-session.spec.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
jest.mock('../../utils/discord');
|
||||
|
||||
import { SessionData } from '@roleypoly/types';
|
||||
import { parseEnvironment } from '../../utils/config';
|
||||
import { AuthType, discordFetch } from '../../utils/discord';
|
||||
import { formDataRequest } from '../../utils/request';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
|
||||
describe('DELETE /auth/session', () => {
|
||||
it('deletes the current user session when it is valid', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const session: SessionData = {
|
||||
sessionID: 'test-session-id',
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
tokens: {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
await config.kv.sessions.put(session.sessionID, session);
|
||||
|
||||
mockDiscordFetch.mockReturnValue(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await makeRequest('DELETE', '/auth/session', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(await config.kv.sessions.get(session.sessionID)).toBeNull();
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
'/oauth2/token/revoke',
|
||||
'',
|
||||
AuthType.None,
|
||||
expect.objectContaining(
|
||||
formDataRequest({
|
||||
client_id: config.botClientID,
|
||||
client_secret: config.botClientSecret,
|
||||
token: session.tokens.access_token,
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
27
packages/api/src/routes/auth/delete-session.ts
Normal file
27
packages/api/src/routes/auth/delete-session.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import { formDataRequest } from '@roleypoly/api/src/utils/request';
|
||||
import { noContent } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
export const authSessionDelete: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (!context.session) {
|
||||
return noContent();
|
||||
}
|
||||
|
||||
await discordFetch(
|
||||
'/oauth2/token/revoke',
|
||||
'',
|
||||
AuthType.None,
|
||||
formDataRequest({
|
||||
client_id: context.config.botClientID,
|
||||
client_secret: context.config.botClientSecret,
|
||||
token: context.session.tokens.access_token,
|
||||
})
|
||||
);
|
||||
|
||||
await context.config.kv.sessions.delete(context.session.sessionID);
|
||||
return noContent();
|
||||
};
|
53
packages/api/src/routes/auth/session.spec.ts
Normal file
53
packages/api/src/routes/auth/session.spec.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { SessionData } from '@roleypoly/types';
|
||||
import { parseEnvironment } from '../../utils/config';
|
||||
import { getBindings, makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
describe('GET /auth/session', () => {
|
||||
it('fetches the current user session when it is valid', async () => {
|
||||
const config = parseEnvironment(getBindings());
|
||||
|
||||
const session: SessionData = {
|
||||
sessionID: 'test-session-id',
|
||||
user: {
|
||||
id: 'test-user-id',
|
||||
username: 'test-username',
|
||||
discriminator: 'test-discriminator',
|
||||
avatar: 'test-avatar',
|
||||
bot: false,
|
||||
},
|
||||
guilds: [],
|
||||
tokens: {
|
||||
access_token: 'test-access-token',
|
||||
refresh_token: 'test-refresh-token',
|
||||
expires_in: 3600,
|
||||
scope: 'identify guilds',
|
||||
token_type: 'Bearer',
|
||||
},
|
||||
};
|
||||
|
||||
await config.kv.sessions.put(session.sessionID, session);
|
||||
|
||||
const response = await makeRequest('GET', '/auth/session', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
sessionID: session.sessionID,
|
||||
user: session.user,
|
||||
guilds: session.guilds,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 401 when session is not valid', async () => {
|
||||
const response = await makeRequest('GET', '/auth/session', {
|
||||
headers: {
|
||||
Authorization: `Bearer invalid-session-id`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
});
|
17
packages/api/src/routes/auth/session.ts
Normal file
17
packages/api/src/routes/auth/session.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
export const authSession: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (context.session) {
|
||||
return json({
|
||||
user: context.session.user,
|
||||
guilds: context.session.guilds,
|
||||
sessionID: context.session.sessionID,
|
||||
});
|
||||
}
|
||||
|
||||
return notFound();
|
||||
};
|
32
packages/api/src/routes/guilds/guild-cache-delete.spec.ts
Normal file
32
packages/api/src/routes/guilds/guild-cache-delete.spec.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import { UserGuildPermissions } from '@roleypoly/types';
|
||||
import { getGuild } from '../../guilds/getters';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
|
||||
describe('DELETE /guilds/:id/cache', () => {
|
||||
it('calls getGuilds and returns No Content', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Admin,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await makeRequest('DELETE', `/guilds/123/cache`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(mockGetGuild).toHaveBeenCalledWith(expect.any(Object), '123', true);
|
||||
});
|
||||
});
|
12
packages/api/src/routes/guilds/guild-cache-delete.ts
Normal file
12
packages/api/src/routes/guilds/guild-cache-delete.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { getGuild } from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { noContent } from '@roleypoly/api/src/utils/response';
|
||||
|
||||
export const guildsCacheDelete: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
await getGuild(context.config, context.params.guildId!, true);
|
||||
|
||||
return noContent();
|
||||
};
|
370
packages/api/src/routes/guilds/guild-roles-put.spec.ts
Normal file
370
packages/api/src/routes/guilds/guild-roles-put.spec.ts
Normal file
|
@ -0,0 +1,370 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
jest.mock('../../utils/discord');
|
||||
|
||||
import {
|
||||
CategoryType,
|
||||
Features,
|
||||
Guild,
|
||||
GuildData,
|
||||
Member,
|
||||
OwnRoleInfo,
|
||||
RoleSafety,
|
||||
RoleUpdate,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
|
||||
import { AuthType, discordFetch } from '../../utils/discord';
|
||||
import { json } from '../../utils/response';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
const mockGetGuildMember = getGuildMember as jest.Mock;
|
||||
const mockGetGuildData = getGuildData as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
doMock();
|
||||
});
|
||||
|
||||
describe('PUT /guilds/:id/roles', () => {
|
||||
it('adds member roles when called with valid roles', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [{ id: 'role-2', action: TransactionType.Add }],
|
||||
};
|
||||
|
||||
mockDiscordFetch.mockReturnValueOnce(
|
||||
json({
|
||||
roles: ['role-1', 'role-2'],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
`/guilds/123/members/${session.user.id}`,
|
||||
'test',
|
||||
AuthType.Bot,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
roles: ['role-1', 'role-2'],
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
|
||||
},
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('removes member roles when called with valid roles', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [{ id: 'role-1', action: TransactionType.Remove }],
|
||||
};
|
||||
|
||||
mockDiscordFetch.mockReturnValueOnce(
|
||||
json({
|
||||
roles: [],
|
||||
})
|
||||
);
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
`/guilds/123/members/${session.user.id}`,
|
||||
'test',
|
||||
AuthType.Bot,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
roles: [],
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
|
||||
},
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('does not update roles when called only with invalid roles', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [
|
||||
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
|
||||
{ id: 'role-5-unsafe', action: TransactionType.Add }, // role is marked unsafe
|
||||
],
|
||||
};
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockDiscordFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters roles that are invalid while accepting ones that are valid', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [
|
||||
{ id: 'role-3', action: TransactionType.Add }, // role is in a hidden category
|
||||
{ id: 'role-2', action: TransactionType.Add }, // role is in a hidden category
|
||||
],
|
||||
};
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockDiscordFetch).toHaveBeenCalledWith(
|
||||
`/guilds/123/members/${session.user.id}`,
|
||||
'test',
|
||||
AuthType.Bot,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
roles: ['role-1', 'role-2'],
|
||||
}),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${config.uiPublicURI}`,
|
||||
},
|
||||
method: 'PATCH',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('400s when no transactions are present', async () => {
|
||||
const [config] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const update: RoleUpdate = {
|
||||
knownState: ['role-1'],
|
||||
transactions: [],
|
||||
};
|
||||
|
||||
const response = await makeRequest(
|
||||
'PUT',
|
||||
`/guilds/123/roles`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify(update),
|
||||
},
|
||||
{
|
||||
BOT_TOKEN: 'test',
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(mockDiscordFetch).not.toHaveBeenCalled();
|
||||
expect(mockGetGuild).not.toHaveBeenCalled();
|
||||
expect(mockGetGuildData).not.toHaveBeenCalled();
|
||||
expect(mockGetGuildMember).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const doMock = () => {
|
||||
const guild: Guild & OwnRoleInfo = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
highestRolePosition: 0,
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-2',
|
||||
name: 'Role 2',
|
||||
color: 0,
|
||||
position: 16,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-3',
|
||||
name: 'Role 3',
|
||||
color: 0,
|
||||
position: 15,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-4',
|
||||
name: 'Role 4',
|
||||
color: 0,
|
||||
position: 14,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.Safe,
|
||||
},
|
||||
{
|
||||
id: 'role-5-unsafe',
|
||||
name: 'Role 5 (Unsafe)',
|
||||
color: 0,
|
||||
position: 14,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
safety: RoleSafety.DangerousPermissions,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const member: Member = {
|
||||
roles: ['role-1'],
|
||||
pending: false,
|
||||
nick: '',
|
||||
};
|
||||
|
||||
const guildData: GuildData = {
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [
|
||||
{
|
||||
id: 'category-1',
|
||||
name: 'Category 1',
|
||||
position: 0,
|
||||
hidden: false,
|
||||
type: CategoryType.Multi,
|
||||
roles: ['role-1', 'role-2'],
|
||||
},
|
||||
{
|
||||
id: 'category-2',
|
||||
name: 'Category 2',
|
||||
position: 1,
|
||||
hidden: true,
|
||||
type: CategoryType.Multi,
|
||||
roles: ['role-3'],
|
||||
},
|
||||
],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
mockGetGuildMember.mockReturnValue(member);
|
||||
mockGetGuildData.mockReturnValue(guildData);
|
||||
mockDiscordFetch.mockReturnValue(json({}));
|
||||
};
|
160
packages/api/src/routes/guilds/guild-roles-put.ts
Normal file
160
packages/api/src/routes/guilds/guild-roles-put.ts
Normal file
|
@ -0,0 +1,160 @@
|
|||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
updateGuildMember,
|
||||
} from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { APIMember, AuthType, discordFetch } from '@roleypoly/api/src/utils/discord';
|
||||
import {
|
||||
engineeringProblem,
|
||||
invalid,
|
||||
json,
|
||||
notFound,
|
||||
serverError,
|
||||
} from '@roleypoly/api/src/utils/response';
|
||||
import {
|
||||
difference,
|
||||
isIdenticalArray,
|
||||
keyBy,
|
||||
union,
|
||||
} from '@roleypoly/misc-utils/collection-tools';
|
||||
import {
|
||||
GuildData,
|
||||
Member,
|
||||
Role,
|
||||
RoleSafety,
|
||||
RoleTransaction,
|
||||
RoleUpdate,
|
||||
TransactionType,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const guildsRolesPut: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
if (!request.body) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const updateRequest: RoleUpdate = await request.json();
|
||||
|
||||
if (updateRequest.transactions.length === 0) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const guildID = context.params.guildId;
|
||||
if (!guildID) {
|
||||
return engineeringProblem('params not set up correctly');
|
||||
}
|
||||
|
||||
const userID = context.session!.user.id;
|
||||
|
||||
const [member, guildData, guild] = await Promise.all([
|
||||
getGuildMember(context.config, guildID, userID),
|
||||
getGuildData(context.config, guildID),
|
||||
getGuild(context.config, guildID),
|
||||
]);
|
||||
|
||||
if (!guild || !member) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newRoles = calculateNewRoles({
|
||||
currentRoles: member.roles,
|
||||
guildRoles: guild.roles,
|
||||
guildData,
|
||||
updateRequest,
|
||||
});
|
||||
|
||||
if (
|
||||
isIdenticalArray(member.roles, newRoles) ||
|
||||
isIdenticalArray(updateRequest.knownState, newRoles)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const patchMemberRoles = await discordFetch<APIMember>(
|
||||
`/guilds/${guildID}/members/${userID}`,
|
||||
context.config.botToken,
|
||||
AuthType.Bot,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-audit-log-reason': `Picked their roles via ${context.config.uiPublicURI}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
roles: newRoles,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!patchMemberRoles) {
|
||||
return serverError(new Error('discord rejected the request'));
|
||||
}
|
||||
|
||||
context.fetchContext.waitUntil(
|
||||
updateGuildMember(context.config, guildID, patchMemberRoles)
|
||||
);
|
||||
|
||||
const updatedMember: Member = {
|
||||
roles: patchMemberRoles.roles,
|
||||
};
|
||||
|
||||
return json(updatedMember);
|
||||
};
|
||||
|
||||
export const calculateNewRoles = ({
|
||||
currentRoles,
|
||||
guildData,
|
||||
guildRoles,
|
||||
updateRequest,
|
||||
}: {
|
||||
currentRoles: string[];
|
||||
guildRoles: Role[];
|
||||
guildData: GuildData;
|
||||
updateRequest: RoleUpdate;
|
||||
}): string[] => {
|
||||
const roleMap = keyBy(guildRoles, 'id');
|
||||
|
||||
// These roles were ones changed between knownState (role picker page load/cache) and current (fresh from discord).
|
||||
// We could cause issues, so we'll re-add them later.
|
||||
// const diffRoles = difference(updateRequest.knownState, currentRoles);
|
||||
|
||||
// Only these are safe
|
||||
const allSafeRoles = guildData.categories.reduce<string[]>(
|
||||
(categorizedRoles, category) =>
|
||||
!category.hidden
|
||||
? [
|
||||
...categorizedRoles,
|
||||
...category.roles.filter(
|
||||
(roleID) => roleMap[roleID]?.safety === RoleSafety.Safe
|
||||
),
|
||||
]
|
||||
: categorizedRoles,
|
||||
[]
|
||||
);
|
||||
|
||||
const safeTransactions = updateRequest.transactions.filter((tx: RoleTransaction) =>
|
||||
allSafeRoles.includes(tx.id)
|
||||
);
|
||||
|
||||
const changesByAction = safeTransactions.reduce<
|
||||
Record<TransactionType, RoleTransaction[]>
|
||||
>((group, value, _1, _2, key = value.action) => (group[key].push(value), group), {
|
||||
[TransactionType.Add]: [],
|
||||
[TransactionType.Remove]: [],
|
||||
});
|
||||
|
||||
const rolesToAdd = (changesByAction[TransactionType.Add] ?? []).map(
|
||||
(tx: RoleTransaction) => tx.id
|
||||
);
|
||||
const rolesToRemove = (changesByAction[TransactionType.Remove] ?? []).map(
|
||||
(tx: RoleTransaction) => tx.id
|
||||
);
|
||||
|
||||
const final = union(difference(currentRoles, rolesToRemove), rolesToAdd);
|
||||
|
||||
return final;
|
||||
};
|
164
packages/api/src/routes/guilds/guild.spec.ts
Normal file
164
packages/api/src/routes/guilds/guild.spec.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import { Features, GuildData, PresentableGuild } from '@roleypoly/types';
|
||||
import { getGuild, getGuildData, getGuildMember } from '../../guilds/getters';
|
||||
import { APIGuild, APIMember } from '../../utils/discord';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
const mockGetGuildMember = getGuildMember as jest.Mock;
|
||||
const mockGetGuildData = getGuildData as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetGuildData.mockReset();
|
||||
mockGetGuild.mockReset();
|
||||
mockGetGuildMember.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /guilds/:id', () => {
|
||||
it('returns a presentable guild', async () => {
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const member: APIMember = {
|
||||
roles: ['role-1'],
|
||||
pending: false,
|
||||
nick: '',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
},
|
||||
};
|
||||
|
||||
const guildData: GuildData = {
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
mockGetGuildMember.mockReturnValue(member);
|
||||
mockGetGuildData.mockReturnValue(guildData);
|
||||
|
||||
const [config] = configContext();
|
||||
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
id: guild.id,
|
||||
guild: session.guilds[0],
|
||||
member: {
|
||||
roles: member.roles,
|
||||
},
|
||||
roles: guild.roles,
|
||||
data: guildData,
|
||||
} as PresentableGuild);
|
||||
});
|
||||
|
||||
it('returns a 404 when the guild is not in session', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config);
|
||||
const response = await makeRequest('GET', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when the guild is not fetchable', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await makeRequest('GET', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 404 when the member is no longer in the guild', async () => {
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
mockGetGuildMember.mockReturnValue(null);
|
||||
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await makeRequest('GET', `/guilds/${guild.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
45
packages/api/src/routes/guilds/guild.ts
Normal file
45
packages/api/src/routes/guilds/guild.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
import {
|
||||
getGuild,
|
||||
getGuildData,
|
||||
getGuildMember,
|
||||
} from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { getQuery } from '@roleypoly/api/src/utils/request';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
import { PresentableGuild } from '@roleypoly/types';
|
||||
|
||||
export const guildsGuild: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const { noCache } = getQuery(request);
|
||||
const guild = await getGuild(context.config, context.params!.guildId!, !!noCache);
|
||||
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const member = await getGuildMember(
|
||||
context.config,
|
||||
context.params!.guildId!,
|
||||
context.session!.user.id,
|
||||
!!noCache
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data = await getGuildData(context.config, guild.id);
|
||||
const presentableGuild: PresentableGuild = {
|
||||
id: guild.id,
|
||||
guild: context.session?.guilds.find((g) => g.id === guild.id)!,
|
||||
roles: guild.roles,
|
||||
member: {
|
||||
roles: member.roles,
|
||||
},
|
||||
data,
|
||||
};
|
||||
|
||||
return json(presentableGuild);
|
||||
};
|
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal file
164
packages/api/src/routes/guilds/guilds-patch.spec.ts
Normal file
|
@ -0,0 +1,164 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import {
|
||||
Features,
|
||||
GuildData,
|
||||
GuildDataUpdate,
|
||||
UserGuildPermissions,
|
||||
} from '@roleypoly/types';
|
||||
import { getGuildData } from '../../guilds/getters';
|
||||
import { configContext, makeRequest, makeSession } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuildData = getGuildData as jest.Mock;
|
||||
|
||||
beforeAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('PATCH /guilds/:id', () => {
|
||||
it('updates guild data when user is an editor', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGetGuildData.mockReturnValue({
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
} as GuildData);
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: 'hello test world!',
|
||||
} as GuildDataUpdate),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newGuildData = await config.kv.guildData.get('123');
|
||||
expect(newGuildData).toMatchObject({
|
||||
message: 'hello test world!',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores extraneous fields sent as updates', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGetGuildData.mockReturnValue({
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
} as GuildData);
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fifteen: 'foxes',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newGuildData = await config.kv.guildData.get('123');
|
||||
expect(newGuildData).not.toMatchObject({
|
||||
fifteen: 'foxes',
|
||||
});
|
||||
});
|
||||
|
||||
it('403s when user is not an editor', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.User,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
mockGetGuildData.mockReturnValue({
|
||||
id: '123',
|
||||
message: 'test',
|
||||
categories: [],
|
||||
features: Features.None,
|
||||
auditLogWebhook: null,
|
||||
accessControl: {
|
||||
allowList: [],
|
||||
blockList: [],
|
||||
blockPending: false,
|
||||
},
|
||||
} as GuildData);
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: 'hello test world!',
|
||||
} as GuildDataUpdate),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('400s when no body is present', async () => {
|
||||
const [config, context] = configContext();
|
||||
const session = await makeSession(config, {
|
||||
guilds: [
|
||||
{
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
permissionLevel: UserGuildPermissions.Manager,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await makeRequest('PATCH', `/guilds/123`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.sessionID}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal file
37
packages/api/src/routes/guilds/guilds-patch.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { getGuildData } from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { invalid, json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
import { GuildData, GuildDataUpdate } from '@roleypoly/types';
|
||||
|
||||
export const guildsGuildPatch: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const id = context.params.guildId!;
|
||||
if (!request.body) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
const update: GuildDataUpdate = await request.json();
|
||||
|
||||
const oldGuildData = await getGuildData(context.config, id);
|
||||
if (!oldGuildData) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const newGuildData: GuildData = {
|
||||
...oldGuildData,
|
||||
|
||||
// TODO: validation
|
||||
message: update.message || oldGuildData.message,
|
||||
categories: update.categories || oldGuildData.categories,
|
||||
accessControl: update.accessControl || oldGuildData.accessControl,
|
||||
|
||||
// TODO: audit log webhooks
|
||||
auditLogWebhook: oldGuildData.auditLogWebhook,
|
||||
};
|
||||
|
||||
await context.config.kv.guildData.put(id, newGuildData);
|
||||
|
||||
return json(newGuildData);
|
||||
};
|
52
packages/api/src/routes/guilds/slug.spec.ts
Normal file
52
packages/api/src/routes/guilds/slug.spec.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
jest.mock('../../guilds/getters');
|
||||
|
||||
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
|
||||
import { getGuild } from '../../guilds/getters';
|
||||
import { APIGuild } from '../../utils/discord';
|
||||
import { makeRequest } from '../../utils/testHelpers';
|
||||
|
||||
const mockGetGuild = getGuild as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetGuild.mockReset();
|
||||
});
|
||||
|
||||
describe('GET /guilds/:id/slug', () => {
|
||||
it('returns a valid slug for a given discord server', async () => {
|
||||
const guild: APIGuild = {
|
||||
id: '123',
|
||||
name: 'test',
|
||||
icon: 'test',
|
||||
roles: [
|
||||
{
|
||||
id: 'role-1',
|
||||
name: 'Role 1',
|
||||
color: 0,
|
||||
position: 17,
|
||||
permissions: '',
|
||||
managed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGetGuild.mockReturnValue(guild);
|
||||
|
||||
const response = await makeRequest('GET', `/guilds/${guild.id}/slug`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({
|
||||
id: guild.id,
|
||||
icon: guild.icon,
|
||||
name: guild.name,
|
||||
permissionLevel: UserGuildPermissions.User,
|
||||
} as GuildSlug);
|
||||
});
|
||||
|
||||
it('returns a 404 if the guild cannot be fetched', async () => {
|
||||
mockGetGuild.mockReturnValue(null);
|
||||
|
||||
const response = await makeRequest('GET', `/guilds/slug/123`);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
31
packages/api/src/routes/guilds/slug.ts
Normal file
31
packages/api/src/routes/guilds/slug.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { getGuild } from '@roleypoly/api/src/guilds/getters';
|
||||
import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context';
|
||||
import { json, notFound } from '@roleypoly/api/src/utils/response';
|
||||
import { GuildSlug, UserGuildPermissions } from '@roleypoly/types';
|
||||
|
||||
export const guildsSlug: RoleypolyHandler = async (
|
||||
request: Request,
|
||||
context: Context
|
||||
) => {
|
||||
const id = context.params.guildId!;
|
||||
|
||||
const guildInSession = context.session?.guilds.find((guild) => guild.id === id);
|
||||
|
||||
if (guildInSession) {
|
||||
return json<GuildSlug>(guildInSession);
|
||||
}
|
||||
|
||||
const guild = await getGuild(context.config, id);
|
||||
if (!guild) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const slug: GuildSlug = {
|
||||
id,
|
||||
name: guild.name,
|
||||
icon: guild.icon,
|
||||
permissionLevel: UserGuildPermissions.User,
|
||||
};
|
||||
|
||||
return json<GuildSlug>(slug);
|
||||
};
|
|
@ -0,0 +1,60 @@
|
|||
jest.mock('../../../utils/discord');
|
||||
|
||||
import { discordFetch } from '../../../utils/discord';
|
||||
import { configContext } from '../../../utils/testHelpers';
|
||||
import {
|
||||
extractInteractionResponse,
|
||||
isDeferred,
|
||||
isEphemeral,
|
||||
makeInteractionsRequest,
|
||||
mockUpdateCall,
|
||||
} from '../testHelpers';
|
||||
|
||||
const mockDiscordFetch = discordFetch as jest.Mock;
|
||||
it('responds with the username when member.nick is missing', async () => {
|
||||
const [, context] = configContext();
|
||||
const response = await makeInteractionsRequest(
|
||||
context,
|
||||
{
|
||||
name: 'hello-world',
|
||||
},
|
||||
false,
|
||||
{
|
||||
member: {
|
||||
nick: undefined,
|
||||
roles: [],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const interaction = await extractInteractionResponse(response);
|
||||
|
||||
expect(isDeferred(interaction)).toBe(true);
|
||||
expect(isEphemeral(interaction)).toBe(true);
|
||||
expect(mockDiscordFetch).toBeCalledWith(
|
||||
...mockUpdateCall(expect, {
|
||||
content: 'Hey there, test-user',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('responds with the nickname when member.nick is set', async () => {
|
||||
const [, context] = configContext();
|
||||
const response = await makeInteractionsRequest(context, {
|
||||
name: 'hello-world',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const interaction = await extractInteractionResponse(response);
|
||||
|
||||
expect(isDeferred(interaction)).toBe(true);
|
||||
expect(isEphemeral(interaction)).toBe(true);
|
||||
expect(mockDiscordFetch).toBeCalledWith(
|
||||
...mockUpdateCall(expect, {
|
||||
content: 'Hey there, test-user-nick',
|
||||
})
|
||||
);
|
||||
});
|
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;
|
131
packages/api/src/routes/interactions/helpers.spec.ts
Normal file
131
packages/api/src/routes/interactions/helpers.spec.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
import { InteractionRequest, InteractionType } from '@roleypoly/types';
|
||||
import nacl from 'tweetnacl';
|
||||
import { configContext } from '../../utils/testHelpers';
|
||||
import { verifyRequest } from './helpers';
|
||||
|
||||
//
|
||||
// Q: Why tweetnacl when WebCrypto is available?
|
||||
// A: Discord uses tweetnacl on their end, thus is also
|
||||
// used in far more examples of Discord Interactions than WebCrypto.
|
||||
// We don't actually use it in Workers, as SubtleCrypto using NODE-ED25519
|
||||
// is better in every way, and still gives us the same effect.
|
||||
//
|
||||
|
||||
describe('verifyRequest', () => {
|
||||
it('validates a successful Discord interactions request', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const timestamp = String(Date.now());
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
const signature = nacl.sign.detached(
|
||||
Buffer.from(timestamp + JSON.stringify(body)),
|
||||
secretKey
|
||||
);
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'x-signature-timestamp': timestamp,
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(true);
|
||||
});
|
||||
|
||||
it('fails to validate a headerless Discord interactions request', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails to validate a bad signature from Discord', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const timestamp = String(Date.now());
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey } = nacl.sign.keyPair();
|
||||
const { secretKey: otherKey } = nacl.sign.keyPair();
|
||||
const signature = nacl.sign.detached(
|
||||
Buffer.from(timestamp + JSON.stringify(body)),
|
||||
otherKey
|
||||
);
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'x-signature-timestamp': timestamp,
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails to validate when signature differs from data', async () => {
|
||||
const [config, context] = configContext();
|
||||
|
||||
const timestamp = String(Date.now());
|
||||
const body: InteractionRequest = {
|
||||
id: '123',
|
||||
type: InteractionType.APPLICATION_COMMAND,
|
||||
application_id: '123',
|
||||
token: '123',
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const { publicKey, secretKey } = nacl.sign.keyPair();
|
||||
const signature = nacl.sign.detached(
|
||||
Buffer.from(timestamp + JSON.stringify({ ...body, id: '456' })),
|
||||
secretKey
|
||||
);
|
||||
config.publicKey = Buffer.from(publicKey).toString('hex');
|
||||
|
||||
const request = new Request('http://local.test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
'x-signature-timestamp': timestamp,
|
||||
'x-signature-ed25519': Buffer.from(signature).toString('hex'),
|
||||
},
|
||||
});
|
||||
|
||||
expect(await verifyRequest(context.config, request, body)).toBe(false);
|
||||
});
|
||||
});
|
94
packages/api/src/routes/interactions/helpers.ts
Normal file
94
packages/api/src/routes/interactions/helpers.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
import { Config } from '@roleypoly/api/src/utils/config';
|
||||
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,
|
||||
request: Request,
|
||||
interaction: InteractionRequest
|
||||
): Promise<boolean> => {
|
||||
const timestamp = request.headers.get('x-signature-timestamp');
|
||||
const signature = request.headers.get('x-signature-ed25519');
|
||||
|
||||
if (!timestamp || !signature) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
Buffer.from(config.publicKey, 'hex'),
|
||||
{ name: 'NODE-ED25519', namedCurve: 'NODE-ED25519', public: true } as any,
|
||||
false,
|
||||
['verify']
|
||||
);
|
||||
|
||||
return crypto.subtle.verify(
|
||||
'NODE-ED25519',
|
||||
key,
|
||||
Buffer.from(signature, 'hex'),
|
||||
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_UPDATE_MESSAGE,
|
||||
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_UPDATE_MESSAGE,
|
||||
data: {
|
||||
content: "I'm sorry, I'm having trouble processing this request.",
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
} as InteractionResponse),
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
49
packages/api/src/routes/interactions/interactions.spec.ts
Normal file
49
packages/api/src/routes/interactions/interactions.spec.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
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_UPDATE_MESSAGE,
|
||||
data: {
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
content: 'Hey there, test-user-nick',
|
||||
},
|
||||
}),
|
||||
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);
|
||||
});
|
81
packages/api/src/routes/interactions/interactions.ts
Normal file
81
packages/api/src/routes/interactions/interactions.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
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 {
|
||||
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,
|
||||
context: Context
|
||||
) => {
|
||||
const interaction: InteractionRequest = await request.json();
|
||||
if (!interaction) {
|
||||
return invalid();
|
||||
}
|
||||
|
||||
if (!(await verifyRequest(context.config, request, interaction))) {
|
||||
return new Response('invalid request signature', { status: 401 });
|
||||
}
|
||||
|
||||
if (interaction.type !== InteractionType.APPLICATION_COMMAND) {
|
||||
if (interaction.type === InteractionType.PING) {
|
||||
return json({ type: InteractionCallbackType.PONG });
|
||||
}
|
||||
|
||||
return json({ err: 'not implemented' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!interaction.data) {
|
||||
return json({ err: 'data missing' }, { status: 400 });
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
38
packages/api/src/routes/interactions/responses.ts
Normal file
38
packages/api/src/routes/interactions/responses.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { InteractionHandler } from '@roleypoly/api/src/routes/interactions/helpers';
|
||||
import {
|
||||
InteractionCallbackType,
|
||||
InteractionFlags,
|
||||
InteractionResponse,
|
||||
} from '@roleypoly/types';
|
||||
|
||||
export const mustBeInGuild: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: ':x: This command has to be used in a server.',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
export const invalid: InteractionHandler = (): InteractionResponse => ({
|
||||
type: InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
|
||||
data: {
|
||||
content: ':x: You filled that command out wrong...',
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
134
packages/api/src/routes/interactions/testHelpers.ts
Normal file
134
packages/api/src/routes/interactions/testHelpers.ts
Normal file
|
@ -0,0 +1,134 @@
|
|||
import { handleInteraction } from '@roleypoly/api/src/routes/interactions/interactions';
|
||||
import { Context } from '@roleypoly/api/src/utils/context';
|
||||
import { AuthType } from '@roleypoly/api/src/utils/discord';
|
||||
import { getID } from '@roleypoly/api/src/utils/id';
|
||||
import {
|
||||
InteractionCallbackData,
|
||||
InteractionCallbackType,
|
||||
InteractionData,
|
||||
InteractionFlags,
|
||||
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,
|
||||
topLevelMixin?: Partial<InteractionRequest>
|
||||
): 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-nick',
|
||||
roles: [],
|
||||
},
|
||||
...topLevelMixin,
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const isDeferred = (response: InteractionResponse): boolean => {
|
||||
return response.type === InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE;
|
||||
};
|
||||
|
||||
export const isEphemeral = (response: InteractionResponse): boolean => {
|
||||
return (
|
||||
(response.data?.flags || 0 & InteractionFlags.EPHEMERAL) ===
|
||||
InteractionFlags.EPHEMERAL
|
||||
);
|
||||
};
|
||||
|
||||
export const interactionData = (
|
||||
response: InteractionResponse
|
||||
): Omit<InteractionCallbackData, 'flags'> | undefined => {
|
||||
const { data } = response;
|
||||
if (!data) return undefined;
|
||||
|
||||
delete data.flags;
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const mockUpdateCall = (
|
||||
expect: any,
|
||||
data: Omit<InteractionCallbackData, 'flags'>
|
||||
) => {
|
||||
return [
|
||||
expect.any(String),
|
||||
'',
|
||||
AuthType.None,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE,
|
||||
data: {
|
||||
flags: InteractionFlags.EPHEMERAL,
|
||||
...data,
|
||||
},
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'PATCH',
|
||||
},
|
||||
];
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue