Broadens the feature-flagged attachment affordance (same flag,
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED) from CLOSED-only to **any PO state
except REJECTED / CANCELLED**, for the same roles: the PO's own submitter plus
Accounts / Manager / SuperUser.
- lib/permissions.ts: canAddClosedPoAttachment → canAddPoAttachment(role,
status, { isSubmitter }); allows the submitter + ACCOUNTS/MANAGER/SUPERUSER
in any non-voided state. REJECTED/CANCELLED are always refused.
- uploadPoDocuments: voided POs are refused regardless of the flag; with the
flag on, uploads are restricted to those roles in any live state (the normal
create/receipt actors qualify, so those flows keep working); with the flag
off, the legacy behaviour stands (closed POs immutable).
- po-detail.tsx: the Attachments card now shows the uploader for any non-voided
state when permitted (not just CLOSED).
- Renamed ClosedPoAttachmentUploader → PoAttachmentUploader and the test file
to po-attachment-permissions.test.ts (flag-on matrix now covers live states +
rejected/cancelled refusal). Docs updated (feature-flags, .env.example,
CLAUDE.md).
Full unit + integration suites green; tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
170 lines
6.6 KiB
TypeScript
170 lines
6.6 KiB
TypeScript
/**
|
|
* 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<typeof import("@/lib/storage")>();
|
|
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<typeof import("@/lib/feature-flags")>();
|
|
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<string, string> = {};
|
|
|
|
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<unknown>).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<string> {
|
|
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<POStatus>(["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<POStatus>(["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);
|
|
});
|
|
});
|