/** * T-162/T-163 — Review workflow (the `pages.reviewState` machine, A-003). * * Covers the full lifecycle + the security negative matrix: * - submit requires the explicit reviewer list (A-003; non-member reviewer → 400) * - only a reviewer OF RECORD may decide (a page-EDIT member who is not a * reviewer → 403 — the explicit list is the gate, not the role) * - illegal transition → 400 (e.g. approve while published) * - APPROVAL_DECISION → the requester (not the decider); REVIEW_REQUESTED → reviewers * - request_changes → resubmit loop; cancel (editor) withdraws * - non-member → 403; unknown page → 404 (no-leak) * - legacy pages default `published` with NO review row (no behavior change) */ 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; const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; interface ReviewStateResp { state: string; review: null | { id: string; reviewerIds: string[]; requestedBy: string; decision: string | null }; } beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); describe("T-162/T-163: review workflow", () => { it("legacy page: GET review → {state: published, review: null}; submit without reviewers → 400", async () => { const owner = await createUser(app.request, { name: "Rv Owner" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "Rv WS"); const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "Doc" } }))) as { id: string }; // legacy default — no review row, published const st = (await json(await app.request("GET", `/api/v1/pages/${page.id}/review`, { headers: h }))) as ReviewStateResp; expect(st.state).toBe("published"); expect(st.review).toBeNull(); // submit without the explicit list (A-003) → 400 const bad = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit" } }); expect(bad.status).toBe(400); // a non-member "reviewer" is a leak → 400 const outsider = await createUser(app.request, { name: "Rv Outsider" }); const bad2 = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [outsider.userId] } }); expect(bad2.status).toBe(400); }); it("full lifecycle: submit → approve (requester notified); request_changes → resubmit → approve", async () => { const owner = await createUser(app.request, { name: "FL Editor" }); const reviewer = await createUser(app.request, { name: "FL Reviewer" }); const h = authHeaders(owner.accessToken!); const hr = authHeaders(reviewer.accessToken!); const ws = await createWorkspace(app.request, h, "FL WS"); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, reviewer.email, "MEMBER"); const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "FL doc" } }))) as { id: string }; // submit (editor) const sub = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId], comment: "please review" } }); expect(sub.status).toBe(201); const subBody = (await json(sub)) as ReviewStateResp; expect(subBody.state).toBe("in_review"); // REVIEW_REQUESTED → the reviewer const reqNotifs = await app.prisma.notification.findMany({ where: { type: "REVIEW_REQUESTED" }, select: { userId: true, title: true } }); expect(reqNotifs.some((n) => n.userId === reviewer.userId)).toBe(true); // a SECOND submit (resubmit from in_review) is ILLEGAL → 400 const illegal = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } }); expect(illegal.status).toBe(400); // request_changes (reviewer) const rc = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: hr, body: { action: "request_changes", comment: "fix the intro" } }); expect(rc.status).toBe(201); expect(((await json(rc)) as ReviewStateResp).state).toBe("changes_requested"); // APPROVAL_DECISION → the REQUESTER (the editor), not the decider const decNotifs = await app.prisma.notification.findMany({ where: { type: "APPROVAL_DECISION" }, select: { userId: true } }); expect(decNotifs.some((n) => n.userId === owner.userId)).toBe(true); expect(decNotifs.some((n) => n.userId === reviewer.userId)).toBe(false); // the review row closed with the decision const closed = await app.prisma.review.findFirst({ where: { pageId: page.id, closedAt: { not: null } }, select: { decision: true, decidedBy: true } }); expect(closed?.decision).toBe("changes_requested"); expect(closed?.decidedBy).toBe(reviewer.userId); // resubmit after fixing (changes_requested → in_review) const resub = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } }); expect(resub.status).toBe(201); // approve const ok = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: hr, body: { action: "approve" } }); expect(ok.status).toBe(201); expect(((await json(ok)) as ReviewStateResp).state).toBe("published"); const final = await app.prisma.page.findUnique({ where: { id: page.id }, select: { reviewState: true } }); expect(final?.reviewState).toBe("published"); // audit trail: submitted → changes_requested → submitted → approved const audits = await app.prisma.auditLog.findMany({ where: { resourceType: "page", resourceId: page.id, action: { startsWith: "review." } }, orderBy: { createdAt: "asc" }, select: { action: true } }); expect(audits.map((a) => a.action)).toEqual(["review.submitted", "review.changes_requested", "review.submitted", "review.approved"]); }); it("A-003 gate: a page-EDIT member who is NOT a reviewer of record → 403 on approve", async () => { const owner = await createUser(app.request, { name: "G Owner" }); const reviewer = await createUser(app.request, { name: "G Reviewer" }); const bystander = await createUser(app.request, { name: "G Bystander" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "G WS"); for (const u of [reviewer, bystander]) await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, u.email, "MEMBER"); const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "G doc" } }))) as { id: string }; await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } }); // the bystander HAS page EDIT (membership floor) but is NOT in reviewerIds → 403 const denied = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: authHeaders(bystander.accessToken!), body: { action: "approve" } }); expect(denied.status).toBe(403); const deniedRc = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: authHeaders(bystander.accessToken!), body: { action: "request_changes" } }); expect(deniedRc.status).toBe(403); // and the editor cannot decide either (submitter ≠ reviewer of record) const deniedEditor = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "approve" } }); expect(deniedEditor.status).toBe(403); }); it("cancel (editor) withdraws an in-flight review; approve with no open review → 400", async () => { const owner = await createUser(app.request, { name: "C Owner" }); const reviewer = await createUser(app.request, { name: "C Reviewer" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "C WS"); await addMemberViaSeam(app.request, app.internalServiceToken, ws.workspaceId, reviewer.email, "MEMBER"); const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "C doc" } }))) as { id: string }; await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "submit", reviewerIds: [reviewer.userId] } }); // only the editor may cancel (the reviewer → 403) const deniedCancel = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: authHeaders(reviewer.accessToken!), body: { action: "cancel" } }); expect(deniedCancel.status).toBe(403); const cancel = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "cancel" } }); expect(cancel.status).toBe(201); expect(((await json(cancel)) as ReviewStateResp).state).toBe("published"); // now approve with NO open review → 400 const none = await app.request("POST", `/api/v1/pages/${page.id}/review`, { headers: h, body: { action: "approve" } }); expect(none.status).toBe(400); }); it("non-member → 403; unknown page → 404 (no-leak)", async () => { const owner = await createUser(app.request, { name: "Lk Owner" }); const outsider = await createUser(app.request, { name: "Lk Outsider" }); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h, "Lk WS"); const page = (await json(await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "Lk doc" } }))) as { id: string }; const denied = await app.request("GET", `/api/v1/pages/${page.id}/review`, { headers: authHeaders(outsider.accessToken!) }); expect(denied.status).toBe(403); const missing = await app.request("GET", `/api/v1/pages/does_not_exist/review`, { headers: h }); expect(missing.status).toBe(404); const missingPost = await app.request("POST", `/api/v1/pages/does_not_exist/review`, { headers: h, body: { action: "approve" } }); expect(missingPost.status).toBe(404); }); });