diff --git a/App/app/(portal)/crewing/crew/[id]/page.tsx b/App/app/(portal)/crewing/crew/[id]/page.tsx index 43a4250..703515b 100644 --- a/App/app/(portal)/crewing/crew/[id]/page.tsx +++ b/App/app/(portal)/crewing/crew/[id]/page.tsx @@ -2,7 +2,7 @@ import { auth } from "@/auth"; import { db } from "@/lib/db"; import { hasPermission } from "@/lib/permissions"; import { CREWING_ENABLED } from "@/lib/feature-flags"; -import { canViewSalary, bankEpfValue } from "@/lib/crew-pii"; +import { canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii"; import { redirect, notFound } from "next/navigation"; import { CrewProfile } from "./crew-profile"; import type { Metadata } from "next"; @@ -68,7 +68,7 @@ export default async function CrewProfilePage({ params }: { params: Promise<{ id documents={c.documents.map((d) => ({ id: d.id, docType: d.docType, - number: d.number, + number: documentNumberValue(d.number, d.docType, role), issueDate: d.issueDate?.toISOString() ?? null, expiryDate: d.expiryDate?.toISOString() ?? null, verificationStatus: d.verificationStatus, diff --git a/App/lib/crew-pii.ts b/App/lib/crew-pii.ts index 0550c5a..ab06947 100644 --- a/App/lib/crew-pii.ts +++ b/App/lib/crew-pii.ts @@ -1,4 +1,4 @@ -import type { Role } from "@prisma/client"; +import type { Role, SeafarerDocType } from "@prisma/client"; // PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8). // Bank account / EPF identity numbers are full only for Accounts (and SuperUser); @@ -8,6 +8,11 @@ export function canViewFullBankEpf(role: Role): boolean { return role === "ACCOUNTS" || role === "SUPERUSER"; } +// Identity documents whose number is itself restricted PII (Aadhaar/PAN), gated +// like bank/EPF (§6, Roles-and-Permissions §3). Other seafarer documents +// (passport, CDC, STCW, COC, medical…) are not number-restricted. +const RESTRICTED_DOC_TYPES = new Set(["AADHAAR", "PAN"]); + export function canViewSalary(role: Role): boolean { // Office roles see salary; site staff see status only (§6, R7). return role !== "SITE_STAFF"; @@ -26,3 +31,18 @@ export function bankEpfValue(value: string | null | undefined, role: Role): stri if (!value) return "—"; return canViewFullBankEpf(role) ? value : maskTail(value); } + +// A seafarer document number, masked for non-privileged roles when the document +// type is itself restricted PII (Aadhaar/PAN). Non-restricted documents pass +// through unchanged. Preserves the `string | null` contract the profile expects. +export function documentNumberValue( + value: string | null | undefined, + docType: SeafarerDocType, + role: Role +): string | null { + if (!value) return null; + if (RESTRICTED_DOC_TYPES.has(docType) && !canViewFullBankEpf(role)) { + return maskTail(value); + } + return value; +} diff --git a/App/tests/integration/crew-pii-page.test.ts b/App/tests/integration/crew-pii-page.test.ts new file mode 100644 index 0000000..1213b36 --- /dev/null +++ b/App/tests/integration/crew-pii-page.test.ts @@ -0,0 +1,87 @@ +/** + * Integration test for the server-side PII masking on the crew profile page. + * Identity-document numbers (Aadhaar/PAN) must be masked BEFORE they cross to the + * client component — full only for Accounts/SuperUser (Crewing-Implementation-Spec + * §6 / Roles-and-Permissions §3). We invoke the server component and inspect the + * props it hands to , so a regression that passes raw numbers to the + * client is caught here. + */ +import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import React from "react"; + +// The integration runner compiles the page's JSX to classic React.createElement +// without injecting React; provide it so invoking the server component works. +(globalThis as unknown as { React: typeof React }).React = React; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); +vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true })); +vi.mock("next/navigation", () => ({ redirect: vi.fn(), notFound: vi.fn() })); +// The client component is irrelevant to this test — we read element.props directly. +vi.mock("@/app/(portal)/crewing/crew/[id]/crew-profile", () => ({ CrewProfile: () => null })); + +import { auth } from "@/auth"; +import { db } from "@/lib/db"; +import CrewProfilePage from "@/app/(portal)/crewing/crew/[id]/page"; +import { makeSession } from "./helpers"; +import type { Role } from "@prisma/client"; + +const AADHAAR = "123456789012"; +const PAN = "ABCDE1234F"; + +let crewId: string; +const as = (role: Role) => + vi.mocked(auth as unknown as () => Promise).mockResolvedValue(makeSession(`u-${role}`, role)); + +// Pull the documents prop the page would pass to the client component. +async function docsFor(role: Role) { + as(role); + const element = (await CrewProfilePage({ params: Promise.resolve({ id: crewId }) })) as { + props: { documents: Array<{ docType: string; number: string | null }> }; + }; + return element.props.documents; +} +const numberFor = (docs: Array<{ docType: string; number: string | null }>, docType: string) => + docs.find((d) => d.docType === docType)?.number ?? null; + +beforeAll(async () => { + const c = await db.crewMember.create({ + data: { name: "PII Crew", status: "EMPLOYEE", type: "NEW", source: "CAREERS", employeeId: `CRW-PII${Date.now() % 100000}` }, + }); + crewId = c.id; + await db.seafarerDocument.createMany({ + data: [ + { crewMemberId: c.id, docType: "AADHAAR", number: AADHAAR }, + { crewMemberId: c.id, docType: "PAN", number: PAN }, + { crewMemberId: c.id, docType: "PASSPORT", number: "P1234567" }, + ], + }); +}); + +afterEach(() => vi.clearAllMocks()); + +afterAll(async () => { + await db.seafarerDocument.deleteMany({ where: { crewMemberId: crewId } }); + await db.crewMember.deleteMany({ where: { id: crewId } }); +}); + +describe("crew profile — identity-document masking (server-side)", () => { + it("masks Aadhaar/PAN for a MANAGER", async () => { + const docs = await docsFor("MANAGER"); + expect(numberFor(docs, "AADHAAR")).toBe("•••• 9012"); + expect(numberFor(docs, "PAN")).toBe("•••• 234F"); + // Non-identity documents are not restricted. + expect(numberFor(docs, "PASSPORT")).toBe("P1234567"); + }); + + it("masks Aadhaar/PAN for SITE_STAFF and the MPO too", async () => { + expect(numberFor(await docsFor("SITE_STAFF"), "AADHAAR")).toBe("•••• 9012"); + expect(numberFor(await docsFor("MANNING"), "PAN")).toBe("•••• 234F"); + }); + + it("shows Aadhaar/PAN in full to ACCOUNTS and SUPERUSER", async () => { + const acc = await docsFor("ACCOUNTS"); + expect(numberFor(acc, "AADHAAR")).toBe(AADHAAR); + expect(numberFor(acc, "PAN")).toBe(PAN); + expect(numberFor(await docsFor("SUPERUSER"), "AADHAAR")).toBe(AADHAAR); + }); +}); diff --git a/App/tests/unit/crew-pii.test.ts b/App/tests/unit/crew-pii.test.ts index 0301f62..48fc080 100644 --- a/App/tests/unit/crew-pii.test.ts +++ b/App/tests/unit/crew-pii.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue } from "@/lib/crew-pii"; +import { maskTail, canViewFullBankEpf, canViewSalary, bankEpfValue, documentNumberValue } from "@/lib/crew-pii"; // PII visibility rules for the crew profile (Crewing-Implementation-Spec §6/§8.8). describe("crew PII masking", () => { @@ -43,4 +43,25 @@ describe("crew PII masking", () => { expect(bankEpfValue(null, "ACCOUNTS")).toBe("—"); }); }); + + describe("documentNumberValue", () => { + it("masks Aadhaar/PAN numbers for non-privileged roles", () => { + expect(documentNumberValue("123456789012", "AADHAAR", "MANAGER")).toBe("•••• 9012"); + expect(documentNumberValue("123456789012", "AADHAAR", "MANNING")).toBe("•••• 9012"); + expect(documentNumberValue("ABCDE1234F", "PAN", "SITE_STAFF")).toBe("•••• 234F"); + }); + it("shows Aadhaar/PAN in full to Accounts and SuperUser", () => { + expect(documentNumberValue("123456789012", "AADHAAR", "ACCOUNTS")).toBe("123456789012"); + expect(documentNumberValue("ABCDE1234F", "PAN", "SUPERUSER")).toBe("ABCDE1234F"); + }); + it("does not restrict non-identity documents for any role", () => { + expect(documentNumberValue("P1234567", "PASSPORT", "SITE_STAFF")).toBe("P1234567"); + expect(documentNumberValue("CDC-99", "CDC", "MANNING")).toBe("CDC-99"); + expect(documentNumberValue("STCW-1", "STCW", "MANAGER")).toBe("STCW-1"); + }); + it("returns null for an empty number regardless of type/role", () => { + expect(documentNumberValue(null, "AADHAAR", "ACCOUNTS")).toBeNull(); + expect(documentNumberValue("", "PASSPORT", "MANAGER")).toBeNull(); + }); + }); });