import { afterAll, expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { serve } from "./server.ts"; import { blobOid, type Forge } from "./sync.ts"; import { sign } from "./who.ts"; // A shared folder through the doors a person uses: connect, invite, join, // write, see the file — with a repository in memory behind the forge's API. const files = new Map([["docs/Welcome.md", "Hello from git. [[Plan]]"]]); let head = "c0"; const forge: Forge = { head: async () => head, list: async (_r, _ref, path) => { const prefix = path === "" ? "" : `${path}/`; const seen = new Map(); for (const [p, c] of files) { if (!p.startsWith(prefix)) continue; const rest = p.slice(prefix.length); const name = rest.split("/")[0]!; seen.set(name, rest.includes("/") ? { name, type: "tree", oid: "t", size: -1 } : { name, type: "blob", oid: blobOid(c), size: c.length }); } return [...seen.values()]; }, read: async (_r, _ref, path) => files.get(path)!, commit: async (_r, _b, _m, changes) => { for (const f of changes) f.content === null ? files.delete(f.path) : files.set(f.path, f.content); head = `c${Number(head.slice(1)) + 1}`; return head; }, }; const KEY = "k"; const server = serve({ port: 0, dataDir: mkdtempSync(join(tmpdir(), "app-shared-")), identityKey: KEY, forge }); const base = `http://${server.hostname}:${server.port}`; afterAll(() => server.stop(true)); const as = (id: string, name: string, role: "user" | "admin" = "user"): Record => { const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`; return { "x-forge-identity": identity, "x-forge-signature": sign(identity, KEY) }; }; const ben = as("1", "ben", "admin"); const sam = as("2", "sam"); const same = { origin: base, "sec-fetch-site": "same-origin" }; const get = (path: string, who: Record = {}) => fetch(base + path, { headers: { ...who, accept: "text/html" }, redirect: "manual" }); const post = (path: string, form: Record, who: Record) => fetch(base + path, { method: "POST", headers: { ...who, ...same, "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(form).toString(), redirect: "manual", }); test("connect a folder, invite someone by link, and write in it together", async () => { // Only an admin may connect a repository. expect((await post("/spaces", { name: "X", repo: "ben/x" }, sam)).headers.get("location")).toBe("/"); const made = await post("/spaces", { name: "Brainstorm", repo: "ben/brainstorm", branch: "main", folder: "docs" }, ben); expect(made.status).toBe(303); const at = made.headers.get("location")!; expect(at).toMatch(/^\/s\/\d+$/); const sid = Number(at.slice(3)); // It opens on what the repository had. const home = await get(at, ben); expect(home.status).toBe(303); const page = await (await get(home.headers.get("location")!, ben)).text(); expect(page).toContain("Hello from git."); expect(page).toContain(`href="/s/${sid}/open?title=Plan"`); expect(page).toContain("docs/Welcome.md"); // Sam can't see it — until the join link. expect((await get(at, sam)).headers.get("location")).toBe("/"); const settings = await (await get(`${at}/settings`, ben)).text(); const link = /value="https?:\/\/[^"]*(\/join\/[\w-]+)"/.exec(settings)![1]!; // Signed out, the link asks you to sign in and remembers where you were going. const guest = await (await get(link)).text(); expect(guest).toContain(`/login?to=${encodeURIComponent(link)}`); const joined = await get(link, sam); expect(joined.headers.get("location")).toBe(at); // Sam writes a note into a folder; it's a file in the repository after a sync. const note = await fetch(`${base}/api/notes`, { method: "POST", headers: { ...sam, ...same, "content-type": "application/json" }, body: JSON.stringify({ title: "ideas/Plan", body: "Ship it.", space: sid }), }); expect(note.status).toBe(201); const made2 = (await note.json()) as { id: number; path: string; updated_at: string }; expect(made2.path).toBe("docs/ideas/Plan.md"); const synced = await post(`${at}/sync`, {}, sam); expect(synced.headers.get("location")).toBe(`${at}/settings`); expect(files.get("docs/ideas/Plan.md")).toBe("Ship it."); // Two people, one note: the second save written against an old version is refused. const patch = (who: Record, body: string, b: string) => fetch(`${base}/api/notes/${made2.id}`, { method: "PATCH", headers: { ...who, ...same, "content-type": "application/json" }, body: JSON.stringify({ body, base: b }), }); await Bun.sleep(3); expect((await patch(ben, "Ben's take", made2.updated_at)).status).toBe(200); const late = await patch(sam, "Sam's take", made2.updated_at); expect(late.status).toBe(409); expect(((await late.json()) as { message: string }).message).toContain("ben"); // Ben's own vault is still Ben's alone, and doesn't hold the shared notes. const mine = (await (await fetch(`${base}/api/notes`, { headers: ben })).json()) as unknown[]; expect(mine).toEqual([]); const theirs = (await (await fetch(`${base}/api/notes?space=${sid}`, { headers: sam })).json()) as { title: string }[]; expect(theirs.map((n) => n.title).sort()).toEqual(["Plan", "Welcome"]); });