wren/glyph Public Public: anyone can read this; nobody can change it.

forked from apps/glyph

workbenchthis machine
Files 308f4625e0ac59206bd37f1f78c4ecba8ca65968
433 lines · 16.3 KB · ~4.8k 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 { setDrawer, settled } from "./draw.ts";
6
import { env } from "./env.ts";
7
import { GLOW, INK, PALETTES } from "./glyph.ts";
8
import { serve } from "./server.ts";
9
import { sign } from "./who.ts";
10
11
const KEY = "a-key-only-the-forge-holds";
12
const server = serve({
13
  port: 0,
14
  dataDir: mkdtempSync(join(tmpdir(), "app-")),
15
  identityKey: KEY,
16
});
17
const base = `http://${server.hostname}:${server.port}`;
18
afterAll(() => server.stop(true));
19
20
/** The headers the forge sends for a signed-in person. */
21
const as = (
22
  id: string,
23
  name: string,
24
  role: "user" | "admin" = "user",
25
): Record<string, string> => {
26
  const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
27
  return {
28
    "x-forge-identity": identity,
29
    "x-forge-signature": sign(identity, KEY),
30
  };
31
};
32
const alice = as("u1", "alice");
33
const bob = as("u2", "bob");
34
const root = as("u0", "root", "admin");
35
const jsonBody = (body: unknown): RequestInit => ({
36
  headers: { "content-type": "application/json" },
37
  body: JSON.stringify(body),
38
});
39
const api = (
40
  path: string,
41
  init: RequestInit = {},
42
  who: Record<string, string> = {},
43
) =>
44
  fetch(base + path, {
45
    ...init,
46
    headers: { ...(init.headers as Record<string, string>), ...who },
47
  });
48
const rpc = (message: unknown, who: Record<string, string> = {}) =>
49
  api("/mcp", { method: "POST", ...jsonBody(message) }, who).then(
50
    (r) => r.json() as Promise<any>,
51
  );
52
53
/** The drawer every test here uses: Claude is never called. */
54
const SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><title>Test Mark</title><circle cx="32" cy="32" r="20" fill="${INK}" stroke="${GLOW}"/></svg>`;
55
setDrawer(async (prompt) => {
56
  if (prompt.includes("fail")) throw new Error("Claude had a bad day.");
57
  return [SVG, SVG];
58
});
59
60
test("the page knows who you are and offers the right door", async () => {
61
  const guest = await (await fetch(base + "/")).text();
62
  expect(guest).toContain("<h1>");
63
  expect(guest).toContain("/__forge/signin?to=/");
64
  expect(guest).toContain('href="/__forge/report"');
65
  expect(guest).not.toContain('action="/drawings"');
66
  const signed = await (await api("/", {}, alice)).text();
67
  expect(signed).toContain("<b>alice</b>");
68
  expect(signed).toContain("/__forge/signout");
69
  expect(signed).toContain('action="/drawings"');
70
});
71
72
test("a form post draws as the person; a guest is sent to sign in; the board shows it", async () => {
73
  const form = new FormData();
74
  form.set("prompt", "hello from a form");
75
  form.set("kind", "icon");
76
  const refused = await api("/drawings", {
77
    method: "POST",
78
    body: form,
79
    redirect: "manual",
80
  });
81
  expect(refused.status).toBe(303);
82
  expect(refused.headers.get("set-cookie")).toContain("notice=");
83
  expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax");
84
  const posted = await api(
85
    "/drawings",
86
    { method: "POST", body: form, redirect: "manual" },
87
    alice,
88
  );
89
  expect(posted.status).toBe(303);
90
  expect(posted.headers.get("set-cookie")).toBeNull();
91
  await settled();
92
  const page = await (await api("/", {}, alice)).text();
93
  expect(page).toContain("hello from a form");
94
  expect(page).toContain("app icon");
95
  expect(page).toContain('alt="Test Mark"');
96
  expect(page).toContain("data:image/svg+xml,");
97
  // Nobody else sees alice's drawing.
98
  expect(await (await api("/", {}, bob)).text()).not.toContain("hello from a form");
99
  // A palette in the address is the palette the tiles are drawn in.
100
  const neon = await (await api("/?palette=neon", {}, alice)).text();
101
  expect(neon).toContain('data-palette="neon" aria-current="true"');
102
  expect(neon).toContain(encodeURIComponent(PALETTES.neon!.ink));
103
});
104
105
test("the JSON API is the same actions with the same identity", async () => {
106
  const created = await api(
107
    "/api/drawings",
108
    { method: "POST", ...jsonBody({ prompt: "over the api", kind: "badge" }) },
109
    alice,
110
  );
111
  expect(created.status).toBe(202);
112
  const drawing = (await created.json()) as { id: number; author_name: string; status: string; svgs: string[] };
113
  expect(drawing.author_name).toBe("alice");
114
  expect(drawing.status).toBe("drawing");
115
  expect(drawing.svgs).toEqual([]);
116
  await settled();
117
  const done = (await (await api(`/api/drawings/${drawing.id}`, {}, alice)).json()) as { status: string; svgs: string[] };
118
  expect(done.status).toBe("done");
119
  expect(done.svgs).toEqual([SVG, SVG]);
120
  const listed = (await (await api("/api/drawings", {}, alice)).json()) as { id: number }[];
121
  expect(listed.map((n) => n.id)).toContain(drawing.id);
122
  expect((await (await api("/api/drawings", {}, bob)).json()) as unknown[]).toEqual([]);
123
  expect((await api(`/api/drawings/${drawing.id}`, {}, bob)).status).toBe(403);
124
  expect(
125
    (await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "anon" }) })).status,
126
  ).toBe(401);
127
  expect((await api(`/api/drawings/${drawing.id}`, { method: "DELETE" }, bob)).status).toBe(403);
128
  expect((await api(`/api/drawings/${drawing.id}`, { method: "DELETE" }, alice)).status).toBe(200);
129
  expect((await api(`/api/drawings/${drawing.id}`, { method: "DELETE" }, alice)).status).toBe(404);
130
  for (const body of [{ prompt: "" }, { prompt: "ok", kind: "poster" }])
131
    expect(
132
      (await api("/api/drawings", { method: "POST", ...jsonBody(body) }, alice)).status,
133
    ).toBe(422);
134
});
135
136
test("a failed drawing says why, and a busy person is asked to wait", async () => {
137
  const carol = as("u3", "carol");
138
  let release = (): void => {};
139
  setDrawer(() => new Promise((resolve) => (release = () => resolve([SVG]))));
140
  try {
141
    const first = await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "slow" }) }, carol);
142
    expect(first.status).toBe(202);
143
    const second = await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "again" }) }, carol);
144
    expect(second.status).toBe(429);
145
    expect(((await second.json()) as { error: string }).error).toBe("busy");
146
    release();
147
    await settled();
148
  } finally {
149
    setDrawer(async (prompt) => {
150
      if (prompt.includes("fail")) throw new Error("Claude had a bad day.");
151
      return [SVG, SVG];
152
    });
153
  }
154
  const failed = (await (
155
    await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "please fail" }) }, carol)
156
  ).json()) as { id: number };
157
  await settled();
158
  const row = (await (await api(`/api/drawings/${failed.id}`, {}, carol)).json()) as { status: string; error: string };
159
  expect(row).toMatchObject({ status: "failed", error: "Claude had a bad day." });
160
  expect(await (await api("/", {}, carol)).text()).toContain("Claude had a bad day.");
161
});
162
163
test("a drawing saves as a file, in the palette asked for, and runs nothing", async () => {
164
  const made = (await (
165
    await api("/api/drawings", { method: "POST", ...jsonBody({ prompt: "to save" }) }, alice)
166
  ).json()) as { id: number };
167
  await settled();
168
  const saved = await api(`/drawings/${made.id}/1.svg?palette=blueprint`, {}, alice);
169
  expect(saved.status).toBe(200);
170
  expect(saved.headers.get("content-type")).toContain("image/svg+xml");
171
  expect(saved.headers.get("content-disposition")).toBe('attachment; filename="test-mark.svg"');
172
  expect(saved.headers.get("content-security-policy")).toContain("sandbox");
173
  const body = await saved.text();
174
  expect(body).toContain(PALETTES.blueprint!.ink);
175
  expect(body).not.toContain(INK);
176
  expect((await api(`/drawings/${made.id}/0.svg`, {}, alice)).status).toBe(200);
177
  expect((await api(`/drawings/${made.id}/7.svg`, {}, alice)).status).toBe(404);
178
  expect((await api(`/drawings/${made.id}/0.svg`, {}, bob)).status).toBe(403);
179
  expect((await api(`/drawings/${made.id}/0.svg`)).status).toBe(401);
180
});
181
182
test("the MCP server lists the tools and calls them as the caller", async () => {
183
  const hello = await rpc({
184
    jsonrpc: "2.0",
185
    id: 1,
186
    method: "initialize",
187
    params: {},
188
  });
189
  expect(hello.result.capabilities).toEqual({ tools: {} });
190
  const listed = await rpc({ jsonrpc: "2.0", id: 2, method: "tools/list" });
191
  expect(listed.result.tools.map((t: { name: string }) => t.name)).toEqual([
192
    "draw",
193
    "get_drawing",
194
    "list_drawings",
195
    "delete_drawing",
196
  ]);
197
  const call = (name: string, args: unknown, who = {}) =>
198
    rpc(
199
      {
200
        jsonrpc: "2.0",
201
        id: 3,
202
        method: "tools/call",
203
        params: { name, arguments: args },
204
      },
205
      who,
206
    );
207
  const refused = await call("draw", { prompt: "as nobody" });
208
  expect(refused.result.isError).toBe(true);
209
  const added = await call("draw", { prompt: "over mcp" }, root);
210
  expect(added.result.isError).toBeUndefined();
211
  const drawing = JSON.parse(added.result.content[0].text);
212
  expect(drawing.author_name).toBe("root");
213
  await settled();
214
  const got = JSON.parse((await call("get_drawing", { id: drawing.id }, root)).result.content[0].text);
215
  expect(got.svgs).toEqual([SVG, SVG]);
216
  const mine = JSON.parse((await call("list_drawings", {}, root)).result.content[0].text);
217
  expect(mine.map((n: { id: number }) => n.id)).toContain(drawing.id);
218
  expect((await call("delete_drawing", { id: drawing.id }, root)).result.isError).toBeUndefined();
219
  expect(
220
    (await rpc({ jsonrpc: "2.0", id: 4, method: "nothing" })).error.code,
221
  ).toBe(-32601);
222
  const notified = await api("/mcp", {
223
    method: "POST",
224
    ...jsonBody({ jsonrpc: "2.0", method: "notifications/initialized" }),
225
  });
226
  expect(notified.status).toBe(202);
227
});
228
229
test("a change is on the event stream, and a late client hears what it missed", async () => {
230
  const stream = await fetch(base + "/events");
231
  expect(stream.headers.get("content-type")).toBe("text/event-stream");
232
  const reader = stream.body!.getReader();
233
  const next = async (): Promise<{ id: string; type: string }> => {
234
    let buffer = "";
235
    for (;;) {
236
      const { value } = await reader.read();
237
      buffer += new TextDecoder().decode(value);
238
      const frame = /id: (\d+)\ndata: (.*)\n\n/.exec(buffer);
239
      if (frame) return { id: frame[1]!, type: JSON.parse(frame[2]!).type };
240
    }
241
  };
242
  await api(
243
    "/api/drawings",
244
    { method: "POST", ...jsonBody({ prompt: "live" }) },
245
    as("u4", "dana"),
246
  );
247
  const heard = await next();
248
  expect(heard.type).toBe("drawing.added");
249
  await reader.cancel();
250
  await settled();
251
  const late = await fetch(base + "/events", {
252
    headers: { "last-event-id": String(Number(heard.id) - 1) },
253
  });
254
  const replay = new TextDecoder().decode(
255
    (await late.body!.getReader().read()).value,
256
  );
257
  expect(replay).toContain(`id: ${heard.id}\n`);
258
  expect(replay).toContain("drawing.added");
259
  expect(replay).toContain("drawing.changed");
260
});
261
262
test("writes are rationed per person", async () => {
263
  const flood = as("u9", "flood");
264
  const answers = await Promise.all(
265
    Array.from({ length: 40 }, () =>
266
      api("/api/drawings/999999", { method: "DELETE" }, flood),
267
    ),
268
  );
269
  const codes = answers.map((r) => r.status);
270
  expect(codes).toContain(404);
271
  expect(codes).toContain(429);
272
});
273
274
test("stands on the security floor", async () => {
275
  const response = await fetch(base + "/");
276
  expect(response.headers.get("content-security-policy")).toContain(
277
    "default-src 'self'",
278
  );
279
  expect(response.headers.get("x-content-type-options")).toBe("nosniff");
280
  expect(
281
    (await fetch(base + "/api/drawings")).headers.get("content-security-policy"),
282
  ).toContain("default-src");
283
});
284
285
test("a body is capped by the bytes read, whatever it claims", async () => {
286
  const response = await api(
287
    "/drawings",
288
    {
289
      method: "POST",
290
      headers: { "content-type": "application/x-www-form-urlencoded" },
291
      body: "prompt=" + "x".repeat(1024 * 1024),
292
    },
293
    alice,
294
  );
295
  expect(response.status).toBe(413);
296
});
297
298
test("HEAD sees the app up", async () => {
299
  const response = await fetch(base + "/", { method: "HEAD" });
300
  expect(response.status).toBe(200);
301
  expect(await response.text()).toBe("");
302
});
303
304
test("refuses a cross-site post", async () => {
305
  const form = new FormData();
306
  form.set("prompt", "from another site");
307
  const posted = await api(
308
    "/drawings",
309
    {
310
      method: "POST",
311
      body: form,
312
      redirect: "manual",
313
      headers: {
314
        origin: "https://evil.example",
315
        "sec-fetch-site": "cross-site",
316
      },
317
    },
318
    alice,
319
  );
320
  expect(posted.status).toBe(403);
321
});
322
323
test("static files are cached for a year when linked by content", async () => {
324
  const page = await (await fetch(base + "/")).text();
325
  const href = /href="(\/static\/app\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
326
  expect(href).toBeDefined();
327
  const css = await fetch(base + href!);
328
  expect(css.headers.get("cache-control")).toContain("immutable");
329
  expect(
330
    (await fetch(base + "/static/app.css")).headers.get("cache-control"),
331
  ).toBe("no-cache");
332
  expect((await fetch(base + "/static/..%2Fpackage.json")).status).toBe(404);
333
});
334
335
test("/healthz says up, apart from the page", async () => {
336
  const response = await fetch(base + "/healthz");
337
  expect(response.status).toBe(200);
338
  expect(response.headers.get("cache-control")).toBe("no-store");
339
  const health = (await response.json()) as Record<string, unknown>;
340
  expect(health.ok).toBe(true);
341
  expect(health.version).toMatch(/^\d+\.\d+\.\d+/);
342
  expect(Number.isInteger(health.uptime)).toBe(true);
343
  expect((await fetch(base + "/healthz", { method: "HEAD" })).status).toBe(200);
344
});
345
346
test("a link to the page unfurls: description, Open Graph, a card", async () => {
347
  const page = await (await fetch(base + "/")).text();
348
  for (const meta of [
349
    'name="description"',
350
    'property="og:title"',
351
    'property="og:description"',
352
    'property="og:url"',
353
    'property="og:type" content="website"',
354
    'name="twitter:card" content="summary"',
355
    'name="theme-color"',
356
  ])
357
    expect([meta, page.includes(meta)]).toEqual([meta, true]);
358
});
359
360
test("the look is the tokens: /tokens.css is linked by content and cached for a year", async () => {
361
  const page = await (await fetch(base + "/")).text();
362
  const href = /href="(\/tokens\.css\?v=[0-9a-f]{8})"/.exec(page)?.[1];
363
  expect(href).toBeDefined();
364
  const css = await fetch(base + href!);
365
  expect(css.headers.get("content-type")).toContain("text/css");
366
  expect(css.headers.get("cache-control")).toContain("immutable");
367
  expect(await css.text()).toContain("--color-bg:");
368
  // The tokens come first, so every rule after them can use them.
369
  expect(page.indexOf("/tokens.css")).toBeLessThan(
370
    page.indexOf("/static/app.css"),
371
  );
372
});
373
374
test("what the web expects: robots.txt, and security.txt from the forge that runs us", async () => {
375
  // Production is read by crawlers, the API is not; anything else is a
376
  // draft and asks not to be indexed at all.
377
  const robots = await (await fetch(base + "/robots.txt")).text();
378
  const noindex = (await fetch(base + "/")).headers.get("x-robots-tag");
379
  if (env === "production") {
380
    expect(robots).toContain("Disallow: /api/\n");
381
    expect(noindex).toBeNull();
382
  } else {
383
    expect(robots).toBe("User-agent: *\nDisallow: /\n");
384
    expect(noindex).toBe("noindex");
385
  }
386
  const security = await fetch(base + "/.well-known/security.txt", {
387
    redirect: "manual",
388
  });
389
  if (process.env.FORGE_URL === undefined) expect(security.status).toBe(404);
390
  else {
391
    expect(security.status).toBe(302);
392
    expect(security.headers.get("location")).toBe(
393
      new URL("/.well-known/security.txt", process.env.FORGE_URL).toString(),
394
    );
395
  }
396
});
397
398
test("another person's prompt and SVG never reach /events, live or replayed", async () => {
399
  const secret = "zq-private-prompt-7";
400
  const live = await fetch(base + "/events", { headers: as("u2", "bob") });
401
  const reader = live.body!.getReader();
402
  let heard = "";
403
  const pump = (async () => {
404
    for (;;) {
405
      const { value, done } = await reader.read();
406
      if (done) return;
407
      heard += new TextDecoder().decode(value);
408
    }
409
  })();
410
  await api(
411
    "/api/drawings",
412
    { method: "POST", ...jsonBody({ prompt: secret }) },
413
    as("u1", "alice"),
414
  );
415
  await settled();
416
  await Bun.sleep(50);
417
  const replay = await (async () => {
418
    const late = await fetch(base + "/events?since=0", { headers: as("u2", "bob") });
419
    const late_reader = late.body!.getReader();
420
    const text = new TextDecoder().decode((await late_reader.read()).value);
421
    await late_reader.cancel();
422
    return text;
423
  })();
424
  await reader.cancel();
425
  await pump;
426
  expect(heard).toContain("drawing.changed");
427
  expect(replay).toContain("drawing.changed");
428
  for (const text of [heard, replay]) {
429
    expect(text).not.toContain(secret);
430
    expect(text).not.toContain("<svg");
431
    expect(text).not.toContain("Test Mark");
432
  }
433
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts