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

forked from benjivers/val

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

Keyboard shortcuts