Files
wizwar6e/packages/server/test/keeper.test.ts
T

152 lines
6.8 KiB
TypeScript

import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
// The store reads its directory when first imported, so the rooms module is
// loaded only after the ledgers have somewhere of their own to go.
const scratch = mkdtempSync(join(tmpdir(), "wizwar-keeper-"));
process.env.WIZWAR_DATA_DIR = join(scratch, "rooms");
const KEY_FILE = join(scratch, "keeper.key");
const KEY = "a".repeat(64);
let rooms: typeof import("../src/rooms");
let keeper: typeof import("../src/keeper");
beforeAll(async () => {
rooms = await import("../src/rooms");
rooms.loadPersistedRooms();
keeper = await import("../src/keeper");
});
function table() {
const { room, token: ann } = rooms.createRoom("Ann");
const joined = rooms.joinRoom(room, "Bo", null);
if ("error" in joined) throw new Error(joined.error);
const bot = rooms.addAutomaton(room);
if ("error" in bot) throw new Error(bot.error);
return { room, ann, bo: joined.token, bot: bot.name };
}
function token(result: { token: string } | { error: string }): string {
if ("error" in result) throw new Error(result.error);
return result.token;
}
describe("the keeper's reseat", () => {
it("gives a person's seat a new token: the old one is refused and the new one opens the seat", () => {
const { room, bo } = table();
const fresh = token(rooms.reseat(room, "Bo"));
expect(fresh).not.toBe(bo);
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
expect(rooms.joinRoom(room, "Bo", fresh)).toEqual({ token: fresh });
expect(rooms.peekSummary(room.id, "Bo", bo)).toBe("badToken");
});
it("re-seats the host the same way", () => {
const { room, ann } = table();
const fresh = token(rooms.reseat(room, "Ann"));
expect("error" in rooms.joinRoom(room, "Ann", ann)).toBe(true);
expect(rooms.joinRoom(room, "Ann", fresh)).toEqual({ token: fresh });
});
it("voids a transfer phrase minted with the old token", () => {
const { room, bo } = table();
const minted = rooms.makeTransferCode(room, "Bo", bo);
if ("error" in minted) throw new Error(minted.error);
rooms.reseat(room, "Bo");
expect("error" in rooms.claimTransferCode(minted.code)).toBe(true);
});
it("keeps the new token across a reload from the ledger", async () => {
const { room, ann, bo } = table();
const fresh = token(rooms.reseat(room, "Bo"));
vi.resetModules();
const reloaded: typeof import("../src/rooms") = await import("../src/rooms");
const again = reloaded.getRoom(room.id);
expect(again).toBeDefined();
expect(again).not.toBe(room);
expect(reloaded.joinRoom(again!, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" });
expect(reloaded.joinRoom(again!, "Bo", fresh)).toEqual({ token: fresh });
expect(reloaded.joinRoom(again!, "Ann", ann)).toEqual({ token: ann });
});
it("re-seats only a person who sits at the table", () => {
const { room, bot } = table();
expect(rooms.reseat(room, bot)).toEqual({ error: "no person sits in that seat" });
expect(rooms.reseat(room, "Cy")).toEqual({ error: "no person sits in that seat" });
});
it("tells the keeper who sits where, and when each person last moved or spoke", () => {
const { room, bot } = table();
rooms.addChat(room, "Bo", "good luck");
const seats = rooms.seatsForKeeper(room);
expect(seats.map((s) => [s.name, s.bot, s.host])).toEqual([["Ann", false, true], ["Bo", false, false], [bot, true, false]]);
expect(seats[0]!.lastActed).toBeNull();
expect(seats[1]!.lastActed).toMatch(/^\d{4}-/);
expect(seats[2]!.lastActed).toBeNull();
});
});
describe("the keeper's door", () => {
let door: import("node:http").Server;
let base: string;
const knock = (path: string, init: RequestInit = {}) =>
fetch(`${base}${path}`, { ...init, headers: { "x-keeper-key": KEY, ...(init.headers as Record<string, string>) } });
beforeAll(async () => {
writeFileSync(KEY_FILE, KEY + "\n");
door = keeper.openKeeperDoor({ port: 0, publicUrl: "https://wizwar.example", keyFile: KEY_FILE, onReseat: () => {} });
await once(door, "listening");
const address = door.address() as AddressInfo;
base = `http://127.0.0.1:${address.port}`;
});
afterAll(() => door.close());
it("listens on the loopback alone", () => {
expect((door.address() as AddressInfo).address).toBe("127.0.0.1");
});
it("lists a table's seats and re-seats one as a link carrying the token in the fragment", async () => {
const { room, bo } = table();
const listed = await (await knock(`/keeper/rooms/${room.id}`)).json();
expect(listed.seats.map((s: { name: string }) => s.name)).toContain("Bo");
const res = await knock(`/keeper/rooms/${room.id.toLowerCase()}/reseat`, {
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ seat: "Bo" }),
});
expect(res.status).toBe(200);
const { name, link } = await res.json();
expect(name).toBe("Bo");
const m = new RegExp(`^https://wizwar\\.example/join/${room.id}#seat=Bo\\.([0-9a-f]{32})$`).exec(link);
expect(m).not.toBeNull();
expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true);
expect(rooms.joinRoom(room, "Bo", m![1]!)).toEqual({ token: m![1] });
});
it("turns away a forwarded request, an unknown room and a bot", async () => {
const { room, bot } = table();
expect((await knock(`/keeper/rooms/${room.id}`, { headers: { "x-forwarded-for": "203.0.113.9" } })).status).toBe(404);
expect((await knock(`/keeper/rooms/${room.id}X`)).status).toBe(404);
const res = await knock(`/keeper/rooms/${room.id}/reseat`, { method: "POST", body: JSON.stringify({ seat: bot }) });
expect(res.status).toBe(404);
expect((await res.json()).error).toBe("no person sits in that seat");
});
it("opens only to the keeper's key, and stays shut while the key file is absent", async () => {
const { room, bo } = table();
const reseatBo = (headers: Record<string, string>) =>
fetch(`${base}/keeper/rooms/${room.id}/reseat`, { method: "POST", headers, body: JSON.stringify({ seat: "Bo" }) });
expect((await reseatBo({})).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": "b".repeat(64) })).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": KEY.slice(1) })).status).toBe(404);
rmSync(KEY_FILE);
try {
expect((await reseatBo({ "x-keeper-key": KEY })).status).toBe(404);
expect((await reseatBo({ "x-keeper-key": "" })).status).toBe(404);
} finally {
writeFileSync(KEY_FILE, KEY + "\n");
}
expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ token: bo });
});
});