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>
This commit is contained in:
parent
93d13a415c
commit
1ef0c53ff0
3 changed files with 154 additions and 17 deletions
93
App/tests/integration/epfo.test.ts
Normal file
93
App/tests/integration/epfo.test.ts
Normal file
|
|
@ -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<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
*/
|
*/
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import type { Browser, BrowserContext, Page } from "playwright";
|
import type { Browser, BrowserContext, Page } from "playwright";
|
||||||
|
import { isUan, mobileHint, stubOtp, stubVerify } from "./stub";
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 3004);
|
const PORT = Number(process.env.PORT ?? 3004);
|
||||||
const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min
|
const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min
|
||||||
|
|
@ -65,9 +66,6 @@ async function getBrowser(): Promise<Browser> {
|
||||||
return _browser;
|
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 ────────────────────────────────────────────────────────────────────────
|
// ── App ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const app = express();
|
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. */
|
/** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */
|
||||||
app.post("/otp", async (req, res) => {
|
app.post("/otp", async (req, res) => {
|
||||||
const { uan } = req.body ?? {};
|
const { uan } = req.body ?? {};
|
||||||
if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" });
|
|
||||||
|
|
||||||
const sessionId = newSessionId();
|
const sessionId = newSessionId();
|
||||||
|
|
||||||
if (!LIVE) {
|
if (!LIVE) {
|
||||||
sessions.set(sessionId, { uan, createdAt: Date.now() });
|
const r = stubOtp(uan, sessionId);
|
||||||
log("INFO", "OTP requested (stub)", { sessionId });
|
if (r.ok) {
|
||||||
return res.json({ sessionId, mobileHint: mobileHint(), stub: true });
|
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 {
|
try {
|
||||||
const browser = await getBrowser();
|
const browser = await getBrowser();
|
||||||
const context = await browser.newContext();
|
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. */
|
/** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
|
||||||
app.post("/verify", async (req, res) => {
|
app.post("/verify", async (req, res) => {
|
||||||
const { sessionId, uan, otp } = req.body ?? {};
|
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 (!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 (!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 (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 {
|
try {
|
||||||
// TODO(live): submit the OTP and scrape the member record (name/DOB/status).
|
// 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 };
|
const result = { matched: false, name: null as string | null, status: null as string | null };
|
||||||
|
|
|
||||||
42
EpfoService/src/stub.ts
Normal file
42
EpfoService/src/stub.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue