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

forked from benjivers/val

workbenchthis machine
Files 5e428dd35c8d9098ba4cf91bebff3eeac07fdf7d
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 } = 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
52
test("refuses a cross-site post", async () => {
53
  const form = new FormData();
54
  form.set("name", "Evil");
55
  const posted = await fetch(base + "/join", {
56
    method: "POST",
57
    body: form,
58
    redirect: "manual",
59
    headers: { origin: "https://evil.example", "sec-fetch-site": "cross-site" },
60
  });
61
  expect(posted.status).toBe(403);
62
  expect(match().players.some((p) => p.name === "Evil")).toBe(false);
63
});
64
65
test("stands on the security floor", async () => {
66
  const response = await fetch(base + "/");
67
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
68
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
69
  const missing = await fetch(base + "/nope");
70
  expect(missing.status).toBe(404);
71
  expect(missing.headers.get("content-security-policy")).toContain("default-src 'self'");
72
});
73
74
test("a bug answers with the floor, not the runtime's page", async () => {
75
  // A percent-escape that is not one: whether the runtime decodes it or
76
  // throws, the answer carries the floor and never a stack trace.
77
  const response = await fetch(base + "/join", {
78
    method: "POST",
79
    headers: { "content-type": "application/x-www-form-urlencoded" },
80
    body: "name=%E0%A4%A",
81
    redirect: "manual",
82
  });
83
  expect([303, 500]).toContain(response.status);
84
  expect(await response.text()).not.toContain("src/server.ts");
85
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
86
});
87
88
test("a body is capped by the bytes read, whatever it claims", async () => {
89
  const response = await fetch(base + "/join", {
90
    method: "POST",
91
    headers: { "content-type": "application/x-www-form-urlencoded" },
92
    body: "name=" + "x".repeat(1024 * 1024),
93
  });
94
  expect(response.status).toBe(413);
95
});
96
97
test("HEAD sees the app up", async () => {
98
  const response = await server.fetch(new Request(base + "/", { method: "HEAD" }));
99
  expect(response.status).toBe(200);
100
  expect(await response.text()).toBe("");
101
});
102
103
test("the map is served as data, once, for the client to draw", async () => {
104
  const response = await fetch(base + "/map.json");
105
  const body = (await response.json()) as { rows: string[]; tile: number };
106
  expect(body.tile).toBe(80);
107
  expect(body.rows.length).toBeGreaterThan(10);
108
  expect(body.rows.some((r) => r.includes("A"))).toBe(true);
109
  expect(body.rows.some((r) => r.includes("B"))).toBe(true);
110
});
111
112
test("a websocket is refused to another site", async () => {
113
  const response = await server.fetch(
114
    new Request(base + "/ws", { headers: { "sec-fetch-site": "cross-site", origin: "https://evil.example" } }),
115
  );
116
  expect(response.status).toBe(403);
117
});
118
119
test("a snapshot only carries the enemies that viewer can see", () => {
120
  const state = match();
121
  const viewer = state.players.find((p) => p.side === "atk")!;
122
  const enemy = state.players.find((p) => p.side === "def")!;
123
  viewer.x = 1200;
124
  viewer.y = 1320;
125
  viewer.blind = 0;
126
  // Both of them alive on purpose: the arena is one live match that other
127
  // tests run forward, and a dead enemy is invisible for a different reason
128
  // than the one this test is about.
129
  viewer.alive = true;
130
  enemy.alive = true;
131
  enemy.x = 1200;
132
  enemy.y = 1240;
133
  enemy.revealed = 0;
134
  const near = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[] };
135
  expect(near.players.some((p) => p.id === enemy.id)).toBe(true);
136
  // Behind the wall on the far side of the map, the same enemy is gone.
137
  enemy.x = 200;
138
  enemy.y = 200;
139
  const far = JSON.parse(snapshot(viewer.id)) as { players: { id: string }[]; board: unknown[] };
140
  expect(far.players.some((p) => p.id === enemy.id)).toBe(false);
141
  // The scoreboard still lists everyone; it carries no position.
142
  expect(far.board.length).toBe(10);
143
  expect(JSON.stringify(far.board)).not.toContain('"x"');
144
});
145
146
test("a command from a seat plants the spike, and nonsense from anyone else does nothing", async () => {
147
  const { command } = await import("./arena.ts");
148
  const { siteCentre } = await import("./map.ts");
149
  const state = match();
150
  state.phase = "live";
151
  state.clock = 100;
152
  const spot = siteCentre("B");
153
  const carrier = state.players.find((p) => p.side === "atk")!;
154
  // Nobody is a bot for these five seconds: no fight can end the round
155
  // underneath the plant and make this depend on who shot whom.
156
  for (const player of state.players) {
157
    player.bot = false;
158
    player.alive = true;
159
    player.hp = 100;
160
    player.input.fire = false;
161
  }
162
  carrier.x = spot.x;
163
  carrier.y = spot.y;
164
  carrier.spike = true;
165
  state.spike = { carried: carrier.id, x: spot.x, y: spot.y, planted: false, fuse: 0, site: null, defuse: 0, checkpoint: 0 };
166
  // Garbage is ignored, and a seat nobody holds cannot be driven.
167
  command(carrier.id, { t: "i", mx: "left" as unknown as number, a: Number.NaN, u: "yes" });
168
  command("nobody", { t: "i", u: true });
169
  expect(carrier.input.use).toBe(false);
170
  command(carrier.id, { t: "i", u: true, mx: 0, my: 0 });
171
  advance(5);
172
  expect(state.spike.planted).toBe(true);
173
  expect(state.spike.site).toBe("B");
174
  for (const player of state.players) player.bot = true;
175
});
176
177
test("the match keeps running and is never parked", () => {
178
  const state = match();
179
  const before = state.now;
180
  advance(30);
181
  expect(state.now).toBeGreaterThan(before);
182
  expect(["buy", "live", "end", "over"]).toContain(state.phase);
183
});
184
185
test("the kit is public: every ability, its price and what it does", async () => {
186
  const response = await fetch(base + "/agents.json");
187
  expect(response.status).toBe(200);
188
  expect(response.headers.get("content-security-policy")).toContain("default-src 'self'");
189
  const body = (await response.json()) as { agents: { id: string; abilities: Record<string, { name: string; cost: number; text: string }> }[] };
190
  expect(body.agents.length).toBe(5);
191
  const veil = body.agents.find((a) => a.id === "veil")!;
192
  expect(veil.abilities.c.name).toBe("Cloudburst");
193
  expect(veil.abilities.c.cost).toBe(100);
194
  expect(veil.abilities.e.text).toContain("smoke");
195
  // The ultimate is never for sale, and no server-only state leaks out.
196
  expect(veil.abilities.x.cost).toBe(0);
197
  expect(JSON.stringify(body)).not.toContain("kind");
198
});
199
200
test("the page carries the first-run card and the prompt line", async () => {
201
  const body = await (await fetch(base + "/")).text();
202
  expect(body).toContain('id="help"');
203
  expect(body).toContain("How to play");
204
  expect(body).toContain('id="prompt"');
205
  // Every buy button says what it costs, so the panel is readable at a glance.
206
  expect(body).toContain('data-buy="vandal" data-cost="2900"');
207
  expect(body).toContain('id="viewtoggle"');
208
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts