every primary path API route is refactored!

This commit is contained in:
41666 2022-01-30 02:24:25 -05:00
parent f2508fbea4
commit d407a015c9
9 changed files with 682 additions and 12 deletions

View file

@ -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...

View 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);
});
});

View 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();
};

View file

@ -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({}));
};

View file

@ -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<Member>(
`/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<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;
};

View file

@ -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',
})
);
});

View file

@ -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,

View file

@ -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),

View file

@ -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<InteractionData>,
forceInvalid?: boolean
forceInvalid?: boolean,
topLevelMixin?: Partial<InteractionRequest>
): Promise<Response> => {
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<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',
},
];
};