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

forked from benjivers/val

workbenchthis machine
Files 5de58656b47b70197675501673bf27526f4f71fb
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.
171
  for (const player of state.players) {
172
    player.bot = false;
173
    player.alive = true;
174
    player.hp = 100;
175
    player.input.fire = false;
176
  }
177
  carrier.x = spot.x;
178
  carrier.y = spot.y;
179
  carrier.spike = true;
180
  state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
181
  // Garbage is ignored, and a seat nobody holds cannot be driven.
182
  command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
183
  command("nobody", { t: "i", u: true });
184
  expect(carrier.input.use).toBe(false);
185
  command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
186
  advance(5);
187
  expect(state.spike.planted).toBe(true);
188
  expect(state.spike.site).toBe("B");
189
  for (const player of state.players) player.bot = true;
190
});
191
192
test("the match keeps running and is never parked", () => {
193
  const state = match();
194
  const before = state.now;
195
  advance(30);
196
  expect(state.now).toBeGreaterThan(before);
197
  expect(["buy", "live", "end", "over"]).toContain(state.phase);
198
});
199
200
test("the kit is public: every ability, its price and what it does", async () => {
201
  const response = await fetch(base + "/agents.json");
202
  expect(response.status).toBe(200);
203
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
204
  const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] };
205
  expect(body.agents.length).toBe(5);
206
  const veil = body.agents.find((a) => a.id === "veil")!;
207
  expect(veil.abilities.c.name).toBe("Cloudburst");
208
  expect(veil.abilities.c.cost).toBe(100);
209
  expect(veil.abilities.e.text).toContain("smoke");
210
  // The ultimate is never for sale, and no server-only state leaks out.
211
  expect(veil.abilities.x.cost).toBe(0);
212
  expect(JSON.stringify(body)).not.toContain("kind");
213
});
214
215
test("the page carries the first-run card and the prompt line", async () => {
216
  const body = await (await fetch(base + "/")).text();
217
  expect(body).toContain('id="help"');
218
  expect(body).toContain("How to play");
219
  expect(body).toContain('id="prompt"');
220
  // Every buy button says what it costs, so the panel is readable at a glance.
221
  expect(body).toContain('data-buy="vandal" data-cost="2900"');
222
  expect(body).toContain('id="viewtoggle"');
223
});
224
225
test("a stale socket closing does not evict a live reconnect's seat", async () => {
226
  // A reload opens the new socket before the old one's close event lands.
227
  // The old close must not turn a player who is still connected into a bot.
228
  const { attach, release } = await import("./arena.ts");
229
  const player = takeSeat("203.0.113.1", "reload-session", "Reload")!;
230
  attach("reload-session", player.id, "203.0.113.1", "first", () => {});
231
  attach("reload-session", player.id, "203.0.113.1", "second", () => {});
232
  release("reload-session", "first");
233
  expect(player.bot).toBe(false);
234
});
235
236
test("one session cannot claim two seats on opposite teams", () => {
237
  // A websocket upgrade finishes on a later tick than the request that asked
238
  // for it, so two requests for the same session used to both read the seat
239
  // map before either had written to it — one client, two seats, opposite
240
  // sides, the vision of both. Nothing here waits for a socket to open:
241
  // the claim has to land the moment a seat is handed out.
242
  const first = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
243
  const second = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
244
  expect(second.id).toBe(first.id);
245
  expect(match().players.filter((p) => !p.bot && p.name === "Cheat").length).toBe(1);
246
});
247
248
test("one address cannot hold seats on both sides through a second session", async () => {
249
  // Three GETs of "/" mint three different, equally legitimate-looking
250
  // cookies. A session names a browser tab, not a person, so capping by
251
  // session alone still lets one machine sit on both teams and read both
252
  // snapshots — every position on the map, no forged traffic required.
253
  const { addressCapped } = await import("./arena.ts");
254
  const first = takeSeat("198.51.100.9", "session-a", "OneMachine")!;
255
  expect(addressCapped("198.51.100.9", "session-b")).toBe(true);
256
  const second = takeSeat("198.51.100.9", "session-b", "OneMachine");
257
  expect(second).toBe(null);
258
  // The same session reconnecting is not a second claim, and is let through.
259
  expect(addressCapped("198.51.100.9", "session-a")).toBe(false);
260
  expect(takeSeat("198.51.100.9", "session-a", "OneMachine")!.id).toBe(first.id);
261
});
262
263
test("a real second connection from one address gets a spectator view, positions and all excluded", async () => {
264
  // The end-to-end path: two sockets from this test's own loopback address,
265
  // one cookie each. The second must come back with no seat and nothing in
266
  // its snapshot that carries a coordinate — score and clock, not a map.
267
  // This loopback address is shared with every other test that runs a real
268
  // request; start from a clean slate rather than trust none of them left it
269
  // holding a seat.
270
  releaseAddress("127.0.0.1");
271
  const first = await fetch(base + "/");
272
  const cookieOne = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
273
  const second = await fetch(base + "/");
274
  const cookieTwo = (second.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
275
  expect(cookieOne).not.toBe(cookieTwo);
276
277
  const wsUrl = base.replace(/^http/, "ws") + "/ws";
278
  const wsOne = new WebSocket(wsUrl, { headers: { cookie: cookieOne } } as unknown as string[]);
279
  const snapOne = await new Promise<{ spectator?: boolean; me?: unknown }>((resolve, reject) => {
280
    wsOne.onmessage = (event) => resolve(JSON.parse(String(event.data)));
281
    wsOne.onerror = reject;
282
  });
283
  // The first connection from this address is the one that actually plays.
284
  expect(snapOne.spectator).toBeUndefined();
285
  expect(snapOne.me).toBeDefined();
286
287
  const wsTwo = new WebSocket(wsUrl, { headers: { cookie: cookieTwo } } as unknown as string[]);
288
  const snapTwo = await new Promise<unknown>((resolve, reject) => {
289
    wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
290
    wsTwo.onerror = reject;
291
  });
292
  // It says why it has no seat, because the two reasons have different
293
  // answers, and it carries the kill feed — names and a gun, never a place.
294
  expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" });
295
  expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true);
296
  // No coordinate anywhere, not under "me", not under a different key either.
297
  expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
298
299
  // And a connection carrying no session at all is told the other reason.
300
  const wsThree = new WebSocket(wsUrl);
301
  const snapThree = await new Promise<unknown>((resolve, reject) => {
302
    wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data)));
303
    wsThree.onerror = reject;
304
  });
305
  expect(snapThree).toMatchObject({ spectator: true, why: "cookie" });
306
307
  wsOne.close();
308
  wsTwo.close();
309
  wsThree.close();
310
  await Bun.sleep(50);
311
});
312
313
test("two people who pick the same name do not end up with the same name", () => {
314
  // Everything that addresses a player — the kill feed, the round log, the
315
  // scoreboard — does it by name, so the match may never hold two of one.
316
  const first = takeSeat("198.51.100.10", "clash-a", "Twin")!;
317
  const second = takeSeat("198.51.100.11", "clash-b", "Twin")!;
318
  expect(first.name).toBe("Twin");
319
  expect(second.name).not.toBe("Twin");
320
  expect(second.name.startsWith("Twin")).toBe(true);
321
  expect(second.name.length).toBeLessThanOrEqual(16);
322
  const names = match().players.map((p) => p.name);
323
  expect(new Set(names).size).toBe(names.length);
324
});
325
326
test("an empty room freezes the world instead of playing on", async () => {
327
  const { match, beat, takeSeat, attach, release } = await import("./arena.ts");
328
  // Whatever else this file left attached, the rule is about the gate itself:
329
  // with nobody there a beat of real time changes nothing.
330
  const { attendance } = await import("./arena.ts");
331
  const start = attendance();
332
  const quiet = { now: match().now, round: match().round };
333
  if (start === 0) {
334
    for (let i = 0; i < 450; i += 1) beat();
335
    expect({ now: match().now, round: match().round }).toEqual(quiet);
336
  }
337
338
  // Somebody arrives and time starts again from where it stopped.
339
  const seat = takeSeat("198.51.100.77", "empty-room-session", "Guest");
340
  expect(seat).not.toBeNull();
341
  attach("empty-room-session", seat!.id, "198.51.100.77", "tok", () => {});
342
  const before = match().now;
343
  for (let i = 0; i < 30; i += 1) beat();
344
  expect(match().now).toBeGreaterThan(before);
345
  release("empty-room-session", "tok");
346
});
347
348
test("attendance counts seats and watchers, and nothing else", async () => {
349
  const { attendance, takeSeat, attach, release, watch, unwatch } = await import("./arena.ts");
350
  const base = attendance();
351
  const seat = takeSeat("198.51.100.78", "counted-session", "Guest");
352
  attach("counted-session", seat!.id, "198.51.100.78", "tok2", () => {});
353
  expect(attendance()).toBe(base + 1);
354
  watch("watcher-token", () => {});
355
  expect(attendance()).toBe(base + 2);
356
  unwatch("watcher-token");
357
  release("counted-session", "tok2");
358
  expect(attendance()).toBe(base);
359
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts