feat(api): add tests

This commit is contained in:
41666 2022-01-27 22:30:24 -05:00
parent 688954a2e0
commit 480987aa90
26 changed files with 541 additions and 91 deletions

View file

@ -0,0 +1,16 @@
module.exports = {
preset: 'ts-jest/presets/default-esm',
name: 'api',
rootDir: '../../',
testEnvironment: 'miniflare',
testEnvironmentOptions: {
wranglerConfigPath: 'packages/api/wrangler.toml',
envPath: '.env',
},
globals: {
'ts-jest': {
tsconfig: '<rootDir>/packages/api/tsconfig.test.json',
useESM: true,
},
},
};

View file

@ -9,20 +9,18 @@
},
"dependencies": {},
"devDependencies": {
"@cloudflare/workers-types": "^2.2.2",
"@cloudflare/workers-types": "^3.3.1",
"@roleypoly/misc-utils": "*",
"@roleypoly/types": "*",
"@roleypoly/worker-emulator": "*",
"@roleypoly/worker-utils": "*",
"@types/deep-equal": "^1.0.1",
"deep-equal": "^2.0.5",
"esbuild": "^0.14.13",
"exponential-backoff": "^3.1.0",
"esbuild": "^0.14.14",
"itty-router": "^2.4.10",
"ksuid": "^2.0.0",
"jest-environment-miniflare": "^2.2.0",
"lodash": "^4.17.21",
"miniflare": "^2.2.0",
"ts-loader": "^8.3.0",
"ts-jest": "^27.1.3",
"ulid-workers": "^1.1.0"
}
}

View file

@ -1,4 +1,4 @@
import { WrappedKVNamespace } from '@roleypoly/api/src/kv';
import { WrappedKVNamespace } from './kv';
export type Environment = {
BOT_CLIENT_ID: string;
@ -35,6 +35,10 @@ export type Config = {
guilds: WrappedKVNamespace;
guildData: WrappedKVNamespace;
};
retention: {
session: number;
sessionState: number;
};
_raw: Environment;
};
@ -60,5 +64,9 @@ export const parseEnvironment = (env: Environment): Config => {
guilds: new WrappedKVNamespace(env.KV_GUILDS),
guildData: new WrappedKVNamespace(env.KV_GUILD_DATA),
},
retention: {
session: 60 * 60 * 6, // 6 hours
sessionState: 60 * 5, // 5 minutes
},
};
};

View file

@ -1,12 +1,20 @@
// @ts-ignore
import { authBounce } from '@roleypoly/api/src/routes/auth/bounce';
import { json, notFound } from '@roleypoly/api/src/utils/response';
import { authBot } from '@roleypoly/api/src/routes/auth/bot';
import { authCallback } from '@roleypoly/api/src/routes/auth/callback';
import { withAuthMode } from '@roleypoly/api/src/sessions/middleware';
import { Router } from 'itty-router';
import { Config, Environment, parseEnvironment } from './config';
import { authBounce } from './routes/auth/bounce';
import { Context } from './utils/context';
import { json, notFound } from './utils/response';
const router = Router();
router.all('*', withAuthMode);
router.get('/auth/bot', authBot);
router.get('/auth/bounce', authBounce);
router.get('/auth/callback', authCallback);
router.get('/', (request: Request, config: Config) =>
json({
@ -25,8 +33,17 @@ router.get('/', (request: Request, config: Config) =>
router.get('*', () => notFound());
export default {
async fetch(request: Request, env: Environment, ctx: FetchEvent) {
async fetch(request: Request, env: Environment, event: Context['fetchContext']) {
const config = parseEnvironment(env);
return router.handle(request, config, ctx);
const context: Context = {
config,
fetchContext: {
waitUntil: event.waitUntil,
},
authMode: {
type: 'anonymous',
},
};
return router.handle(request, context);
},
};

View file

@ -1,5 +1,11 @@
export class WrappedKVNamespace {
constructor(private kvNamespace: KVNamespace) {}
constructor(private kvNamespace: KVNamespace) {
this.getRaw = kvNamespace.get.bind(kvNamespace);
this.putRaw = kvNamespace.put.bind(kvNamespace);
this.delete = kvNamespace.delete.bind(kvNamespace);
this.list = kvNamespace.list.bind(kvNamespace);
this.getWithMetadata = kvNamespace.getWithMetadata.bind(kvNamespace);
}
async get<T>(key: string): Promise<T | null> {
const data = await this.kvNamespace.get(key, 'text');
@ -16,8 +22,19 @@ export class WrappedKVNamespace {
});
}
getRaw = this.kvNamespace.get;
list = this.kvNamespace.list;
getWithMetadata = this.kvNamespace.getWithMetadata;
delete = this.kvNamespace.delete;
public getRaw: (
...args: Parameters<KVNamespace['get']>
) => ReturnType<KVNamespace['get']>;
public putRaw: (
...args: Parameters<KVNamespace['put']>
) => ReturnType<KVNamespace['put']>;
public list: (
...args: Parameters<KVNamespace['list']>
) => ReturnType<KVNamespace['list']>;
public getWithMetadata: (
...args: Parameters<KVNamespace['getWithMetadata']>
) => ReturnType<KVNamespace['getWithMetadata']>;
public delete: (
...args: Parameters<KVNamespace['delete']>
) => ReturnType<KVNamespace['delete']>;
}

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

View file

@ -0,0 +1,40 @@
import { Context } 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 = (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'],
})
);
};

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

View file

@ -1,5 +1,6 @@
import { Config } from '@roleypoly/api/src/config';
import { setupStateSession } from '@roleypoly/api/src/sessions/state';
import { Context } 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';
@ -17,18 +18,33 @@ export const buildURL = (params: URLParams) =>
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 (
host === config.apiPublicURI ||
config.allowedCallbackHosts.includes(host) ||
config.allowedCallbackHosts
.filter((callbackHost) => callbackHost.includes('*'))
.find((wildcard) => new RegExp(wildcard.replace('*', '[a-z0-9-]+')).test(host)) !==
null
hostMatch(host, config.apiPublicURI) ||
config.allowedCallbackHosts.some((allowedHost) =>
allowedHost.includes('*')
? wildcardMatch(allowedHost, host)
: hostMatch(allowedHost, host)
)
);
};
export const authBounce = async (request: Request, config: Config) => {
export const authBounce = async (request: Request, { config }: Context) => {
const stateSessionData: StateSession = {};
const { cbh: callbackHost } = getQuery(request);
@ -36,9 +52,9 @@ export const authBounce = async (request: Request, config: Config) => {
stateSessionData.callbackHost = callbackHost;
}
const state = await setupStateSession(config.kv.sessions, stateSessionData);
const state = await setupStateSession(config, stateSessionData);
const redirectURI = `${config.apiPublicURI}/login-callback`;
const redirectURI = `${config.apiPublicURI}/auth/callback`;
const clientID = config.botClientID;
return seeOther(buildURL({ state, redirectURI, clientID }));

View file

@ -0,0 +1,97 @@
jest.mock('../../utils/discord');
jest.mock('../../sessions/create');
import { parseEnvironment } from '../../config';
import { createSession } from '../../sessions/create';
import { setupStateSession } from '../../sessions/state';
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', () => {
beforeEach(() => {
mockDiscordFetch.mockClear();
});
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'
);
});
});

View file

@ -1,12 +1,12 @@
import { Config } from '@roleypoly/api/src/config';
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 { getQuery, seeOther } from '@roleypoly/api/src/utils';
import { Context } from '@roleypoly/api/src/utils/context';
import { AuthType, discordAPIBase, discordFetch } from '@roleypoly/api/src/utils/discord';
import { formData } from '@roleypoly/api/src/utils/request';
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';
import { decodeTime, monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
const authFailure = (uiPublicURI: string, extra?: string) =>
seeOther(
@ -14,7 +14,7 @@ const authFailure = (uiPublicURI: string, extra?: string) =>
`/machinery/error?error_code=authFailure${extra ? `&extra=${extra}` : ''}`
);
export const authCallback = async (request: Request, config: Config) => {
export const authCallback = async (request: Request, { config }: Context) => {
let bounceBaseUrl = config.uiPublicURI;
const { state: stateValue, code } = getQuery(request);
@ -24,18 +24,15 @@ export const authCallback = async (request: Request, config: Config) => {
}
try {
const stateTime = decodeTime(stateValue);
const stateExpiry = stateTime + 1000 * 60 * 5;
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.kv.sessions,
stateValue
);
const stateSession = await getStateSession<StateSession>(config, stateValue);
if (
stateSession?.callbackHost &&
isAllowedCallbackHost(config, stateSession.callbackHost)
@ -43,33 +40,34 @@ export const authCallback = async (request: Request, config: Config) => {
bounceBaseUrl = stateSession.callbackHost;
}
} catch (e) {
return authFailure('state invalid');
return authFailure(config.uiPublicURI, 'state invalid');
}
if (!code) {
return authFailure('code missing');
return authFailure(config.uiPublicURI, 'code missing');
}
const response = await discordFetch<AuthTokenResponse>(
`${discordAPIBase}/oauth2/token`,
'',
AuthType.None,
{
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: formData({
client_id: config.botClientID,
client_secret: config.botClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.apiPublicURI + '/auth/callback',
}),
}
formDataRequest({
client_id: config.botClientID,
client_secret: config.botClientSecret,
grant_type: 'authorization_code',
code,
redirect_uri: config.apiPublicURI + '/auth/callback',
})
);
if (!response) {
return authFailure('code auth failure');
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);
};

View file

@ -0,0 +1,8 @@
import { notImplemented } from '@roleypoly/api/src/utils/response';
/**
* Full import flow for legacy config.
*/
export const legacyImport = () => {
notImplemented();
};

View file

@ -0,0 +1,8 @@
import { notImplemented } from '@roleypoly/api/src/utils/response';
/**
* Fetch setup from beta.roleypoly.com to show the admin.
*/
export const legacyPreflight = () => {
notImplemented();
};

View file

@ -1,8 +1,32 @@
import { Config } from "@roleypoly/api/src/config";
import { AuthTokenResponse } from "@roleypoly/types";
import { Config } from '@roleypoly/api/src/config';
import { getTokenGuilds, getTokenUser } from '@roleypoly/api/src/utils/discord';
import { AuthTokenResponse, SessionData } from '@roleypoly/types';
import { monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
export const createSession = async (
config: Config,
sessionId: string,
tokens: AuthTokenResponse
)
): Promise<SessionData | null> => {
const [user, guilds] = await Promise.all([
getTokenUser(tokens.access_token),
getTokenGuilds(tokens.access_token),
]);
if (!user) {
return null;
}
const sessionID = ulid();
const session: SessionData = {
sessionID,
user,
guilds,
tokens,
};
await config.kv.sessions.put(sessionID, session, config.retention.session);
return session;
};

View file

@ -1,5 +0,0 @@
import { SessionData, SessionFlags } from '@roleypoly/types';
export const getSessionFlags = async (session: SessionData): Promise<SessionFlags> => {
return SessionFlags.None;
};

View file

@ -0,0 +1,46 @@
import { Context } from '@roleypoly/api/src/utils/context';
export const withSession = (request: Request, context: Context) => {};
export const requireSession = (request: Request, context: Context) => {
if (context.authMode.type !== 'bearer') {
throw new Error('Not authed');
}
};
export const withAuthMode = (request: Request, context: Context) => {
const auth = extractAuthentication(request);
if (auth.authType === 'Bearer') {
context.authMode = {
type: 'bearer',
sessionId: auth.token,
};
return;
}
if (auth.authType === 'Bot') {
context.authMode = {
type: 'bot',
identity: auth.token,
};
return;
}
context.authMode = {
type: 'anonymous',
};
};
export const extractAuthentication = (
request: Request
): { authType: string; token: string } => {
const authHeader = request.headers.get('authorization');
if (!authHeader) {
return { authType: 'None', token: '' };
}
const [authType, token] = authHeader.split(' ');
return { authType, token };
};

View file

@ -1,23 +1,19 @@
import { WrappedKVNamespace } from '@roleypoly/api/src/kv';
import { monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
import { Config } from '@roleypoly/api/src/config';
import { getID } from '@roleypoly/api/src/utils/id';
export const setupStateSession = async <T>(
Sessions: WrappedKVNamespace,
data: T
): Promise<string> => {
const stateID = ulid();
export const setupStateSession = async <T>(config: Config, data: T): Promise<string> => {
const stateID = getID();
await Sessions.put(`state_${stateID}`, { data }, 60 * 5);
await config.kv.sessions.put(`state_${stateID}`, { data }, config.retention.session);
return stateID;
};
export const getStateSession = async <T>(
Sessions: WrappedKVNamespace,
config: Config,
stateID: string
): Promise<T | undefined> => {
const stateSession = await Sessions.get<{ data: T }>(`state_${stateID}`);
const stateSession = await config.kv.sessions.get<{ data: T }>(`state_${stateID}`);
return stateSession?.data;
};

View file

@ -0,0 +1,26 @@
import { Config } from '@roleypoly/api/src/config';
import { SessionData } from '@roleypoly/types';
export type AuthMode =
| {
type: 'anonymous';
}
| {
type: 'bearer';
sessionId: string;
}
| {
type: 'bot';
identity: string;
};
export type Context = {
config: Config;
fetchContext: {
waitUntil: FetchEvent['waitUntil'];
};
authMode: AuthMode;
// Must include withSession middleware for population
session?: SessionData;
};

View file

@ -1,4 +1,3 @@
import { Config } from '@roleypoly/api/src/config';
import {
evaluatePermission,
permissions as Permissions,
@ -13,7 +12,7 @@ import {
export const userAgent =
'DiscordBot (https://github.com/roleypoly/roleypoly, git-main) (+https://roleypoly.com)';
export const discordAPIBase = 'https://discordapp.com/api/v9';
export const discordAPIBase = 'https://discord.com/api/v9';
export enum AuthType {
Bearer = 'Bearer',
@ -82,7 +81,7 @@ type UserGuildsPayload = {
features: string[];
}[];
export const getTokenGuilds = async (accessToken: string, config: Config) => {
export const getTokenGuilds = async (accessToken: string) => {
const guilds = await discordFetch<UserGuildsPayload>(
'/users/@me/guilds',
accessToken,
@ -97,11 +96,7 @@ export const getTokenGuilds = async (accessToken: string, config: Config) => {
id: guild.id,
name: guild.name,
icon: guild.icon,
permissionLevel: parsePermissions(
BigInt(guild.permissions),
guild.owner,
config.roleypolyServerID
),
permissionLevel: parsePermissions(BigInt(guild.permissions), guild.owner),
}));
return guildSlugs;

View file

@ -0,0 +1,6 @@
import { decodeTime, monotonicFactory } from 'ulid-workers';
const ulid = monotonicFactory();
export const getID = () => ulid();
export const dateFromID = (id: string) => decodeTime(id);

View file

@ -13,3 +13,18 @@ export const formData = (obj: Record<string, any>): string => {
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(obj[key])}`)
.join('&');
};
export const formDataRequest = (
obj: Record<string, any>,
init?: RequestInit
): RequestInit => {
return {
method: 'POST', // First, so it can be overridden.
...init,
headers: {
...(init?.headers || {}),
'content-type': 'application/x-www-form-urlencoded',
},
body: formData(obj),
};
};

View file

@ -9,8 +9,6 @@ export const json = (obj: any, init?: ResponseInit): Response => {
});
};
export const notFound = () => json({ error: 'not found' }, { status: 404 });
export const seeOther = (url: string) =>
new Response(
`<!doctype html>If you are not redirected soon, <a href="${url}">click here.</a>`,
@ -22,3 +20,12 @@ export const seeOther = (url: string) =>
},
}
);
export const unauthorized = () => json({ error: 'unauthorized' }, { status: 401 });
export const forbidden = () => json({ error: 'forbidden' }, { status: 403 });
export const notFound = () => json({ error: 'not found' }, { status: 404 });
export const serverError = (error: Error) => {
console.error(error);
return json({ error: 'internal server error' }, { status: 500 });
};
export const notImplemented = () => json({ error: 'not implemented' }, { status: 501 });

View file

@ -0,0 +1,27 @@
import { Environment } from '@roleypoly/api/src/config';
import index from '../index';
export const makeRequest = (
method: string,
path: string,
init?: RequestInit,
env?: Partial<Environment>
): Promise<Response> => {
const request = new Request(`https://localhost:22000${path}`, {
method,
...init,
});
return index.fetch(
request,
{
...getMiniflareBindings(),
...env,
},
{
waitUntil: async (promise: Promise<{}>) => await promise,
}
);
};
export const getBindings = (): Environment => getMiniflareBindings();

8
packages/api/test/miniflare.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
declare global {
function getMiniflareBindings(): Environment;
function getMiniflareDurableObjectStorage(
id: DurableObjectId
): Promise<DurableObjectStorage>;
}
export {};

View file

@ -1,17 +1,14 @@
{
"compilerOptions": {
"outDir": "./dist",
"target": "esnext",
"module": "esnext",
"lib": ["esnext", "webworker", "ES2020.BigInt", "ES2020.Promise"],
"types": ["@cloudflare/workers-types"],
"target": "ES2019",
"esModuleInterop": true,
"module": "commonjs"
"moduleResolution": "node"
},
"include": [
"./*.ts",
"./**/*.ts",
"../../node_modules/@cloudflare/workers-types/index.d.ts"
],
"exclude": ["./**/*.spec.ts", "./dist/**"],
"include": ["src/**/*", "test/**/*"],
"exclude": ["./**/*.spec.ts"],
"extends": "../../tsconfig.json"
}

View file

@ -0,0 +1,6 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@cloudflare/workers-types"]
}
}