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

Files shared
410 lines · 17.6 KB · ~5.2k 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="/new"');
56
  const signed = await (await api("/", {}, as("u5", "erin"))).text();
57
  expect(signed).toContain("Your vault is empty");
58
  expect(signed).toContain("erin");
59
  expect(signed).toContain("/__forge/signout");
60
  expect(signed).toContain('action="/welcome"');
61
});
62
63
test("a form post writes as the person and lands on the note; a guest is sent to sign in", async () => {
64
  const form = new FormData();
65
  form.set("title", "From a form");
66
  const refused = await api("/new", { method: "POST", body: form, redirect: "manual" });
67
  expect(refused.status).toBe(303);
68
  expect(refused.headers.get("set-cookie")).toContain("notice=");
69
  expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
70
  const posted = await api("/new", { method: "POST", body: form, redirect: "manual" }, alice);
71
  expect(posted.status).toBe(303);
72
  const at = posted.headers.get("location")!;
73
  expect(at).toMatch(/^\/n\/\d+$/);
74
  const page = await (await api(at, {}, alice)).text();
75
  expect(page).toContain('value="From a form"');
76
  expect(page).toContain("<title>From a form · ");
77
  // The vault opens on the note last written.
78
  const home = await api("/", { redirect: "manual" }, alice);
79
  expect(home.headers.get("location")).toBe(at);
80
  // Saving without JavaScript: the form posts the whole note.
81
  const save = new FormData();
82
  save.set("title", "From a form");
83
  save.set("body", "Now with [[Words]] and #tags");
84
  expect((await api(at, { method: "POST", body: save, redirect: "manual" }, alice)).status).toBe(303);
85
  const saved = await (await api(at, {}, alice)).text();
86
  expect(saved).toContain('class="wikilink unresolved"');
87
  expect(saved).toContain('href="/search?q=%23tags"');
88
  // Another person's note is not theirs to open.
89
  const peek = await api(at, { redirect: "manual" }, bob);
90
  expect(peek.status).toBe(303);
91
  expect(peek.headers.get("location")).toBe("/");
92
});
93
94
test("a link to a note not written yet offers to write it", async () => {
95
  const page = await (await api("/open?title=Someday", {}, alice)).text();
96
  expect(page).toContain("doesn't exist yet");
97
  const form = new FormData();
98
  form.set("title", "Someday");
99
  const made = await api("/open", { method: "POST", body: form, redirect: "manual" }, alice);
100
  expect(made.headers.get("location")).toMatch(/^\/n\/\d+$/);
101
  const again = await api("/open?title=someday", { redirect: "manual" }, alice);
102
  expect(again.headers.get("location")).toBe(made.headers.get("location"));
103
});
104
105
test("the JSON API is the same actions with the same identity", async () => {
106
  const created = await api(
107
    "/api/notes",
108
    { method: "POST", ...jsonBody({ title: "Over the API", body: "hello #api" }) },
109
    alice,
110
  );
111
  expect(created.status).toBe(201);
112
  const note = (await created.json()) as { id: number; author_name: string; title: string };
113
  expect(note.author_name).toBe("alice");
114
  const listed = (await (await api("/api/notes", {}, alice)).json()) as { id: number }[];
115
  expect(listed.map((n) => n.id)).toContain(note.id);
116
  const found = (await (await api("/api/notes?q=%23api", {}, alice)).json()) as { id: number }[];
117
  expect(found.map((n) => n.id)).toEqual([note.id]);
118
  expect(((await (await api("/api/notes", {}, bob)).json()) as unknown[]).length).toBe(0);
119
  const renamed = await api(
120
    `/api/notes/${note.id}`,
121
    { method: "PATCH", ...jsonBody({ title: "Renamed", pinned: true }) },
122
    alice,
123
  );
124
  expect(renamed.status).toBe(200);
125
  expect(((await renamed.json()) as { pinned: number }).pinned).toBe(1);
126
  expect((await api("/api/notes", { method: "POST", ...jsonBody({ title: "anon" }) })).status).toBe(401);
127
  expect((await api(`/api/notes/${note.id}`, {}, bob)).status).toBe(403);
128
  expect((await api(`/api/notes/${note.id}`, { method: "DELETE" }, bob)).status).toBe(403);
129
  const raw = await api(`/n/${note.id}/raw`, {}, alice);
130
  expect(raw.headers.get("content-type")).toContain("text/markdown");
131
  expect(await raw.text()).toBe("hello #api");
132
  const dump = (await (await api("/export", {}, alice)).json()) as { notes: { title: string }[] };
133
  expect(dump.notes.map((n) => n.title)).toContain("Renamed");
134
  expect((await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status).toBe(200);
135
  expect((await api(`/api/notes/${note.id}`, { method: "DELETE" }, alice)).status).toBe(404);
136
  expect(
137
    (await api("/api/notes", { method: "POST", ...jsonBody({ title: "a|b" }) }, alice)).status,
138
  ).toBe(422);
139
});
140
141
test("the MCP server lists the tools and calls them as the caller", async () => {
142
  const hello = await rpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} });
143
  expect(hello.result.capabilities).toEqual({ tools: {} });
144
  const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
145
  expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
146
    "list_notes",
147
    "search_notes",
148
    "read_note",
149
    "create_note",
150
    "update_note",
151
    "append_to_note",
152
    "delete_note",
153
  ]);
154
  const call = (name: string, args: unknown, who = {}) =>
155
    rpc({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name, arguments: args } }, who);
156
  expect((await call("create_note", { title: "as nobody" })).result.isError).toBe(true);
157
  expect((await call("list_notes", {})).result.isError).toBe(true);
158
  const added = await call("create_note", { title: "Over MCP", body: "first" }, root);
159
  expect(added.result.isError).toBeUndefined();
160
  const note = JSON.parse(added.result.content[0].text);
161
  expect(note.author_name).toBe("root");
162
  await call("append_to_note", { title: "Over MCP", text: "second" }, root);
163
  const read = JSON.parse((await call("read_note", { title: "over mcp" }, root)).result.content[0].text);
164
  expect(read.body).toBe("first\n\nsecond\n");
165
  const titles = JSON.parse((await call("list_notes", {}, root)).result.content[0].text);
166
  expect(titles.map((n: { id: number }) => n.id)).toContain(note.id);
167
  expect(JSON.parse((await call("search_notes", { q: "second" }, root)).result.content[0].text)[0].id).toBe(note.id);
168
  expect((await call("delete_note", { id: note.id }, root)).result.isError).toBeUndefined();
169
  expect((await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code).toBe(-32601);
170
  const notified = await api("/mcp", {
171
    method: "POST",
172
    ...jsonBody({ jsonrpc: "2.0", method: "notifications/initialized" }),
173
  });
174
  expect(notified.status).toBe(202);
175
});
176
177
test("a change is on its author's event stream only, and a late client hears what it missed", async () => {
178
  const read = (stream: Response) => {
179
    const reader = stream.body!.getReader();
180
    let buffer = "";
181
    return {
182
      reader,
183
      next: async (): Promise<{ id: string; type: string; data: any }> => {
184
        for (;;) {
185
          const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer);
186
          if (frame) {
187
            buffer = buffer.slice(frame.index + frame[0].length);
188
            const { type, data } = JSON.parse(frame[2]!);
189
            return { id: frame[1]!, type, data };
190
          }
191
          const { value, done } = await reader.read();
192
          if (done) throw new Error("the stream ended");
193
          buffer += new TextDecoder().decode(value);
194
        }
195
      },
196
    };
197
  };
198
  const mine = read(await api("/events", {}, alice));
199
  const stranger = read(await fetch(base + "/events"));
200
  expect((await fetch(base + "/events")).headers.get("content-type")).toBe("text/event-stream");
201
  await api("/api/notes", { method: "POST", ...jsonBody({ title: "Live one", body: "private words" }) }, alice);
202
  // A new stream first replays what this person missed; read to the new note.
203
  let heard = await mine.next();
204
  while (heard.data?.title !== "Live one") heard = await mine.next();
205
  expect(heard.type).toBe("note.added");
206
  expect(heard.data.title).toBe("Live one");
207
  // Nobody else's stream hears it — not even that it happened.
208
  const nothing = await Promise.race([
209
    stranger.next().then(
210
      () => "heard",
211
      () => "silence",
212
    ),
213
    Bun.sleep(300).then(() => "silence"),
214
  ]);
215
  expect(nothing).toBe("silence");
216
  await mine.reader.cancel();
217
  await stranger.reader.cancel();
218
  const late = await api("/events", { headers: { "last-event-id": String(Number(heard.id) - 1) } }, alice);
219
  const replay = new TextDecoder().decode((await late.body!.getReader().read()).value);
220
  expect(replay).toContain(`id: ${heard.id}\n`);
221
  expect(replay).toContain("note.added");
222
  expect(JSON.stringify(heard)).not.toContain("private words");
223
});
224
225
test("someone with no forge account signs up, gets their own vault, and logs out", async () => {
226
  const form = (fields: Record<string, string>) => {
227
    const f = new FormData();
228
    for (const [k, v] of Object.entries(fields)) f.set(k, v);
229
    return { method: "POST", body: f, redirect: "manual" as const };
230
  };
231
  const landing = await (await fetch(base + "/")).text();
232
  expect(landing).toContain('href="/signup"');
233
  expect(landing).toContain('href="/login"');
234
  const made = await fetch(base + "/signup", form({ email: "[email protected]", password: "a long password", name: "Pat" }));
235
  expect(made.status).toBe(303);
236
  const set = made.headers.getSetCookie().find((c) => c.startsWith("sid="))!;
237
  expect(set).toContain("HttpOnly");
238
  expect(set).toContain("SameSite=Lax");
239
  const sid = { cookie: set.split(";")[0]! };
240
  // Signed in by the cookie alone: an empty vault, theirs.
241
  const home = await (await api("/", {}, sid)).text();
242
  expect(home).toContain("Your vault is empty");
243
  expect(home).toContain('action="/logout"');
244
  const note = await api("/api/notes", { method: "POST", ...jsonBody({ title: "Pat's secret" }) }, sid);
245
  expect(note.status).toBe(201);
246
  const id = ((await note.json()) as { id: number }).id;
247
  // Not alice's, not a stranger's.
248
  expect((await api(`/api/notes/${id}`, {}, alice)).status).toBe(403);
249
  expect((await fetch(`${base}/api/notes/${id}`)).status).toBe(401);
250
  expect(((await (await api("/api/notes", {}, alice)).json()) as { title: string }[]).map((n) => n.title)).not.toContain("Pat's secret");
251
  // The same email can't sign up twice; the wrong password doesn't log in.
252
  expect((await fetch(base + "/signup", form({ email: "[email protected]", password: "another password" }))).status).toBe(422);
253
  expect((await fetch(base + "/login", form({ email: "[email protected]", password: "wrong password" }))).status).toBe(401);
254
  // A session cookie made up is nobody.
255
  expect((await api("/api/notes", {}, { cookie: `sid=${"A".repeat(43)}` })).status).toBe(401);
256
  // Log out ends it; log in again finds the same vault.
257
  const out = await api("/logout", { method: "POST", redirect: "manual" }, sid);
258
  expect(out.headers.getSetCookie().some((c) => c.startsWith("sid=;") && c.includes("Max-Age=0"))).toBe(true);
259
  expect((await api("/api/notes", {}, sid)).status).toBe(401);
260
  const back = await fetch(base + "/login", form({ email: "[email protected]", password: "a long password" }));
261
  expect(back.status).toBe(303);
262
  const again = { cookie: back.headers.getSetCookie().find((c) => c.startsWith("sid="))!.split(";")[0]! };
263
  const notes = (await (await api("/api/notes", {}, again)).json()) as { id: number }[];
264
  expect(notes.map((n) => n.id)).toEqual([id]);
265
  expect(await (await api("/account", {}, again)).text()).toContain("[email protected]");
266
  // A cross-site page can't log someone in or out.
267
  expect(
268
    (await fetch(base + "/login", { ...form({ email: "[email protected]", password: "a long password" }), headers: { "sec-fetch-site": "cross-site" } })).status,
269
  ).toBe(403);
270
});
271
272
test("writes are rationed per person", async () => {
273
  const flood = as("u9", "flood");
274
  const answers = await Promise.all(
275
    Array.from({ length: 40 }, () =>
276
      api(
277
        "/api/notes",
278
        { method: "POST", ...jsonBody({ body: "again" }) },
279
        flood,
280
      ),
281
    ),
282
  );
283
  const codes = answers.map((r) => r.status);
284
  expect(codes).toContain(201);
285
  expect(codes).toContain(429);
286
});
287
288
test("stands on the security floor", async () => {
289
  const response = await fetch(base + "/");
290
  expect(response.headers.get("content-security-policy")).toContain(
291
    "default-src 'self'",
292
  );
293
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
294
  expect(
295
    (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
296
  ).toContain("default-src");
297
});
298
299
test("a body is capped by the bytes read, whatever it claims", async () => {
300
  const response = await api(
301
    "/new",
302
    {
303
      method: "POST",
304
      headers: { "content-type": "application/x-www-form-urlencoded" },
305
      body: "title=" + "x".repeat(1024 * 1024),
306
    },
307
    alice,
308
  );
309
  expect(response.status).toBe(413);
310
});
311
312
test("HEAD sees the app up", async () => {
313
  const response = await fetch(base + "/", { method: "HEAD" });
314
  expect(response.status).toBe(200);
315
  expect(await response.text()).toBe("");
316
});
317
318
test("refuses a cross-site post", async () => {
319
  const form = new FormData();
320
  form.set("title", "from another site");
321
  const posted = await api(
322
    "/new",
323
    {
324
      method: "POST",
325
      body: form,
326
      redirect: "manual",
327
      headers: {
328
        origin: "https://evil.example",
329
        "sec-fetch-site": "cross-site",
330
      },
331
    },
332
    alice,
333
  );
334
  expect(posted.status).toBe(403);
335
});
336
337
test("static files are cached for a year when linked by content", async () => {
338
  const page = await (await fetch(base + "/")).text();
339
  const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
340
  expect(href).toBeDefined();
341
  const css = await fetch(base + href!);
342
  expect(css.headers.get("cache-control")).toContain("immutable");
343
  expect(
344
    (await fetch(base + "/static/app.css")).headers.get("cache-control"),
345
  ).toBe("no-cache");
346
  expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
347
});
348
349
test("/healthz says up, apart from the page", async () => {
350
  const response = await fetch(base + "/healthz");
351
  expect(response.status).toBe(200);
352
  expect(response.headers.get("cache-control")).toBe("no-store");
353
  const health = (await response.json()) as Record<string, unknown>;
354
  expect(health.ok).toBe(true);
355
  expect(health.version).toMatch(/^\d+\.\d+\.\d+/);
356
  expect(Number.isInteger(health.uptime)).toBe(true);
357
  expect((await fetch(base + "/healthz", { method: "HEAD" })).status).toBe(200);
358
});
359
360
test("a link to the page unfurls: description, Open Graph, a card", async () => {
361
  const page = await (await fetch(base + "/")).text();
362
  for (const meta of [
363
    'name="description"',
364
    'property="og:title"',
365
    'property="og:description"',
366
    'property="og:url"',
367
    'property="og:type" content="website"',
368
    'name="twitter:card" content="summary"',
369
    'name="theme-color"',
370
  ])
371
    expect([meta, page.includes(meta)]).toEqual([meta, true]);
372
});
373
374
test("the look is the tokens: /tokens.css is linked by content and cached for a year", async () => {
375
  const page = await (await fetch(base + "/")).text();
376
  const href = /href="(\/tokens\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
377
  expect(href).toBeDefined();
378
  const css = await fetch(base + href!);
379
  expect(css.headers.get("content-type")).toContain("text/css");
380
  expect(css.headers.get("cache-control")).toContain("immutable");
381
  expect(await css.text()).toContain("--color-bg:");
382
  // The tokens come first, so every rule after them can use them.
383
  expect(page.indexOf("/tokens.css")).toBeLessThan(
384
    page.indexOf("/static/app.css"),
385
  );
386
});
387
388
test("what the web expects: robots.txt, and security.txt from the forge that runs us", async () => {
389
  // Production is read by crawlers, the API is not; anything else is a
390
  // draft and asks not to be indexed at all.
391
  const robots = await (await fetch(base + "/robots.txt")).text();
392
  const noindex = (await fetch(base + "/")).headers.get("x-robots-tag");
393
  if (env === "production") {
394
    expect(robots).toContain("Disallow: /api/\n");
395
    expect(noindex).toBeNull();
396
  } else {
397
    expect(robots).toBe("User-agent: *\nDisallow: /\n");
398
    expect(noindex).toBe("noindex");
399
  }
400
  const security = await fetch(base + "/.well-known/security.txt", {
401
    redirect: "manual",
402
  });
403
  if (process.env.FORGE_URL === undefined) expect(security.status).toBe(404);
404
  else {
405
    expect(security.status).toBe(302);
406
    expect(security.headers.get("location")).toBe(
407
      new URL("/.well-known/security.txt", process.env.FORGE_URL).toString(),
408
    );
409
  }
410
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts