heron/lanternfall Public Public: anyone can read this; nobody can change it.

workbenchthis machine
Files fa8aea72e9cab5ed2a004bcd8bfa540e643d8b9d
144 lines · 5.0 KB · ~1.4k tok Raw History
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
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts