/** * Integration test for the feature-flagged PO-attachment permission * (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a PO's own * submitter — plus Accounts / Manager / SuperUser — may attach documents to a PO * in **any state except REJECTED / CANCELLED**; everyone else, and any voided PO, * is refused. (The flag-OFF behaviour lives in po-document-upload.test.ts.) */ import { vi, describe, it, expect, beforeAll, afterEach } from "vitest"; vi.mock("@/auth", () => ({ auth: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("@/lib/storage", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, uploadBuffer: vi.fn().mockResolvedValue(undefined) }; }); // Flip ONLY the attachment flag on; everything else stays real. vi.mock("@/lib/feature-flags", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, CLOSED_PO_ATTACHMENTS_ENABLED: true }; }); import { auth } from "@/auth"; import { db } from "@/lib/db"; import { uploadBuffer } from "@/lib/storage"; import type { Role, POStatus } from "@prisma/client"; import { createPo } from "@/app/(portal)/po/new/actions"; import { uploadPoDocuments } from "@/app/actions/upload-po-documents"; import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers"; const PREFIX = "INTTEST_POATTACH_"; const VOID_ERROR = "Attachments can't be added to a rejected or cancelled purchase order."; const DENY_ERROR = "Adding attachments to this purchase order isn't allowed."; let techId: string; // the PO's submitter let vesselId: string; let accountId: string; const userIds: Record = {}; beforeAll(async () => { const [tech, accounts, manager, superuser, manning, auditor, vessel, account] = await Promise.all([ getSeedUser("tech@pelagia.local"), getSeedUser("accounts@pelagia.local"), getSeedUser("manager@pelagia.local"), getSeedUser("superuser@pelagia.local"), getSeedUser("manning@pelagia.local"), getSeedUser("auditor@pelagia.local"), getSeedVessel("MV Pelagia Star"), getSeedAccount("700201"), ]); techId = tech.id; vesselId = vessel.id; accountId = account.id; userIds.ACCOUNTS = accounts.id; userIds.MANAGER = manager.id; userIds.SUPERUSER = superuser.id; userIds.MANNING = manning.id; userIds.AUDITOR = auditor.id; }); afterEach(async () => { await deletePosByTitle(PREFIX); vi.clearAllMocks(); }); function as(userId: string, role: Role) { vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(userId, role)); } function pdf(name: string): File { return new File(["%PDF-1.4 hello"], name, { type: "application/pdf" }); } // A PO submitted by the TECHNICAL user, forced into `status`. async function makePo(title: string, status: POStatus): Promise { as(techId, "TECHNICAL"); const result = await createPo(makePoForm({ title, vesselId, accountId, intent: "draft" })); expect(result).not.toHaveProperty("error"); const poId = (result as { id: string }).id; if (status !== "DRAFT") { await db.purchaseOrder.update({ where: { id: poId }, data: { status } }); } return poId; } describe("PO attachment permissions (flag on)", () => { it("lets the PO's own submitter attach to their PO", async () => { const poId = await makePo(`${PREFIX}Submitter`, "CLOSED"); as(techId, "TECHNICAL"); const err = await uploadPoDocuments(poId, [pdf("missing-invoice.pdf")]); expect(err).toBeNull(); expect(await db.pODocument.count({ where: { poId } })).toBe(1); }); it.each<[string, Role]>([ ["ACCOUNTS", "ACCOUNTS"], ["MANAGER", "MANAGER"], ["SUPERUSER", "SUPERUSER"], ])("lets %s attach to a closed PO they did not submit", async (key, role) => { const poId = await makePo(`${PREFIX}${key}`, "CLOSED"); as(userIds[key], role); const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]); expect(err).toBeNull(); expect(await db.pODocument.count({ where: { poId } })).toBe(1); }); // The headline of this change: not just CLOSED — any live state. it.each(["MGR_REVIEW", "MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "EDITS_REQUESTED"])( "lets Manager attach to a PO in %s", async (status) => { const poId = await makePo(`${PREFIX}${status}`, status); as(userIds.MANAGER, "MANAGER"); const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]); expect(err).toBeNull(); expect(await db.pODocument.count({ where: { poId } })).toBe(1); } ); it.each(["REJECTED", "CANCELLED"])( "refuses attachments to a %s PO, even for Manager", async (status) => { const poId = await makePo(`${PREFIX}${status}`, status); as(userIds.MANAGER, "MANAGER"); const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]); expect(err).toEqual({ error: VOID_ERROR }); expect(uploadBuffer).not.toHaveBeenCalled(); expect(await db.pODocument.count({ where: { poId } })).toBe(0); } ); it("refuses a voided PO even for its own submitter", async () => { const poId = await makePo(`${PREFIX}SubmitterRejected`, "REJECTED"); as(techId, "TECHNICAL"); const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]); expect(err).toEqual({ error: VOID_ERROR }); expect(await db.pODocument.count({ where: { poId } })).toBe(0); }); it("refuses a submitter-role user who is not this PO's submitter", async () => { const poId = await makePo(`${PREFIX}OtherSubmitter`, "MGR_APPROVED"); as(userIds.MANNING, "MANNING"); // a submitter role, but not the PO's submitter const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]); expect(err).toEqual({ error: DENY_ERROR }); expect(uploadBuffer).not.toHaveBeenCalled(); expect(await db.pODocument.count({ where: { poId } })).toBe(0); }); it("refuses a role outside the allow-list (auditor)", async () => { const poId = await makePo(`${PREFIX}Auditor`, "CLOSED"); as(userIds.AUDITOR, "AUDITOR"); const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]); expect(err).toEqual({ error: DENY_ERROR }); expect(await db.pODocument.count({ where: { poId } })).toBe(0); }); it("still allows the normal create flow (DRAFT submitter)", async () => { const poId = await makePo(`${PREFIX}Draft`, "DRAFT"); as(techId, "TECHNICAL"); const err = await uploadPoDocuments(poId, [pdf("draft-doc.pdf")]); expect(err).toBeNull(); expect(await db.pODocument.count({ where: { poId } })).toBe(1); }); });