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

Files 1060c95de59213030a45f268d791921201715cde
363 lines · 14.6 KB · ~4.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="/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("writes are rationed per person", async () => {
226
  const flood = as("u9", "flood");
227
  const answers = await Promise.all(
228
    Array.from({ length: 40 }, () =>
229
      api(
230
        "/api/notes",
231
        { method: "POST", ...jsonBody({ body: "again" }) },
232
        flood,
233
      ),
234
    ),
235
  );
236
  const codes = answers.map((r) => r.status);
237
  expect(codes).toContain(201);
238
  expect(codes).toContain(429);
239
});
240
241
test("stands on the security floor", async () => {
242
  const response = await fetch(base + "/");
243
  expect(response.headers.get("content-security-policy")).toContain(
244
    "default-src 'self'",
245
  );
246
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
247
  expect(
248
    (await fetch(base + "/api/notes")).headers.get("content-security-policy"),
249
  ).toContain("default-src");
250
});
251
252
test("a body is capped by the bytes read, whatever it claims", async () => {
253
  const response = await api(
254
    "/new",
255
    {
256
      method: "POST",
257
      headers: { "content-type": "application/x-www-form-urlencoded" },
258
      body: "title=" + "x".repeat(1024 * 1024),
259
    },
260
    alice,
261
  );
262
  expect(response.status).toBe(413);
263
});
264
265
test("HEAD sees the app up", async () => {
266
  const response = await fetch(base + "/", { method: "HEAD" });
267
  expect(response.status).toBe(200);
268
  expect(await response.text()).toBe("");
269
});
270
271
test("refuses a cross-site post", async () => {
272
  const form = new FormData();
273
  form.set("title", "from another site");
274
  const posted = await api(
275
    "/new",
276
    {
277
      method: "POST",
278
      body: form,
279
      redirect: "manual",
280
      headers: {
281
        origin: "https://evil.example",
282
        "sec-fetch-site": "cross-site",
283
      },
284
    },
285
    alice,
286
  );
287
  expect(posted.status).toBe(403);
288
});
289
290
test("static files are cached for a year when linked by content", async () => {
291
  const page = await (await fetch(base + "/")).text();
292
  const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
293
  expect(href).toBeDefined();
294
  const css = await fetch(base + href!);
295
  expect(css.headers.get("cache-control")).toContain("immutable");
296
  expect(
297
    (await fetch(base + "/static/app.css")).headers.get("cache-control"),
298
  ).toBe("no-cache");
299
  expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
300
});
301
302
test("/healthz says up, apart from the page", async () => {
303
  const response = await fetch(base + "/healthz");
304
  expect(response.status).toBe(200);
305
  expect(response.headers.get("cache-control")).toBe("no-store");
306
  const health = (await response.json()) as Record<string, unknown>;
307
  expect(health.ok).toBe(true);
308
  expect(health.version).toMatch(/^\d+\.\d+\.\d+/);
309
  expect(Number.isInteger(health.uptime)).toBe(true);
310
  expect((await fetch(base + "/healthz", { method: "HEAD" })).status).toBe(200);
311
});
312
313
test("a link to the page unfurls: description, Open Graph, a card", async () => {
314
  const page = await (await fetch(base + "/")).text();
315
  for (const meta of [
316
    'name="description"',
317
    'property="og:title"',
318
    'property="og:description"',
319
    'property="og:url"',
320
    'property="og:type" content="website"',
321
    'name="twitter:card" content="summary"',
322
    'name="theme-color"',
323
  ])
324
    expect([meta, page.includes(meta)]).toEqual([meta, true]);
325
});
326
327
test("the look is the tokens: /tokens.css is linked by content and cached for a year", async () => {
328
  const page = await (await fetch(base + "/")).text();
329
  const href = /href="(\/tokens\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
330
  expect(href).toBeDefined();
331
  const css = await fetch(base + href!);
332
  expect(css.headers.get("content-type")).toContain("text/css");
333
  expect(css.headers.get("cache-control")).toContain("immutable");
334
  expect(await css.text()).toContain("--color-bg:");
335
  // The tokens come first, so every rule after them can use them.
336
  expect(page.indexOf("/tokens.css")).toBeLessThan(
337
    page.indexOf("/static/app.css"),
338
  );
339
});
340
341
test("what the web expects: robots.txt, and security.txt from the forge that runs us", async () => {
342
  // Production is read by crawlers, the API is not; anything else is a
343
  // draft and asks not to be indexed at all.
344
  const robots = await (await fetch(base + "/robots.txt")).text();
345
  const noindex = (await fetch(base + "/")).headers.get("x-robots-tag");
346
  if (env === "production") {
347
    expect(robots).toContain("Disallow: /api/\n");
348
    expect(noindex).toBeNull();
349
  } else {
350
    expect(robots).toBe("User-agent: *\nDisallow: /\n");
351
    expect(noindex).toBe("noindex");
352
  }
353
  const security = await fetch(base + "/.well-known/security.txt", {
354
    redirect: "manual",
355
  });
356
  if (process.env.FORGE_URL === undefined) expect(security.status).toBe(404);
357
  else {
358
    expect(security.status).toBe(302);
359
    expect(security.headers.get("location")).toBe(
360
      new URL("/.well-known/security.txt", process.env.FORGE_URL).toString(),
361
    );
362
  }
363
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts