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 { forbiddenResponse, unauthorizedResponse } from "$lib/server/responseBodies";
 | 
				
			||||||
import { routeAuth, type Method } from "$lib/server/routeAuth";
 | 
					import { routeAuth, type Method } from "$lib/server/routeAuth";
 | 
				
			||||||
import { type Handle } from "@sveltejs/kit";
 | 
					import { type Handle } from "@sveltejs/kit";
 | 
				
			||||||
 | 
					import { JWT_SECRET } from "$env/static/private";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const handle: Handle = async ({ event, resolve }) => {
 | 
					export const handle: Handle = getHandleFn(JWT_SECRET);
 | 
				
			||||||
	const creds = await auth.authenticate(event);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (!creds) {
 | 
					export function getHandleFn(jwtSecret: string): Handle {
 | 
				
			||||||
		return unauthorizedResponse();
 | 
						return async ({ event, resolve }) => {
 | 
				
			||||||
	}
 | 
							const creds = await auth.authenticate(event, jwtSecret);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	const authResult = auth.isAuthorized(
 | 
							if (!creds) {
 | 
				
			||||||
		routeAuth,
 | 
								return unauthorizedResponse();
 | 
				
			||||||
		event.request.method as Method,
 | 
							}
 | 
				
			||||||
		event.url.pathname,
 | 
					 | 
				
			||||||
		creds,
 | 
					 | 
				
			||||||
	);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (authResult === auth.AuthorizationResult.Denied) {
 | 
							const authResult = auth.isAuthorized(
 | 
				
			||||||
		return forbiddenResponse();
 | 
								routeAuth,
 | 
				
			||||||
	} else if (authResult === auth.AuthorizationResult.Unauthenticated) {
 | 
								event.request.method as Method,
 | 
				
			||||||
		return unauthorizedResponse();
 | 
								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;
 | 
									state.dieCount = 6;
 | 
				
			||||||
			} else {
 | 
								} else {
 | 
				
			||||||
				// ...otherwise, setup for tie breaking rolls.
 | 
									// ...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) {
 | 
						run(state: State) {
 | 
				
			||||||
		const scores = state.scores ?? [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		throwIfGameOver(state);
 | 
							throwIfGameOver(state);
 | 
				
			||||||
		throwIfWrongTurn(state, this.player);
 | 
							throwIfWrongTurn(state, this.player);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -346,7 +346,11 @@ export class Score implements GameEvent {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	run(state: State) {
 | 
						run(state: State) {
 | 
				
			||||||
		const { dieCount, heldScore, scores } = 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);
 | 
							throwIfGameOver(state);
 | 
				
			||||||
		throwIfWrongTurn(state, this.player);
 | 
							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
 | 
							// It's safe to tell the compiler that the player is not undefined because of the
 | 
				
			||||||
		// check above.
 | 
							// 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
 | 
							// Increment the index of the active player, circling back to 1 if the player
 | 
				
			||||||
		// who just scored was the last player in the array.
 | 
							// who just scored was the last player in the array.
 | 
				
			||||||
		state.playing =
 | 
							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;
 | 
							state.dieCount = 6;
 | 
				
			||||||
		delete state.heldScore;
 | 
							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";
 | 
					import { isId, type Id } from "$lib/Id";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface Listing<T = unknown> {
 | 
					export interface Listing<T = unknown> {
 | 
				
			||||||
	id: Id;
 | 
						id: Id;
 | 
				
			||||||
	createdAt: string;
 | 
						createdAt: string;
 | 
				
			||||||
	modifiedAt: string | null;
 | 
					 | 
				
			||||||
	deleted: boolean;
 | 
						deleted: boolean;
 | 
				
			||||||
 | 
						modifiedAt: string | null;
 | 
				
			||||||
	data: T;
 | 
						data: T;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isListing<T>(
 | 
					export function isListing<T>(
 | 
				
			||||||
	target: unknown,
 | 
						target: unknown,
 | 
				
			||||||
	dataGuard?: (target: unknown) => target is T
 | 
						dataGuard?: (target: unknown) => target is T,
 | 
				
			||||||
): target is Listing<T> {
 | 
					): target is Listing<T> {
 | 
				
			||||||
	if (!hasProperty(target, "id", isId)) return false;
 | 
						if (!hasProperty(target, "id", isId)) return false;
 | 
				
			||||||
	if (!hasProperty(target, "createdAt", "string")) 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, "deleted", "boolean")) return false;
 | 
				
			||||||
	if (!hasProperty(target, "data", "object")) 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;
 | 
						return dataGuard?.((target as any)["data"]) ?? true;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -7,6 +7,10 @@ export type ServerResponse =
 | 
				
			|||||||
	| { error: string };
 | 
						| { error: string };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isServerResponse(target: unknown): target is ServerResponse {
 | 
					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, "item", "object")) return true;
 | 
				
			||||||
	if (hasProperty(target, "items", "object[]")) return true;
 | 
						if (hasProperty(target, "items", "object[]")) return true;
 | 
				
			||||||
	if (hasProperty(target, "error", "string")) 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 type { RequestEvent } from "@sveltejs/kit";
 | 
				
			||||||
import jwt from "jsonwebtoken";
 | 
					import jwt from "jsonwebtoken";
 | 
				
			||||||
import { type Method, type RouteAuthRule } from "./routeAuth";
 | 
					import { type Method, type RouteAuthRule } from "./routeAuth";
 | 
				
			||||||
@ -17,10 +16,10 @@ export enum AuthorizationResult {
 | 
				
			|||||||
	Unauthenticated,
 | 
						Unauthenticated,
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function createToken(listing: Listing<LoginData>) {
 | 
					export async function createToken(listing: Listing<LoginData>, secret: string) {
 | 
				
			||||||
	return await jwt.sign(
 | 
						return await jwt.sign(
 | 
				
			||||||
		{ sub: listing.id, username: listing.data.username, role: listing.data.role },
 | 
							{ sub: listing.id, username: listing.data.username, role: listing.data.role },
 | 
				
			||||||
		JWT_SECRET,
 | 
							secret,
 | 
				
			||||||
		{
 | 
							{
 | 
				
			||||||
			expiresIn: "1d",
 | 
								expiresIn: "1d",
 | 
				
			||||||
		},
 | 
							},
 | 
				
			||||||
@ -29,21 +28,13 @@ export async function createToken(listing: Listing<LoginData>) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export async function authenticate(
 | 
					export async function authenticate(
 | 
				
			||||||
	event: RequestEvent,
 | 
						event: RequestEvent,
 | 
				
			||||||
 | 
						jwtSecret: string,
 | 
				
			||||||
): Promise<LocalCredentials | null> {
 | 
					): Promise<LocalCredentials | null> {
 | 
				
			||||||
	let path = event.url.pathname;
 | 
						const authHeader = event.request.headers.get("authorization");
 | 
				
			||||||
	let tokenKind: "Basic" | "Bearer" | "None";
 | 
						let tokenKind: "Basic" | "Bearer" | "None";
 | 
				
			||||||
	let tokenRole: string;
 | 
						let tokenRole: string;
 | 
				
			||||||
	let tokenDesc: LocalCredentials;
 | 
						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
 | 
						// get token kind and role from the header
 | 
				
			||||||
	if (!authHeader) {
 | 
						if (!authHeader) {
 | 
				
			||||||
		// This is a stranger: they have no token and they will be assigned the default
 | 
							// 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";
 | 
								tokenKind = "Bearer";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			// The role can be derived from the JWT token.
 | 
								// 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") {
 | 
								if (typeof payload === "string") {
 | 
				
			||||||
				// I do not assign, and don't know what to do with, these kinds of
 | 
									// 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
 | 
									// tokens. Perhaps an error should be logged here, since this is a
 | 
				
			||||||
@ -93,7 +84,7 @@ export async function authenticate(
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function isAuthorized(
 | 
					export function isAuthorized(
 | 
				
			||||||
	roleRules: { [k: string]: RouteAuthRule[] },
 | 
						roleRules: Record<string, RouteAuthRule[]>,
 | 
				
			||||||
	method: Method,
 | 
						method: Method,
 | 
				
			||||||
	path: string,
 | 
						path: string,
 | 
				
			||||||
	creds: LocalCredentials,
 | 
						creds: LocalCredentials,
 | 
				
			||||||
@ -125,8 +116,9 @@ export function isAuthorized(
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function breakupPath(path: string): string[] {
 | 
					function breakupPath(path: string): string[] {
 | 
				
			||||||
	// remove a leading /
 | 
						// remove leading and trailing /
 | 
				
			||||||
	if (path[0] === "/") path = path.slice(1);
 | 
						if (path[0] === "/") path = path.slice(1);
 | 
				
			||||||
 | 
						if (path[path.length - 1] === "/") path = path.slice(0, path.length - 1);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return path.split("/");
 | 
						return path.split("/");
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -144,6 +136,9 @@ function matchesRequest(
 | 
				
			|||||||
	for (let i = 0; i < ruleParts.length; i++) {
 | 
						for (let i = 0; i < ruleParts.length; i++) {
 | 
				
			||||||
		const rulePart = ruleParts[i];
 | 
							const rulePart = ruleParts[i];
 | 
				
			||||||
		const reqPart = requestParts[i];
 | 
							const reqPart = requestParts[i];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
							if (!reqPart) return false;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if (rulePart[0] === "[" && rulePart[rulePart.length - 1] === "]") {
 | 
							if (rulePart[0] === "[" && rulePart[rulePart.length - 1] === "]") {
 | 
				
			||||||
			// This part of the path represents an ID.
 | 
								// This part of the path represents an ID.
 | 
				
			||||||
			continue;
 | 
								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 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";
 | 
					import type { Id } from "../Id";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type ListingFromMongo = Omit<Listing, "id"> & { _id: ObjectId };
 | 
					type ListingFromMongo = Omit<Listing, "id"> & { _id: ObjectId };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const uri = `mongodb://127.0.0.1:27017`;
 | 
					const uri = `mongodb://127.0.0.1:27017`;
 | 
				
			||||||
const DATABASE = "ten-thousand";
 | 
					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, {
 | 
						const c = new MongoClient(uri, {
 | 
				
			||||||
		serverApi: {
 | 
							serverApi: {
 | 
				
			||||||
			version: ServerApiVersion.v1,
 | 
								version: ServerApiVersion.v1,
 | 
				
			||||||
			strict: true,
 | 
								strict: true,
 | 
				
			||||||
			deprecationErrors: true
 | 
								deprecationErrors: true,
 | 
				
			||||||
		}
 | 
							},
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	await c.connect();
 | 
						await c.connect();
 | 
				
			||||||
@ -22,55 +30,60 @@ async function init() {
 | 
				
			|||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export async function writeListing(col: string, listing: Listing) {
 | 
					export async function writeListing(col: string, listing: Listing) {
 | 
				
			||||||
	if (client === null) {
 | 
						const client = await getClient();
 | 
				
			||||||
		client = await init();
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	await client.db(DATABASE).collection(col).insertOne(fixListingForMongo(listing));
 | 
						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>(
 | 
					export async function readListingById<T>(
 | 
				
			||||||
	col: string,
 | 
						col: string,
 | 
				
			||||||
	id: Id,
 | 
						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>(
 | 
					export async function readListingByQuery<T>(
 | 
				
			||||||
	col: string,
 | 
						col: string,
 | 
				
			||||||
	query: object,
 | 
						query: object,
 | 
				
			||||||
	dataGuard: (target: unknown) => target is Listing<T>
 | 
						dataGuard: (target: unknown) => target is Listing<T>,
 | 
				
			||||||
): Promise<Listing<T> | null> {
 | 
					): Promise<Listing<T> | null> {
 | 
				
			||||||
	if (client === null) {
 | 
						const client = await getClient();
 | 
				
			||||||
		client = await init();
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
	const res = await client.db(DATABASE).collection(col).findOne(query);
 | 
						const res = await client.db(DATABASE).collection(col).findOne(query);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (res === null) {
 | 
						if (res === null) return null;
 | 
				
			||||||
		return null;
 | 
					 | 
				
			||||||
	}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return fixListingFromMongo(res, dataGuard);
 | 
						return fixListingFromMongo(res, dataGuard);
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function fixListingForMongo(listing: Listing): ListingFromMongo {
 | 
					function fixListingForMongo(listing: Listing): ListingFromMongo {
 | 
				
			||||||
	// TODO: These two statements are tricky without any. Perhaps id could be optional,
 | 
						const { id, ...rest } = listing;
 | 
				
			||||||
	//       but that's kind of stupid because it's not optional on the type I'm
 | 
						return {
 | 
				
			||||||
	//       constructing. Figure out something better here.
 | 
							_id: id,
 | 
				
			||||||
	const adjusted: any = { _id: listing.id, ...listing };
 | 
							...rest,
 | 
				
			||||||
	delete adjusted.id;
 | 
						};
 | 
				
			||||||
	return adjusted;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
function fixListingFromMongo<T>(
 | 
					function fixListingFromMongo<T>(
 | 
				
			||||||
	target: WithId<Document>,
 | 
						target: WithId<Document>,
 | 
				
			||||||
	dataGuard: (target: unknown) => target is Listing<T>
 | 
						dataGuard: (target: unknown) => target is Listing<T>,
 | 
				
			||||||
): Listing<T> {
 | 
					): Listing<T> {
 | 
				
			||||||
	// TODO: These two statements are tricky without any. Perhaps id could be optional,
 | 
						const { _id, ...rest } = target;
 | 
				
			||||||
	//       but that's kind of stupid because it's not optional on the type I'm
 | 
						const adjusted = { id: _id, ...rest };
 | 
				
			||||||
	//       constructing. Figure out something better here.
 | 
					 | 
				
			||||||
	const adjusted: any = { id: target._id, ...target };
 | 
					 | 
				
			||||||
	delete adjusted._id;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (!dataGuard(adjusted)) {
 | 
						if (!dataGuard(adjusted)) {
 | 
				
			||||||
		throw new Error("the returned document does not conform to the provided type");
 | 
							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"
 | 
						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
 | 
						// 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
 | 
						// 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!
 | 
						// 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 { deepEqual, equal, ok } from "node:assert/strict";
 | 
				
			||||||
import { isId } from "$lib/Id";
 | 
					import { isId } from "$lib/Id";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe("Listing", () => {
 | 
					describe("modifyListing", () => {
 | 
				
			||||||
	describe("createNewListing", () => {
 | 
						describe("createNewListing", () => {
 | 
				
			||||||
		it("should create a new Listing with the provided data, and a new UUID", () => {
 | 
							it("should create a new Listing with the provided data, and a new UUID", () => {
 | 
				
			||||||
			const game = new Game();
 | 
								const game = new Game();
 | 
				
			||||||
@ -29,8 +29,17 @@ describe("GameData", () => {
 | 
				
			|||||||
			equal(isGameData(data), false);
 | 
								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", () => {
 | 
							it("rejects an object with extra properties", () => {
 | 
				
			||||||
			const data: GameData & { extra: boolean } = {
 | 
								const data: unknown = {
 | 
				
			||||||
				players: [idFromString(idString)],
 | 
									players: [idFromString(idString)],
 | 
				
			||||||
				isStarted: false,
 | 
									isStarted: false,
 | 
				
			||||||
				state: {},
 | 
									state: {},
 | 
				
			||||||
@ -9,11 +9,11 @@ import {
 | 
				
			|||||||
	RollForFirst,
 | 
						RollForFirst,
 | 
				
			||||||
	Score,
 | 
						Score,
 | 
				
			||||||
	SeatPlayers,
 | 
						SeatPlayers,
 | 
				
			||||||
} from "../../GameEvent";
 | 
					} from "$lib/GameEvent";
 | 
				
			||||||
import type { GameEventData } from "../../GameEvent";
 | 
					import type { GameEventData } from "$lib/GameEvent";
 | 
				
			||||||
import type { GameData } from "../../GameData";
 | 
					import type { GameData } from "$lib/GameData";
 | 
				
			||||||
import { describe, it } from "vitest";
 | 
					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 { doesNotThrow, deepStrictEqual, equal, ok, throws } from "assert";
 | 
				
			||||||
import { createId, idFromString, stringFromId } from "$lib/Id";
 | 
					import { createId, idFromString, stringFromId } from "$lib/Id";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -220,7 +220,10 @@ describe("Game Events", () => {
 | 
				
			|||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			it("should throw if the value is not a number", () => {
 | 
								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] };
 | 
									const state: State = { scores: [FIRST_ROLL_PENDING, FIRST_ROLL_PENDING] };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				throws(() => ev.run(state));
 | 
									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", () => {
 | 
								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", () => {
 | 
								it("should reset the scores and set the winning player when everyone has rolled", () => {
 | 
				
			||||||
				const state: State = {
 | 
									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({
 | 
									let ev = new RollForFirst({
 | 
				
			||||||
@ -313,14 +325,24 @@ describe("Game Events", () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
				deepStrictEqual(state, {
 | 
									deepStrictEqual(state, {
 | 
				
			||||||
					dieCount: 6,
 | 
										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,
 | 
										playing: 2,
 | 
				
			||||||
				});
 | 
									});
 | 
				
			||||||
			});
 | 
								});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			it("should reset tied players for tie breaker", () => {
 | 
								it("should reset tied players for tie breaker", () => {
 | 
				
			||||||
				const state: State = {
 | 
									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({
 | 
									let ev = new RollForFirst({
 | 
				
			||||||
@ -340,13 +362,23 @@ describe("Game Events", () => {
 | 
				
			|||||||
				ev.run(state);
 | 
									ev.run(state);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
				deepStrictEqual(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", () => {
 | 
								it("should throw if a player whose lost tries to roll again", () => {
 | 
				
			||||||
				const state: State = {
 | 
									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({
 | 
									const ev = new RollForFirst({
 | 
				
			||||||
@ -359,7 +391,12 @@ describe("Game Events", () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
			it("should allow tied players to keep rolling until somoene wins", () => {
 | 
								it("should allow tied players to keep rolling until somoene wins", () => {
 | 
				
			||||||
				const state: State = {
 | 
									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
 | 
									// simulate another 3-way tie
 | 
				
			||||||
@ -379,7 +416,12 @@ describe("Game Events", () => {
 | 
				
			|||||||
				deepStrictEqual(
 | 
									deepStrictEqual(
 | 
				
			||||||
					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,
 | 
				
			||||||
 | 
											],
 | 
				
			||||||
					},
 | 
										},
 | 
				
			||||||
					"shouldn't change in a 3-way tie",
 | 
										"shouldn't change in a 3-way tie",
 | 
				
			||||||
				);
 | 
									);
 | 
				
			||||||
@ -397,7 +439,12 @@ describe("Game Events", () => {
 | 
				
			|||||||
				deepStrictEqual(
 | 
									deepStrictEqual(
 | 
				
			||||||
					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,
 | 
				
			||||||
 | 
											],
 | 
				
			||||||
					},
 | 
										},
 | 
				
			||||||
					"should update for a smaller tie",
 | 
										"should update for a smaller tie",
 | 
				
			||||||
				);
 | 
									);
 | 
				
			||||||
@ -813,6 +860,17 @@ describe("Game Events", () => {
 | 
				
			|||||||
				throws(() => ev.run(state));
 | 
									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", () => {
 | 
								it("should add the score to the players score and activate the next player", () => {
 | 
				
			||||||
				let state: State = {
 | 
									let state: State = {
 | 
				
			||||||
					dieCount: 4,
 | 
										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 { describe, it } from "vitest";
 | 
				
			||||||
import { equal, ok } from "node:assert/strict";
 | 
					import { equal, ok } from "node:assert/strict";
 | 
				
			||||||
import { hasProperty, hasOnlyKeys } from "../../validation";
 | 
					import { hasProperty, hasOnlyKeys } from "$lib/validation";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe("validation", () => {
 | 
					describe("validation", () => {
 | 
				
			||||||
	describe("hasProperty", () => {
 | 
						describe("hasProperty", () => {
 | 
				
			||||||
@ -46,7 +46,7 @@ describe("validation", () => {
 | 
				
			|||||||
				third: false,
 | 
									third: false,
 | 
				
			||||||
				fourth: null,
 | 
									fourth: null,
 | 
				
			||||||
				fifth: { something: "important" },
 | 
									fifth: { something: "important" },
 | 
				
			||||||
				sixth: ["one", "two"]
 | 
									sixth: ["one", "two"],
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			ok(hasProperty(target, "first", "string"));
 | 
								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", () => {
 | 
							it("should return false if passed an array type and the property isn't an array", () => {
 | 
				
			||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				arr: "not array"
 | 
									arr: "not array",
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			equal(hasProperty(target, "arr", "string[]"), false);
 | 
								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", () => {
 | 
							it("should return false if the defined array contains a non-matching element", () => {
 | 
				
			||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				arr: ["I", "was", "born", "in", 1989]
 | 
									arr: ["I", "was", "born", "in", 1989],
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			equal(hasProperty(target, "arr", "string[]"), false);
 | 
								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", () => {
 | 
							it("should return true if all the elements in a defined array match", () => {
 | 
				
			||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				arr: ["I", "was", "born", "in", "1989"]
 | 
									arr: ["I", "was", "born", "in", "1989"],
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			ok(hasProperty(target, "arr", "string[]"));
 | 
								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", () => {
 | 
							it("should return true if all the elements in a defined array match one of multiple types", () => {
 | 
				
			||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				arr: ["I", "was", "born", "in", 1989]
 | 
									arr: ["I", "was", "born", "in", 1989],
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			ok(hasProperty(target, "arr", "(string|number)[]"));
 | 
								ok(hasProperty(target, "arr", "(string|number)[]"));
 | 
				
			||||||
@ -91,7 +91,7 @@ describe("validation", () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
		it("should return true if type is null but property is nullable", () => {
 | 
							it("should return true if type is null but property is nullable", () => {
 | 
				
			||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				nullable: null
 | 
									nullable: null,
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			ok(hasProperty(target, "nullable", "string", true));
 | 
								ok(hasProperty(target, "nullable", "string", true));
 | 
				
			||||||
@ -107,7 +107,7 @@ describe("validation", () => {
 | 
				
			|||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				one: "one",
 | 
									one: "one",
 | 
				
			||||||
				two: "two",
 | 
									two: "two",
 | 
				
			||||||
				three: "three"
 | 
									three: "three",
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const keys = ["one", "two"];
 | 
								const keys = ["one", "two"];
 | 
				
			||||||
@ -119,7 +119,7 @@ describe("validation", () => {
 | 
				
			|||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				one: "one",
 | 
									one: "one",
 | 
				
			||||||
				two: "two",
 | 
									two: "two",
 | 
				
			||||||
				three: "three"
 | 
									three: "three",
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const keys = ["one", "two", "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", () => {
 | 
							it("should return true if the target has only a subset of the provided keys", () => {
 | 
				
			||||||
			const target = {
 | 
								const target = {
 | 
				
			||||||
				one: "one"
 | 
									one: "one",
 | 
				
			||||||
			};
 | 
								};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
			const keys = ["one", "two", "three"];
 | 
								const keys = ["one", "two", "three"];
 | 
				
			||||||
@ -2,10 +2,12 @@ import type { RequestHandler } from "@sveltejs/kit";
 | 
				
			|||||||
import { listResponse, singleResponse } from "$lib/server/responseBodies";
 | 
					import { listResponse, singleResponse } from "$lib/server/responseBodies";
 | 
				
			||||||
import { createNewListing } from "$lib/server/modifyListing";
 | 
					import { createNewListing } from "$lib/server/modifyListing";
 | 
				
			||||||
import { Game } from "$lib/server/Game";
 | 
					import { Game } from "$lib/server/Game";
 | 
				
			||||||
import { games } from "$lib/server/cache";
 | 
					import { listCollection, writeListing } from "$lib/server/mongo";
 | 
				
			||||||
import { 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);
 | 
						return listResponse(games);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -13,7 +15,6 @@ export const POST: RequestHandler = async (): Promise<Response> => {
 | 
				
			|||||||
	const newListing = createNewListing(new Game());
 | 
						const newListing = createNewListing(new Game());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	await writeListing("games", newListing);
 | 
						await writeListing("games", newListing);
 | 
				
			||||||
	games.push(newListing);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
	return singleResponse(newListing.id);
 | 
						return singleResponse(newListing.id);
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -1,15 +1,29 @@
 | 
				
			|||||||
import { games } from "$lib/server/cache";
 | 
					import type { GameData } from "$lib/GameData";
 | 
				
			||||||
import { badRequestResponse, notFoundResponse, singleResponse } from "$lib/server/responseBodies";
 | 
					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";
 | 
					import type { RequestHandler } from "@sveltejs/kit";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const GET: RequestHandler = ({ params }): Response => {
 | 
					export const GET: RequestHandler = async ({ params }): Promise<Response> => {
 | 
				
			||||||
	const id = params["gameid"];
 | 
						const idStr = params["gameid"];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	if (!id) {
 | 
						if (!idStr) {
 | 
				
			||||||
		return badRequestResponse("missing gameid parameter");
 | 
							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) {
 | 
						if (!game) {
 | 
				
			||||||
		return notFoundResponse();
 | 
							return notFoundResponse();
 | 
				
			||||||
 | 
				
			|||||||
@ -10,6 +10,7 @@ import {
 | 
				
			|||||||
import type { RequestHandler } from "@sveltejs/kit";
 | 
					import type { RequestHandler } from "@sveltejs/kit";
 | 
				
			||||||
import { compare } from "bcrypt";
 | 
					import { compare } from "bcrypt";
 | 
				
			||||||
import { createToken } from "$lib/server/auth";
 | 
					import { createToken } from "$lib/server/auth";
 | 
				
			||||||
 | 
					import { JWT_SECRET } from "$env/static/private";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
 | 
					export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
 | 
				
			||||||
	try {
 | 
						try {
 | 
				
			||||||
@ -33,7 +34,7 @@ export const POST: RequestHandler = async ({ locals }): Promise<Response> => {
 | 
				
			|||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		if (await compare(password, listing.data.password)) {
 | 
							if (await compare(password, listing.data.password)) {
 | 
				
			||||||
			const token = await createToken(listing);
 | 
								const token = await createToken(listing, JWT_SECRET);
 | 
				
			||||||
			return singleResponse(token);
 | 
								return singleResponse(token);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,7 +1,7 @@
 | 
				
			|||||||
import type { Cookies, RequestEvent } from "@sveltejs/kit";
 | 
					import type { Cookies, RequestEvent } from "@sveltejs/kit";
 | 
				
			||||||
import { describe, it, expect, afterEach } from "vitest";
 | 
					import { describe, it, expect, afterEach } from "vitest";
 | 
				
			||||||
import * as auth from "../lib/server/auth";
 | 
					import * as auth from "../lib/server/auth";
 | 
				
			||||||
import { handle } from "../hooks.server";
 | 
					import { getHandleFn } from "../hooks.server";
 | 
				
			||||||
import { createId } from "$lib/Id";
 | 
					import { createId } from "$lib/Id";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
let events: RequestEvent[] = [];
 | 
					let events: RequestEvent[] = [];
 | 
				
			||||||
@ -32,7 +32,7 @@ const resolve = async (event: RequestEvent): Promise<Response> => {
 | 
				
			|||||||
	return new Response();
 | 
						return new Response();
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
describe("handle", () => {
 | 
					describe("getHandleFn", () => {
 | 
				
			||||||
	afterEach(() => {
 | 
						afterEach(() => {
 | 
				
			||||||
		event.locals.user = {};
 | 
							event.locals.user = {};
 | 
				
			||||||
		event.request.headers.delete("authorization");
 | 
							event.request.headers.delete("authorization");
 | 
				
			||||||
@ -41,12 +41,12 @@ describe("handle", () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
	it("returns unauthorized response if caller isn't properly authenticated", async () => {
 | 
						it("returns unauthorized response if caller isn't properly authenticated", async () => {
 | 
				
			||||||
		event.request.headers.set("authorization", "Nonesense Token");
 | 
							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);
 | 
							expect(res.status).to.equal(401);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	it("returns unauthorized response if caller is missing required auth header", async () => {
 | 
						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);
 | 
							expect(res.status).to.equal(401);
 | 
				
			||||||
	});
 | 
						});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -63,21 +63,57 @@ describe("handle", () => {
 | 
				
			|||||||
			request: new Request("https://localhost/api/some/secret/route"),
 | 
								request: new Request("https://localhost/api/some/secret/route"),
 | 
				
			||||||
		};
 | 
							};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		const token = await auth.createToken({
 | 
							const token = await auth.createToken(
 | 
				
			||||||
			id: createId(),
 | 
								{
 | 
				
			||||||
			createdAt: new Date().toString(),
 | 
									id: createId(),
 | 
				
			||||||
			modifiedAt: new Date().toString(),
 | 
									createdAt: new Date().toString(),
 | 
				
			||||||
			deleted: false,
 | 
									modifiedAt: new Date().toString(),
 | 
				
			||||||
			data: {
 | 
									deleted: false,
 | 
				
			||||||
				password: "somethin' secret!",
 | 
									data: {
 | 
				
			||||||
				username: "Mr. Man",
 | 
										password: "somethin' secret!",
 | 
				
			||||||
				role: "default",
 | 
										username: "Mr. Man",
 | 
				
			||||||
 | 
										role: "default",
 | 
				
			||||||
 | 
									},
 | 
				
			||||||
			},
 | 
								},
 | 
				
			||||||
		});
 | 
								"server-secret",
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		ev.request.headers.set("authorization", `Bearer ${token}`);
 | 
							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);
 | 
							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
 | 
					@token=token
 | 
				
			||||||
 | 
					 | 
				
			||||||
GET https://localhost:5173/api
 | 
					GET https://localhost:5173/api
 | 
				
			||||||
Accept: application/json
 | 
					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
 | 
					Accept: application/json
 | 
				
			||||||
 | 
					Authorization: Bearer {{token}}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
###
 | 
					###
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user