pelagia-portal/App/tests/integration/epfo.test.ts
Hardik 1ef0c53ff0
All checks were successful
PR checks / checks (pull_request) Successful in 45s
PR checks / integration (pull_request) Successful in 30s
test(crewing): cover EPFO stub contract + /api/epfo permission gate
- Extract EpfoService's pure stub + validation logic into a dependency-free
  module (EpfoService/src/stub.ts); index.ts now uses it in its stub branches so
  the tested logic IS the production stub behaviour.
- epfo.test.ts (App integration): the deterministic stub contract
  (OTP 000000 → matched, UAN/OTP validation, session expiry) and the Next proxy
  routes' verify_bank_epf gate — 401 unauthenticated, 403 for the MPO, Accounts
  passes through to a mocked upstream, body validated before the upstream call.
  No EPFO_LIVE, no running service.

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

93 lines
4.1 KiB
TypeScript

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