wren/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files 230b84c268f985fd747740bbba16bff522b20d0b
| 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 } = 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 | }); |
| 209 | |
| 210 | test("a stale socket closing does not evict a live reconnect's seat", async () => { |
| 211 | // A reload opens the new socket before the old one's close event lands. |
| 212 | // The old close must not turn a player who is still connected into a bot. |
| 213 | const { attach, release } = await import("./arena.ts"); |
| 214 | const player = takeSeat("reload-session", "Reload")!; |
| 215 | attach("reload-session", player.id, "first", () => {}); |
| 216 | attach("reload-session", player.id, "second", () => {}); |
| 217 | release("reload-session", "first"); |
| 218 | expect(player.bot).toBe(false); |
| 219 | }); |
| 220 | |
| 221 | test("two people who pick the same name do not end up with the same name", () => { |
| 222 | // Everything that addresses a player — the kill feed, the round log, the |
| 223 | // scoreboard — does it by name, so the match may never hold two of one. |
| 224 | const first = takeSeat("clash-a", "Twin")!; |
| 225 | const second = takeSeat("clash-b", "Twin")!; |
| 226 | expect(first.name).toBe("Twin"); |
| 227 | expect(second.name).not.toBe("Twin"); |
| 228 | expect(second.name.startsWith("Twin")).toBe(true); |
| 229 | expect(second.name.length).toBeLessThanOrEqual(16); |
| 230 | const names = match().players.map((p) => p.name); |
| 231 | expect(new Set(names).size).toBe(names.length); |
| 232 | }); |