benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 2b2f00dc243e6583e6d8719a8b99fe2335f1e03a
341 lines · 11.1 KB · ~3.3k tok Raw History
1
import { afterAll, expect, test } from "bun:test";
2
import { mkdtempSync } from "node:fs";
3
import { tmpdir } from "node:os";
4
import { join } from "node:path";
5
import { env } from "./env.ts";
6
import { serve } from "./server.ts";
7
import { sign } from "./who.ts";
8
9
const KEY = "a-key-only-the-forge-holds";
10
const server = serve({
11
  port: 0,
12
  dataDir: mkdtempSync(join(tmpdir(), "app-")),
13
  identityKey: KEY,
14
});
15
const base = `http://${server.hostname}:${server.port}`;
16
afterAll(() => server.stop(true));
17
18
/** The headers the forge sends for a signed-in person. */
19
const as = (
20
  id: string,
21
  name: string,
22
  role: "user" | "admin" = "user",
23
): Record<string, string> => {
24
  const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
25
  return {
26
    "x-forge-identity": identity,
27
    "x-forge-signature": sign(identity, KEY),
28
  };
29
};
30
const alice = as("u1", "alice");
31
const bob = as("u2", "bob");
32
const root = as("u0", "root", "admin");
33
const jsonBody = (body: unknown): RequestInit => ({
34
  headers: { "content-type": "application/json" },
35
  body: JSON.stringify(body),
36
});
37
const api = (
38
  path: string,
39
  init: RequestInit = {},
40
  who: Record<string, string> = {},
41
) =>
42
  fetch(base + path, {
43
    ...init,
44
    headers: { ...(init.headers as Record<string, string>), ...who },
45
  });
46
const rpc = (message: unknown, who: Record<string, string> = {}) =>
47
  api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
48
    (r) => r.json() as Promise<any>,
49
  );
50
51
test("the page knows who you are and offers the right door", async () => {
52
  const guest = await (await fetch(base + "/")).text();
53
  expect(guest).toContain("<h1>");
54
  expect(guest).toContain("/__forge/signin?to=/");
55
  expect(guest).not.toContain('action="/notes"');
56
  const signed = await (await api("/", {}, alice)).text();
57
  expect(signed).toContain("<b>alice</b>");
58
  expect(signed).toContain("/__forge/signout");
59
  expect(signed).toContain('action="/notes"');
60
});
61
62
test("a form post adds as the person; a guest is sent to sign in", async () => {
63
  const form = new FormData();
64
  form.set("body", "hello from a form");
65
  const refused = await api("/notes", {
66
    method: "POST",
67
    body: form,
68
    redirect: "manual",
69
  });
70
  expect(refused.status).toBe(303);
71
  expect(refused.headers.get("set-cookie")).toContain("notice=");
72
  expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
73
  expect(await (await fetch(base + "/")).text()).not.toContain(
74
    "hello from a form",
75
  );
76
  const posted = await api(
77
    "/notes",
78
    { method: "POST", body: form, redirect: "manual" },
79
    alice,
80
  );
81
  expect(posted.status).toBe(303);
82
  expect(posted.headers.get("set-cookie")).toBeNull();
83
  const page = await (await api("/", {}, alice)).text();
84
  expect(page).toContain("hello from a form");
85
  expect(page).toContain("alice ·");
86
});
87
88
test("the JSON API is the same actions with the same identity", async () => {
89
  const created = await api(
90
    "/api/notes",
91
    { method: "POST", ...jsonBody({ body: "over the api" }) },
92
    alice,
93
  );
94
  expect(created.status).toBe(201);
95
  const note = (await created.json()) as { id: number; author_name: string };
96
  expect(note.author_name).toBe("alice");
97
  const listed = (await (await fetch(base + "/api/notes")).json()) as {
98
    id: number;
99
  }[];
100
  expect(listed.map((n) => n.id)).toContain(note.id);
101
  expect(
102
    (await api("/api/notes", { method: "POST", ...jsonBody({ body: "anon" }) }))
103
      .status,
104
  ).toBe(401);
105
  expect(
106
    (await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status,
107
  ).toBe(403);
108
  expect(
109
    (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status,
110
  ).toBe(200);
111
  expect(
112
    (await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status,
113
  ).toBe(404);
114
  expect(
115
    (
116
      await api(
117
        "/api/notes",
118
        { method: "POST", ...jsonBody({ body: "" }) },
119
        alice,
120
      )
121
    ).status,
122
  ).toBe(422);
123
});
124
125
test("the MCP server lists the tools and calls them as the caller", async () => {
126
  const hello = await rpc({
127
    jsonrpc: "2.0",
128
    id: 1,
129
    method: "initialize",
130
    params: {},
131
  });
132
  expect(hello.result.capabilities).toEqual({ tools: {} });
133
  const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
134
  expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
135
    "list_notes",
136
    "add_note",
137
    "delete_note",
138
  ]);
139
  const call = (name: string, args: unknown, who = {}) =>
140
    rpc(
141
      {
142
        jsonrpc: "2.0",
143
        id: 3,
144
        method: "tools/call",
145
        params: { name, arguments: args },
146
      },
147
      who,
148
    );
149
  const refused = await call("add_note", { body: "as nobody" });
150
  expect(refused.result.isError).toBe(true);
151
  const added = await call("add_note", { body: "over mcp" }, root);
152
  expect(added.result.isError).toBeUndefined();
153
  const note = JSON.parse(added.result.content[0].text);
154
  expect(note.author_name).toBe("root");
155
  const notes = JSON.parse(
156
    (await call("list_notes", {})).result.content[0].text,
157
  );
158
  expect(notes.map((n: { id: number }) => n.id)).toContain(note.id);
159
  expect(
160
    (await call("delete_note", { id: note.id }, root)).result.isError,
161
  ).toBeUndefined();
162
  expect(
163
    (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
164
  ).toBe(-32601);
165
  const notified = await api("/mcp", {
166
    method: "POST",
167
    ...jsonBody({ jsonrpc: "2.0", method: "notifications/initialized" }),
168
  });
169
  expect(notified.status).toBe(202);
170
});
171
172
test("a change is on the event stream, and a late client hears what it missed", async () => {
173
  const stream = await fetch(base + "/events");
174
  expect(stream.headers.get("content-type")).toBe("text/event-stream");
175
  const reader = stream.body!.getReader();
176
  const next = async (): Promise<{ id: string; type: string }> => {
177
    let buffer = "";
178
    for (;;) {
179
      const { value } = await reader.read();
180
      buffer += new TextDecoder().decode(value);
181
      const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer);
182
      if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type };
183
    }
184
  };
185
  await api(
186
    "/api/notes",
187
    { method: "POST", ...jsonBody({ body: "live" }) },
188
    alice,
189
  );
190
  const heard = await next();
191
  expect(heard.type).toBe("note.added");
192
  await reader.cancel();
193
  const late = await fetch(base + "/events", {
194
    headers: { "last-event-id": String(Number(heard.id) - 1) },
195
  });
196
  const replay = new TextDecoder().decode(
197
    (await late.body!.getReader().read()).value,
198
  );
199
  expect(replay).toContain(`id: ${heard.id}\n`);
200
  expect(replay).toContain("note.added");
201
});
202
203
test("writes are rationed per person", async () => {
204
  const flood = as("u9", "flood");
205
  const answers = await Promise.all(
206
    Array.from({ length: 40 }, () =>
207
      api(
208
        "/api/notes",
209
        { method: "POST", ...jsonBody({ body: "again" }) },
210
        flood,
211
      ),
212
    ),
213
  );
214
  const codes = answers.map((r) => r.status);
215
  expect(codes).toContain(201);
216
  expect(codes).toContain(429);
217
});
218
219
test("stands on the security floor", async () => {
220
  const response = await fetch(base + "/");
221
  expect(response.headers.get("content-security-policy")).toContain(
222
    "default-src 'self'",
223
  );
224
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
225
  expect(
226
    (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
227
  ).toContain("default-src");
228
});
229
230
test("a body is capped by the bytes read, whatever it claims", async () => {
231
  const response = await api(
232
    "/notes",
233
    {
234
      method: "POST",
235
      headers: { "content-type": "application/x-www-form-urlencoded" },
236
      body: "body=" + "x".repeat(1024 * 1024),
237
    },
238
    alice,
239
  );
240
  expect(response.status).toBe(413);
241
});
242
243
test("HEAD sees the app up", async () => {
244
  const response = await fetch(base + "/", { method: "HEAD" });
245
  expect(response.status).toBe(200);
246
  expect(await response.text()).toBe("");
247
});
248
249
test("refuses a cross-site post", async () => {
250
  const form = new FormData();
251
  form.set("body", "from another site");
252
  const posted = await api(
253
    "/notes",
254
    {
255
      method: "POST",
256
      body: form,
257
      redirect: "manual",
258
      headers: {
259
        origin: "https://evil.example",
260
        "sec-fetch-site": "cross-site",
261
      },
262
    },
263
    alice,
264
  );
265
  expect(posted.status).toBe(403);
266
});
267
268
test("static files are cached for a year when linked by content", async () => {
269
  const page = await (await fetch(base + "/")).text();
270
  const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
271
  expect(href).toBeDefined();
272
  const css = await fetch(base + href!);
273
  expect(css.headers.get("cache-control")).toContain("immutable");
274
  expect(
275
    (await fetch(base + "/static/app.css")).headers.get("cache-control"),
276
  ).toBe("no-cache");
277
  expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
278
});
279
280
test("/healthz says up, apart from the page", async () => {
281
  const response = await fetch(base + "/healthz");
282
  expect(response.status).toBe(200);
283
  expect(response.headers.get("cache-control")).toBe("no-store");
284
  const health = (await response.json()) as Record<string, unknown>;
285
  expect(health.ok).toBe(true);
286
  expect(health.version).toMatch(/^\d+\.\d+\.\d+/);
287
  expect(Number.isInteger(health.uptime)).toBe(true);
288
  expect((await fetch(base + "/healthz", { method: "HEAD" })).status).toBe(200);
289
});
290
291
test("a link to the page unfurls: description, Open Graph, a card", async () => {
292
  const page = await (await fetch(base + "/")).text();
293
  for (const meta of [
294
    'name="description"',
295
    'property="og:title"',
296
    'property="og:description"',
297
    'property="og:url"',
298
    'property="og:type" content="website"',
299
    'name="twitter:card" content="summary"',
300
    'name="theme-color"',
301
  ])
302
    expect([meta, page.includes(meta)]).toEqual([meta, true]);
303
});
304
305
test("the look is the tokens: /tokens.css is linked by content and cached for a year", async () => {
306
  const page = await (await fetch(base + "/")).text();
307
  const href = /href="(\/tokens\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
308
  expect(href).toBeDefined();
309
  const css = await fetch(base + href!);
310
  expect(css.headers.get("content-type")).toContain("text/css");
311
  expect(css.headers.get("cache-control")).toContain("immutable");
312
  expect(await css.text()).toContain("--color-bg:");
313
  // The tokens come first, so every rule after them can use them.
314
  expect(page.indexOf("/tokens.css")).toBeLessThan(
315
    page.indexOf("/static/app.css"),
316
  );
317
});
318
319
test("what the web expects: robots.txt, and security.txt from the forge that runs us", async () => {
320
  // Production is read by crawlers, the API is not; anything else is a
321
  // draft and asks not to be indexed at all.
322
  const robots = await (await fetch(base + "/robots.txt")).text();
323
  const noindex = (await fetch(base + "/")).headers.get("x-robots-tag");
324
  if (env === "production") {
325
    expect(robots).toContain("Disallow: /api/\n");
326
    expect(noindex).toBeNull();
327
  } else {
328
    expect(robots).toBe("User-agent: *\nDisallow: /\n");
329
    expect(noindex).toBe("noindex");
330
  }
331
  const security = await fetch(base + "/.well-known/security.txt", {
332
    redirect: "manual",
333
  });
334
  if (process.env.FORGE_URL === undefined) expect(security.status).toBe(404);
335
  else {
336
    expect(security.status).toBe(302);
337
    expect(security.headers.get("location")).toBe(
338
      new URL("/.well-known/security.txt", process.env.FORGE_URL).toString(),
339
    );
340
  }
341
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts