From 1ef0c53ff03b561090bd62a80761b86453274a87 Mon Sep 17 00:00:00 2001 From: Hardik Date: Mon, 22 Jun 2026 23:56:14 +0530 Subject: [PATCH] test(crewing): cover EPFO stub contract + /api/epfo permission gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- App/tests/integration/epfo.test.ts | 93 ++++++++++++++++++++++++++++++ EpfoService/src/index.ts | 36 ++++++------ EpfoService/src/stub.ts | 42 ++++++++++++++ 3 files changed, 154 insertions(+), 17 deletions(-) create mode 100644 App/tests/integration/epfo.test.ts create mode 100644 EpfoService/src/stub.ts diff --git a/App/tests/integration/epfo.test.ts b/App/tests/integration/epfo.test.ts new file mode 100644 index 0000000..4d48f5c --- /dev/null +++ b/App/tests/integration/epfo.test.ts @@ -0,0 +1,93 @@ +/** + * 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(); + }); +}); diff --git a/EpfoService/src/index.ts b/EpfoService/src/index.ts index ecf1070..80a719f 100644 --- a/EpfoService/src/index.ts +++ b/EpfoService/src/index.ts @@ -18,6 +18,7 @@ */ import express from "express"; import type { Browser, BrowserContext, Page } from "playwright"; +import { isUan, mobileHint, stubOtp, stubVerify } from "./stub"; const PORT = Number(process.env.PORT ?? 3004); const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min @@ -65,9 +66,6 @@ async function getBrowser(): Promise { return _browser; } -const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s); -const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••"); - // ── App ──────────────────────────────────────────────────────────────────────── const app = express(); @@ -80,16 +78,19 @@ app.get("/health", (_req, res) => { /** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */ app.post("/otp", async (req, res) => { const { uan } = req.body ?? {}; - if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" }); - const sessionId = newSessionId(); if (!LIVE) { - sessions.set(sessionId, { uan, createdAt: Date.now() }); - log("INFO", "OTP requested (stub)", { sessionId }); - return res.json({ sessionId, mobileHint: mobileHint(), stub: true }); + const r = stubOtp(uan, sessionId); + if (r.ok) { + sessions.set(sessionId, { uan, createdAt: Date.now() }); + log("INFO", "OTP requested (stub)", { sessionId }); + } + return res.status(r.status).json(r.body); } + if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" }); + try { const browser = await getBrowser(); const context = await browser.newContext(); @@ -109,19 +110,20 @@ app.post("/otp", async (req, res) => { /** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */ app.post("/verify", async (req, res) => { const { sessionId, uan, otp } = req.body ?? {}; - const s = sessionId && sessions.get(sessionId); + const s = (sessionId && sessions.get(sessionId)) || undefined; + + if (!LIVE) { + const r = stubVerify(s, uan, otp); + // A valid handshake consumes the session (one OTP per request). + if (r.ok && sessionId) sessions.delete(sessionId); + log("INFO", "Verify (stub)", { sessionId, matched: r.body.matched }); + return res.status(r.status).json(r.body); + } + if (!s) return res.status(410).json({ error: "Session expired — request a new OTP" }); if (!isUan(uan) || s.uan !== uan) return res.status(400).json({ error: "UAN mismatch" }); if (typeof otp !== "string" || !/^\d{4,8}$/.test(otp)) return res.status(400).json({ error: "A valid OTP is required" }); - if (!LIVE) { - sessions.delete(sessionId); - // Deterministic stub: OTP 000000 → matched member; anything else → not matched. - const matched = otp === "000000"; - log("INFO", "Verify (stub)", { sessionId, matched }); - return res.json({ matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true }); - } - try { // TODO(live): submit the OTP and scrape the member record (name/DOB/status). const result = { matched: false, name: null as string | null, status: null as string | null }; diff --git a/EpfoService/src/stub.ts b/EpfoService/src/stub.ts new file mode 100644 index 0000000..de6af13 --- /dev/null +++ b/EpfoService/src/stub.ts @@ -0,0 +1,42 @@ +/** + * Pure, dependency-free EPFO stub + validation logic (no express/playwright), so + * the deterministic contract the PPMS app relies on can be unit-tested without + * launching the service. `index.ts` uses these in its stub branches, so the + * tested logic IS the production stub behaviour. + * + * Deterministic stub contract (EPFO_LIVE unset): + * /otp validates the UAN and opens a session. + * /verify validates session + UAN + OTP; matched iff OTP === STUB_MATCH_OTP. + */ + +export const STUB_MATCH_OTP = "000000"; + +export const isUan = (s: unknown): s is string => typeof s === "string" && /^\d{12}$/.test(s); +export const isOtp = (s: unknown): s is string => typeof s === "string" && /^\d{4,8}$/.test(s); + +export const mobileHint = (m?: string) => (m && m.length >= 4 ? `••••••${m.slice(-4)}` : "••••••••"); + +export interface StubResult { + ok: boolean; + status: number; + body: Record; +} + +/** Stub of POST /otp — validate the UAN and (caller-supplied) open a session. */ +export function stubOtp(uan: unknown, sessionId: string): StubResult { + if (!isUan(uan)) return { ok: false, status: 400, body: { error: "A 12-digit UAN is required" } }; + return { ok: true, status: 200, body: { sessionId, mobileHint: mobileHint(), stub: true } }; +} + +/** Stub of POST /verify — validate the session/UAN/OTP and return the match. */ +export function stubVerify(session: { uan: string } | undefined, uan: unknown, otp: unknown): StubResult { + if (!session) return { ok: false, status: 410, body: { error: "Session expired — request a new OTP" } }; + if (!isUan(uan) || session.uan !== uan) return { ok: false, status: 400, body: { error: "UAN mismatch" } }; + if (!isOtp(otp)) return { ok: false, status: 400, body: { error: "A valid OTP is required" } }; + const matched = otp === STUB_MATCH_OTP; + return { + ok: true, + status: 200, + body: { matched, name: matched ? "EPFO Member (stub)" : null, status: matched ? "ACTIVE" : null, stub: true }, + }; +}