/** * Confluence expansion — Templates (T-141 directory, T-142 variables, * T-145 versions). * * T-141 GET /workspaces/:id/templates?category=&search= — additive filters * T-142 create-with-variables + apply with `values` (plain-text substitution) * T-145 POST/GET/restore template versions (history never overwritten) * Authz builtin templates are read-only for non-FULL_ACCESS members */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, createWorkspace } from "./helpers/seed"; let app: TestAppHandle; async function mkUser(name: string) { // register + login through the real HTTP surface (canonical user path). const email = `${name.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}@test.local`; const password = `Str0ng-Pass-${Math.random().toString(36).slice(2, 10)}`; const reg = await app.request("POST", "/api/v1/auth/register", { body: { email, password, name } }); if (reg.status !== 201 && reg.status !== 200) throw new Error(`register ${name}: ${reg.status} ${await reg.text()}`); const login = await app.request("POST", "/api/v1/auth/login", { body: { email, password } }); if (login.status !== 200 && login.status !== 201) throw new Error(`login ${name}: ${login.status} ${await login.text()}`); const body = (await login.json()) as { user?: { id?: string }; accessToken?: string }; return { userId: body.user?.id ?? "", email, accessToken: body.accessToken ?? "" }; } describe("Templates: directory + variables + versions (T-141/142/145)", () => { beforeAll(async () => { app = await createTestApp(); }, 120_000); afterAll(async () => { await app?.close(); }); it("lists built-in templates with category + search filters (T-141)", async () => { const owner = await mkUser("Tpl Owner"); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Template Space"); const h = authHeaders(owner.accessToken); // workspace creation seeds the 15 built-ins (A-005) const all = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates`, { headers: h }); expect(all.status).toBe(200); const builtins = (await all.json()).data.filter((t) => t.isBuiltin); expect(builtins.length).toBe(15); expect(new Set(builtins.map((t) => t.name)).size).toBe(15); // category filter const meetings = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates?category=Meetings`, { headers: h }); const meetingsBody = await meetings.json(); expect(meetingsBody.data.length).toBe(2); // Meeting Notes + Retrospective expect(meetingsBody.data.every((t) => t.category === "Meetings")).toBe(true); // search (name, case-insensitive) const search = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates?search=incident`, { headers: h }); expect((await search.json()).data.map((t) => t.name)).toEqual(["Incident Report"]); // unknown category → empty, not an error const none = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates?category=DoesNotExist`, { headers: h }); expect((await none.json()).data).toHaveLength(0); // non-member: the list never includes the foreign workspace's user templates const outsider = await mkUser("Tpl Outsider"); const nomem = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates`, { headers: authHeaders(outsider.accessToken) }); expect(nomem.status).toBe(200); for (const t of (await nomem.json()).data) { expect(t.isBuiltin).toBe(true); } }); it("create-with-variables + apply substitutes values (T-142)", async () => { const owner = await mkUser("Var Owner"); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Var Space"); const h = authHeaders(owner.accessToken); h["x-factory-workspace"] = workspaceId; // a source page to snapshot (with variable tokens in its text) const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Draft Plan" } }); expect(pageRes.status).toBe(201); const page = (await pageRes.json()) as { id: string }; const blockRes = await app.request("POST", "/api/v1/blocks/batch", { headers: h, body: { pageId: page.id, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text: "Project {{project}} owned by {{owner}}; milestone on {{date}}" } } }] } }); expect(blockRes.status).toBe(201); // create the template with variable definitions const tplRes = await app.request("POST", `/api/v1/pages/${page.id}/templates`, { headers: h, body: { name: "My Plan Template", variables: [ { name: "project", label: "Project", type: "text", required: true }, { name: "owner", label: "Owner", type: "text" }, { name: "date", label: "Date", type: "date", defaultValue: "2026-12-31" } ] } }); expect(tplRes.status).toBe(201); const tpl = (await tplRes.json()) as { id: string; variables: unknown[] }; expect(tpl.variables).toHaveLength(3); // invalid variable definitions are rejected (trust boundary) const badTpl = await app.request("POST", `/api/v1/pages/${page.id}/templates`, { headers: h, body: { name: "Bad", variables: [{ name: "no spaces!", label: "L", type: "text" }] } }); expect(badTpl.status).toBe(400); // apply WITHOUT the required value → 400 const missReq = await app.request("POST", `/api/v1/templates/${tpl.id}/apply`, { headers: h, body: {} }); expect(missReq.status).toBe(400); // apply with values → tokens substituted in the new page's blocks const apply = await app.request("POST", `/api/v1/templates/${tpl.id}/apply`, { headers: h, body: { values: { project: "Apollo", owner: "Ada" } } }); expect(apply.status).toBe(201); const newPage = (await apply.json()) as { id: string }; // the block carrying the tokens (the auto title block has its own text; // Prisma Json path filters have no `contains`, so filter in the client) const blocks = await app.prisma.block.findMany({ where: { pageId: newPage.id, deletedAt: null } }); const block = blocks.find((b) => ((b.content as { text?: string }).text ?? "").includes("owned by")); expect(block).toBeTruthy(); const text = (block?.content as { text: string }).text; expect(text).toContain("Project Apollo owned by Ada"); expect(text).toContain("milestone on 2026-12-31"); // default applied expect(text).not.toContain("{{"); // bad date value rejected before anything is created const badValues = await app.request("POST", `/api/v1/templates/${tpl.id}/apply`, { headers: h, body: { values: { project: "X", date: "tomorrow" } } }); expect(badValues.status).toBe(400); }); it("preview: member gets the current snapshot (read-only); non-member 404 (no leak) (T-141)", async () => { const owner = await mkUser("Preview Owner"); const outsider = await mkUser("Preview Outsider"); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Preview Space"); const h = authHeaders(owner.accessToken); h["x-factory-workspace"] = workspaceId; const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Preview Source" } }); const page = (await pageRes.json()) as { id: string }; const blockRes = await app.request("POST", "/api/v1/blocks/batch", { headers: h, body: { pageId: page.id, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text: "preview body text" } } }] } }); expect(blockRes.status).toBe(201); const tplRes = await app.request("POST", `/api/v1/pages/${page.id}/templates`, { headers: h, body: { name: "Preview Tpl" } }); expect(tplRes.status).toBe(201); const tpl = (await tplRes.json()) as { id: string }; // ID-scoped (no workspace header): a member of the template's own ws → 200 // with the frozen snapshot (page + the block's content). const ok = await app.request("GET", `/api/v1/templates/${tpl.id}/preview`, { headers: authHeaders(owner.accessToken) }); expect(ok.status).toBe(200); const snap = (await ok.json()).data.snapshot as { page: { title: string }; blocks: { type: string; content: { text?: string } }[] }; expect(snap.page.title).toBe("Preview Source"); const bodyBlock = snap.blocks.find((b) => b.content?.text === "preview body text"); expect(bodyBlock).toBeTruthy(); // A user with no membership in the template's workspace → 404 (NOT_FOUND, // not 403 — no cross-tenant existence leak). const denied = await app.request("GET", `/api/v1/templates/${tpl.id}/preview`, { headers: authHeaders(outsider.accessToken) }); expect(denied.status).toBe(404); // Unknown id → 404 as well (same shape). const missing = await app.request("GET", `/api/v1/templates/does-not-exist/preview`, { headers: authHeaders(owner.accessToken) }); expect(missing.status).toBe(404); }); it("version history: snapshot, list, restore (history never overwritten) (T-145)", async () => { const owner = await mkUser("Ver Owner"); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Ver Space"); const h = authHeaders(owner.accessToken); h["x-factory-workspace"] = workspaceId; const pageRes = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Versioned Source" } }); const page = (await pageRes.json()) as { id: string }; const blockRes = await app.request("POST", "/api/v1/blocks/batch", { headers: h, body: { pageId: page.id, ops: [{ op: "upsert", block: { type: "PARAGRAPH", content: { text: "v1 content" } } }] } }); expect(blockRes.status).toBe(201); const tplRes = await app.request("POST", `/api/v1/pages/${page.id}/templates`, { headers: h, body: { name: "Versioned" } }); const tpl = (await tplRes.json()) as { id: string }; // creation captures v1 let list = await app.request("GET", `/api/v1/templates/${tpl.id}/versions`, { headers: h }); expect(list.status).toBe(200); expect((await list.json()).data.map((v) => v.version)).toEqual([1]); // snapshot v2 (a checkpoint of the current state) const v2 = await app.request("POST", `/api/v1/templates/${tpl.id}/versions`, { headers: h, body: { note: "checkpoint" } }); expect(v2.status).toBe(201); expect((await v2.json()).version).toBe(2); list = await app.request("GET", `/api/v1/templates/${tpl.id}/versions`, { headers: h }); const versions = (await list.json()).data; expect(versions.map((v) => v.version).sort((a, b) => b - a)).toEqual([2, 1]); expect(versions.find((v) => v.version === 2).note).toBe("checkpoint"); // restore v1 → writes current state = v1 snapshot AND appends a NEW // version (v3) — history is append-only const v1 = versions.find((v) => v.version === 1); const restore = await app.request("POST", `/api/v1/templates/${tpl.id}/versions/${v1.id}/restore`, { headers: h }); expect(restore.status).toBe(201); expect(await restore.json()).toMatchObject({ restoredFromVersion: 1, newVersion: 3 }); list = await app.request("GET", `/api/v1/templates/${tpl.id}/versions`, { headers: h }); expect((await list.json()).data).toHaveLength(3); }); it("built-in templates are read-only for members (only FULL_ACCESS can version them)", async () => { const owner = await mkUser("Builtin Owner"); const member = await mkUser("Builtin Member"); const { workspaceId } = await createWorkspace(app.request, authHeaders(owner.accessToken), "Builtin Space"); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER"); const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/templates`, { headers: authHeaders(owner.accessToken) }); const builtin = (await list.json()).data.find((t) => t.isBuiltin && t.name === "Meeting Notes"); expect(builtin).toBeTruthy(); const mh = authHeaders(member.accessToken); mh["x-factory-workspace"] = workspaceId; // MEMBER (EDIT in ws, not FULL_ACCESS) cannot snapshot a builtin version. const v = await app.request("POST", `/api/v1/templates/${builtin.id}/versions`, { headers: mh, body: {} }); expect(v.status).toBe(403); // the OWNER (FULL_ACCESS) CAN. const oh = authHeaders(owner.accessToken); oh["x-factory-workspace"] = workspaceId; const ov = await app.request("POST", `/api/v1/templates/${builtin.id}/versions`, { headers: oh, body: { note: "admin audit" } }); expect(ov.status).toBe(201); expect((await ov.json()).version).toBe(1); }); });