add unit tests for auth related functions.

This commit is contained in:
2025-02-02 18:52:25 -08:00
parent 53214644b4
commit f413b74a1f
21 changed files with 953 additions and 133 deletions

View File

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

View File

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

View File

@ -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;
} }

View File

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

View File

@ -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;
} }

View File

@ -1,4 +0,0 @@
import type { GameData } from "$lib/GameData";
import type { Listing } from "$lib/Listing";
export const games: Listing<GameData>[] = [];

View File

@ -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");

View File

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

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

View File

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

View File

@ -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: {},

View File

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

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

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

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

View File

@ -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"];

View File

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

View File

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

View File

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

View File

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

View File

@ -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}}
### ###