pelagia-portal/App/tests/integration/closed-po-attachments.test.ts
Hardik 158b446117
All checks were successful
PR checks / checks (pull_request) Successful in 51s
PR checks / integration (pull_request) Successful in 31s
feat(po): feature-flagged attachments on closed POs (bug remediation)
Adds NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED. When on, a CLOSED PO's own
submitter -- plus Accounts / Manager / SuperUser -- can attach documents to
it, so POs whose uploads were lost to the document-upload bug can be fixed
without reopening them. Off by default, so production stays unchanged until
enabled.

- lib/permissions.ts: canAddClosedPoAttachment(role, { isSubmitter }) gated
  by the flag; allowed roles are ACCOUNTS/MANAGER/SUPERUSER (plus the PO's
  own submitter regardless of role).
- uploadPoDocuments: a CLOSED PO is otherwise immutable, so it now enforces
  the permission server-side; the normal create/receipt flows upload while
  the PO is pre-CLOSED and are unaffected.
- po-detail.tsx: when allowed, the Attachments card renders an uploader
  (ClosedPoAttachmentUploader) and shows even when the PO has no docs yet.
- Enabled on staging (staging-up.sh) so the remediation can be exercised;
  documented in .env.example and CLAUDE.md.

Tests: closed-po-attachments.test.ts covers the flag-on role matrix (own
submitter / Accounts / Manager / SuperUser allowed; other submitter-role and
auditor refused; non-closed PO unaffected); po-document-upload.test.ts adds
the flag-off case (closed PO stays immutable). Full unit + integration suites
green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 01:11:29 +05:30

131 lines
5.2 KiB
TypeScript

/**
* Integration test for the feature-flagged closed-PO attachment remediation
* (NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED). With the flag ON, a CLOSED PO's own
* submitter — plus Accounts / Manager / SuperUser — may attach documents; everyone
* else is refused. (The flag-OFF case 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 remediation 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 } 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_CLOSEDPO_";
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 CLOSED PO submitted by the TECHNICAL user.
async function makeClosedPo(title: string): 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;
await db.purchaseOrder.update({ where: { id: poId }, data: { status: "CLOSED" } });
return poId;
}
describe("closed-PO attachments (flag on)", () => {
it("lets the PO's own submitter attach to their closed PO", async () => {
const poId = await makeClosedPo(`${PREFIX}Submitter`);
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 makeClosedPo(`${PREFIX}${key}`);
as(userIds[key], role);
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(1);
});
it("refuses a submitter-role user who is not this PO's submitter", async () => {
const poId = await makeClosedPo(`${PREFIX}OtherSubmitter`);
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: "Adding attachments to a closed purchase order isn't allowed." });
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 makeClosedPo(`${PREFIX}Auditor`);
as(userIds.AUDITOR, "AUDITOR");
const err = await uploadPoDocuments(poId, [pdf("doc.pdf")]);
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("still allows uploads to a non-closed PO (normal flow unaffected)", async () => {
as(techId, "TECHNICAL");
const result = await createPo(makePoForm({ title: `${PREFIX}Draft`, vesselId, accountId, intent: "draft" }));
const poId = (result as { id: string }).id; // stays 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);
});
});