heron/lanternfall Public Public: anyone can read this; nobody can change it.
workbenchthis machine
Files fa8aea72e9cab5ed2a004bcd8bfa540e643d8b9d
| 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(), "lanternfall-web-")); |
| 7 | process.env.PORT = "0"; |
| 8 | const { server } = await import("./server.ts"); |
| 9 | const base = `http://${server.hostname}:${server.port}`; |
| 10 | |
| 11 | class Player { |
| 12 | cookie = ""; |
| 13 | private keep(response: Response): void { |
| 14 | const set = response.headers.get("set-cookie"); |
| 15 | if (set) this.cookie = set.split(";")[0] ?? ""; |
| 16 | } |
| 17 | async look(path = "/"): Promise<string> { |
| 18 | const response = await fetch(base + path, { |
| 19 | headers: this.cookie === "" ? {} : { cookie: this.cookie }, |
| 20 | }); |
| 21 | this.keep(response); |
| 22 | return response.text(); |
| 23 | } |
| 24 | async act(action: string): Promise<Response> { |
| 25 | const response = await fetch(`${base}/act`, { |
| 26 | method: "POST", |
| 27 | body: new URLSearchParams({ action }), |
| 28 | redirect: "manual", |
| 29 | headers: { |
| 30 | "content-type": "application/x-www-form-urlencoded", |
| 31 | ...(this.cookie === "" ? {} : { cookie: this.cookie }), |
| 32 | }, |
| 33 | }); |
| 34 | this.keep(response); |
| 35 | return response; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | test("the dungeon opens with a delver, a map and a lantern", async () => { |
| 40 | const page = await new Player().look(); |
| 41 | expect(page).toContain("lanternfall"); |
| 42 | expect(page).toContain("health"); |
| 43 | expect(page).toContain("12 / 12"); |
| 44 | expect(page).toContain('aria-label="the dungeon floor, drawn in ascii"'); |
| 45 | // 48 x 24 cells, all rendered. |
| 46 | expect((page.match(/<span class="/g) ?? []).length).toBeGreaterThanOrEqual(48 * 24); |
| 47 | }); |
| 48 | |
| 49 | test("a visitor gets one delver, kept in an HttpOnly cookie", async () => { |
| 50 | const response = await fetch(`${base}/`); |
| 51 | const set = response.headers.get("set-cookie") ?? ""; |
| 52 | expect(set).toMatch(/delver=[a-f0-9]{32}/); |
| 53 | expect(set).toContain("HttpOnly"); |
| 54 | expect(set).toContain("SameSite=Lax"); |
| 55 | }); |
| 56 | |
| 57 | test("moving takes a turn and is remembered", async () => { |
| 58 | const player = new Player(); |
| 59 | await player.look(); |
| 60 | const moved = await player.act("west"); |
| 61 | expect(moved.status).toBe(303); |
| 62 | expect(moved.headers.get("location")).toBe("/"); |
| 63 | const page = await player.look(); |
| 64 | expect(page).toMatch(/turns<\/span><b>[1-9]/); |
| 65 | }); |
| 66 | |
| 67 | test("an action the game does not have is refused, not guessed at", async () => { |
| 68 | const player = new Player(); |
| 69 | await player.look(); |
| 70 | const response = await player.act("teleport"); |
| 71 | expect(response.status).toBe(303); |
| 72 | expect(response.headers.get("location")).toBe("/"); |
| 73 | expect(await player.look()).toContain("12 / 12"); |
| 74 | }); |
| 75 | |
| 76 | test("two players share one dungeon and one map", async () => { |
| 77 | const first = new Player(); |
| 78 | await first.look(); |
| 79 | for (const move of ["west", "west", "west", "north"]) await first.act(move); |
| 80 | const second = new Player(); |
| 81 | const page = await second.look(); |
| 82 | // The tiles the first one lit are remembered on the second one's screen. |
| 83 | expect((page.match(/class="m"/g) ?? []).length).toBeGreaterThan(0); |
| 84 | expect(page).toContain("tiles lit, ever"); |
| 85 | }); |
| 86 | |
| 87 | test("the map hides what has never been lit", async () => { |
| 88 | const page = await new Player().look(); |
| 89 | expect((page.match(/class="u"/g) ?? []).length).toBeGreaterThan(400); |
| 90 | }); |
| 91 | |
| 92 | test("the keyboard layer is a file, not an inline script", async () => { |
| 93 | const page = await new Player().look(); |
| 94 | expect(page).toContain('<script src="/static/keys.v1.js" defer></script>'); |
| 95 | expect(page).not.toMatch(/<script>[^<]/); |
| 96 | const script = await fetch(`${base}/static/keys.v1.js`); |
| 97 | expect(script.status).toBe(200); |
| 98 | expect(script.headers.get("cache-control")).toContain("immutable"); |
| 99 | }); |
| 100 | |
| 101 | test("stands on the security floor", async () => { |
| 102 | const response = await fetch(`${base}/`); |
| 103 | expect(response.headers.get("content-security-policy")).toContain("default-src 'self'"); |
| 104 | expect(response.headers.get("x-content-type-options")).toBe("nosniff"); |
| 105 | }); |
| 106 | |
| 107 | test("refuses a cross-site move", async () => { |
| 108 | const response = await fetch(`${base}/act`, { |
| 109 | method: "POST", |
| 110 | body: new URLSearchParams({ action: "west" }), |
| 111 | redirect: "manual", |
| 112 | headers: { |
| 113 | "content-type": "application/x-www-form-urlencoded", |
| 114 | origin: "https://evil.example", |
| 115 | "sec-fetch-site": "cross-site", |
| 116 | }, |
| 117 | }); |
| 118 | expect(response.status).toBe(403); |
| 119 | }); |
| 120 | |
| 121 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 122 | const response = await fetch(`${base}/act`, { |
| 123 | method: "POST", |
| 124 | headers: { "content-type": "application/x-www-form-urlencoded" }, |
| 125 | body: "action=" + "x".repeat(1024 * 1024), |
| 126 | }); |
| 127 | expect(response.status).toBe(413); |
| 128 | }); |
| 129 | |
| 130 | test("HEAD sees the app up", async () => { |
| 131 | const response = await server.fetch(new Request(`${base}/`, { method: "HEAD" })); |
| 132 | expect(response.status).toBe(200); |
| 133 | expect(await response.text()).toBe(""); |
| 134 | }); |
| 135 | |
| 136 | test("the end pages belong to a run that ended", async () => { |
| 137 | const player = new Player(); |
| 138 | await player.look(); |
| 139 | const response = await fetch(`${base}/died`, { |
| 140 | headers: { cookie: player.cookie }, |
| 141 | redirect: "manual", |
| 142 | }); |
| 143 | expect(response.status).toBe(303); |
| 144 | }); |