/** * Unit tests for the water-log auth layer. * * Two modules are exercised here: * * - `src/actions/water-log/auth.ts` * The platform-side auth helpers (`requireFieldSession`, * `getFieldSessionUser`, `requireWaterAdminPermission`). These * are used by `/water` (irrigator PIN) and `/admin/water-log/*` * (Neon Auth + admin_users gate). * * - `src/lib/water-admin-pin-auth.ts` * The brand-side PIN-only auth for `/water/admin/*`. Cookie * presence = authenticated, no DB session table, no Neon Auth. * * Both modules are tested for the property that they never call * `getSession()` from `@/lib/auth`. The static check at the top of * the file enforces this on the platform-side module; the * brand-side module has no `@/lib/auth` import at all by design. */ import { describe, it, expect, vi, beforeEach } from "vitest"; // ── Hoisted state (visible inside vi.mock factory closures) ──────────── const state = vi.hoisted(() => ({ cookieValue: undefined as string | undefined, mockDb: { select: vi.fn() } as { select: ReturnType }, mockWithPlatformAdmin: null as unknown as ReturnType, mockWithBrand: null as unknown as ReturnType, mockGetAdminUser: null as unknown as ReturnType, })); state.mockWithPlatformAdmin = vi.fn( async (fn: (db: typeof state.mockDb) => Promise) => fn(state.mockDb), ); state.mockWithBrand = vi.fn( async (_brandId: string, fn: (db: typeof state.mockDb) => Promise) => fn(state.mockDb), ); state.mockGetAdminUser = vi.fn(); vi.mock("server-only", () => ({})); vi.mock("@/db/client", () => ({ withPlatformAdmin: state.mockWithPlatformAdmin, withBrand: state.mockWithBrand, withDb: vi.fn(), })); vi.mock("@/lib/admin-permissions", () => ({ getAdminUser: state.mockGetAdminUser, })); vi.mock("next/headers", () => ({ cookies: () => Promise.resolve({ get: () => state.cookieValue !== undefined ? { value: state.cookieValue } : undefined, set: vi.fn(), delete: vi.fn(), }), headers: () => Promise.resolve(new Headers()), })); beforeEach(() => { state.cookieValue = undefined; state.mockDb.select.mockReset(); state.mockWithPlatformAdmin.mockClear(); state.mockWithBrand.mockClear(); state.mockGetAdminUser.mockReset(); }); // ── Module guards (the regression this whole PR exists for) ───────────── describe("water-log/auth — module surface", () => { it("does not import getSession from @/lib/auth", async () => { const fs = await import("node:fs/promises"); const path = await import("node:path"); const raw = await fs.readFile( path.resolve(process.cwd(), "src/actions/water-log/auth.ts"), "utf8", ); const stripped = raw .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, "") .replace(/\s+/g, " "); expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/); expect(stripped).not.toMatch(/\bgetSession\s*\(/); }); }); describe("water-admin-pin-auth — module surface", () => { it("does not import getSession from @/lib/auth", async () => { const fs = await import("node:fs/promises"); const path = await import("node:path"); const raw = await fs.readFile( path.resolve(process.cwd(), "src/lib/water-admin-pin-auth.ts"), "utf8", ); const stripped = raw .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, "") .replace(/\s+/g, " "); expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/); expect(stripped).not.toMatch(/\bgetSession\s*\(/); expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/); }); }); // ── requireFieldSession ───────────────────────────────────────────────── describe("requireFieldSession()", () => { it("returns { ok: false, error: 'Not logged in' } when wl_session cookie is missing", async () => { state.cookieValue = undefined; const { requireFieldSession } = await import( "@/actions/water-log/auth" ); const result = await requireFieldSession(); expect(result).toEqual({ ok: false, error: "Not logged in" }); expect(state.mockWithPlatformAdmin).not.toHaveBeenCalled(); }); it("returns { ok: false, error: 'Session not found' } when cookie has no matching row", async () => { state.cookieValue = "missing-session-id"; state.mockDb.select.mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where: () => ({ limit: async () => [], }), }), }), }); const { requireFieldSession } = await import( "@/actions/water-log/auth" ); const result = await requireFieldSession(); expect(result).toEqual({ ok: false, error: "Session not found" }); }); it("returns { ok: false, error: 'Session expired' } and best-effort deletes the row when past expires_at", async () => { state.cookieValue = "expired-id"; const past = new Date(Date.now() - 60_000); state.mockDb.select.mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where: () => ({ limit: async () => [ { session: { id: "expired-id", expiresAt: past }, irrigator: { id: "u1", brandId: "b1", role: "irrigator", active: true, }, }, ], }), }), }), }); const mockDelete = vi.fn().mockReturnValue({ where: async () => undefined, }); (state.mockDb as unknown as { select: typeof vi.fn; delete: typeof vi.fn }).delete = mockDelete; const { requireFieldSession } = await import( "@/actions/water-log/auth" ); const result = await requireFieldSession(); expect(result).toEqual({ ok: false, error: "Session expired" }); expect(mockDelete).toHaveBeenCalled(); }); it("returns { ok: false, error: 'User is inactive' } when irrigator is soft-deleted", async () => { state.cookieValue = "valid-id"; const future = new Date(Date.now() + 60_000); state.mockDb.select.mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where: () => ({ limit: async () => [ { session: { id: "valid-id", expiresAt: future }, irrigator: { id: "u1", brandId: "b1", role: "irrigator", active: false, }, }, ], }), }), }), }); const { requireFieldSession } = await import( "@/actions/water-log/auth" ); const result = await requireFieldSession(); expect(result).toEqual({ ok: false, error: "User is inactive" }); }); it("returns { ok: true, userId, brandId, role } on a happy-path session", async () => { state.cookieValue = "valid-id"; const future = new Date(Date.now() + 60_000); state.mockDb.select.mockReturnValueOnce({ from: () => ({ innerJoin: () => ({ where: () => ({ limit: async () => [ { session: { id: "valid-id", expiresAt: future }, irrigator: { id: "u1", brandId: "b1", role: "irrigator", active: true, }, }, ], }), }), }), }); const { requireFieldSession } = await import( "@/actions/water-log/auth" ); const result = await requireFieldSession(); expect(result).toEqual({ ok: true, userId: "u1", brandId: "b1", role: "irrigator", }); }); }); // ── getFieldSessionUser ───────────────────────────────────────────────── describe("getFieldSessionUser()", () => { it("returns null when there is no active field session", async () => { state.cookieValue = undefined; const { getFieldSessionUser } = await import( "@/actions/water-log/auth" ); expect(await getFieldSessionUser()).toBeNull(); }); }); // ── requireWaterAdminPermission (site-admin gate) ─────────────────────── describe("requireWaterAdminPermission()", () => { it("returns { ok: false, error: 'Not signed in' } when getAdminUser is null", async () => { state.mockGetAdminUser.mockResolvedValue(null); const { requireWaterAdminPermission } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminPermission(); expect(result).toEqual({ ok: false, error: "Not signed in" }); }); it("returns { ok: false, error: 'Not authorized' } when admin lacks can_manage_water_log and is not platform_admin", async () => { state.mockGetAdminUser.mockResolvedValue({ user_id: "u1", role: "brand_admin", can_manage_water_log: false, }); const { requireWaterAdminPermission } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminPermission(); expect(result).toEqual({ ok: false, error: "Not authorized" }); }); it("returns { ok: true, adminUser } when platform_admin", async () => { const adminUser = { user_id: "u1", role: "platform_admin" }; state.mockGetAdminUser.mockResolvedValue(adminUser); const { requireWaterAdminPermission } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminPermission(); expect(result).toEqual({ ok: true, adminUser }); }); it("returns { ok: true, adminUser } when brand_admin with can_manage_water_log", async () => { const adminUser = { user_id: "u1", role: "brand_admin", can_manage_water_log: true, }; state.mockGetAdminUser.mockResolvedValue(adminUser); const { requireWaterAdminPermission } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminPermission(); expect(result).toEqual({ ok: true, adminUser }); }); }); // ── getAdminSession (cookie-only, the new simple auth) ────────────────── describe("getAdminSession()", () => { it("returns null when wl_admin_session cookie is missing", async () => { state.cookieValue = undefined; const { getAdminSession } = await import( "@/lib/water-admin-pin-auth" ); expect(await getAdminSession()).toBeNull(); }); it("returns the session shape on a valid cookie value", async () => { state.cookieValue = "abc-123"; const { getAdminSession, TUXEDO_BRAND_ID } = await import( "@/lib/water-admin-pin-auth" ); expect(await getAdminSession()).toEqual({ sessionId: "abc-123", brandId: TUXEDO_BRAND_ID, }); }); }); // ── verifyAdminPin (PIN verify + cookie set, no DB session) ───────────── describe("verifyAdminPin()", () => { function makeSettingsRow(overrides: Partial<{ enabled: boolean; pinHash: string | null; sessionDurationHours: number; }> = {}) { return { enabled: overrides.enabled ?? true, pinHash: overrides.pinHash ?? "scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", sessionDurationHours: overrides.sessionDurationHours ?? 8, }; } it("returns ok:false 'No PIN configured' when stored hash is missing", async () => { state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [{ enabled: true, pinHash: null, sessionDurationHours: 8 }], }), }), }); const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth"); const result = await verifyAdminPin("1234"); expect(result).toEqual({ ok: false, error: "No PIN configured — generate one in /admin/water-log/settings", }); }); it("returns ok:false 'Admin portal is disabled' when settings.enabled is false", async () => { state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [{ enabled: false, pinHash: "any", sessionDurationHours: 8 }], }), }), }); const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth"); const result = await verifyAdminPin("1234"); expect(result).toEqual({ ok: false, error: "Admin portal is disabled" }); }); it("returns ok:false 'Invalid PIN' on PIN format failure (no DB read)", async () => { const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth"); const result = await verifyAdminPin("12ab"); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toMatch(/PIN/); } expect(state.mockWithBrand).not.toHaveBeenCalled(); }); it("returns ok:true when PIN matches the stored hash", async () => { // Pre-compute a real scrypt hash for "1234" so verifyPin succeeds. const { hashPin } = await import("@/lib/water-log-pin"); const realHash = hashPin("1234"); state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [ makeSettingsRow({ pinHash: realHash, sessionDurationHours: 4 }), ], }), }), }); const { verifyAdminPin, TUXEDO_BRAND_ID } = await import( "@/lib/water-admin-pin-auth" ); const result = await verifyAdminPin("1234"); expect(result.ok).toBe(true); if (result.ok) { expect(result.session.brandId).toBe(TUXEDO_BRAND_ID); expect(typeof result.session.sessionId).toBe("string"); expect(result.session.sessionId.length).toBeGreaterThan(8); } }); }); // ── clearAdminSession ─────────────────────────────────────────────────── describe("clearAdminSession()", () => { it("returns without throwing", async () => { const { clearAdminSession } = await import( "@/lib/water-admin-pin-auth" ); await expect(clearAdminSession()).resolves.toBeUndefined(); }); });