add majority of routes and datapaths, start on interactions

This commit is contained in:
41666 2022-01-29 01:28:29 -05:00
parent bbc0053383
commit 3033ebacb7
47 changed files with 1650 additions and 58 deletions

View file

@ -0,0 +1,5 @@
it('works', () => {
expect(true).toBeTruthy();
});
export {};

View file

View file

@ -0,0 +1,130 @@
jest.mock('../utils/discord');
import { Features, GuildData } from '@roleypoly/types';
import { APIGuild, discordFetch } from '../utils/discord';
import { configContext } from '../utils/testHelpers';
import { getGuild, getGuildData, getGuildMember } from './getters';
const mockDiscordFetch = discordFetch as jest.Mock;
beforeEach(() => {
mockDiscordFetch.mockReset();
});
describe('getGuild', () => {
it('gets a guild from discord', async () => {
const [config] = configContext();
const guild = {
id: '123',
name: 'test',
icon: 'test',
roles: [],
};
mockDiscordFetch.mockReturnValue(guild);
const result = await getGuild(config, '123');
expect(result).toMatchObject(guild);
});
it('gets a guild from cache automatically', async () => {
const [config] = configContext();
const guild: APIGuild = {
id: '123',
name: 'test',
icon: 'test',
roles: [],
};
await config.kv.guilds.put('guilds/123', guild, config.retention.guild);
mockDiscordFetch.mockReturnValue({ ...guild, name: 'test2' });
const result = await getGuild(config, '123');
expect(result).toMatchObject(guild);
expect(result!.name).toBe('test');
});
});
describe('getGuildData', () => {
it('gets guild data from store', async () => {
const [config] = configContext();
const guildData: GuildData = {
id: '123',
message: 'Hello world!',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
await config.kv.guildData.put('123', guildData);
const result = await getGuildData(config, '123');
expect(result).toMatchObject(guildData);
});
it('adds fields that are missing from the stored data', async () => {
const [config] = configContext();
const guildData: Partial<GuildData> = {
id: '123',
message: 'Hello world!',
categories: [],
features: Features.None,
};
await config.kv.guildData.put('123', guildData);
const result = await getGuildData(config, '123');
expect(result).toMatchObject({
...guildData,
auditLogWebhook: null,
accessControl: expect.any(Object),
});
});
});
describe('getGuildMember', () => {
it('gets a member from discord', async () => {
const [config] = configContext();
const member = {
roles: [],
pending: false,
nick: 'test',
};
mockDiscordFetch.mockReturnValue(member);
const result = await getGuildMember(config, '123', '123');
expect(result).toMatchObject(member);
});
it('gets a member from cache automatically', async () => {
const [config] = configContext();
const member = {
roles: [],
pending: false,
nick: 'test2',
};
await config.kv.guilds.put('guilds/123/members/123', member, config.retention.guild);
mockDiscordFetch.mockReturnValue({ ...member, nick: 'test' });
const result = await getGuildMember(config, '123', '123');
expect(result).toMatchObject(member);
expect(result!.nick).toBe('test2');
});
});

View file

@ -0,0 +1,149 @@
import { Config } from '@roleypoly/api/src/utils/config';
import {
APIGuild,
APIMember,
APIRole,
AuthType,
discordFetch,
getHighestRole,
} from '@roleypoly/api/src/utils/discord';
import { evaluatePermission, permissions } from '@roleypoly/misc-utils/hasPermission';
import {
Features,
Guild,
GuildData,
Member,
OwnRoleInfo,
Role,
RoleSafety,
} from '@roleypoly/types';
export const getGuild = async (
config: Config,
id: string,
forceMiss?: boolean
): Promise<(Guild & OwnRoleInfo) | null> =>
config.kv.guilds.cacheThrough(
`guilds/${id}`,
async () => {
const guildRaw = await discordFetch<APIGuild>(
`/guilds/${id}`,
config.botToken,
AuthType.Bot
);
if (!guildRaw) {
return null;
}
const botMemberRoles =
(await getGuildMember(config, id, config.botClientID))?.roles || [];
const highestRolePosition =
getHighestRole(
botMemberRoles
.map((r) => guildRaw.roles.find((r2) => r2.id === r))
.filter((x) => x !== undefined) as APIRole[]
)?.position || -1;
const roles = guildRaw.roles.map<Role>((role) => ({
id: role.id,
name: role.name,
color: role.color,
managed: role.managed,
position: role.position,
permissions: role.permissions,
safety: RoleSafety.Safe, // TODO: calculate this
}));
const guild: Guild & OwnRoleInfo = {
id,
name: guildRaw.name,
icon: guildRaw.icon,
roles,
highestRolePosition,
};
return guild;
},
config.retention.guild,
forceMiss
);
export const getGuildData = async (config: Config, id: string): Promise<GuildData> => {
const guildData = await config.kv.guildData.get<GuildData>(id);
const empty = {
id,
message: '',
categories: [],
features: Features.None,
auditLogWebhook: null,
accessControl: {
allowList: [],
blockList: [],
blockPending: true,
},
};
if (!guildData) {
return empty;
}
return {
...empty,
...guildData,
};
};
export const getGuildMember = async (
config: Config,
serverID: string,
userID: string,
forceMiss?: boolean,
overrideRetention?: number // allows for own-member to be cached as long as it's used.
): Promise<Member | null> =>
config.kv.guilds.cacheThrough(
`guilds/${serverID}/members/${userID}`,
async () => {
const discordMember = await discordFetch<APIMember>(
`/guilds/${serverID}/members/${userID}`,
config.botToken,
AuthType.Bot
);
if (!discordMember) {
return null;
}
return {
guildid: serverID,
roles: discordMember.roles,
pending: discordMember.pending,
nick: discordMember.nick,
};
},
overrideRetention || config.retention.member,
forceMiss
);
const calculateRoleSafety = (role: Role | APIRole, highestBotRolePosition: number) => {
let safety = RoleSafety.Safe;
if (role.managed) {
safety |= RoleSafety.ManagedRole;
}
if (role.position > highestBotRolePosition) {
safety |= RoleSafety.HigherThanBot;
}
const permBigInt = BigInt(role.permissions);
if (
evaluatePermission(permBigInt, permissions.ADMINISTRATOR) ||
evaluatePermission(permBigInt, permissions.MANAGE_ROLES)
) {
safety |= RoleSafety.DangerousPermissions;
}
return safety;
};

View file

@ -0,0 +1,78 @@
import { Router } from 'itty-router';
import { json } from '../utils/response';
import { configContext, makeSession } from '../utils/testHelpers';
import { requireEditor } from './middleware';
describe('requireEditor', () => {
it('continues the request when user is an editor', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/${session.guilds[1].id}`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: session.guilds[1].id } }
);
expect(response.status).toBe(200);
expect(testFn).toHaveBeenCalledTimes(1);
});
it('403s the request when user is not an editor', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/${session.guilds[0].id}`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: session.guilds[0].id } }
);
expect(response.status).toBe(403);
expect(testFn).not.toHaveBeenCalled();
});
it('404s the request when the guild isnt in session', async () => {
const testFn = jest.fn();
const [config, context] = configContext();
const session = await makeSession(config);
const router = Router();
router.all('*', requireEditor).get('/:guildId', (request, context) => {
testFn();
return json({});
});
const response = await router.handle(
new Request(`http://test.local/invalid-session-id`, {
headers: {
authorization: `Bearer ${session.sessionID}`,
},
}),
{ ...context, session, params: { guildId: 'invalid-session-id' } }
);
expect(response.status).toBe(404);
expect(testFn).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
import { Context, RoleypolyMiddleware } from '@roleypoly/api/src/utils/context';
import {
engineeringProblem,
forbidden,
notFound,
} from '@roleypoly/api/src/utils/response';
import { UserGuildPermissions } from '@roleypoly/types';
export const requireEditor: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (!context.params.guildId) {
return engineeringProblem('params not set up correctly');
}
if (!context.session) {
return engineeringProblem('middleware not set up correctly');
}
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
if (!guild) {
return notFound(); // 404 because we don't want enumeration of guilds
}
if (guild.permissionLevel === UserGuildPermissions.User) {
return forbidden();
}
};
export const requireMember: RoleypolyMiddleware = async (
request: Request,
context: Context
) => {
if (!context.params.guildId) {
return engineeringProblem('params not set up correctly');
}
if (!context.session) {
return engineeringProblem('middleware not set up correctly');
}
const guild = context.session.guilds.find((g) => g.id === context.params.guildId);
if (!guild) {
return notFound(); // 404 because we don't want enumeration of guilds
}
};