/** * T-167 — Whiteboard page kind + canvas schema (API surface). * * A whiteboard is a PAGE kind (`Page.kind = "whiteboard"`, A-001) whose * canvas lives in the 1:1 `whiteboards` sidecar, validated by the zero-dep * `@factory/whiteboard` schema. Covers: * - create via the pages surface (`type: "whiteboard"`) → page with the * kind + an empty, version-stamped canvas sidecar; * - `GET /pages/:id/whiteboard` (VIEW) — round-trip, `valid` flag; * - `PUT /pages/:id/whiteboard` (EDIT) — full-canvas save validated by * `validateCanvas` (per-type fields + referential integrity), audit row, * page version bump; * - 400 on an invalid document (unknown version / bad connector refs / * bad element fields) — field-level `errors` in the envelope; * - no-leak: non-whiteboard page → 404, unknown page → 404; * - authz: GUEST can read (VIEW) but not save (EDIT), non-member 403; * - the 2 MB route-scoped body cap (413 — a large canvas above the global * 256 KB JSON ceiling still saves). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; interface Setup { headers: Record; workspaceId: string; pageId: string; } /** Create a whiteboard page through the pages surface. */ async function setupBoard(): Promise { const user = await createUser(app.request, { name: "Board Owner" }); const headers = authHeaders(user.accessToken!); const { workspaceId } = await createWorkspace(app.request, headers, "Board WS"); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "My Board", type: "whiteboard" }, }); expect([200, 201]).toContain(res.status); const page = (await json(res)) as { id: string; kind?: string }; expect(page.kind).toBe("whiteboard"); return { headers, workspaceId, pageId: page.id }; } /** A valid canvas with one element of each non-connector type + a connector + a group. */ function richCanvas() { return { version: 1, elements: { s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "hello", color: "yellow" }, t1: { id: "t1", type: "text", x: 0, y: 0, w: 60, h: 20, z: 2, text: "label", fontSize: 14 }, r1: { id: "r1", type: "rect", x: 0, y: 0, w: 30, h: 40, z: 3, fill: "blue", stroke: null }, e1: { id: "e1", type: "ellipse", x: 0, y: 0, w: 30, h: 30, z: 4, fill: null, stroke: "pink" }, f1: { id: "f1", type: "freehand", x: 0, y: 0, w: 50, h: 50, z: 5, points: [0, 0, 5, 5, 10, 2], strokeWidth: 3 }, c1: { id: "c1", type: "connector", x: 0, y: 0, w: 0, h: 0, z: 6, from: { elementId: "s1", anchor: "right" }, to: { elementId: "r1", anchor: "left" }, kind: "orthogonal", arrow: true }, g1: { id: "g1", type: "group", x: 0, y: 0, w: 100, h: 80, z: 7, children: ["s1", "t1"] } } }; } describe("T-167 whiteboard — create (page kind)", () => { it("POST /workspaces/:id/pages type=whiteboard → page.kind=whiteboard + empty canvas sidecar", async () => { const { headers, pageId } = await setupBoard(); const res = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers }); expect(res.status).toBe(200); const body = (await json(res)) as { pageId: string; schemaVersion: number; valid: boolean; canvas: { version: number; elements: Record }; }; expect(body.pageId).toBe(pageId); expect(body.schemaVersion).toBe(1); expect(body.valid).toBe(true); expect(body.canvas.version).toBe(1); expect(Object.keys(body.canvas.elements)).toHaveLength(0); }); it("a legacy page create does NOT get a sidecar (kind stays page, no whiteboard row)", async () => { const { headers, workspaceId } = await setupBoard(); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "normal" } }); expect([200, 201]).toContain(res.status); const page = (await json(res)) as { id: string; kind: string }; expect(page.kind).toBe("page"); // no sidecar row was created for a plain page expect(await app.prisma.whiteboard.findUnique({ where: { pageId: page.id } })).toBeNull(); }); }); describe("T-167 whiteboard — save (PUT) + read (GET)", () => { it("PUT persists a valid canvas (all element types + connector + group); GET round-trips", async () => { const { headers, pageId } = await setupBoard(); const put = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: richCanvas() }); expect(put.status).toBe(200); const saved = (await json(put)) as { schemaVersion: number; canvas: { elements: Record }; }; expect(saved.schemaVersion).toBe(1); expect(Object.keys(saved.canvas.elements)).toHaveLength(7); const get = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers }); expect(get.status).toBe(200); const body = (await json(get)) as { valid: boolean; canvas: { elements: Record } }; expect(body.valid).toBe(true); expect(body.canvas.elements.c1).toMatchObject({ type: "connector", arrow: true }); expect(body.canvas.elements.g1).toMatchObject({ type: "group", children: ["s1", "t1"] }); // save is audited expect(await countAuditEvents(app.prisma, { action: "page.whiteboard_updated" })).toBeGreaterThanOrEqual(1); }); it("unknown canvas version → 400 VALIDATION_ERROR (renderer read-only contract)", async () => { const { headers, pageId } = await setupBoard(); const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: { version: 99, elements: {} } }); expect(res.status).toBe(400); const body = await json(res); expect((body as { error: { code: string } }).error.code).toBe("VALIDATION_ERROR"); }); it("broken connector referential integrity → 400 with the field-level error", async () => { const { headers, pageId } = await setupBoard(); const doc = richCanvas(); (doc.elements.c1 as { from: { elementId: string } }).from.elementId = "missing"; const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: doc }); expect(res.status).toBe(400); const details = ((await json(res)) as { error: { details?: { issue?: string }[] } }).error.details ?? []; expect(details.some((d) => String(d.issue).includes("missing element"))).toBe(true); }); it("bad per-type fields (fontSize range, strokeWidth, points parity) → 400", async () => { const { headers, pageId } = await setupBoard(); for (const bad of [ { version: 1, elements: { t1: { id: "t1", type: "text", x: 0, y: 0, w: 1, h: 1, z: 1, text: "x", fontSize: 999 } } }, { version: 1, elements: { f1: { id: "f1", type: "freehand", x: 0, y: 0, w: 1, h: 1, z: 1, points: [0, 0, 1], strokeWidth: 2 } } }, { version: 1, elements: { f2: { id: "f2", type: "freehand", x: 0, y: 0, w: 1, h: 1, z: 1, points: [0, 0], strokeWidth: 99 } } } ]) { const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: bad }); expect(res.status).toBe(400); } }); }); describe("T-167 whiteboard — no-leak + authz", () => { it("a NON-whiteboard page has no canvas (404, not 403 — no kind leak)", async () => { const { headers, workspaceId } = await setupBoard(); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "plain page" } }); expect([200, 201]).toContain(res.status); const plain = (await json(res)) as { id: string; kind: string }; expect(plain.kind).toBe("page"); const canvas = await app.request("GET", `/api/v1/pages/${plain.id}/whiteboard`, { headers }); expect(canvas.status).toBe(404); const put = await app.request("PUT", `/api/v1/pages/${plain.id}/whiteboard`, { headers, body: { version: 1, elements: {} } }); expect(put.status).toBe(404); }); it("unknown page id → 404 (GET and PUT)", async () => { const { headers } = await setupBoard(); const g = await app.request("GET", `/api/v1/pages/${crypto.randomUUID()}/whiteboard`, { headers }); expect(g.status).toBe(404); const p = await app.request("PUT", `/api/v1/pages/${crypto.randomUUID()}/whiteboard`, { headers, body: { version: 1, elements: {} } }); expect(p.status).toBe(404); }); it("GUEST (VIEW) can read but not save; non-member 403; save is EDIT-gated", async () => { const { headers, workspaceId, pageId } = await setupBoard(); const guest = await createUser(app.request, { name: "Board Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gHeaders = authHeaders(guest.accessToken!); const read = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers: gHeaders }); expect(read.status).toBe(200); const write = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers: gHeaders, body: { version: 1, elements: {} } }); expect(write.status).toBe(403); const stranger = await createUser(app.request, { name: "Board Stranger" }); const sRead = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers: authHeaders(stranger.accessToken!) }); expect(sRead.status).toBe(403); const sWrite = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers: authHeaders(stranger.accessToken!), body: { version: 1, elements: {} } }); expect(sWrite.status).toBe(403); }); }); describe("T-167 whiteboard — body cap (route-scoped 2 MB parser)", () => { it("a canvas body over 2 MB → 413 PAYLOAD_TOO_LARGE; a large-but-valid one saves", async () => { const { headers, pageId } = await setupBoard(); // ~2.3 MB of stickies (each text at the 20k cap) → the declared // Content-Length alone trips the fast-path 413. const big: Record = {}; for (let i = 0; i < 120; i++) { big[`s${i}`] = { id: `s${i}`, type: "sticky", x: 0, y: 0, w: 100, h: 80, z: i + 1, text: "x".repeat(20_000), color: "yellow" }; } const res = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: { version: 1, elements: big } }); expect(res.status).toBe(413); expect(((await json(res)) as { error: { code: string } }).error.code).toBe("PAYLOAD_TOO_LARGE"); // A canvas that exceeds the GLOBAL 256 KB ceiling but stays under the // 2 MB cap must still save (the point of the route-scoped parser). const mid: Record = {}; for (let i = 0; i < 15; i++) { mid[`m${i}`] = { id: `m${i}`, type: "sticky", x: i, y: 0, w: 100, h: 80, z: i + 1, text: "y".repeat(20_000), color: "blue" }; } const ok = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: { version: 1, elements: mid } }); expect(ok.status).toBe(200); const body = (await json(ok)) as { canvas: { elements: Record } }; expect(Object.keys(body.canvas.elements)).toHaveLength(15); }); }); // ---------------------------------------------------------------- T-170 — // version snapshots & restore of the canvas sidecar (save & restore). describe("T-170 whiteboard — version snapshots & restore (canvas sidecar)", () => { it("createVersion snapshots the canvas; restore brings it back after edits (audited)", async () => { const { headers, pageId } = await setupBoard(); // save a board with one sticky, then take an explicit version snapshot const put1 = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: { version: 1, elements: { a1: { id: "a1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "before", color: "yellow" } } } }); expect(put1.status).toBe(200); const cv = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers, body: { note: "board v1" } }); expect([200, 201]).toContain(cv.status); const versionRow = (await json(cv)) as { id: string; version: number }; expect(versionRow.version).toBe(1); // the snapshot (readable via the preview endpoint) carries the canvas const pv = await app.request("GET", `/api/v1/pages/${pageId}/versions/${versionRow.id}`, { headers }); expect(pv.status).toBe(200); const snapshot = ((await json(pv)) as { snapshot: { whiteboard?: { canvas: { elements: Record } } } }).snapshot; expect(snapshot.whiteboard?.canvas.elements.a1).toBeDefined(); // overwrite the board with a different element… const put2 = await app.request("PUT", `/api/v1/pages/${pageId}/whiteboard`, { headers, body: { version: 1, elements: { b1: { id: "b1", type: "sticky", x: 5, y: 5, w: 60, h: 40, z: 1, text: "after", color: "blue" } } } }); expect(put2.status).toBe(200); const mid = (await json( (await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers })) )) as { canvas: { elements: Record } }; expect(Object.keys(mid.canvas.elements)).toEqual(["b1"]); // …restore the version — the original canvas is back const before = await countAuditEvents(app.prisma, { action: "page.version_restored" }); const rr = await app.request("POST", `/api/v1/pages/${pageId}/versions/${versionRow.id}/restore`, { headers }); expect([200, 201]).toContain(rr.status); expect(((await json(rr)) as { restored: boolean }).restored).toBe(true); const after = (await json( (await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers })) )) as { canvas: { elements: Record } }; expect(Object.keys(after.canvas.elements)).toEqual(["a1"]); expect((after.canvas.elements.a1 as { text: string }).text).toBe("before"); // restore is a sensitive op → audited expect(await countAuditEvents(app.prisma, { action: "page.version_restored" })).toBe(before + 1); }); it("a version of a NON-whiteboard page has no whiteboard key in its snapshot", async () => { const { headers, workspaceId } = await setupBoard(); const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "doc" } }); expect([200, 201]).toContain(res.status); const pageId = ((await json(res)) as { id: string }).id; const cv = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers }); expect([200, 201]).toContain(cv.status); const row = (await json(cv)) as { id: string }; const pv = await app.request("GET", `/api/v1/pages/${pageId}/versions/${row.id}`, { headers }); expect(pv.status).toBe(200); const snapshot = ((await json(pv)) as { snapshot: Record }).snapshot; expect(snapshot.whiteboard).toBeUndefined(); // and restoring it must NOT touch the (nonexistent) whiteboard sidecar const rr = await app.request("POST", `/api/v1/pages/${pageId}/versions/${row.id}/restore`, { headers }); expect([200, 201]).toContain(rr.status); expect(await app.prisma.whiteboard.findUnique({ where: { pageId } })).toBeNull(); }); }); // ---------------------------------------------------------------- T-171 — // convert a whiteboard selection into a real page (or task page, A-006). describe("T-171 whiteboard — convert selection to page/task", () => { async function boardWithText() { const s = await setupBoard(); const put = await app.request("PUT", `/api/v1/pages/${s.pageId}/whiteboard`, { headers: s.headers, body: { version: 1, elements: { s1: { id: "s1", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 1, text: "first line\nsecond line", color: "yellow" }, t1: { id: "t1", type: "text", x: 0, y: 0, w: 60, h: 20, z: 2, text: "note text", fontSize: 14 }, r1: { id: "r1", type: "rect", x: 0, y: 0, w: 30, h: 40, z: 3, fill: "blue", stroke: null }, empty: { id: "empty", type: "sticky", x: 0, y: 0, w: 100, h: 80, z: 4, text: "", color: "blue" } } } }); expect(put.status).toBe(200); return s; } it("text-bearing elements become blocks in a new page (server re-reads the canvas)", async () => { const { headers, pageId } = await boardWithText(); const res = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, { headers, body: { elementIds: ["s1", "t1", "r1", "empty", "nope"], target: "page" } }); expect([200, 201]).toContain(res.status); const body = (await json(res)) as { page: { id: string; kind: string; title: string }; task: null; blockCount: number; }; expect(body.page.kind).toBe("page"); // title defaults to the first line of the first text element expect(body.page.title).toBe("first line"); // 2 title lines + 1 text + 1 title block = 4 blocks total expect(body.blockCount).toBe(4); // the created page's content matches (order preserved: s1 before t1) const content = (await json( (await app.request("GET", `/api/v1/pages/${body.page.id}/content`, { headers })) )) as { text: string }; expect(content.text).toBe("first line\nfirst line\nsecond line\nnote text"); // shapes/connectors/empty/unknown ids contributed nothing expect(content.text).not.toContain("r1"); // audit + the source board is untouched expect(await countAuditEvents(app.prisma, { action: "page.whiteboard_converted" })).toBe(1); const src = (await json( (await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers })) )) as { canvas: { elements: Record } }; expect(Object.keys(src.canvas.elements)).toHaveLength(4); }); it("target=task creates a task page + sidecar (A-006)", async () => { const { headers, workspaceId, pageId } = await boardWithText(); const res = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, { headers, body: { elementIds: ["s1"], target: "task", title: "From the board" } }); expect([200, 201]).toContain(res.status); const body = (await json(res)) as { page: { id: string; kind: string; title: string }; task: { id: string; status: string; pageId: string }; }; expect(body.page.kind).toBe("task"); expect(body.page.title).toBe("From the board"); expect(body.task.status).toBe("todo"); expect(body.task.pageId).toBe(body.page.id); // the task is listed on the tasks surface (same workspace) const list = (await json( (await app.request("GET", `/api/v1/workspaces/${workspaceId}/tasks?scope=all`, { headers })) )) as { data: Array<{ title: string }> }; expect(list.data.some((t) => t.title === "From the board")).toBe(true); expect(await countAuditEvents(app.prisma, { action: "page.task_converted" })).toBe(1); }); it("400 when nothing has text; 404 on a non-whiteboard page; GUEST cannot convert", async () => { const { headers, workspaceId, pageId } = await boardWithText(); // only shapes/empty → no text content const bad = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, { headers, body: { elementIds: ["r1", "empty"], target: "page" } }); expect(bad.status).toBe(400); expect(((await json(bad)) as { error: { code: string } }).error.code).toBe("VALIDATION_ERROR"); // non-whiteboard page → 404 (no-leak) const doc = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers, body: { title: "doc" } }); const docId = ((await json(doc)) as { id: string }).id; const nf = await app.request("POST", `/api/v1/pages/${docId}/whiteboard/convert`, { headers, body: { elementIds: ["s1"], target: "page" } }); expect(nf.status).toBe(404); // GUEST (VIEW) can read the board but not convert (EDIT-gated) const guest = await createUser(app.request, { name: "Board Guest" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, guest.email, "GUEST"); const gh = authHeaders(guest.accessToken!); const read = await app.request("GET", `/api/v1/pages/${pageId}/whiteboard`, { headers: gh }); expect(read.status).toBe(200); const denied = await app.request("POST", `/api/v1/pages/${pageId}/whiteboard/convert`, { headers: gh, body: { elementIds: ["s1"], target: "page" } }); expect(denied.status).toBe(403); }); });