/** * EPFO assisted-verification coverage: * - the EpfoService deterministic STUB contract the app relies on (no live * portal): OTP 000000 → matched; UAN/OTP validation; session expiry. * - the Next proxy routes' verify_bank_epf permission gate (§6) — only Accounts * (or SuperUser) may reach the upstream service. * No EPFO_LIVE, no running service: the stub logic is imported directly and the * upstream fetch is mocked. */ import { vi, describe, it, expect, beforeEach } from "vitest"; vi.mock("@/auth", () => ({ auth: vi.fn() })); import { auth } from "@/auth"; import { POST as otpPOST } from "@/app/api/epfo/otp/route"; import { POST as verifyPOST } from "@/app/api/epfo/route"; import { stubOtp, stubVerify, isUan, STUB_MATCH_OTP } from "../../../EpfoService/src/stub"; import { makeSession } from "./helpers"; import type { NextRequest } from "next/server"; import type { Role } from "@prisma/client"; const UAN = "100200300400"; const as = (role: Role | null) => vi.mocked(auth as unknown as () => Promise).mockResolvedValue(role ? makeSession(`u-${role}`, role) : null); // Minimal NextRequest stand-in: the handlers only call req.json(). const req = (body: unknown) => ({ json: async () => body } as unknown as NextRequest); beforeEach(() => vi.clearAllMocks()); describe("EpfoService stub contract", () => { it("stubOtp validates the 12-digit UAN and opens a session", () => { const ok = stubOtp(UAN, "sess-1"); expect(ok.status).toBe(200); expect(ok.body).toMatchObject({ sessionId: "sess-1", stub: true }); expect(stubOtp("123", "sess-1").status).toBe(400); // too short expect(stubOtp(undefined, "sess-1").status).toBe(400); expect(isUan(UAN)).toBe(true); expect(isUan("12345678901")).toBe(false); }); it("stubVerify matches only OTP 000000 and validates session/uan/otp", () => { const session = { uan: UAN }; const matched = stubVerify(session, UAN, STUB_MATCH_OTP); expect(matched.status).toBe(200); expect(matched.body).toMatchObject({ matched: true, name: "EPFO Member (stub)", status: "ACTIVE" }); const wrong = stubVerify(session, UAN, "123456"); expect(wrong.body).toMatchObject({ matched: false, name: null }); expect(stubVerify(undefined, UAN, STUB_MATCH_OTP).status).toBe(410); // expired/unknown session expect(stubVerify(session, "999999999999", STUB_MATCH_OTP).status).toBe(400); // UAN mismatch expect(stubVerify(session, UAN, "12").status).toBe(400); // OTP too short expect(stubVerify(session, UAN, "abcd").status).toBe(400); // non-numeric OTP }); }); describe("EPFO proxy routes — verify_bank_epf gate (§6)", () => { it("rejects an unauthenticated caller (401) on both routes", async () => { as(null); expect((await otpPOST(req({ uan: UAN }))).status).toBe(401); expect((await verifyPOST(req({ sessionId: "s", uan: UAN, otp: STUB_MATCH_OTP }))).status).toBe(401); }); it("forbids a role without verify_bank_epf (MPO → 403)", async () => { as("MANNING"); expect((await otpPOST(req({ uan: UAN }))).status).toBe(403); expect((await verifyPOST(req({ sessionId: "s", uan: UAN, otp: STUB_MATCH_OTP }))).status).toBe(403); }); it("lets Accounts through to the upstream service (mocked)", async () => { as("ACCOUNTS"); const fetchMock = vi.spyOn(global, "fetch").mockResolvedValue( new Response(JSON.stringify({ sessionId: "epfo_1", mobileHint: "••••••••", stub: true }), { status: 200, headers: { "Content-Type": "application/json" }, }) ); const res = await otpPOST(req({ uan: UAN })); expect(res.status).toBe(200); expect(await res.json()).toMatchObject({ sessionId: "epfo_1" }); expect(fetchMock).toHaveBeenCalledOnce(); fetchMock.mockRestore(); }); it("validates the body before calling upstream (Accounts, missing fields → 400)", async () => { as("ACCOUNTS"); const fetchMock = vi.spyOn(global, "fetch"); expect((await otpPOST(req({}))).status).toBe(400); expect((await verifyPOST(req({ uan: UAN }))).status).toBe(400); // no sessionId/otp expect(fetchMock).not.toHaveBeenCalled(); }); });