fable/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files main
1
import { expect, test } from "bun:test";
2
import { mkdtempSync } from "node:fs";
3
import { tmpdir } from "node:os";
4
import { join } from "node:path";
5
6
process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
7
process.env.PORT = "0";
8
const { server } = await import("./server.ts");
9
const { match, snapshot, advance, takeSeat, release, releaseAddress } = await import("./arena.ts");
10
const { visible } = await import("./match.ts");
11
const base = `http://${server.hostname}:${server.port}`;
12
13
test("serves a live match on the first paint, with no script needed", async () => {
14
  const response = await fetch(base + "/");
15
  expect(response.status).toBe(200);
16
  const body = await response.text();
17
  expect(body).toContain("<h1>val</h1>");
18
  // The score, the round and both team sheets are in the HTML itself.
19
  expect(body).toContain("red " + match().score.red);
20
  expect(body).toContain("round " + match().round);
21
  for (const player of match().players) expect(body).toContain(player.name);
22
});
23
24
test("the page hands out a session cookie and the script is named by content", async () => {
25
  const response = await fetch(base + "/", { redirect: "manual" });
26
  const cookie = response.headers.get("set-cookie") ?? "";
27
  expect(cookie).toContain("val_session=");
28
  expect(cookie).toContain("HttpOnly");
29
  const body = await response.text();
30
  const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(body);
31
  expect(src).not.toBe(null);
32
  const script = await fetch(base + src![1]!);
33
  expect(script.status).toBe(200);
34
  expect(script.headers.get("cache-control")).toContain("immutable");
35
  expect(await script.text()).toContain("WebSocket");
36
});
37
38
test("a name is taken from a same-origin form and sanitised", async () => {
39
  // Every test in this file reaches the server from the same address, and a
40
  // playing seat is rationed per address on purpose. Earlier tests here take
41
  // that seat and never open a socket to close, so nothing frees it: without
42
  // this line the join below is correctly refused and the assertion measures
43
  // the leftovers of another test rather than this one's behaviour.
44
  releaseAddress("127.0.0.1");
45
  releaseAddress("::1");
46
  releaseAddress("::ffff:127.0.0.1");
47
  const form = new FormData();
48
  form.set("name", '  Wren<script>"  ');
49
  const posted = await fetch(base + "/join", { method: "POST", body: form, redirect: "manual" });
50
  expect(posted.status).toBe(303);
51
  const cookie = posted.headers.getSetCookie().join(" ");
52
  expect(cookie).toContain("val_name=");
53
  // The angle brackets and the quote never reach the cookie or the match.
54
  expect(cookie).not.toContain("<");
55
  expect(cookie).not.toContain("%3C");
56
  expect(match().players.every((p) => !p.name.includes("<"))).toBe(true);
57
  // The seat that name took is a real player in the running match.
58
  expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true);
59
60
  // A join never opens a socket to close, so nothing ever frees the address
61
  // it claimed on its own; every other test in this run shares this same
62
  // loopback address, so free it explicitly rather than starve them of it.
63
  const sessionCookie = posted.headers.getSetCookie().find((c) => c.startsWith("val_session="));
64
  const session = sessionCookie?.split(";")[0]?.split("=")[1];
65
  if (session) release(session, "");
66
});
67
68
test("refuses a cross-site post", async () => {
69
  const form = new FormData();
70
  form.set("name", "Evil");
71
  const posted = await fetch(base + "/join", {
72
    method: "POST",
73
    body: form,
74
    redirect: "manual",
75
    headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
76
  });
77
  expect(posted.status).toBe(403);
78
  expect(match().players.some((p) => p.name === "Evil")).toBe(false);
79
});
80
81
test("stands on the security floor", async () => {
82
  const response = await fetch(base + "/");
83
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
84
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
85
  const missing = await fetch(base + "/nope");
86
  expect(missing.status).toBe(404);
87
  expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'");
88
});
89
90
test("a bug answers with the floor, not the runtime's page", async () => {
91
  // A percent-escape that is not one: whether the runtime decodes it or
92
  // throws, the answer carries the floor and never a stack trace.
93
  const response = await fetch(base + "/join", {
94
    method: "POST",
95
    headers: { "content-type": "application/x-www-form-urlencoded" },
96
    body: "name=%E0%A4%A",
97
    redirect: "manual",
98
  });
99
  expect([303, 500]).toContain(response.status);
100
  expect(await response.text()).not.toContain("src/server.ts");
101
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
102
});
103
104
test("a body is capped by the bytes read, whatever it claims", async () => {
105
  const response = await fetch(base + "/join", {
106
    method: "POST",
107
    headers: { "content-type": "application/x-www-form-urlencoded" },
108
    body: "name=" + "x".repeat(1024 * 1024),
109
  });
110
  expect(response.status).toBe(413);
111
});
112
113
test("HEAD sees the app up", async () => {
114
  const response = await server.fetch(new Request(base + "/", { method: "HEAD" }));
115
  expect(response.status).toBe(200);
116
  expect(await response.text()).toBe("");
117
});
118
119
test("the map is served as data, once, for the client to draw", async () => {
120
  const response = await fetch(base + "/map.json");
121
  const body = (await response.json()) as { rows: string[]; tile: number };
122
  expect(body.tile).toBe(80);
123
  expect(body.rows.length).toBeGreaterThan(10);
124
  expect(body.rows.some((r) => r.includes("A"))).toBe(true);
125
  expect(body.rows.some((r) => r.includes("B"))).toBe(true);
126
});
127
128
test("a websocket is refused to another site", async () => {
129
  const response = await server.fetch(
130
    new Request(base + "/ws", { headers: { "sec-fetch-site": "cross-site", origin: "https://evil.example" } }),
131
  );
132
  expect(response.status).toBe(403);
133
});
134
135
test("a snapshot only carries the enemies that viewer can see", () => {
136
  const state = match();
137
  const viewer = state.players.find((p) => p.side === "atk")!;
138
  const enemy = state.players.find((p) => p.side === "def")!;
139
  viewer.x = 1200;
140
  viewer.y = 1320;
141
  viewer.blind = 0;
142
  // Both of them alive on purpose: the arena is one live match that other
143
  // tests run forward, and a dead enemy is invisible for a different reason
144
  // than the one this test is about.
145
  viewer.alive = true;
146
  enemy.alive = true;
147
  enemy.x = 1200;
148
  enemy.y = 1240;
149
  enemy.revealed = 0;
150
  const near = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[] };
151
  expect(near.players.some((p) => p.id === enemy.id)).toBe(true);
152
  // Behind the wall on the far side of the map, the same enemy is gone.
153
  enemy.x = 200;
154
  enemy.y = 200;
155
  const far = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[]; board: unknown[] };
156
  expect(far.players.some((p) => p.id === enemy.id)).toBe(false);
157
  // The scoreboard still lists everyone; it carries no position.
158
  expect(far.board.length).toBe(10);
159
  expect(JSON.stringify(far.board)).not.toContain('"x"');
160
});
161
162
test("the whole snapshot carries no id or position of a body the viewer cannot see", () => {
163
  // Generic on purpose: this scans the whole stringified payload rather
164
  // than one named field, so it would have caught each of the three leaks
165
  // of this shape — a hidden body's id or coordinate reaching the wire
166
  // through some field other than `players` — and would catch a fourth.
167
  const state = match();
168
  const viewer = state.players.find((p) => p.side === "atk")!;
169
  viewer.x = 1200;
170
  viewer.y = 1320;
171
  viewer.blind = 0;
172
  viewer.alive = true;
173
  const enemy = state.players.find((p) => p.side === "def")!;
174
  enemy.alive = true;
175
  enemy.revealed = 0;
176
  // A distinctive decimal, not a round number, so it cannot be mistaken for
177
  // an unrelated field (hp, credits, damage) that legitimately holds 200.
178
  enemy.x = 217.3;
179
  enemy.y = 233.9;
180
  expect(visible(state, viewer, enemy)).toBe(false);
181
182
  // A molly lands at the thrower's own feet — the shape any thrown ability
183
  // takes — planted on the enemy this viewer cannot see.
184
  const zoneId = state.nextZone++;
185
  state.zones.push({
186
    id: zoneId,
187
    kind: "molly",
188
    owner: enemy.id,
189
    side: enemy.side,
190
    x: enemy.x,
191
    y: enemy.y,
192
    r: 40,
193
    life: 5,
194
    fuse: 0,
195
    beat: 0,
196
    pulses: 0,
197
    damage: 0,
198
    blocks: false,
199
    solid: false,
200
  });
201
202
  const text = snapshot(viewer.id);
203
  const round2 = (n: number) => Math.round(n * 10) / 10;
204
  // A number counts only bare, not as a substring of a larger one — "217.3"
205
  // inside "1217.3" would be a false alarm, "217.3" on its own is the leak.
206
  const bare = (n: number) => new RegExp(String.raw`(?<![\d.-])${n}(?![\d.])`);
207
  // The scoreboard is a named, reviewed exception: it lists every id on
208
  // purpose (kills, deaths, credits for the whole match), never a position.
209
  // The id check walks everything else; the position check still walks the
210
  // scoreboard too, so a coordinate added there would still be caught.
211
  const { board: _board, ...rest } = JSON.parse(text) as Record<string, unknown>;
212
  expect(JSON.stringify(rest)).not.toContain(JSON.stringify(enemy.id));
213
  expect(bare(round2(enemy.x)).test(text)).toBe(false);
214
  expect(bare(round2(enemy.y)).test(text)).toBe(false);
215
216
  state.zones = state.zones.filter((z) => z.id !== zoneId);
217
});
218
219
test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => {
220
  const { command } = await import("./arena.ts");
221
  const { siteCentre } = await import("./map.ts");
222
  const state = match();
223
  state.phase = "live";
224
  state.clock = 100;
225
  const spot = siteCentre("B");
226
  const carrier = state.players.find((p) => p.side === "atk")!;
227
  // Nobody is a bot for these five seconds: no fight can end the round
228
  // underneath the plant and make this depend on who shot whom. And nobody
229
  // else is mid-plant: this arena is live and shared, and once in a dozen
230
  // runs a bot was standing on A with the spike and F held when this test
231
  // began. Left with `spike` set and `use` held, it planted on A first, and
232
  // every seat test after this one failed because the bots were never
233
  // restored — the deploy gate's coin flip.
234
  for (const player of state.players) {
235
    player.bot = false;
236
    player.alive = true;
237
    player.hp = 100;
238
    player.spike = false;
239
    player.planting = 0;
240
    player.input.fire = false;
241
    player.input.use = false;
242
    player.input.mx = 0;
243
    player.input.my = 0;
244
  }
245
  try {
246
    carrier.x = spot.x;
247
    carrier.y = spot.y;
248
    carrier.spike = true;
249
    state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
250
    // Garbage is ignored, and a seat nobody holds cannot be driven.
251
    command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
252
    command("nobody", { t: "i", u: true });
253
    expect(carrier.input.use).toBe(false);
254
    command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
255
    advance(5);
256
    expect(state.spike.planted).toBe(true);
257
    expect(state.spike.site).toBe("B");
258
  } finally {
259
    for (const player of state.players) player.bot = true;
260
  }
261
});
262
263
test("the scoreboard is sent when it changes and left out when it has not", async () => {
264
  // Ten names, agents and tallies were almost half of every snapshot, thirty
265
  // times a second, for a table that changes a few times a round. A seat is
266
  // sent it the first time and again only when it differs.
267
  const { snapshotForSeat } = await import("./arena.ts");
268
  const state = match();
269
  const player = state.players[0]!;
270
  const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, stickyCache: "" };
271
  const first = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[]; last?: unknown };
272
  expect(first.board?.length).toBe(10);
273
  const second = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[] };
274
  expect(second.board).toBeUndefined();
275
  // Something on the board moves — a kill — and it comes back with the next tick.
276
  player.kills += 1;
277
  const third = JSON.parse(snapshotForSeat(seat)) as { board?: { kills: number }[] };
278
  expect(third.board?.length).toBe(10);
279
  expect(third.board?.find((p) => (p as { id?: string }).id === player.id)?.kills).toBe(player.kills);
280
  // The full snapshot a socket is handed on open, and every test reads, is untouched.
281
  expect((JSON.parse(snapshot(player.id)) as { board: unknown[] }).board.length).toBe(10);
282
  // The bytes: the board is the bulk of what was going out.
283
  const withBoard = Buffer.byteLength(snapshot(player.id));
284
  const without = Buffer.byteLength(snapshotForSeat(seat));
285
  expect(without).toBeLessThan(withBoard * 0.7);
286
});
287
288
test("the match keeps running and is never parked", () => {
289
  const state = match();
290
  const before = state.now;
291
  advance(30);
292
  expect(state.now).toBeGreaterThan(before);
293
  expect(["buy", "live", "end", "over"]).toContain(state.phase);
294
});
295
296
test("the kit is public: every ability, its price and what it does", async () => {
297
  const response = await fetch(base + "/agents.json");
298
  expect(response.status).toBe(200);
299
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
300
  const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] };
301
  expect(body.agents.length).toBe(5);
302
  const veil = body.agents.find((a) => a.id === "veil")!;
303
  expect(veil.abilities.c.name).toBe("Cloudburst");
304
  expect(veil.abilities.c.cost).toBe(100);
305
  expect(veil.abilities.e.text).toContain("smoke");
306
  // The ultimate is never for sale, and no server-only state leaks out.
307
  expect(veil.abilities.x.cost).toBe(0);
308
  expect(JSON.stringify(body)).not.toContain("kind");
309
});
310
311
test("the page carries the first-run card and the prompt line", async () => {
312
  const body = await (await fetch(base + "/")).text();
313
  expect(body).toContain('id="help"');
314
  expect(body).toContain("How to play");
315
  expect(body).toContain('id="prompt"');
316
  // Every buy button says what it costs, so the panel is readable at a glance,
317
  // and the rows are labelled for somebody meeting the panel for the first
318
  // time: which of these is a gun, which is armour, which is an ability.
319
  expect(body).toContain('data-buy="vandal" data-cost="2900"');
320
  for (const label of ["agent", "gun", "armour", "abilities"]) expect(body).toContain(`<span class="lbl">${label}</span>`);
321
  expect(body).not.toContain("refused server-side");
322
  expect(body).toContain('id="viewtoggle"');
323
});
324
325
test("the first paint says who took the round while it is over", async () => {
326
  // Somebody arriving between rounds used to read "round over" and nothing
327
  // else; the outcome was in the log three lines down at eighty percent.
328
  const state = match();
329
  const was = { phase: state.phase, clock: state.clock, last: state.last };
330
  state.phase = "end";
331
  state.clock = 4;
332
  state.last = { round: state.round, winner: "blue", why: "Spike defused" };
333
  try {
334
    const body = await (await fetch(base + "/")).text();
335
    expect(body).toContain("round over — blue took it, spike defused");
336
  } finally {
337
    Object.assign(state, was);
338
  }
339
});
340
341
test("a stale socket closing does not evict a live reconnect's seat", async () => {
342
  // A reload opens the new socket before the old one's close event lands.
343
  // The old close must not turn a player who is still connected into a bot.
344
  const { attach, release } = await import("./arena.ts");
345
  const player = takeSeat("203.0.113.1", "reload-session", "Reload")!;
346
  attach("reload-session", player.id, "203.0.113.1", "first", () => {});
347
  attach("reload-session", player.id, "203.0.113.1", "second", () => {});
348
  release("reload-session", "first");
349
  expect(player.bot).toBe(false);
350
});
351
352
test("one session cannot claim two seats on opposite teams", () => {
353
  // A websocket upgrade finishes on a later tick than the request that asked
354
  // for it, so two requests for the same session used to both read the seat
355
  // map before either had written to it — one client, two seats, opposite
356
  // sides, the vision of both. Nothing here waits for a socket to open:
357
  // the claim has to land the moment a seat is handed out.
358
  const first = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
359
  const second = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
360
  expect(second.id).toBe(first.id);
361
  expect(match().players.filter((p) => !p.bot && p.name === "Cheat").length).toBe(1);
362
});
363
364
test("one address cannot hold seats on both sides through a second session", async () => {
365
  // Three GETs of "/" mint three different, equally legitimate-looking
366
  // cookies. A session names a browser tab, not a person, so capping by
367
  // session alone still lets one machine sit on both teams and read both
368
  // snapshots — every position on the map, no forged traffic required.
369
  const { addressCapped } = await import("./arena.ts");
370
  const first = takeSeat("198.51.100.9", "session-a", "OneMachine")!;
371
  expect(addressCapped("198.51.100.9", "session-b")).toBe(true);
372
  const second = takeSeat("198.51.100.9", "session-b", "OneMachine");
373
  expect(second).toBe(null);
374
  // The same session reconnecting is not a second claim, and is let through.
375
  expect(addressCapped("198.51.100.9", "session-a")).toBe(false);
376
  expect(takeSeat("198.51.100.9", "session-a", "OneMachine")!.id).toBe(first.id);
377
});
378
379
test("a real second connection from one address gets a spectator view, positions and all excluded", async () => {
380
  // The end-to-end path: two sockets from this test's own loopback address,
381
  // one cookie each. The second must come back with no seat and nothing in
382
  // its snapshot that carries a coordinate — score and clock, not a map.
383
  // This loopback address is shared with every other test that runs a real
384
  // request; start from a clean slate rather than trust none of them left it
385
  // holding a seat.
386
  releaseAddress("127.0.0.1");
387
  const first = await fetch(base + "/");
388
  const cookieOne = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
389
  const second = await fetch(base + "/");
390
  const cookieTwo = (second.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
391
  expect(cookieOne).not.toBe(cookieTwo);
392
393
  const wsUrl = base.replace(/^http/, "ws") + "/ws";
394
  const wsOne = new WebSocket(wsUrl, { headers: { cookie: cookieOne } } as unknown as string[]);
395
  const snapOne = await new Promise<{ spectator?: boolean; me?: unknown }>((resolve, reject) => {
396
    wsOne.onmessage = (event) => resolve(JSON.parse(String(event.data)));
397
    wsOne.onerror = reject;
398
  });
399
  // The first connection from this address is the one that actually plays.
400
  expect(snapOne.spectator).toBeUndefined();
401
  expect(snapOne.me).toBeDefined();
402
403
  const wsTwo = new WebSocket(wsUrl, { headers: { cookie: cookieTwo } } as unknown as string[]);
404
  const snapTwo = await new Promise<unknown>((resolve, reject) => {
405
    wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
406
    wsTwo.onerror = reject;
407
  });
408
  // It says why it has no seat, because the two reasons have different
409
  // answers, and it carries the kill feed — names and a gun, never a place.
410
  expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" });
411
  expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true);
412
  // No coordinate anywhere, not under "me", not under a different key either.
413
  expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
414
415
  // And a connection carrying no session at all is told the other reason.
416
  const wsThree = new WebSocket(wsUrl);
417
  const snapThree = await new Promise<unknown>((resolve, reject) => {
418
    wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data)));
419
    wsThree.onerror = reject;
420
  });
421
  expect(snapThree).toMatchObject({ spectator: true, why: "cookie" });
422
423
  wsOne.close();
424
  wsTwo.close();
425
  wsThree.close();
426
  await Bun.sleep(50);
427
});
428
429
test("two people who pick the same name do not end up with the same name", () => {
430
  // Everything that addresses a player — the kill feed, the round log, the
431
  // scoreboard — does it by name, so the match may never hold two of one.
432
  const first = takeSeat("198.51.100.10", "clash-a", "Twin")!;
433
  const second = takeSeat("198.51.100.11", "clash-b", "Twin")!;
434
  expect(first.name).toBe("Twin");
435
  expect(second.name).not.toBe("Twin");
436
  expect(second.name.startsWith("Twin")).toBe(true);
437
  expect(second.name.length).toBeLessThanOrEqual(16);
438
  const names = match().players.map((p) => p.name);
439
  expect(new Set(names).size).toBe(names.length);
440
});
441
442
test("an empty room freezes the world instead of playing on", async () => {
443
  const { match, beat, takeSeat, attach, release } = await import("./arena.ts");
444
  // Whatever else this file left attached, the rule is about the gate itself:
445
  // with nobody there a beat of real time changes nothing.
446
  const { attendance } = await import("./arena.ts");
447
  const start = attendance();
448
  const quiet = { now: match().now, round: match().round };
449
  if (start === 0) {
450
    for (let i = 0; i < 450; i += 1) beat();
451
    expect({ now: match().now, round: match().round }).toEqual(quiet);
452
  }
453
454
  // Somebody arrives and time starts again from where it stopped.
455
  const seat = takeSeat("198.51.100.77", "empty-room-session", "Guest");
456
  expect(seat).not.toBeNull();
457
  attach("empty-room-session", seat!.id, "198.51.100.77", "tok", () => {});
458
  const before = match().now;
459
  for (let i = 0; i < 30; i += 1) beat();
460
  expect(match().now).toBeGreaterThan(before);
461
  release("empty-room-session", "tok");
462
});
463
464
test("attendance counts seats and watchers, and nothing else", async () => {
465
  const { attendance, takeSeat, attach, release, watch, unwatch } = await import("./arena.ts");
466
  const base = attendance();
467
  const seat = takeSeat("198.51.100.78", "counted-session", "Guest");
468
  attach("counted-session", seat!.id, "198.51.100.78", "tok2", () => {});
469
  expect(attendance()).toBe(base + 1);
470
  watch("watcher-token", () => {});
471
  expect(attendance()).toBe(base + 2);
472
  unwatch("watcher-token");
473
  release("counted-session", "tok2");
474
  expect(attendance()).toBe(base);
475
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts