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

Files main
114 lines · 5.4 KB · ~1.6k 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 { serve } from "./server.ts";
6
import { blobOid, type Forge } from "./sync.ts";
7
import { sign } from "./who.ts";
8
9
// A shared folder through the doors a person uses: connect, invite, join,
10
// write, see the file — with a repository in memory behind the forge's API.
11
const files = new Map<string, string>([["docs/Welcome.md", "Hello from git. [[Plan]]"]]);
12
let head = "c0";
13
const forge: Forge = {
14
  head: async () => head,
15
  list: async (_r, _ref, path) => {
16
    const prefix = path === "" ? "" : `${path}/`;
17
    const seen = new Map<string, { name: string; type: string; oid: string; size: number }>();
18
    for (const [p, c] of files) {
19
      if (!p.startsWith(prefix)) continue;
20
      const rest = p.slice(prefix.length);
21
      const name = rest.split("/")[0]!;
22
      seen.set(name, rest.includes("/") ? { name, type: "tree", oid: "t", size: -1 } : { name, type: "blob", oid: blobOid(c), size: c.length });
23
    }
24
    return [...seen.values()];
25
  },
26
  read: async (_r, _ref, path) => files.get(path)!,
27
  commit: async (_r, _b, _m, changes) => {
28
    for (const f of changes) f.content === null ? files.delete(f.path) : files.set(f.path, f.content);
29
    head = `c${Number(head.slice(1)) + 1}`;
30
    return head;
31
  },
32
};
33
34
const KEY = "k";
35
const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-shared-")), identityKey: KEY, forge });
36
const base = `http://${server.hostname}:${server.port}`;
37
afterAll(() => server.stop(true));
38
39
const as = (id: string, name: string, role: "user" | "admin" = "user"): Record<string, string> => {
40
  const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
41
  return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) };
42
};
43
const ben = as("1", "ben", "admin");
44
const sam = as("2", "sam");
45
const same = { origin: base, "sec-fetch-site": "same-origin" };
46
const get = (path: string, who: Record<string, string> = {}) =>
47
  fetch(base + path, { headers: { ...who, accept: "text/html" }, redirect: "manual" });
48
const post = (path: string, form: Record<string, string>, who: Record<string, string>) =>
49
  fetch(base + path, {
50
    method: "POST",
51
    headers: { ...who, ...same, "content-type": "application/x-www-form-urlencoded" },
52
    body: new URLSearchParams(form).toString(),
53
    redirect: "manual",
54
  });
55
56
test("connect a folder, invite someone by link, and write in it together", async () => {
57
  // Only an admin may connect a repository.
58
  expect((await post("/spaces", { name: "X", repo: "ben/x" }, sam)).headers.get("location")).toBe("/");
59
  const made = await post("/spaces", { name: "Brainstorm", repo: "ben/brainstorm", branch: "main", folder: "docs" }, ben);
60
  expect(made.status).toBe(303);
61
  const at = made.headers.get("location")!;
62
  expect(at).toMatch(/^\/s\/\d+$/);
63
  const sid = Number(at.slice(3));
64
65
  // It opens on what the repository had.
66
  const home = await get(at, ben);
67
  expect(home.status).toBe(303);
68
  const page = await (await get(home.headers.get("location")!, ben)).text();
69
  expect(page).toContain("Hello from git.");
70
  expect(page).toContain(`href="/s/${sid}/open?title=Plan"`);
71
  expect(page).toContain("docs/Welcome.md");
72
73
  // Sam can't see it — until the join link.
74
  expect((await get(at, sam)).headers.get("location")).toBe("/");
75
  const settings = await (await get(`${at}/settings`, ben)).text();
76
  const link = /value="https?:\/\/[^"]*(\/join\/[\w-]+)"/.exec(settings)![1]!;
77
  // Signed out, the link asks you to sign in and remembers where you were going.
78
  const guest = await (await get(link)).text();
79
  expect(guest).toContain(`/login?to=${encodeURIComponent(link)}`);
80
  const joined = await get(link, sam);
81
  expect(joined.headers.get("location")).toBe(at);
82
83
  // Sam writes a note into a folder; it's a file in the repository after a sync.
84
  const note = await fetch(`${base}/api/notes`, {
85
    method: "POST",
86
    headers: { ...sam, ...same, "content-type": "application/json" },
87
    body: JSON.stringify({ title: "ideas/Plan", body: "Ship it.", space: sid }),
88
  });
89
  expect(note.status).toBe(201);
90
  const made2 = (await note.json()) as { id: number; path: string; updated_at: string };
91
  expect(made2.path).toBe("docs/ideas/Plan.md");
92
  const synced = await post(`${at}/sync`, {}, sam);
93
  expect(synced.headers.get("location")).toBe(`${at}/settings`);
94
  expect(files.get("docs/ideas/Plan.md")).toBe("Ship it.");
95
96
  // Two people, one note: the second save written against an old version is refused.
97
  const patch = (who: Record<string, string>, body: string, b: string) =>
98
    fetch(`${base}/api/notes/${made2.id}`, {
99
      method: "PATCH",
100
      headers: { ...who, ...same, "content-type": "application/json" },
101
      body: JSON.stringify({ body, base: b }),
102
    });
103
  await Bun.sleep(3);
104
  expect((await patch(ben, "Ben's take", made2.updated_at)).status).toBe(200);
105
  const late = await patch(sam, "Sam's take", made2.updated_at);
106
  expect(late.status).toBe(409);
107
  expect(((await late.json()) as { message: string }).message).toContain("ben");
108
109
  // Ben's own vault is still Ben's alone, and doesn't hold the shared notes.
110
  const mine = (await (await fetch(`${base}/api/notes`, { headers: ben })).json()) as unknown[];
111
  expect(mine).toEqual([]);
112
  const theirs = (await (await fetch(`${base}/api/notes?space=${sid}`, { headers: sam })).json()) as { title: string }[];
113
  expect(theirs.map((n) => n.title).sort()).toEqual(["Plan", "Welcome"]);
114
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts