pelagia-portal/App/tests/integration/crew-pii-page.test.ts
Hardik 06ff587024 fix(crewing): mask Aadhaar/PAN document numbers server-side
The crew profile page passed SeafarerDocument.number to the client unmasked for
all roles and all doc types, exposing full Aadhaar/PAN identity numbers to MPO /
Manager / Site staff — contradicting the field's PII annotation and §6 /
Roles-and-Permissions §3 (Aadhaar/PAN are gated to Accounts/SuperUser, same as
the bank account number).

- crew-pii.ts: add documentNumberValue(number, docType, role) — masks AADHAAR /
  PAN for non-privileged roles via the existing canViewFullBankEpf gate +
  maskTail; non-identity docs (passport, CDC, STCW…) pass through; preserves the
  string|null contract.
- crew/[id]/page.tsx: mask the number server-side before it crosses to the client.
- Tests: unit cases for the helper; an integration test that invokes the server
  component and asserts the documents prop is masked for MANAGER/SITE_STAFF/MPO
  and full for ACCOUNTS/SUPERUSER.

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

87 lines
3.7 KiB
TypeScript

/**
* 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 <CrewProfile>, 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<unknown>).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);
});
});