diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 25eaab8..bc24978 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,10 +1,10 @@ -// @ts-ignore import { requireEditor, requireMember } from '@roleypoly/api/src/guilds/middleware'; import { authBot } from '@roleypoly/api/src/routes/auth/bot'; import { authCallback } from '@roleypoly/api/src/routes/auth/callback'; import { authSessionDelete } from '@roleypoly/api/src/routes/auth/delete-session'; import { authSession } from '@roleypoly/api/src/routes/auth/session'; import { guildsGuild } from '@roleypoly/api/src/routes/guilds/guild'; +import { guildsCacheDelete } from '@roleypoly/api/src/routes/guilds/guild-cache-delete'; import { guildsRolesPut } from '@roleypoly/api/src/routes/guilds/guild-roles-put'; import { guildsGuildPatch } from '@roleypoly/api/src/routes/guilds/guilds-patch'; import { guildsSlug } from '@roleypoly/api/src/routes/guilds/slug'; @@ -34,7 +34,12 @@ router.delete('/auth/session', withSession, requireSession, authSessionDelete); const guildsCommon = [injectParams, withSession, requireSession, requireMember]; router.get('/guilds/:guildId', ...guildsCommon, guildsGuild); router.patch('/guilds/:guildId', ...guildsCommon, requireEditor, guildsGuildPatch); -router.delete('/guilds/:guildId/cache', ...guildsCommon, requireEditor, notImplemented); +router.delete( + '/guilds/:guildId/cache', + ...guildsCommon, + requireEditor, + guildsCacheDelete +); router.put('/guilds/:guildId/roles', ...guildsCommon, guildsRolesPut); // Slug is unauthenticated... diff --git a/packages/api/src/routes/guilds/guild-cache-delete.spec.ts b/packages/api/src/routes/guilds/guild-cache-delete.spec.ts new file mode 100644 index 0000000..352098b --- /dev/null +++ b/packages/api/src/routes/guilds/guild-cache-delete.spec.ts @@ -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); + }); +}); diff --git a/packages/api/src/routes/guilds/guild-cache-delete.ts b/packages/api/src/routes/guilds/guild-cache-delete.ts new file mode 100644 index 0000000..297bdd4 --- /dev/null +++ b/packages/api/src/routes/guilds/guild-cache-delete.ts @@ -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(); +}; diff --git a/packages/api/src/routes/guilds/guild-roles-put.spec.ts b/packages/api/src/routes/guilds/guild-roles-put.spec.ts index 38deb85..c524784 100644 --- a/packages/api/src/routes/guilds/guild-roles-put.spec.ts +++ b/packages/api/src/routes/guilds/guild-roles-put.spec.ts @@ -1,5 +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('returns Not Implemented when called', () => { - expect(true).toBe(true); + 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({})); +}; diff --git a/packages/api/src/routes/guilds/guild-roles-put.ts b/packages/api/src/routes/guilds/guild-roles-put.ts index fbabee6..14586b2 100644 --- a/packages/api/src/routes/guilds/guild-roles-put.ts +++ b/packages/api/src/routes/guilds/guild-roles-put.ts @@ -1,9 +1,154 @@ +import { + getGuild, + getGuildData, + getGuildMember, +} from '@roleypoly/api/src/guilds/getters'; import { Context, RoleypolyHandler } from '@roleypoly/api/src/utils/context'; -import { notImplemented } from '@roleypoly/api/src/utils/response'; +import { 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 ) => { - return notImplemented(); + 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)) { + return invalid(); + } + + const patchMemberRoles = await discordFetch( + `/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(getGuildMember(context.config, guildID, userID, true)); + + 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( + (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 + >((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; }; diff --git a/packages/api/src/routes/interactions/commands/hello-world.spec.ts b/packages/api/src/routes/interactions/commands/hello-world.spec.ts new file mode 100644 index 0000000..ced6b65 --- /dev/null +++ b/packages/api/src/routes/interactions/commands/hello-world.spec.ts @@ -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', + }) + ); +}); diff --git a/packages/api/src/routes/interactions/helpers.ts b/packages/api/src/routes/interactions/helpers.ts index d42a1b5..aaa5a1b 100644 --- a/packages/api/src/routes/interactions/helpers.ts +++ b/packages/api/src/routes/interactions/helpers.ts @@ -59,7 +59,7 @@ export const runAsync = async ( 'Content-Type': 'application/json', }, body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE, data: { flags: handler.ephemeral ? InteractionFlags.EPHEMERAL : 0, ...response.data, @@ -82,7 +82,7 @@ export const runAsync = async ( 'Content-Type': 'application/json', }, body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE, data: { content: "I'm sorry, I'm having trouble processing this request.", flags: InteractionFlags.EPHEMERAL, diff --git a/packages/api/src/routes/interactions/interactions.spec.ts b/packages/api/src/routes/interactions/interactions.spec.ts index 2d1439b..77d0bd0 100644 --- a/packages/api/src/routes/interactions/interactions.spec.ts +++ b/packages/api/src/routes/interactions/interactions.spec.ts @@ -24,10 +24,10 @@ it('responds with a simple hello-world!', async () => { }); expect(mockDiscordFetch).toBeCalledWith(expect.any(String), '', AuthType.None, { body: JSON.stringify({ - type: InteractionCallbackType.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE, + type: InteractionCallbackType.DEFERRED_UPDATE_MESSAGE, data: { flags: InteractionFlags.EPHEMERAL, - content: 'Hey there, test-user', + content: 'Hey there, test-user-nick', }, }), headers: expect.any(Object), diff --git a/packages/api/src/routes/interactions/testHelpers.ts b/packages/api/src/routes/interactions/testHelpers.ts index a9baeeb..67fe639 100644 --- a/packages/api/src/routes/interactions/testHelpers.ts +++ b/packages/api/src/routes/interactions/testHelpers.ts @@ -1,8 +1,12 @@ 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, @@ -32,7 +36,8 @@ export const getSignatureHeaders = ( export const makeInteractionsRequest = async ( context: Context, interactionData: Partial, - forceInvalid?: boolean + forceInvalid?: boolean, + topLevelMixin?: Partial ): Promise => { context.config.publicKey = hexPublicKey; @@ -55,9 +60,10 @@ export const makeInteractionsRequest = async ( avatar: '', }, member: { - nick: 'test-user', + nick: 'test-user-nick', roles: [], }, + ...topLevelMixin, }; const request = new Request('http://localhost:3000/interactions', { @@ -81,3 +87,48 @@ export const extractInteractionResponse = async ( 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 | undefined => { + const { data } = response; + if (!data) return undefined; + + delete data.flags; + return response.data; +}; + +export const mockUpdateCall = ( + expect: any, + data: Omit +) => { + 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', + }, + ]; +};