Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
152 lines
6.8 KiB
TypeScript
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 });
|
|
});
|
|
});
|