| 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 |
|
| 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 |
|
| 45 |
expect(cookie).not.toContain("<"); |
| 46 |
expect(cookie).not.toContain("%3C"); |
| 47 |
expect(match().players.every((p) => !p.name.includes("<"))).toBe(true); |
| 48 |
|
| 49 |
expect(match().players.some((p) => !p.bot && p.name.includes("Wren"))).toBe(true); |
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 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 |
|
| 83 |
|
| 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 |
|
| 134 |
|
| 135 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 162 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 219 |
|
| 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 |
|
| 230 |
|
| 231 |
|
| 232 |
|
| 233 |
|
| 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 |
|
| 242 |
|
| 243 |
|
| 244 |
|
| 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 |
|
| 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 |
|
| 257 |
|
| 258 |
|
| 259 |
|
| 260 |
|
| 261 |
|
| 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 |
|
| 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 |
|
| 285 |
|
| 286 |
expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" }); |
| 287 |
expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true); |
| 288 |
|
| 289 |
expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/); |
| 290 |
|
| 291 |
|
| 292 |
const wsThree = new WebSocket(wsUrl); |
| 293 |
const snapThree = await new Promise<unknown>((resolve, reject) => { |
| 294 |
wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data))); |
| 295 |
wsThree.onerror = reject; |
| 296 |
}); |
| 297 |
expect(snapThree).toMatchObject({ spectator: true, why: "cookie" }); |
| 298 |
|
| 299 |
wsOne.close(); |
| 300 |
wsTwo.close(); |
| 301 |
wsThree.close(); |
| 302 |
await Bun.sleep(50); |
| 303 |
}); |
| 304 |
|
| 305 |
test("two people who pick the same name do not end up with the same name", () => { |
| 306 |
|
| 307 |
|
| 308 |
const first = takeSeat("198.51.100.10", "clash-a", "Twin")!; |
| 309 |
const second = takeSeat("198.51.100.11", "clash-b", "Twin")!; |
| 310 |
expect(first.name).toBe("Twin"); |
| 311 |
expect(second.name).not.toBe("Twin"); |
| 312 |
expect(second.name.startsWith("Twin")).toBe(true); |
| 313 |
expect(second.name.length).toBeLessThanOrEqual(16); |
| 314 |
const names = match().players.map((p) => p.name); |
| 315 |
expect(new Set(names).size).toBe(names.length); |
| 316 |
}); |
| 317 |
|
| 318 |
test("an empty room freezes the world instead of playing on", async () => { |
| 319 |
const { match, beat, takeSeat, attach, release } = await import("./arena.ts"); |
| 320 |
|
| 321 |
|
| 322 |
const { attendance } = await import("./arena.ts"); |
| 323 |
const start = attendance(); |
| 324 |
const quiet = { now: match().now, round: match().round }; |
| 325 |
if (start === 0) { |
| 326 |
for (let i = 0; i < 450; i += 1) beat(); |
| 327 |
expect({ now: match().now, round: match().round }).toEqual(quiet); |
| 328 |
} |
| 329 |
|
| 330 |
|
| 331 |
const seat = takeSeat("198.51.100.77", "empty-room-session", "Guest"); |
| 332 |
expect(seat).not.toBeNull(); |
| 333 |
attach("empty-room-session", seat!.id, "198.51.100.77", "tok", () => {}); |
| 334 |
const before = match().now; |
| 335 |
for (let i = 0; i < 30; i += 1) beat(); |
| 336 |
expect(match().now).toBeGreaterThan(before); |
| 337 |
release("empty-room-session", "tok"); |
| 338 |
}); |
| 339 |
|
| 340 |
test("attendance counts seats and watchers, and nothing else", async () => { |
| 341 |
const { attendance, takeSeat, attach, release, watch, unwatch } = await import("./arena.ts"); |
| 342 |
const base = attendance(); |
| 343 |
const seat = takeSeat("198.51.100.78", "counted-session", "Guest"); |
| 344 |
attach("counted-session", seat!.id, "198.51.100.78", "tok2", () => {}); |
| 345 |
expect(attendance()).toBe(base + 1); |
| 346 |
watch("watcher-token", () => {}); |
| 347 |
expect(attendance()).toBe(base + 2); |
| 348 |
unwatch("watcher-token"); |
| 349 |
release("counted-session", "tok2"); |
| 350 |
expect(attendance()).toBe(base); |
| 351 |
}); |