add unit tests for auth related functions.
This commit is contained in:
		@ -2,28 +2,33 @@ import * as auth from "$lib/server/auth";
 | 
			
		||||
import { forbiddenResponse, unauthorizedResponse } from "$lib/server/responseBodies";
 | 
			
		||||
import { routeAuth, type Method } from "$lib/server/routeAuth";
 | 
			
		||||
import { type Handle } from "@sveltejs/kit";
 | 
			
		||||
import { JWT_SECRET } from "$env/static/private";
 | 
			
		||||
 | 
			
		||||
export const handle: Handle = async ({ event, resolve }) => {
 | 
			
		||||
	const creds = await auth.authenticate(event);
 | 
			
		||||
export const handle: Handle = getHandleFn(JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
	if (!creds) {
 | 
			
		||||
		return unauthorizedResponse();
 | 
			
		||||
	}
 | 
			
		||||
export function getHandleFn(jwtSecret: string): Handle {
 | 
			
		||||
	return async ({ event, resolve }) => {
 | 
			
		||||
		const creds = await auth.authenticate(event, jwtSecret);
 | 
			
		||||
 | 
			
		||||
	const authResult = auth.isAuthorized(
 | 
			
		||||
		routeAuth,
 | 
			
		||||
		event.request.method as Method,
 | 
			
		||||
		event.url.pathname,
 | 
			
		||||
		creds,
 | 
			
		||||
	);
 | 
			
		||||
		if (!creds) {
 | 
			
		||||
			return unauthorizedResponse();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	if (authResult === auth.AuthorizationResult.Denied) {
 | 
			
		||||
		return forbiddenResponse();
 | 
			
		||||
	} else if (authResult === auth.AuthorizationResult.Unauthenticated) {
 | 
			
		||||
		return unauthorizedResponse();
 | 
			
		||||
	}
 | 
			
		||||
		const authResult = auth.isAuthorized(
 | 
			
		||||
			routeAuth,
 | 
			
		||||
			event.request.method as Method,
 | 
			
		||||
			event.url.pathname,
 | 
			
		||||
			creds,
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
	event.locals.user = creds;
 | 
			
		||||
		if (authResult === auth.AuthorizationResult.Denied) {
 | 
			
		||||
			return forbiddenResponse();
 | 
			
		||||
		} else if (authResult === auth.AuthorizationResult.Unauthenticated) {
 | 
			
		||||
			return unauthorizedResponse();
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	return await resolve(event);
 | 
			
		||||
};
 | 
			
		||||
		event.locals.user = creds;
 | 
			
		||||
 | 
			
		||||
		return await resolve(event);
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -174,7 +174,9 @@ export class RollForFirst implements GameEvent {
 | 
			
		||||
				state.dieCount = 6;
 | 
			
		||||
			} else {
 | 
			
		||||
				// ...otherwise, setup for tie breaking rolls.
 | 
			
		||||
				state.scores = scores.map((_, i) => (ties.has(i) ? FIRST_ROLL_PENDING : FIRST_ROLL_LOST));
 | 
			
		||||
				state.scores = scores.map((_, i) =>
 | 
			
		||||
					ties.has(i) ? FIRST_ROLL_PENDING : FIRST_ROLL_LOST,
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@ -201,8 +203,6 @@ export class Roll implements GameEvent {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	run(state: State) {
 | 
			
		||||
		const scores = state.scores ?? [];
 | 
			
		||||
 | 
			
		||||
		throwIfGameOver(state);
 | 
			
		||||
		throwIfWrongTurn(state, this.player);
 | 
			
		||||
 | 
			
		||||
@ -346,7 +346,11 @@ export class Score implements GameEvent {
 | 
			
		||||
 | 
			
		||||
	run(state: State) {
 | 
			
		||||
		const { dieCount, heldScore, scores } = state;
 | 
			
		||||
		const playerCount = scores?.length ?? 0;
 | 
			
		||||
		const playerCount = scores?.length;
 | 
			
		||||
 | 
			
		||||
		if (!playerCount) {
 | 
			
		||||
			throw new Error("there are no players");
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		throwIfGameOver(state);
 | 
			
		||||
		throwIfWrongTurn(state, this.player);
 | 
			
		||||
@ -361,12 +365,14 @@ export class Score implements GameEvent {
 | 
			
		||||
 | 
			
		||||
		// It's safe to tell the compiler that the player is not undefined because of the
 | 
			
		||||
		// check above.
 | 
			
		||||
		state.scores![this.player] += heldScore ?? 0;
 | 
			
		||||
		state.scores![this.player] += heldScore!;
 | 
			
		||||
 | 
			
		||||
		// Increment the index of the active player, circling back to 1 if the player
 | 
			
		||||
		// who just scored was the last player in the array.
 | 
			
		||||
		state.playing =
 | 
			
		||||
			playerCount - 1 === this.player ? (state.playing = 0) : (state.playing = this.player + 1);
 | 
			
		||||
			playerCount - 1 === this.player
 | 
			
		||||
				? (state.playing = 0)
 | 
			
		||||
				: (state.playing = this.player + 1);
 | 
			
		||||
 | 
			
		||||
		state.dieCount = 6;
 | 
			
		||||
		delete state.heldScore;
 | 
			
		||||
 | 
			
		||||
@ -1,17 +1,17 @@
 | 
			
		||||
import { hasProperty } from "$lib/validation";
 | 
			
		||||
import { hasOnlyKeys, hasProperty } from "$lib/validation";
 | 
			
		||||
import { isId, type Id } from "$lib/Id";
 | 
			
		||||
 | 
			
		||||
export interface Listing<T = unknown> {
 | 
			
		||||
	id: Id;
 | 
			
		||||
	createdAt: string;
 | 
			
		||||
	modifiedAt: string | null;
 | 
			
		||||
	deleted: boolean;
 | 
			
		||||
	modifiedAt: string | null;
 | 
			
		||||
	data: T;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isListing<T>(
 | 
			
		||||
	target: unknown,
 | 
			
		||||
	dataGuard?: (target: unknown) => target is T
 | 
			
		||||
	dataGuard?: (target: unknown) => target is T,
 | 
			
		||||
): target is Listing<T> {
 | 
			
		||||
	if (!hasProperty(target, "id", isId)) return false;
 | 
			
		||||
	if (!hasProperty(target, "createdAt", "string")) return false;
 | 
			
		||||
@ -23,5 +23,8 @@ export function isListing<T>(
 | 
			
		||||
	if (!hasProperty(target, "deleted", "boolean")) return false;
 | 
			
		||||
	if (!hasProperty(target, "data", "object")) return false;
 | 
			
		||||
 | 
			
		||||
	if (!hasOnlyKeys(target, ["id", "createdAt", "modifiedAt", "deleted", "data"]))
 | 
			
		||||
		return false;
 | 
			
		||||
 | 
			
		||||
	return dataGuard?.((target as any)["data"]) ?? true;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,6 +7,10 @@ export type ServerResponse =
 | 
			
		||||
	| { error: string };
 | 
			
		||||
 | 
			
		||||
export function isServerResponse(target: unknown): target is ServerResponse {
 | 
			
		||||
	const props = Object.getOwnPropertyNames(target);
 | 
			
		||||
 | 
			
		||||
	if (props.length !== 1) return false;
 | 
			
		||||
 | 
			
		||||
	if (hasProperty(target, "item", "object")) return true;
 | 
			
		||||
	if (hasProperty(target, "items", "object[]")) return true;
 | 
			
		||||
	if (hasProperty(target, "error", "string")) return true;
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,3 @@
 | 
			
		||||
import { JWT_SECRET } from "$env/static/private";
 | 
			
		||||
import type { RequestEvent } from "@sveltejs/kit";
 | 
			
		||||
import jwt from "jsonwebtoken";
 | 
			
		||||
import { type Method, type RouteAuthRule } from "./routeAuth";
 | 
			
		||||
@ -17,10 +16,10 @@ export enum AuthorizationResult {
 | 
			
		||||
	Unauthenticated,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function createToken(listing: Listing<LoginData>) {
 | 
			
		||||
export async function createToken(listing: Listing<LoginData>, secret: string) {
 | 
			
		||||
	return await jwt.sign(
 | 
			
		||||
		{ sub: listing.id, username: listing.data.username, role: listing.data.role },
 | 
			
		||||
		JWT_SECRET,
 | 
			
		||||
		secret,
 | 
			
		||||
		{
 | 
			
		||||
			expiresIn: "1d",
 | 
			
		||||
		},
 | 
			
		||||
@ -29,21 +28,13 @@ export async function createToken(listing: Listing<LoginData>) {
 | 
			
		||||
 | 
			
		||||
export async function authenticate(
 | 
			
		||||
	event: RequestEvent,
 | 
			
		||||
	jwtSecret: string,
 | 
			
		||||
): Promise<LocalCredentials | null> {
 | 
			
		||||
	let path = event.url.pathname;
 | 
			
		||||
	const authHeader = event.request.headers.get("authorization");
 | 
			
		||||
	let tokenKind: "Basic" | "Bearer" | "None";
 | 
			
		||||
	let tokenRole: string;
 | 
			
		||||
	let tokenDesc: LocalCredentials;
 | 
			
		||||
 | 
			
		||||
	const parts = breakupPath(path);
 | 
			
		||||
 | 
			
		||||
	if (parts[0] !== "api") {
 | 
			
		||||
		// not concerned about requests made to the frontend server
 | 
			
		||||
		return { kind: "None", role: "default" };
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const authHeader = event.request.headers.get("authorization");
 | 
			
		||||
 | 
			
		||||
	// get token kind and role from the header
 | 
			
		||||
	if (!authHeader) {
 | 
			
		||||
		// This is a stranger: they have no token and they will be assigned the default
 | 
			
		||||
@ -58,7 +49,7 @@ export async function authenticate(
 | 
			
		||||
			tokenKind = "Bearer";
 | 
			
		||||
 | 
			
		||||
			// The role can be derived from the JWT token.
 | 
			
		||||
			const payload = await jwt.verify(token, JWT_SECRET);
 | 
			
		||||
			const payload = await jwt.verify(token, jwtSecret);
 | 
			
		||||
			if (typeof payload === "string") {
 | 
			
		||||
				// I do not assign, and don't know what to do with, these kinds of
 | 
			
		||||
				// tokens. Perhaps an error should be logged here, since this is a
 | 
			
		||||
@ -93,7 +84,7 @@ export async function authenticate(
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function isAuthorized(
 | 
			
		||||
	roleRules: { [k: string]: RouteAuthRule[] },
 | 
			
		||||
	roleRules: Record<string, RouteAuthRule[]>,
 | 
			
		||||
	method: Method,
 | 
			
		||||
	path: string,
 | 
			
		||||
	creds: LocalCredentials,
 | 
			
		||||
@ -125,8 +116,9 @@ export function isAuthorized(
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function breakupPath(path: string): string[] {
 | 
			
		||||
	// remove a leading /
 | 
			
		||||
	// remove leading and trailing /
 | 
			
		||||
	if (path[0] === "/") path = path.slice(1);
 | 
			
		||||
	if (path[path.length - 1] === "/") path = path.slice(0, path.length - 1);
 | 
			
		||||
 | 
			
		||||
	return path.split("/");
 | 
			
		||||
}
 | 
			
		||||
@ -144,6 +136,9 @@ function matchesRequest(
 | 
			
		||||
	for (let i = 0; i < ruleParts.length; i++) {
 | 
			
		||||
		const rulePart = ruleParts[i];
 | 
			
		||||
		const reqPart = requestParts[i];
 | 
			
		||||
 | 
			
		||||
		if (!reqPart) return false;
 | 
			
		||||
 | 
			
		||||
		if (rulePart[0] === "[" && rulePart[rulePart.length - 1] === "]") {
 | 
			
		||||
			// This part of the path represents an ID.
 | 
			
		||||
			continue;
 | 
			
		||||
@ -159,5 +154,7 @@ function matchesRequest(
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return true;
 | 
			
		||||
	// Finished comparing without seeing a wildcard, meaning the requested path
 | 
			
		||||
	// and the path rules should also be the same length.
 | 
			
		||||
	return requestParts.length === ruleParts.length;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,4 +0,0 @@
 | 
			
		||||
import type { GameData } from "$lib/GameData";
 | 
			
		||||
import type { Listing } from "$lib/Listing";
 | 
			
		||||
 | 
			
		||||
export const games: Listing<GameData>[] = [];
 | 
			
		||||
@ -1,20 +1,28 @@
 | 
			
		||||
import type { Listing } from "$lib/Listing";
 | 
			
		||||
import { MongoClient, ObjectId, ServerApiVersion, type Document, type WithId } from "mongodb";
 | 
			
		||||
import {
 | 
			
		||||
	MongoClient,
 | 
			
		||||
	ObjectId,
 | 
			
		||||
	ServerApiVersion,
 | 
			
		||||
	type Document,
 | 
			
		||||
	type WithId,
 | 
			
		||||
} from "mongodb";
 | 
			
		||||
import type { Id } from "../Id";
 | 
			
		||||
 | 
			
		||||
type ListingFromMongo = Omit<Listing, "id"> & { _id: ObjectId };
 | 
			
		||||
 | 
			
		||||
const uri = `mongodb://127.0.0.1:27017`;
 | 
			
		||||
const DATABASE = "ten-thousand";
 | 
			
		||||
let client: MongoClient | null = null;
 | 
			
		||||
let cachedClient: MongoClient | null = null;
 | 
			
		||||
 | 
			
		||||
async function getClient() {
 | 
			
		||||
	if (cachedClient !== null) return cachedClient;
 | 
			
		||||
 | 
			
		||||
async function init() {
 | 
			
		||||
	const c = new MongoClient(uri, {
 | 
			
		||||
		serverApi: {
 | 
			
		||||
			version: ServerApiVersion.v1,
 | 
			
		||||
			strict: true,
 | 
			
		||||
			deprecationErrors: true
 | 
			
		||||
		}
 | 
			
		||||
			deprecationErrors: true,
 | 
			
		||||
		},
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	await c.connect();
 | 
			
		||||
@ -22,55 +30,60 @@ async function init() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function writeListing(col: string, listing: Listing) {
 | 
			
		||||
	if (client === null) {
 | 
			
		||||
		client = await init();
 | 
			
		||||
	}
 | 
			
		||||
	const client = await getClient();
 | 
			
		||||
 | 
			
		||||
	await client.db(DATABASE).collection(col).insertOne(fixListingForMongo(listing));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function listCollection<T>(
 | 
			
		||||
	col: string,
 | 
			
		||||
	dataGuard: (target: unknown) => target is Listing<T>,
 | 
			
		||||
) {
 | 
			
		||||
	const client = await getClient();
 | 
			
		||||
	const res = await client.db(DATABASE).collection(col).find();
 | 
			
		||||
 | 
			
		||||
	return (await res.toArray()).map((doc) => fixListingFromMongo(doc, dataGuard));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function readListingById<T>(
 | 
			
		||||
	col: string,
 | 
			
		||||
	id: Id,
 | 
			
		||||
	dataGuard: (target: unknown) => target is T
 | 
			
		||||
) {}
 | 
			
		||||
	dataGuard: (target: unknown) => target is Listing<T>,
 | 
			
		||||
) {
 | 
			
		||||
	const client = await getClient();
 | 
			
		||||
	const res = await client.db(DATABASE).collection(col).findOne({ _id: id });
 | 
			
		||||
 | 
			
		||||
	if (res === null) return null;
 | 
			
		||||
	return fixListingFromMongo(res, dataGuard);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function readListingByQuery<T>(
 | 
			
		||||
	col: string,
 | 
			
		||||
	query: object,
 | 
			
		||||
	dataGuard: (target: unknown) => target is Listing<T>
 | 
			
		||||
	dataGuard: (target: unknown) => target is Listing<T>,
 | 
			
		||||
): Promise<Listing<T> | null> {
 | 
			
		||||
	if (client === null) {
 | 
			
		||||
		client = await init();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const client = await getClient();
 | 
			
		||||
	const res = await client.db(DATABASE).collection(col).findOne(query);
 | 
			
		||||
 | 
			
		||||
	if (res === null) {
 | 
			
		||||
		return null;
 | 
			
		||||
	}
 | 
			
		||||
	if (res === null) return null;
 | 
			
		||||
 | 
			
		||||
	return fixListingFromMongo(res, dataGuard);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function fixListingForMongo(listing: Listing): ListingFromMongo {
 | 
			
		||||
	// TODO: These two statements are tricky without any. Perhaps id could be optional,
 | 
			
		||||
	//       but that's kind of stupid because it's not optional on the type I'm
 | 
			
		||||
	//       constructing. Figure out something better here.
 | 
			
		||||
	const adjusted: any = { _id: listing.id, ...listing };
 | 
			
		||||
	delete adjusted.id;
 | 
			
		||||
	return adjusted;
 | 
			
		||||
	const { id, ...rest } = listing;
 | 
			
		||||
	return {
 | 
			
		||||
		_id: id,
 | 
			
		||||
		...rest,
 | 
			
		||||
	};
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function fixListingFromMongo<T>(
 | 
			
		||||
	target: WithId<Document>,
 | 
			
		||||
	dataGuard: (target: unknown) => target is Listing<T>
 | 
			
		||||
	dataGuard: (target: unknown) => target is Listing<T>,
 | 
			
		||||
): Listing<T> {
 | 
			
		||||
	// TODO: These two statements are tricky without any. Perhaps id could be optional,
 | 
			
		||||
	//       but that's kind of stupid because it's not optional on the type I'm
 | 
			
		||||
	//       constructing. Figure out something better here.
 | 
			
		||||
	const adjusted: any = { id: target._id, ...target };
 | 
			
		||||
	delete adjusted._id;
 | 
			
		||||
	const { _id, ...rest } = target;
 | 
			
		||||
	const adjusted = { id: _id, ...rest };
 | 
			
		||||
 | 
			
		||||
	if (!dataGuard(adjusted)) {
 | 
			
		||||
		throw new Error("the returned document does not conform to the provided type");
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ export interface RouteAuthRule {
 | 
			
		||||
	tokenKind?: "None" | "Bearer" | "Basic"; // defaults to "Bearer"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const routeAuth: { [k: string]: RouteAuthRule[] } = {
 | 
			
		||||
export const routeAuth: Record<string, RouteAuthRule[]> = {
 | 
			
		||||
	// default is an unknown user. They are allowed to create a new user for themselves
 | 
			
		||||
	// without any token, and they are allowed to access the token endpoint to login with
 | 
			
		||||
	// a Basic token. Other than that, they cannot do anything!
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										456
									
								
								src/lib/server/test/auth.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										456
									
								
								src/lib/server/test/auth.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,456 @@
 | 
			
		||||
import { describe, expect, it } from "vitest";
 | 
			
		||||
import * as jwt from "jsonwebtoken";
 | 
			
		||||
import type { Listing } from "$lib/Listing";
 | 
			
		||||
import type { LoginData } from "$lib/Login";
 | 
			
		||||
import { createId } from "$lib/Id";
 | 
			
		||||
import {
 | 
			
		||||
	authenticate,
 | 
			
		||||
	AuthorizationResult,
 | 
			
		||||
	createToken,
 | 
			
		||||
	isAuthorized,
 | 
			
		||||
	type LocalCredentials,
 | 
			
		||||
} from "$lib/server/auth";
 | 
			
		||||
import type { Cookies, RequestEvent } from "@sveltejs/kit";
 | 
			
		||||
import type { Method, RouteAuthRule } from "$lib/server/routeAuth";
 | 
			
		||||
 | 
			
		||||
type TestData = {
 | 
			
		||||
	heading: string;
 | 
			
		||||
	conditions: {
 | 
			
		||||
		token: LocalCredentials;
 | 
			
		||||
		method: Method;
 | 
			
		||||
		path: string;
 | 
			
		||||
	};
 | 
			
		||||
	expectations: {
 | 
			
		||||
		result: AuthorizationResult;
 | 
			
		||||
	};
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const JWT_SECRET = "server-secret";
 | 
			
		||||
 | 
			
		||||
const loginListing: Listing<LoginData> = {
 | 
			
		||||
	id: createId(),
 | 
			
		||||
	createdAt: new Date().toString(),
 | 
			
		||||
	modifiedAt: new Date().toString(),
 | 
			
		||||
	deleted: false,
 | 
			
		||||
	data: {
 | 
			
		||||
		username: "Somebody Important",
 | 
			
		||||
		password: "some-password-hash",
 | 
			
		||||
		role: "test-role",
 | 
			
		||||
	},
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function getEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
 | 
			
		||||
	const event: RequestEvent = {
 | 
			
		||||
		cookies: {} as Cookies,
 | 
			
		||||
		fetch: async (): Promise<Response> => {
 | 
			
		||||
			return new Response();
 | 
			
		||||
		},
 | 
			
		||||
		getClientAddress: () => "",
 | 
			
		||||
		locals: { user: {} },
 | 
			
		||||
		params: {},
 | 
			
		||||
		platform: undefined,
 | 
			
		||||
		request: new Request(new URL("https://localhost/api")),
 | 
			
		||||
		route: { id: "" },
 | 
			
		||||
		setHeaders: () => {},
 | 
			
		||||
		url: new URL("https://localhost/api"),
 | 
			
		||||
		isDataRequest: false,
 | 
			
		||||
		isSubRequest: false,
 | 
			
		||||
	};
 | 
			
		||||
 | 
			
		||||
	return { ...event, ...overrides };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
describe("auth", () => {
 | 
			
		||||
	describe("createToken", () => {
 | 
			
		||||
		it("should create a json web token", async () => {
 | 
			
		||||
			const token = await createToken(loginListing, "some-server-secret");
 | 
			
		||||
 | 
			
		||||
			expect(jwt.decode(token)).to.not.be.null;
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe("authenticate", () => {
 | 
			
		||||
		it("should return a default/'None' object when no auth header is present", async () => {
 | 
			
		||||
			const event = getEvent();
 | 
			
		||||
 | 
			
		||||
			expect(await authenticate(event, JWT_SECRET)).to.deep.equal({
 | 
			
		||||
				kind: "None",
 | 
			
		||||
				role: "default",
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return a 'Bearer' object with the correct user role when a valid jwt token is passed", async () => {
 | 
			
		||||
			const token = await createToken(loginListing, JWT_SECRET);
 | 
			
		||||
			const request = new Request("http://localhost/api", {
 | 
			
		||||
				headers: [["Authorization", `Bearer ${token}`]],
 | 
			
		||||
			});
 | 
			
		||||
			const event = getEvent({ request });
 | 
			
		||||
			const tokenDesc = await authenticate(event, JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
			expect(tokenDesc).property("kind", "Bearer");
 | 
			
		||||
			const { payload, ...rest } = tokenDesc as {
 | 
			
		||||
				kind: "Bearer";
 | 
			
		||||
				role: string;
 | 
			
		||||
				payload: jwt.JwtPayload;
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(payload).property("sub", loginListing.id.toString());
 | 
			
		||||
			expect(payload).property("username", loginListing.data.username);
 | 
			
		||||
			expect(payload).property("role", loginListing.data.role);
 | 
			
		||||
			expect(rest).to.deep.equal({
 | 
			
		||||
				kind: "Bearer",
 | 
			
		||||
				role: loginListing.data.role,
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return null if a JWT token payload is a string", async () => {
 | 
			
		||||
			const token = await jwt.sign("I'm just a dumb string!", JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
			const request = new Request("http://localhost/api", {
 | 
			
		||||
				headers: [["Authorization", `Bearer ${token}`]],
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const event = getEvent({ request });
 | 
			
		||||
			const tokenDesc = await authenticate(event, JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
			expect(tokenDesc).to.be.null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return null if a JWT token payload is missing a role", async () => {
 | 
			
		||||
			const token = await jwt.sign(
 | 
			
		||||
				{
 | 
			
		||||
					sub: loginListing.id,
 | 
			
		||||
					username: loginListing.data.username,
 | 
			
		||||
				},
 | 
			
		||||
				JWT_SECRET,
 | 
			
		||||
				{
 | 
			
		||||
					expiresIn: "1d",
 | 
			
		||||
				},
 | 
			
		||||
			);
 | 
			
		||||
 | 
			
		||||
			const request = new Request("http://localhost/api", {
 | 
			
		||||
				headers: [["Authorization", `Bearer ${token}`]],
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const event = getEvent({ request });
 | 
			
		||||
			const tokenDesc = await authenticate(event, JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
			expect(tokenDesc).to.be.null;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return a default/'Basic' object when a Basic header is provided", async () => {
 | 
			
		||||
			const user = "someuser";
 | 
			
		||||
			const pass = "somepass";
 | 
			
		||||
			const token = Buffer.from(`${user}:${pass}`).toString("base64");
 | 
			
		||||
			const request = new Request("http://localhost/api", {
 | 
			
		||||
				headers: [["Authorization", `Basic ${token}`]],
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const event = getEvent({ request });
 | 
			
		||||
			const tokenDesc = await authenticate(event, JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
			expect(tokenDesc).to.deep.equal({
 | 
			
		||||
				kind: "Basic",
 | 
			
		||||
				payload: {
 | 
			
		||||
					username: user,
 | 
			
		||||
					password: pass,
 | 
			
		||||
				},
 | 
			
		||||
				role: "default",
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return null if the token type is nonesense", async () => {
 | 
			
		||||
			const request = new Request("http://localhost/api", {
 | 
			
		||||
				headers: [["Authorization", "Nonesense tokenbody"]],
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			const event = getEvent({ request });
 | 
			
		||||
			const tokenDesc = await authenticate(event, JWT_SECRET);
 | 
			
		||||
 | 
			
		||||
			expect(tokenDesc).to.be.null;
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe("isAuthorized", () => {
 | 
			
		||||
		const authRules: Record<string, RouteAuthRule[]> = {
 | 
			
		||||
			alternativeRole: [
 | 
			
		||||
				{
 | 
			
		||||
					action: "allow",
 | 
			
		||||
					methods: ["*"],
 | 
			
		||||
					endpoint: "/api/thing",
 | 
			
		||||
				},
 | 
			
		||||
			],
 | 
			
		||||
			superUser: [
 | 
			
		||||
				{
 | 
			
		||||
					action: "allow",
 | 
			
		||||
					methods: ["GET", "POST", "DELETE", "PUT"],
 | 
			
		||||
					endpoint: "*",
 | 
			
		||||
				},
 | 
			
		||||
			],
 | 
			
		||||
			tokenBearer: [
 | 
			
		||||
				// user is explicitely not allowed to use PATCH
 | 
			
		||||
				{ action: "deny", methods: ["PATCH"], endpoint: "*" },
 | 
			
		||||
 | 
			
		||||
				// user is allowed to POST to resources
 | 
			
		||||
				{
 | 
			
		||||
					action: "allow",
 | 
			
		||||
					methods: ["POST"],
 | 
			
		||||
					endpoint: "/api/resource",
 | 
			
		||||
					tokenKind: "Bearer",
 | 
			
		||||
				},
 | 
			
		||||
 | 
			
		||||
				// user is allowed to use any method on a single resource
 | 
			
		||||
				{ action: "allow", methods: ["*"], endpoint: "/api/resource/[resourceid]" },
 | 
			
		||||
 | 
			
		||||
				// user is allowed to GET or DELETE any resource nested under resource
 | 
			
		||||
				{
 | 
			
		||||
					action: "allow",
 | 
			
		||||
					methods: ["GET", "DELETE"],
 | 
			
		||||
					endpoint: "/api/resource/[resourceid]/*",
 | 
			
		||||
				},
 | 
			
		||||
			],
 | 
			
		||||
			tokenNone: [
 | 
			
		||||
				// user is allowed to hit an endpoint with a "None" token
 | 
			
		||||
				{ action: "allow", methods: ["POST"], endpoint: "/api/user", tokenKind: "None" },
 | 
			
		||||
			],
 | 
			
		||||
			tokenBasic: [
 | 
			
		||||
				// user is allowed to hit an endpoint with a "Basic token"
 | 
			
		||||
				{
 | 
			
		||||
					action: "allow",
 | 
			
		||||
					methods: ["POST"],
 | 
			
		||||
					endpoint: "/api/token",
 | 
			
		||||
					tokenKind: "Basic",
 | 
			
		||||
				},
 | 
			
		||||
			],
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const tokenBearer = { kind: "Bearer", role: "tokenBearer", payload: {} } as const;
 | 
			
		||||
 | 
			
		||||
		const tests: TestData[] = [
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return allowed when a user calls an explicitly allowed path",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					path: "/api/resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should match paths based on the role provided in the token",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: { kind: "Bearer", role: "alternativeRole", payload: {} },
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					path: "/api/thing",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should ignore paths that only exist on other roles",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: { kind: "Bearer", role: "alternativeRole", payload: {} },
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					path: "/api/resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Denied,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return unauthenticated when a user has the wrong token type",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: {
 | 
			
		||||
						kind: "Basic",
 | 
			
		||||
						role: "tokenBearer",
 | 
			
		||||
						payload: { username: "Mr. Sneaky", password: "something-secret" },
 | 
			
		||||
					},
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					path: "/api/resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Unauthenticated,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return allowed when a rule opens a path with a wildcard method",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "PUT",
 | 
			
		||||
					path: "/api/resource/some-resource-id",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return denied when the endpoint is valid, but the method is not",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path: "/api/resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Denied,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return allowed when matching a wildcard endpoint",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: {
 | 
			
		||||
						kind: "Bearer",
 | 
			
		||||
						role: "superUser",
 | 
			
		||||
						payload: {},
 | 
			
		||||
					},
 | 
			
		||||
					method: "PUT",
 | 
			
		||||
					path: "/api/magicresource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading:
 | 
			
		||||
					"should return denied wehen matching a wildcard endpoint with an invalid method",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: {
 | 
			
		||||
						kind: "Bearer",
 | 
			
		||||
						role: "superUser",
 | 
			
		||||
						payload: {},
 | 
			
		||||
					},
 | 
			
		||||
					method: "PATCH",
 | 
			
		||||
					path: "/api/magicresource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Denied,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return allowed when matching a nested endpoint wildcard",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path: "/api/resource/some-resource-id/nested-resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading:
 | 
			
		||||
					"should return return allowed when matching a resource deeply " +
 | 
			
		||||
					"nested under an endpoint wildcard",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path:
 | 
			
		||||
						"/api/resource/some-resource-id/nested-resource/" +
 | 
			
		||||
						"more-nested-resource/more-nested-id",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading:
 | 
			
		||||
					"should not match a path rule if the request path ends before the " +
 | 
			
		||||
					"rule path's endpoint wildcard",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path: "/api/resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Denied,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading:
 | 
			
		||||
					"should return denied when a 'denied' rule and an 'allowed' rule both match",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "PATCH",
 | 
			
		||||
					path: "/api/resource/some-resource-id",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Denied,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "should return denied when a user calls an unmentioned path",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path: "/api/unmentioned",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Denied,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading:
 | 
			
		||||
					"should return unauthenticated when a user calls an unmentioned " +
 | 
			
		||||
					"path with a 'None' token",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: { kind: "None", role: "tokenBearer" },
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path: "/api/unmentioned",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Unauthenticated,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading:
 | 
			
		||||
					"should return unauthenticated when a user calls an unmentioned " +
 | 
			
		||||
					"path with a 'Basic' token",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: {
 | 
			
		||||
						kind: "Basic",
 | 
			
		||||
						role: "tokenBearer",
 | 
			
		||||
						payload: { username: "someuser", password: "super-secure" },
 | 
			
		||||
					},
 | 
			
		||||
					method: "GET",
 | 
			
		||||
					path: "/api/unmentioned",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Unauthenticated,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "correctly matches a route with a trailing /",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					path: "/api/resource/",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				heading: "corretly matches a route without a leading /",
 | 
			
		||||
				conditions: {
 | 
			
		||||
					token: tokenBearer,
 | 
			
		||||
					method: "POST",
 | 
			
		||||
					path: "api/resource",
 | 
			
		||||
				},
 | 
			
		||||
				expectations: {
 | 
			
		||||
					result: AuthorizationResult.Allowed,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		];
 | 
			
		||||
 | 
			
		||||
		for (const { heading, conditions, expectations } of tests) {
 | 
			
		||||
			it(heading, () => {
 | 
			
		||||
				const { token, method, path } = conditions;
 | 
			
		||||
				const { result } = expectations;
 | 
			
		||||
 | 
			
		||||
				expect(isAuthorized(authRules, method, path, token)).to.equal(result);
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
@ -4,7 +4,7 @@ import { Game } from "$lib/server/Game";
 | 
			
		||||
import { deepEqual, equal, ok } from "node:assert/strict";
 | 
			
		||||
import { isId } from "$lib/Id";
 | 
			
		||||
 | 
			
		||||
describe("Listing", () => {
 | 
			
		||||
describe("modifyListing", () => {
 | 
			
		||||
	describe("createNewListing", () => {
 | 
			
		||||
		it("should create a new Listing with the provided data, and a new UUID", () => {
 | 
			
		||||
			const game = new Game();
 | 
			
		||||
@ -29,8 +29,17 @@ describe("GameData", () => {
 | 
			
		||||
			equal(isGameData(data), false);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("rejects an object without a players property", () => {
 | 
			
		||||
			const data: unknown = {
 | 
			
		||||
				state: {},
 | 
			
		||||
				isStarted: false,
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			equal(isGameData(data), false);
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("rejects an object with extra properties", () => {
 | 
			
		||||
			const data: GameData & { extra: boolean } = {
 | 
			
		||||
			const data: unknown = {
 | 
			
		||||
				players: [idFromString(idString)],
 | 
			
		||||
				isStarted: false,
 | 
			
		||||
				state: {},
 | 
			
		||||
@ -9,11 +9,11 @@ import {
 | 
			
		||||
	RollForFirst,
 | 
			
		||||
	Score,
 | 
			
		||||
	SeatPlayers,
 | 
			
		||||
} from "../../GameEvent";
 | 
			
		||||
import type { GameEventData } from "../../GameEvent";
 | 
			
		||||
import type { GameData } from "../../GameData";
 | 
			
		||||
} from "$lib/GameEvent";
 | 
			
		||||
import type { GameEventData } from "$lib/GameEvent";
 | 
			
		||||
import type { GameData } from "$lib/GameData";
 | 
			
		||||
import { describe, it } from "vitest";
 | 
			
		||||
import type { State } from "../../State";
 | 
			
		||||
import type { State } from "$lib/State";
 | 
			
		||||
import { doesNotThrow, deepStrictEqual, equal, ok, throws } from "assert";
 | 
			
		||||
import { createId, idFromString, stringFromId } from "$lib/Id";
 | 
			
		||||
 | 
			
		||||
@ -220,7 +220,10 @@ describe("Game Events", () => {
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			it("should throw if the value is not a number", () => {
 | 
			
		||||
				throws(() => new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: [4] }));
 | 
			
		||||
				throws(
 | 
			
		||||
					() =>
 | 
			
		||||
						new RollForFirst({ kind: GameEventKind.RollForFirst, player: 0, value: [4] }),
 | 
			
		||||
				);
 | 
			
		||||
			});
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
@ -262,6 +265,10 @@ describe("Game Events", () => {
 | 
			
		||||
				const state: State = { scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING] };
 | 
			
		||||
 | 
			
		||||
				throws(() => ev.run(state));
 | 
			
		||||
 | 
			
		||||
				// should treat missing scores as an empty array
 | 
			
		||||
				delete state.scores;
 | 
			
		||||
				throws(() => ev.run(state));
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			it("should throw if the player has already rolled", () => {
 | 
			
		||||
@ -290,7 +297,12 @@ describe("Game Events", () => {
 | 
			
		||||
 | 
			
		||||
			it("should reset the scores and set the winning player when everyone has rolled", () => {
 | 
			
		||||
				const state: State = {
 | 
			
		||||
					scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_PENDING],
 | 
			
		||||
					scores: [
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
					],
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				let ev = new RollForFirst({
 | 
			
		||||
@ -313,14 +325,24 @@ describe("Game Events", () => {
 | 
			
		||||
 | 
			
		||||
				deepStrictEqual(state, {
 | 
			
		||||
					dieCount: 6,
 | 
			
		||||
					scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_PENDING],
 | 
			
		||||
					scores: [
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
					],
 | 
			
		||||
					playing: 2,
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			it("should reset tied players for tie breaker", () => {
 | 
			
		||||
				const state: State = {
 | 
			
		||||
					scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_PENDING],
 | 
			
		||||
					scores: [
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
					],
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				let ev = new RollForFirst({
 | 
			
		||||
@ -340,13 +362,23 @@ describe("Game Events", () => {
 | 
			
		||||
				ev.run(state);
 | 
			
		||||
 | 
			
		||||
				deepStrictEqual(state, {
 | 
			
		||||
					scores: [FIRST_ROLL_PENDING, FIRST_ROLL_LOST, FIRST_ROLL_LOST, FIRST_ROLL_PENDING],
 | 
			
		||||
					scores: [
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_LOST,
 | 
			
		||||
						FIRST_ROLL_LOST,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
					],
 | 
			
		||||
				});
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			it("should throw if a player whose lost tries to roll again", () => {
 | 
			
		||||
				const state: State = {
 | 
			
		||||
					scores: [FIRST_ROLL_PENDING, FIRST_ROLL_LOST, FIRST_ROLL_LOST, FIRST_ROLL_PENDING],
 | 
			
		||||
					scores: [
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_LOST,
 | 
			
		||||
						FIRST_ROLL_LOST,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
					],
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				const ev = new RollForFirst({
 | 
			
		||||
@ -359,7 +391,12 @@ describe("Game Events", () => {
 | 
			
		||||
 | 
			
		||||
			it("should allow tied players to keep rolling until somoene wins", () => {
 | 
			
		||||
				const state: State = {
 | 
			
		||||
					scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_LOST, FIRST_ROLL_PENDING],
 | 
			
		||||
					scores: [
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
						FIRST_ROLL_LOST,
 | 
			
		||||
						FIRST_ROLL_PENDING,
 | 
			
		||||
					],
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				// simulate another 3-way tie
 | 
			
		||||
@ -379,7 +416,12 @@ describe("Game Events", () => {
 | 
			
		||||
				deepStrictEqual(
 | 
			
		||||
					state,
 | 
			
		||||
					{
 | 
			
		||||
						scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING, FIRST_ROLL_LOST, FIRST_ROLL_PENDING],
 | 
			
		||||
						scores: [
 | 
			
		||||
							FIRST_ROLL_PENDING,
 | 
			
		||||
							FIRST_ROLL_PENDING,
 | 
			
		||||
							FIRST_ROLL_LOST,
 | 
			
		||||
							FIRST_ROLL_PENDING,
 | 
			
		||||
						],
 | 
			
		||||
					},
 | 
			
		||||
					"shouldn't change in a 3-way tie",
 | 
			
		||||
				);
 | 
			
		||||
@ -397,7 +439,12 @@ describe("Game Events", () => {
 | 
			
		||||
				deepStrictEqual(
 | 
			
		||||
					state,
 | 
			
		||||
					{
 | 
			
		||||
						scores: [FIRST_ROLL_PENDING, FIRST_ROLL_LOST, FIRST_ROLL_LOST, FIRST_ROLL_PENDING],
 | 
			
		||||
						scores: [
 | 
			
		||||
							FIRST_ROLL_PENDING,
 | 
			
		||||
							FIRST_ROLL_LOST,
 | 
			
		||||
							FIRST_ROLL_LOST,
 | 
			
		||||
							FIRST_ROLL_PENDING,
 | 
			
		||||
						],
 | 
			
		||||
					},
 | 
			
		||||
					"should update for a smaller tie",
 | 
			
		||||
				);
 | 
			
		||||
@ -813,6 +860,17 @@ describe("Game Events", () => {
 | 
			
		||||
				throws(() => ev.run(state));
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			it("should throw if there are no players", () => {
 | 
			
		||||
				const state: State = {
 | 
			
		||||
					dieCount: 4,
 | 
			
		||||
					heldScore: 250,
 | 
			
		||||
					playing: 0,
 | 
			
		||||
				};
 | 
			
		||||
 | 
			
		||||
				const ev = new Score({ kind: GameEventKind.Score, player: 0, value: 250 });
 | 
			
		||||
				throws(() => ev.run(state));
 | 
			
		||||
			});
 | 
			
		||||
 | 
			
		||||
			it("should add the score to the players score and activate the next player", () => {
 | 
			
		||||
				let state: State = {
 | 
			
		||||
					dieCount: 4,
 | 
			
		||||
							
								
								
									
										111
									
								
								src/lib/test/Listing.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/lib/test/Listing.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,111 @@
 | 
			
		||||
import { createId } from "$lib/Id";
 | 
			
		||||
import { isListing, type Listing } from "$lib/Listing";
 | 
			
		||||
import { describe, expect, it } from "vitest";
 | 
			
		||||
 | 
			
		||||
let spiedValue: unknown;
 | 
			
		||||
 | 
			
		||||
const passingDataGuard = (target: unknown): target is any => true;
 | 
			
		||||
const failingDataGuard = (target: unknown): target is any => false;
 | 
			
		||||
const spyingDataGuard = (target: unknown): target is any => {
 | 
			
		||||
	spiedValue = target;
 | 
			
		||||
	return true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe("Listing", () => {
 | 
			
		||||
	describe("isListing", () => {
 | 
			
		||||
		it("should return false if a required property is missing", () => {
 | 
			
		||||
			const fullListing: Listing = {
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString(),
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			let listing: Partial<Listing> = { ...fullListing };
 | 
			
		||||
			delete listing.id;
 | 
			
		||||
			expect(isListing(listing)).to.be.false;
 | 
			
		||||
 | 
			
		||||
			listing = { ...fullListing };
 | 
			
		||||
			delete listing.createdAt;
 | 
			
		||||
			expect(isListing(listing)).to.be.false;
 | 
			
		||||
 | 
			
		||||
			listing = { ...fullListing };
 | 
			
		||||
			delete listing.deleted;
 | 
			
		||||
			expect(isListing(listing)).to.be.false;
 | 
			
		||||
 | 
			
		||||
			listing = { ...fullListing };
 | 
			
		||||
			delete listing.modifiedAt;
 | 
			
		||||
			expect(isListing(listing)).to.be.false;
 | 
			
		||||
 | 
			
		||||
			listing = { ...fullListing };
 | 
			
		||||
			delete listing.data;
 | 
			
		||||
			expect(isListing(listing)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return false if there is an extra property", () => {
 | 
			
		||||
			const fullListing = {
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString(),
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				extra: "property",
 | 
			
		||||
				data: {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isListing(fullListing)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return true if all the required properties are present", () => {
 | 
			
		||||
			const fullListing = {
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString() as string | null,
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isListing(fullListing)).to.be.true;
 | 
			
		||||
 | 
			
		||||
			fullListing.modifiedAt = null;
 | 
			
		||||
			expect(isListing(fullListing)).to.be.true;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return true if properties are present and the data guard returns true", () => {
 | 
			
		||||
			const fullListing = {
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString() as string | null,
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isListing(fullListing, passingDataGuard)).to.be.true;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return false if properties are present and the data guard returns false", () => {
 | 
			
		||||
			const fullListing = {
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString() as string | null,
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isListing(fullListing, failingDataGuard)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should pass the data object to the provided data guard", () => {
 | 
			
		||||
			const fullListing = {
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString() as string | null,
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: { am: "the test object" },
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isListing(fullListing, spyingDataGuard)).to.be.true;
 | 
			
		||||
			expect(spiedValue).to.deep.equal(fullListing.data);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										62
									
								
								src/lib/test/Login.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								src/lib/test/Login.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,62 @@
 | 
			
		||||
import { hashPassword, isLoginData, type LoginData } from "$lib/Login";
 | 
			
		||||
import { describe, expect, it } from "vitest";
 | 
			
		||||
 | 
			
		||||
describe("Login", () => {
 | 
			
		||||
	describe("isLoginData", () => {
 | 
			
		||||
		it("should return false if a required property is missing", () => {
 | 
			
		||||
			const fullLoginData: LoginData = {
 | 
			
		||||
				password: "some-password",
 | 
			
		||||
				username: "Sir User Userly III",
 | 
			
		||||
				role: "default",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			let loginData: Partial<LoginData> = {
 | 
			
		||||
				...fullLoginData,
 | 
			
		||||
			};
 | 
			
		||||
			delete loginData.password;
 | 
			
		||||
			expect(isLoginData(loginData)).to.be.false;
 | 
			
		||||
 | 
			
		||||
			loginData = {
 | 
			
		||||
				...fullLoginData,
 | 
			
		||||
			};
 | 
			
		||||
			delete loginData.username;
 | 
			
		||||
			expect(isLoginData(loginData)).to.be.false;
 | 
			
		||||
 | 
			
		||||
			loginData = {
 | 
			
		||||
				...fullLoginData,
 | 
			
		||||
			};
 | 
			
		||||
			delete loginData.role;
 | 
			
		||||
			expect(isLoginData(loginData)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return false if there are extra properties", () => {
 | 
			
		||||
			const fullLoginData: LoginData & { extra: "property" } = {
 | 
			
		||||
				password: "some-password",
 | 
			
		||||
				username: "Sir User Userly III",
 | 
			
		||||
				role: "default",
 | 
			
		||||
				extra: "property",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isLoginData(fullLoginData)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return true if the provided target is a Login", () => {
 | 
			
		||||
			const fullLoginData: LoginData = {
 | 
			
		||||
				password: "some-password",
 | 
			
		||||
				username: "Sir User Userly III",
 | 
			
		||||
				role: "default",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isLoginData(fullLoginData)).to.be.true;
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	describe("hashPassword", () => {
 | 
			
		||||
		it("should hash a password", async () => {
 | 
			
		||||
			const pass = "some-password";
 | 
			
		||||
			const hash = hashPassword(pass);
 | 
			
		||||
 | 
			
		||||
			expect(hash).to.not.equal(pass);
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										47
									
								
								src/lib/test/ServerResponse.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/lib/test/ServerResponse.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,47 @@
 | 
			
		||||
import { isServerResponse } from "$lib/ServerResponse";
 | 
			
		||||
import { describe, expect, it } from "vitest";
 | 
			
		||||
 | 
			
		||||
describe("ServerResponse", () => {
 | 
			
		||||
	describe("isServerResponse", () => {
 | 
			
		||||
		it("should return false no optional property exists", () => {
 | 
			
		||||
			const emptyResponse = {};
 | 
			
		||||
 | 
			
		||||
			expect(isServerResponse(emptyResponse)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return false if more than one property is present", () => {
 | 
			
		||||
			const overstuffedResponse = {
 | 
			
		||||
				item: {},
 | 
			
		||||
				error: "something is wrong",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isServerResponse(overstuffedResponse)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return false if there is an unkown property", () => {
 | 
			
		||||
			const gibberishServerResponse = {
 | 
			
		||||
				some: "gibberish",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isServerResponse(gibberishServerResponse)).to.be.false;
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
		it("should return true when target is a proper server response", () => {
 | 
			
		||||
			const singleServerResponse = {
 | 
			
		||||
				item: {},
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			const listServerResponse = {
 | 
			
		||||
				items: [],
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			const errorServerResponse = {
 | 
			
		||||
				error: "something is wrong",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			expect(isServerResponse(singleServerResponse)).to.be.true;
 | 
			
		||||
			expect(isServerResponse(listServerResponse)).to.be.true;
 | 
			
		||||
			expect(isServerResponse(errorServerResponse)).to.be.true;
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
import { describe, it } from "vitest";
 | 
			
		||||
import { equal, ok } from "node:assert/strict";
 | 
			
		||||
import { hasProperty, hasOnlyKeys } from "../../validation";
 | 
			
		||||
import { hasProperty, hasOnlyKeys } from "$lib/validation";
 | 
			
		||||
 | 
			
		||||
describe("validation", () => {
 | 
			
		||||
	describe("hasProperty", () => {
 | 
			
		||||
@ -46,7 +46,7 @@ describe("validation", () => {
 | 
			
		||||
				third: false,
 | 
			
		||||
				fourth: null,
 | 
			
		||||
				fifth: { something: "important" },
 | 
			
		||||
				sixth: ["one", "two"]
 | 
			
		||||
				sixth: ["one", "two"],
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			ok(hasProperty(target, "first", "string"));
 | 
			
		||||
@ -59,7 +59,7 @@ describe("validation", () => {
 | 
			
		||||
 | 
			
		||||
		it("should return false if passed an array type and the property isn't an array", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				arr: "not array"
 | 
			
		||||
				arr: "not array",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			equal(hasProperty(target, "arr", "string[]"), false);
 | 
			
		||||
@ -67,7 +67,7 @@ describe("validation", () => {
 | 
			
		||||
 | 
			
		||||
		it("should return false if the defined array contains a non-matching element", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				arr: ["I", "was", "born", "in", 1989]
 | 
			
		||||
				arr: ["I", "was", "born", "in", 1989],
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			equal(hasProperty(target, "arr", "string[]"), false);
 | 
			
		||||
@ -75,7 +75,7 @@ describe("validation", () => {
 | 
			
		||||
 | 
			
		||||
		it("should return true if all the elements in a defined array match", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				arr: ["I", "was", "born", "in", "1989"]
 | 
			
		||||
				arr: ["I", "was", "born", "in", "1989"],
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			ok(hasProperty(target, "arr", "string[]"));
 | 
			
		||||
@ -83,7 +83,7 @@ describe("validation", () => {
 | 
			
		||||
 | 
			
		||||
		it("should return true if all the elements in a defined array match one of multiple types", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				arr: ["I", "was", "born", "in", 1989]
 | 
			
		||||
				arr: ["I", "was", "born", "in", 1989],
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			ok(hasProperty(target, "arr", "(string|number)[]"));
 | 
			
		||||
@ -91,7 +91,7 @@ describe("validation", () => {
 | 
			
		||||
 | 
			
		||||
		it("should return true if type is null but property is nullable", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				nullable: null
 | 
			
		||||
				nullable: null,
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			ok(hasProperty(target, "nullable", "string", true));
 | 
			
		||||
@ -107,7 +107,7 @@ describe("validation", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				one: "one",
 | 
			
		||||
				two: "two",
 | 
			
		||||
				three: "three"
 | 
			
		||||
				three: "three",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			const keys = ["one", "two"];
 | 
			
		||||
@ -119,7 +119,7 @@ describe("validation", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				one: "one",
 | 
			
		||||
				two: "two",
 | 
			
		||||
				three: "three"
 | 
			
		||||
				three: "three",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			const keys = ["one", "two", "three"];
 | 
			
		||||
@ -129,7 +129,7 @@ describe("validation", () => {
 | 
			
		||||
 | 
			
		||||
		it("should return true if the target has only a subset of the provided keys", () => {
 | 
			
		||||
			const target = {
 | 
			
		||||
				one: "one"
 | 
			
		||||
				one: "one",
 | 
			
		||||
			};
 | 
			
		||||
 | 
			
		||||
			const keys = ["one", "two", "three"];
 | 
			
		||||
@ -2,10 +2,12 @@ import type { RequestHandler } from "@sveltejs/kit";
 | 
			
		||||
import { listResponse, singleResponse } from "$lib/server/responseBodies";
 | 
			
		||||
import { createNewListing } from "$lib/server/modifyListing";
 | 
			
		||||
import { Game } from "$lib/server/Game";
 | 
			
		||||
import { games } from "$lib/server/cache";
 | 
			
		||||
import { writeListing } from "$lib/server/mongo";
 | 
			
		||||
import { listCollection, writeListing } from "$lib/server/mongo";
 | 
			
		||||
import { isListing } from "$lib/Listing";
 | 
			
		||||
import type { GameData } from "$lib/GameData";
 | 
			
		||||
 | 
			
		||||
export const GET: RequestHandler = (): Response => {
 | 
			
		||||
export const GET: RequestHandler = async (): Promise<Response> => {
 | 
			
		||||
	const games = await listCollection("games", isListing<GameData>);
 | 
			
		||||
	return listResponse(games);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -13,7 +15,6 @@ export const POST: RequestHandler = async (): Promise<Response> => {
 | 
			
		||||
	const newListing = createNewListing(new Game());
 | 
			
		||||
 | 
			
		||||
	await writeListing("games", newListing);
 | 
			
		||||
	games.push(newListing);
 | 
			
		||||
 | 
			
		||||
	return singleResponse(newListing.id);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,15 +1,29 @@
 | 
			
		||||
import { games } from "$lib/server/cache";
 | 
			
		||||
import { badRequestResponse, notFoundResponse, singleResponse } from "$lib/server/responseBodies";
 | 
			
		||||
import type { GameData } from "$lib/GameData";
 | 
			
		||||
import { idFromString, type Id } from "$lib/Id";
 | 
			
		||||
import { isListing } from "$lib/Listing";
 | 
			
		||||
import { readListingById } from "$lib/server/mongo";
 | 
			
		||||
import {
 | 
			
		||||
	badRequestResponse,
 | 
			
		||||
	notFoundResponse,
 | 
			
		||||
	singleResponse,
 | 
			
		||||
} from "$lib/server/responseBodies";
 | 
			
		||||
import type { RequestHandler } from "@sveltejs/kit";
 | 
			
		||||
 | 
			
		||||
export const GET: RequestHandler = ({ params }): Response => {
 | 
			
		||||
	const id = params["gameid"];
 | 
			
		||||
export const GET: RequestHandler = async ({ params }): Promise<Response> => {
 | 
			
		||||
	const idStr = params["gameid"];
 | 
			
		||||
 | 
			
		||||
	if (!id) {
 | 
			
		||||
	if (!idStr) {
 | 
			
		||||
		return badRequestResponse("missing gameid parameter");
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const game = games.find(({ id: gid }) => id === gid.toString());
 | 
			
		||||
	let id: Id;
 | 
			
		||||
	try {
 | 
			
		||||
		id = idFromString(idStr);
 | 
			
		||||
	} catch (err) {
 | 
			
		||||
		return notFoundResponse();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const game = await readListingById("games", id, isListing<GameData>);
 | 
			
		||||
 | 
			
		||||
	if (!game) {
 | 
			
		||||
		return notFoundResponse();
 | 
			
		||||
 | 
			
		||||
@ -10,6 +10,7 @@ import {
 | 
			
		||||
import type { RequestHandler } from "@sveltejs/kit";
 | 
			
		||||
import { compare } from "bcrypt";
 | 
			
		||||
import { createToken } from "$lib/server/auth";
 | 
			
		||||
import { JWT_SECRET } from "$env/static/private";
 | 
			
		||||
 | 
			
		||||
export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
 | 
			
		||||
	try {
 | 
			
		||||
@ -33,7 +34,7 @@ export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (await compare(password, listing.data.password)) {
 | 
			
		||||
			const token = await createToken(listing);
 | 
			
		||||
			const token = await createToken(listing, JWT_SECRET);
 | 
			
		||||
			return singleResponse(token);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
import type { Cookies, RequestEvent } from "@sveltejs/kit";
 | 
			
		||||
import { describe, it, expect, afterEach } from "vitest";
 | 
			
		||||
import * as auth from "../lib/server/auth";
 | 
			
		||||
import { handle } from "../hooks.server";
 | 
			
		||||
import { getHandleFn } from "../hooks.server";
 | 
			
		||||
import { createId } from "$lib/Id";
 | 
			
		||||
 | 
			
		||||
let events: RequestEvent[] = [];
 | 
			
		||||
@ -32,7 +32,7 @@ const resolve = async (event: RequestEvent): Promise<Response> => {
 | 
			
		||||
	return new Response();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
describe("handle", () => {
 | 
			
		||||
describe("getHandleFn", () => {
 | 
			
		||||
	afterEach(() => {
 | 
			
		||||
		event.locals.user = {};
 | 
			
		||||
		event.request.headers.delete("authorization");
 | 
			
		||||
@ -41,12 +41,12 @@ describe("handle", () => {
 | 
			
		||||
 | 
			
		||||
	it("returns unauthorized response if caller isn't properly authenticated", async () => {
 | 
			
		||||
		event.request.headers.set("authorization", "Nonesense Token");
 | 
			
		||||
		const res = await handle({ event, resolve });
 | 
			
		||||
		const res = await getHandleFn("server-secret")({ event, resolve });
 | 
			
		||||
		expect(res.status).to.equal(401);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it("returns unauthorized response if caller is missing required auth header", async () => {
 | 
			
		||||
		const res = await handle({ event, resolve });
 | 
			
		||||
		const res = await getHandleFn("server-secret")({ event, resolve });
 | 
			
		||||
		expect(res.status).to.equal(401);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
@ -63,21 +63,57 @@ describe("handle", () => {
 | 
			
		||||
			request: new Request("https://localhost/api/some/secret/route"),
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const token = await auth.createToken({
 | 
			
		||||
			id: createId(),
 | 
			
		||||
			createdAt: new Date().toString(),
 | 
			
		||||
			modifiedAt: new Date().toString(),
 | 
			
		||||
			deleted: false,
 | 
			
		||||
			data: {
 | 
			
		||||
				password: "somethin' secret!",
 | 
			
		||||
				username: "Mr. Man",
 | 
			
		||||
				role: "default",
 | 
			
		||||
		const token = await auth.createToken(
 | 
			
		||||
			{
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString(),
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: {
 | 
			
		||||
					password: "somethin' secret!",
 | 
			
		||||
					username: "Mr. Man",
 | 
			
		||||
					role: "default",
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
			"server-secret",
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		ev.request.headers.set("authorization", `Bearer ${token}`);
 | 
			
		||||
 | 
			
		||||
		const res = await handle({ event: ev, resolve });
 | 
			
		||||
		const res = await getHandleFn("server-secret")({ event: ev, resolve });
 | 
			
		||||
		expect(res.status).to.equal(403);
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	it("attaches information about the credentials to the event and resolves when the user is authorized", async () => {
 | 
			
		||||
		const ev: RequestEvent = {
 | 
			
		||||
			...event,
 | 
			
		||||
			url: new URL("https://localhost/api/some/secret/route"),
 | 
			
		||||
			request: new Request("https://localhost/api/some/secret/route"),
 | 
			
		||||
		};
 | 
			
		||||
 | 
			
		||||
		const token = await auth.createToken(
 | 
			
		||||
			{
 | 
			
		||||
				id: createId(),
 | 
			
		||||
				createdAt: new Date().toString(),
 | 
			
		||||
				modifiedAt: new Date().toString(),
 | 
			
		||||
				deleted: false,
 | 
			
		||||
				data: {
 | 
			
		||||
					password: "somethin' secret!",
 | 
			
		||||
					username: "Mr. Man",
 | 
			
		||||
					role: "player",
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
			"server-secret",
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		ev.request.headers.set("authorization", `Bearer ${token}`);
 | 
			
		||||
 | 
			
		||||
		const res = await getHandleFn("server-secret")({ event: ev, resolve });
 | 
			
		||||
		expect(res.status).to.equal(200);
 | 
			
		||||
 | 
			
		||||
		const user: auth.LocalCredentials = events[0].locals.user;
 | 
			
		||||
 | 
			
		||||
		expect(user.role).to.equal("player");
 | 
			
		||||
		expect(user.kind).to.equal("Bearer");
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
@token=token
 | 
			
		||||
 | 
			
		||||
GET https://localhost:5173/api
 | 
			
		||||
Accept: application/json
 | 
			
		||||
Authorization: Bearer {{token}}
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
 | 
			
		||||
@ -17,8 +17,9 @@ Authorization: Bearer {{token}}
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
 | 
			
		||||
GET https://localhost:5173/api/games/de4cdb8c-0346-4ac6-a7a8-b4135b2d79e3
 | 
			
		||||
GET https://localhost:5173/api/games/6790386c4a41c3599d47d986
 | 
			
		||||
Accept: application/json
 | 
			
		||||
Authorization: Bearer {{token}}
 | 
			
		||||
 | 
			
		||||
###
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user