The keeper can bring back a seat a browser lost: a reseat line on the ledger gives that wizard's seat a new token and the old one opens nothing after, wizwar-reseat.sh on the host asks for it through a keeper door that listens on the loopback alone and opens only to a key the deploy makes once, and the link it prints carries the seat in the fragment, which leaves the address bar at once and is kept only when no seat held in this browser still opens and the server takes it; a gallery watching a game in play is told both ways back, the front page asks before forgetting a game not yet finished, and the server's tests now run with the rest
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1b9acfd7c4
commit
5ea0d4fdec
@@ -0,0 +1,151 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user