/** * Tests for `src/lib/water-log-pin.ts` — PIN hashing/verification used * by the field portal. The hashing layer is critical: a 4-digit PIN is * essentially a 10,000-entry dictionary, so a slow KDF and a per-row * random salt are the only defense-in-depth that matters if the * database leaks. * * The `server-only` import is stubbed so the test runner can import * the module (the pattern matches `tests/unit/passwords.test.ts`). */ import { describe, it, expect, vi } from "vitest"; vi.mock("server-only", () => ({})); import { hashPin, verifyPin, validatePin, isWeakPin, generatePin, PIN_MIN, PIN_MAX, } from "@/lib/water-log-pin"; describe("validatePin", () => { it("accepts a 4-digit numeric PIN", () => { expect(validatePin("1234")).toBeNull(); }); it("accepts the full 8-digit range", () => { expect(validatePin("12345678")).toBeNull(); }); it("rejects pins shorter than PIN_MIN", () => { expect(validatePin("12")).toMatch(/4.*8/); }); it("rejects pins longer than PIN_MAX", () => { expect(validatePin("123456789")).toMatch(/4.*8/); }); it("rejects non-numeric input", () => { expect(validatePin("123a")).toBe("PIN must be numeric"); }); it("rejects non-string types", () => { expect(validatePin(1234)).toBe("PIN must be a string"); expect(validatePin(null)).toBe("PIN must be a string"); expect(validatePin(undefined)).toBe("PIN must be a string"); }); it("accepts leading zeros", () => { expect(validatePin("0007")).toBeNull(); }); }); describe("isWeakPin", () => { it("flags repeated digits", () => { expect(isWeakPin("1111")).toBe(true); expect(isWeakPin("0000")).toBe(true); }); it("flags monotonic runs", () => { expect(isWeakPin("1234")).toBe(true); expect(isWeakPin("4321")).toBe(true); }); it("flags palindromes", () => { expect(isWeakPin("1221")).toBe(true); expect(isWeakPin("3443")).toBe(true); }); it("does not flag random-looking pins", () => { expect(isWeakPin("4739")).toBe(false); expect(isWeakPin("8201")).toBe(false); }); }); describe("hashPin / verifyPin", () => { it("round-trips a 4-digit PIN", () => { const h = hashPin("4739"); expect(verifyPin("4739", h)).toBe(true); expect(verifyPin("4740", h)).toBe(false); }); it("uses a random salt — same PIN produces different hashes", () => { const a = hashPin("4739"); const b = hashPin("4739"); expect(a).not.toBe(b); expect(verifyPin("4739", a)).toBe(true); expect(verifyPin("4739", b)).toBe(true); }); it("uses a self-describing format (scrypt$N$r$p$salt$hash)", () => { const h = hashPin("4739"); const parts = h.split("$"); expect(parts[0]).toBe("scrypt"); expect(Number.parseInt(parts[1], 10)).toBeGreaterThan(0); expect(Number.parseInt(parts[2], 10)).toBeGreaterThan(0); expect(Number.parseInt(parts[3], 10)).toBeGreaterThan(0); // Salt is 16 bytes base64 — length depends on padding but always > 16 expect(parts[4].length).toBeGreaterThanOrEqual(16); // Hash is 32 bytes base64 expect(parts[5].length).toBeGreaterThanOrEqual(40); }); it("returns false (no exception) for tampered hashes", () => { expect(verifyPin("4739", "not-a-real-hash")).toBe(false); expect(verifyPin("4739", "scrypt$1$1$1$xx$yy")).toBe(false); expect(verifyPin("4739", "")).toBe(false); }); it("returns false for the wrong PIN (without revealing why)", () => { const h = hashPin("4739"); expect(verifyPin("0000", h)).toBe(false); expect(verifyPin("9999", h)).toBe(false); }); it("rejects the empty string (no bypass for missing PIN)", () => { const h = hashPin("4739"); expect(verifyPin("", h)).toBe(false); }); }); describe("generatePin", () => { it("returns a 4-digit string", () => { const pin = generatePin(); expect(pin).toMatch(/^\d{4}$/); }); it("avoids obvious weak patterns (no 1111, 1234, etc.)", () => { // 1000 attempts — should never see a weak pin if the generator works. for (let i = 0; i < 1000; i++) { const pin = generatePin(); expect(isWeakPin(pin)).toBe(false); } }); it("produces variety across many calls (statistical)", () => { const seen = new Set(); for (let i = 0; i < 200; i++) { seen.add(generatePin()); } // With 10k possible 4-digit pins, we'd expect to see well over 100 // distinct values in 200 draws. This is a sanity check, not a hard // guarantee — if it ever flakes, increase the iteration count. expect(seen.size).toBeGreaterThan(80); }); }); describe("PIN length constants", () => { it("PINs are 4–8 digits", () => { expect(PIN_MIN).toBe(4); expect(PIN_MAX).toBe(8); }); });