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