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>
87 lines
3.7 KiB
TypeScript
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);
|
|
});
|
|
});
|