/** * T-150/T-151 — Version compare + read-only preview. * * - compare returns a keyed-by-block-id diff (added/deleted/modified/moved) * - preview returns the full snapshot for one version * - both are VIEW-level; a non-member gets 404 (no-leak) * - unknown version → 404; from === to → 400 */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; interface WireBlock { id: string; type: string; content: { text?: string }; position: string; } async function mkPage(wsId: string, headers: Record, title = "Diff Page"): Promise { const res = await app.request("POST", `/api/v1/workspaces/${wsId}/pages`, { headers, body: { title } }); expect(res.status).toBe(201); return ((await res.json()) as { id: string }).id; } async function mkBlock(pageId: string, headers: Record, text: string): Promise { const res = await app.request("POST", "/api/v1/blocks/batch", { headers, body: { pageId, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text } } }] } }); expect(res.status).toBe(201); const body = (await res.json()) as { blockIds: string[] }; const id = body.blockIds[0]; expect(id, "batch upsert returned no blockId").toBeTruthy(); return id!; } /** Find a block id by its content text (blocks list, depth=all). */ async function blockIdByText(pageId: string, headers: Record, text: string): Promise { const res = await app.request("GET", `/api/v1/pages/${pageId}/blocks?depth=all`, { headers }); expect(res.status).toBe(200); const body = (await res.json()) as { data: WireBlock[] }; const hit = body.data.find((b) => b.content?.text === text); expect(hit, `block with text "${text}" not found`).toBeTruthy(); return hit!.id; } async function patchBlock(pageId: string, blockId: string, headers: Record, patch: Record) { const res = await app.request("POST", "/api/v1/blocks/batch", { headers, body: { pageId, ops: [{ op: "update", blockId, patch }] } }); expect(res.status).toBe(201); } async function deleteBlock(pageId: string, blockId: string, headers: Record) { const res = await app.request("POST", "/api/v1/blocks/batch", { headers, body: { pageId, ops: [{ op: "delete", blockId }] } }); expect(res.status).toBe(201); } async function createVersion(pageId: string, headers: Record, note?: string) { const res = await app.request("POST", `/api/v1/pages/${pageId}/versions`, { headers, body: note ? { note } : {} }); expect(res.status).toBe(201); return (await res.json()) as { id: string; version: number }; } beforeAll(async () => { app = await createTestApp(); }, 60_000); afterAll(async () => { await app?.close(); }); describe("Versions: compare + preview (T-150/151)", () => { it("compare returns added/deleted/modified/moved keyed by block id", async () => { const owner = await createUser(app.request, { name: "Diff Owner" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Diff WS"); const headers = authHeaders(owner.accessToken!); const pageId = await mkPage(ws.workspaceId, headers); // v1 state: alpha, beta, delta (delta will be deleted after the snapshot) await mkBlock(pageId, headers, "alpha"); await mkBlock(pageId, headers, "beta"); await mkBlock(pageId, headers, "delta-will-die"); const v1 = await createVersion(pageId, headers, "v1"); // evolve: modify alpha's text, delete delta, add gamma const alphaId = await blockIdByText(pageId, headers, "alpha"); const deltaId = await blockIdByText(pageId, headers, "delta-will-die"); await patchBlock(pageId, alphaId, headers, { content: { text: "ALPHA revised" } }); await deleteBlock(pageId, deltaId, headers); await mkBlock(pageId, headers, "gamma-new"); const v2 = await createVersion(pageId, headers, "v2"); const res = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v2.version}`, { headers }); expect(res.status).toBe(200); const diff = (await res.json()) as { from: number; to: number; added: { id: string; content: { text: string } }[]; deleted: { id: string; content: { text: string } }[]; modified: { id: string; fields: string[] }[]; moved: unknown[]; summary: { added: number; deleted: number; modified: number; moved: number; unchanged: number }; }; expect([diff.from, diff.to]).toEqual([v1.version, v2.version]); expect(diff.added.map((b) => b.content.text)).toEqual(["gamma-new"]); expect(diff.deleted.map((b) => b.content.text)).toEqual(["delta-will-die"]); expect(diff.modified).toEqual([{ id: alphaId, fields: ["content"] }]); // "moved" is structural only: the two content-unchanged blocks (title + // beta) partition into moved | unchanged. The delete's gap-fill // rebalance may legitimately shift beta's position string, so `moved` // may be non-empty — but only for those ids, never for a modified one. const titleId = await blockIdByText(pageId, headers, "Diff Page"); const betaId = await blockIdByText(pageId, headers, "beta"); const movedIds = diff.moved.map((m) => m.id); for (const id of movedIds) expect([titleId, betaId]).toContain(id); expect(diff.summary.moved).toBe(movedIds.length); expect(diff.summary.unchanged).toBe(2 - movedIds.length); expect(diff.summary).toMatchObject({ added: 1, deleted: 1, modified: 1 }); }); it("preview returns the full snapshot for one version", async () => { const owner = await createUser(app.request, { name: "Preview Owner" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Preview WS"); const headers = authHeaders(owner.accessToken!); const pageId = await mkPage(ws.workspaceId, headers, "Preview Title"); await mkBlock(pageId, headers, "snapshot text"); const v1 = await createVersion(pageId, headers); // mutate after the snapshot — the preview must still show the old state const id = await blockIdByText(pageId, headers, "snapshot text"); await patchBlock(pageId, id, headers, { content: { text: "changed after snapshot" } }); const res = await app.request("GET", `/api/v1/pages/${pageId}/versions/${v1.id}`, { headers }); expect(res.status).toBe(200); const body = (await res.json()) as { version: number; note: string | null; snapshot: { page: { title: string }; blocks: { content: { text: string } }[] }; }; expect(body.version).toBe(1); expect(body.snapshot.page.title).toBe("Preview Title"); const texts = (body.snapshot.blocks ?? []).map((b) => b.content?.text).filter(Boolean); expect(texts).toContain("snapshot text"); expect(texts).not.toContain("changed after snapshot"); }); it("validation + no-leak: from===to → 400, unknown version → 404, non-member → 404", async () => { const owner = await createUser(app.request, { name: "VCL Owner" }); const outsider = await createUser(app.request, { name: "VCL Outsider" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "VCL WS"); const headers = authHeaders(owner.accessToken!); const pageId = await mkPage(ws.workspaceId, headers); await mkBlock(pageId, headers, "x"); const v1 = await createVersion(pageId, headers); // from === to → 400 const same = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v1.version}`, { headers }); expect(same.status).toBe(400); // unknown version → 404 const missing = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=1&to=999`, { headers }); expect(missing.status).toBe(404); // non-member → 403 (repo no-leak contract: no access, no page info) const other = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=1&to=999`, { headers: authHeaders(outsider.accessToken!) }); expect(other.status).toBe(403); const otherPreview = await app.request("GET", `/api/v1/pages/${pageId}/versions/${v1.id}`, { headers: authHeaders(outsider.accessToken!) }); expect(otherPreview.status).toBe(403); }); it("a revoked viewer loses access to compare/preview (per-request authz)", async () => { const owner = await createUser(app.request, { name: "Revoke Owner" }); const member = await createUser(app.request, { name: "Revoke Member" }); const ws = await createWorkspace(app.request, authHeaders(owner.accessToken!), "Revoke WS"); const ownerHeaders = authHeaders(owner.accessToken!); const pageId = await mkPage(ws.workspaceId, ownerHeaders); await mkBlock(pageId, ownerHeaders, "secret-ish"); const v1 = await createVersion(pageId, ownerHeaders); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, member.email, "MEMBER"); const memberHeaders = authHeaders(member.accessToken!); const ok = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v1.version}`, { headers: memberHeaders }); // from===to is still 400 (validation) — proves the member was AUTHORIZED (404 would mean no access) expect(ok.status).toBe(400); // strip access: remove the membership → no membership → NONE → 404 no-leak const revokeRes = await app.request("DELETE", `/api/v1/workspaces/${ws.workspaceId}/members/${member.userId}`, { headers: ownerHeaders }); expect(revokeRes.status).toBe(200); const denied = await app.request("GET", `/api/v1/pages/${pageId}/versions/compare?from=${v1.version}&to=${v1.version}`, { headers: memberHeaders }); expect(denied.status).toBe(403); }); });