- 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>
93 lines
4.1 KiB
TypeScript
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();
|
|
});
|
|
});
|