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

forked from benjivers/val

workbenchthis machine
Files 57987e47b6c0ecefde950821fbf546f83a4bb36a
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
  const form = new FormData();
39
  form.set("name", '  Wren<script>"  ');
40
  const posted = await fetch(base + "/join", { method: "POST", body: form, redirect: "manual" });
41
  expect(posted.status).toBe(303);
42
  const cookie = posted.headers.getSetCookie().join(" ");
43
  expect(cookie).toContain("val_name=");
44
  // The angle brackets and the quote never reach the cookie or the match.
45
  expect(cookie).not.toContain("<");
46
  expect(cookie).not.toContain("%3C");
47
  expect(match().players.every((p) => !p.name.includes("<"))).toBe(true);
48
  // The seat that name took is a real player in the running match.
49
  expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true);
50
51
  // A join never opens a socket to close, so nothing ever frees the address
52
  // it claimed on its own; every other test in this run shares this same
53
  // loopback address, so free it explicitly rather than starve them of it.
54
  const sessionCookie = posted.headers.getSetCookie().find((c) => c.startsWith("val_session="));
55
  const session = sessionCookie?.split(";")[0]?.split("=")[1];
56
  if (session) release(session, "");
57
});
58
59
test("refuses a cross-site post", async () => {
60
  const form = new FormData();
61
  form.set("name", "Evil");
62
  const posted = await fetch(base + "/join", {
63
    method: "POST",
64
    body: form,
65
    redirect: "manual",
66
    headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
67
  });
68
  expect(posted.status).toBe(403);
69
  expect(match().players.some((p) => p.name === "Evil")).toBe(false);
70
});
71
72
test("stands on the security floor", async () => {
73
  const response = await fetch(base + "/");
74
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
75
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
76
  const missing = await fetch(base + "/nope");
77
  expect(missing.status).toBe(404);
78
  expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'");
79
});
80
81
test("a bug answers with the floor, not the runtime's page", async () => {
82
  // A percent-escape that is not one: whether the runtime decodes it or
83
  // throws, the answer carries the floor and never a stack trace.
84
  const response = await fetch(base + "/join", {
85
    method: "POST",
86
    headers: { "content-type": "application/x-www-form-urlencoded" },
87
    body: "name=%E0%A4%A",
88
    redirect: "manual",
89
  });
90
  expect([303, 500]).toContain(response.status);
91
  expect(await response.text()).not.toContain("src/server.ts");
92
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
93
});
94
95
test("a body is capped by the bytes read, whatever it claims", async () => {
96
  const response = await fetch(base + "/join", {
97
    method: "POST",
98
    headers: { "content-type": "application/x-www-form-urlencoded" },
99
    body: "name=" + "x".repeat(1024 * 1024),
100
  });
101
  expect(response.status).toBe(413);
102
});
103
104
test("HEAD sees the app up", async () => {
105
  const response = await server.fetch(new Request(base + "/", { method: "HEAD" }));
106
  expect(response.status).toBe(200);
107
  expect(await response.text()).toBe("");
108
});
109
110
test("the map is served as data, once, for the client to draw", async () => {
111
  const response = await fetch(base + "/map.json");
112
  const body = (await response.json()) as { rows: string[]; tile: number };
113
  expect(body.tile).toBe(80);
114
  expect(body.rows.length).toBeGreaterThan(10);
115
  expect(body.rows.some((r) => r.includes("A"))).toBe(true);
116
  expect(body.rows.some((r) => r.includes("B"))).toBe(true);
117
});
118
119
test("a websocket is refused to another site", async () => {
120
  const response = await server.fetch(
121
    new Request(base + "/ws", { headers: { "sec-fetch-site": "cross-site", origin: "https://evil.example" } }),
122
  );
123
  expect(response.status).toBe(403);
124
});
125
126
test("a snapshot only carries the enemies that viewer can see", () => {
127
  const state = match();
128
  const viewer = state.players.find((p) => p.side === "atk")!;
129
  const enemy = state.players.find((p) => p.side === "def")!;
130
  viewer.x = 1200;
131
  viewer.y = 1320;
132
  viewer.blind = 0;
133
  // Both of them alive on purpose: the arena is one live match that other
134
  // tests run forward, and a dead enemy is invisible for a different reason
135
  // than the one this test is about.
136
  viewer.alive = true;
137
  enemy.alive = true;
138
  enemy.x = 1200;
139
  enemy.y = 1240;
140
  enemy.revealed = 0;
141
  const near = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[] };
142
  expect(near.players.some((p) => p.id === enemy.id)).toBe(true);
143
  // Behind the wall on the far side of the map, the same enemy is gone.
144
  enemy.x = 200;
145
  enemy.y = 200;
146
  const far = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[]; board: unknown[] };
147
  expect(far.players.some((p) => p.id === enemy.id)).toBe(false);
148
  // The scoreboard still lists everyone; it carries no position.
149
  expect(far.board.length).toBe(10);
150
  expect(JSON.stringify(far.board)).not.toContain('"x"');
151
});
152
153
test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => {
154
  const { command } = await import("./arena.ts");
155
  const { siteCentre } = await import("./map.ts");
156
  const state = match();
157
  state.phase = "live";
158
  state.clock = 100;
159
  const spot = siteCentre("B");
160
  const carrier = state.players.find((p) => p.side === "atk")!;
161
  // Nobody is a bot for these five seconds: no fight can end the round
162
  // underneath the plant and make this depend on who shot whom.
163
  for (const player of state.players) {
164
    player.bot = false;
165
    player.alive = true;
166
    player.hp = 100;
167
    player.input.fire = false;
168
  }
169
  carrier.x = spot.x;
170
  carrier.y = spot.y;
171
  carrier.spike = true;
172
  state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
173
  // Garbage is ignored, and a seat nobody holds cannot be driven.
174
  command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
175
  command("nobody", { t: "i", u: true });
176
  expect(carrier.input.use).toBe(false);
177
  command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
178
  advance(5);
179
  expect(state.spike.planted).toBe(true);
180
  expect(state.spike.site).toBe("B");
181
  for (const player of state.players) player.bot = true;
182
});
183
184
test("the match keeps running and is never parked", () => {
185
  const state = match();
186
  const before = state.now;
187
  advance(30);
188
  expect(state.now).toBeGreaterThan(before);
189
  expect(["buy", "live", "end", "over"]).toContain(state.phase);
190
});
191
192
test("the kit is public: every ability, its price and what it does", async () => {
193
  const response = await fetch(base + "/agents.json");
194
  expect(response.status).toBe(200);
195
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
196
  const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] };
197
  expect(body.agents.length).toBe(5);
198
  const veil = body.agents.find((a) => a.id === "veil")!;
199
  expect(veil.abilities.c.name).toBe("Cloudburst");
200
  expect(veil.abilities.c.cost).toBe(100);
201
  expect(veil.abilities.e.text).toContain("smoke");
202
  // The ultimate is never for sale, and no server-only state leaks out.
203
  expect(veil.abilities.x.cost).toBe(0);
204
  expect(JSON.stringify(body)).not.toContain("kind");
205
});
206
207
test("the page carries the first-run card and the prompt line", async () => {
208
  const body = await (await fetch(base + "/")).text();
209
  expect(body).toContain('id="help"');
210
  expect(body).toContain("How to play");
211
  expect(body).toContain('id="prompt"');
212
  // Every buy button says what it costs, so the panel is readable at a glance.
213
  expect(body).toContain('data-buy="vandal" data-cost="2900"');
214
  expect(body).toContain('id="viewtoggle"');
215
});
216
217
test("a stale socket closing does not evict a live reconnect's seat", async () => {
218
  // A reload opens the new socket before the old one's close event lands.
219
  // The old close must not turn a player who is still connected into a bot.
220
  const { attach, release } = await import("./arena.ts");
221
  const player = takeSeat("203.0.113.1", "reload-session", "Reload")!;
222
  attach("reload-session", player.id, "203.0.113.1", "first", () => {});
223
  attach("reload-session", player.id, "203.0.113.1", "second", () => {});
224
  release("reload-session", "first");
225
  expect(player.bot).toBe(false);
226
});
227
228
test("one session cannot claim two seats on opposite teams", () => {
229
  // A websocket upgrade finishes on a later tick than the request that asked
230
  // for it, so two requests for the same session used to both read the seat
231
  // map before either had written to it — one client, two seats, opposite
232
  // sides, the vision of both. Nothing here waits for a socket to open:
233
  // the claim has to land the moment a seat is handed out.
234
  const first = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
235
  const second = takeSeat("203.0.113.2", "cheat-session", "Cheat")!;
236
  expect(second.id).toBe(first.id);
237
  expect(match().players.filter((p) => !p.bot && p.name === "Cheat").length).toBe(1);
238
});
239
240
test("one address cannot hold seats on both sides through a second session", async () => {
241
  // Three GETs of "/" mint three different, equally legitimate-looking
242
  // cookies. A session names a browser tab, not a person, so capping by
243
  // session alone still lets one machine sit on both teams and read both
244
  // snapshots — every position on the map, no forged traffic required.
245
  const { addressCapped } = await import("./arena.ts");
246
  const first = takeSeat("198.51.100.9", "session-a", "OneMachine")!;
247
  expect(addressCapped("198.51.100.9", "session-b")).toBe(true);
248
  const second = takeSeat("198.51.100.9", "session-b", "OneMachine");
249
  expect(second).toBe(null);
250
  // The same session reconnecting is not a second claim, and is let through.
251
  expect(addressCapped("198.51.100.9", "session-a")).toBe(false);
252
  expect(takeSeat("198.51.100.9", "session-a", "OneMachine")!.id).toBe(first.id);
253
});
254
255
test("a real second connection from one address gets a spectator view, positions and all excluded", async () => {
256
  // The end-to-end path: two sockets from this test's own loopback address,
257
  // one cookie each. The second must come back with no seat and nothing in
258
  // its snapshot that carries a coordinate — score and clock, not a map.
259
  // This loopback address is shared with every other test that runs a real
260
  // request; start from a clean slate rather than trust none of them left it
261
  // holding a seat.
262
  releaseAddress("127.0.0.1");
263
  const first = await fetch(base + "/");
264
  const cookieOne = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
265
  const second = await fetch(base + "/");
266
  const cookieTwo = (second.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
267
  expect(cookieOne).not.toBe(cookieTwo);
268
269
  const wsUrl = base.replace(/^http/, "ws") + "/ws";
270
  const wsOne = new WebSocket(wsUrl, { headers: { cookie: cookieOne } } as unknown as string[]);
271
  const snapOne = await new Promise<{ spectator?: boolean; me?: unknown }>((resolve, reject) => {
272
    wsOne.onmessage = (event) => resolve(JSON.parse(String(event.data)));
273
    wsOne.onerror = reject;
274
  });
275
  // The first connection from this address is the one that actually plays.
276
  expect(snapOne.spectator).toBeUndefined();
277
  expect(snapOne.me).toBeDefined();
278
279
  const wsTwo = new WebSocket(wsUrl, { headers: { cookie: cookieTwo } } as unknown as string[]);
280
  const snapTwo = await new Promise<unknown>((resolve, reject) => {
281
    wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
282
    wsTwo.onerror = reject;
283
  });
284
  expect(snapTwo).toMatchObject({ t: "s", spectator: true });
285
  // No coordinate anywhere, not under "me", not under a different key either.
286
  expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
287
  wsOne.close();
288
  wsTwo.close();
289
  await Bun.sleep(50);
290
});
291
292
test("two people who pick the same name do not end up with the same name", () => {
293
  // Everything that addresses a player — the kill feed, the round log, the
294
  // scoreboard — does it by name, so the match may never hold two of one.
295
  const first = takeSeat("198.51.100.10", "clash-a", "Twin")!;
296
  const second = takeSeat("198.51.100.11", "clash-b", "Twin")!;
297
  expect(first.name).toBe("Twin");
298
  expect(second.name).not.toBe("Twin");
299
  expect(second.name.startsWith("Twin")).toBe(true);
300
  expect(second.name.length).toBeLessThanOrEqual(16);
301
  const names = match().players.map((p) => p.name);
302
  expect(new Set(names).size).toBe(names.length);
303
});
304
305
test("an empty room freezes the world instead of playing on", async () => {
306
  const { match, beat, takeSeat, attach, release } = await import("./arena.ts");
307
  // Whatever else this file left attached, the rule is about the gate itself:
308
  // with nobody there a beat of real time changes nothing.
309
  const { attendance } = await import("./arena.ts");
310
  const start = attendance();
311
  const quiet = { now: match().now, round: match().round };
312
  if (start === 0) {
313
    for (let i = 0; i < 450; i += 1) beat();
314
    expect({ now: match().now, round: match().round }).toEqual(quiet);
315
  }
316
317
  // Somebody arrives and time starts again from where it stopped.
318
  const seat = takeSeat("198.51.100.77", "empty-room-session", "Guest");
319
  expect(seat).not.toBeNull();
320
  attach("empty-room-session", seat!.id, "198.51.100.77", "tok", () => {});
321
  const before = match().now;
322
  for (let i = 0; i < 30; i += 1) beat();
323
  expect(match().now).toBeGreaterThan(before);
324
  release("empty-room-session", "tok");
325
});
326
327
test("attendance counts seats and watchers, and nothing else", async () => {
328
  const { attendance, takeSeat, attach, release, watch, unwatch } = await import("./arena.ts");
329
  const base = attendance();
330
  const seat = takeSeat("198.51.100.78", "counted-session", "Guest");
331
  attach("counted-session", seat!.id, "198.51.100.78", "tok2", () => {});
332
  expect(attendance()).toBe(base + 1);
333
  watch("watcher-token", () => {});
334
  expect(attendance()).toBe(base + 2);
335
  unwatch("watcher-token");
336
  release("counted-session", "tok2");
337
  expect(attendance()).toBe(base);
338
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts