/** * Unit tests for `src/actions/water-log/auth.ts` — the centralized auth * helpers for the water log module. * * These helpers are the gate for every field/admin-PIN server action * (`/water`, `/water/admin/*`). Critically, **they must not call * `getSession()` from `@/lib/auth`** (the Neon Auth wrapper) — that * was the source of the bug where PIN submission hung for users with * no platform login. * * If a future refactor accidentally re-introduces a `getSession()` call * in this module, the `does not import getSession from @/lib/auth` test * below will fail. */ 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, mockGetAdminUser: null as unknown as ReturnType, })); state.mockWithPlatformAdmin = vi.fn( async (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: vi.fn(), 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, }), headers: () => Promise.resolve(new Headers()), })); beforeEach(() => { state.cookieValue = undefined; state.mockDb.select.mockReset(); state.mockWithPlatformAdmin.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 () => { // Strip comments (block + line) and collapse whitespace before checking // — the JSDoc deliberately references these symbols as documentation // of what the module does NOT do. 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*\(/); }); }); // ── Module guard for the Tuxedo admin-PIN entry flow ─────────────────── // // The `/water/admin/login` page is Tuxedo-brand-specific and PIN-only. // Users must be able to enter the admin PIN WITHOUT being signed into // the platform. If a future refactor re-adds a Neon Auth dependency // (getSession / getAdminUser) inside `verifyWaterAdminPin`, the PIN // submission 500s for unauthenticated field staff. // // Note: other functions in settings.ts (getWaterAdminSettings, // saveWaterAdminSettings, regenerateAdminPin) are called from the // platform-admin-gated `/admin/water-log/settings/*` UI and correctly // DO depend on `getAdminUser()`. Only `verifyWaterAdminPin` is the // PIN-only entry point. describe("water-log/settings — verifyWaterAdminPin surface", () => { /** * Extract just the body of `verifyWaterAdminPin` from settings.ts so * the regression check is scoped to that function (and not falsely * tripped by sibling functions that legitimately use getAdminUser). * * The function is the LAST export in the file, so we read from * `export async function verifyWaterAdminPin` to EOF. */ async function getVerifyWaterAdminPinSource(): Promise { 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/settings.ts", ), "utf8", ); const idx = raw.indexOf("export async function verifyWaterAdminPin"); if (idx === -1) throw new Error("verifyWaterAdminPin not found"); return raw.slice(idx); } function stripComments(src: string): string { return src .replace(/\/\*[\s\S]*?\*\//g, "") .replace(/^\s*\/\/.*$/gm, "") .replace(/\s+/g, " "); } it("does not call getAdminUser inside verifyWaterAdminPin", async () => { const stripped = stripComments(await getVerifyWaterAdminPinSource()); expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/); }); it("does not call getSession inside verifyWaterAdminPin", async () => { const stripped = stripComments(await getVerifyWaterAdminPinSource()); expect(stripped).not.toMatch(/\bgetSession\s*\(/); }); it("always inserts the zero-UUID fallback adminUserId", async () => { const stripped = stripComments(await getVerifyWaterAdminPinSource()); // Confirms the session row is inserted with the placeholder UUID // — i.e. the function no longer conditions adminUserId on a // successful platform-admin lookup. expect(stripped).toMatch( /00000000-0000-0000-0000-000000000000/, ); }); }); // ── 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", }); }); }); // ── requireWaterAdminSession ──────────────────────────────────────────── describe("requireWaterAdminSession()", () => { it("returns { ok: false, error: 'Not signed in' } when wl_admin_session cookie is missing", async () => { state.cookieValue = undefined; const { requireWaterAdminSession } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminSession(); expect(result).toEqual({ ok: false, error: "Not signed in" }); }); it("returns { ok: false, error: 'Session expired' } when row is past expires_at", async () => { state.cookieValue = "expired-admin"; const past = new Date(Date.now() - 60_000); state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [ { id: "expired-admin", brandId: "b1", adminUserId: "au1", expiresAt: past, }, ], }), }), }); const { requireWaterAdminSession } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminSession(); expect(result).toEqual({ ok: false, error: "Session expired" }); }); it("returns { ok: true, sessionId, brandId, adminUserId } on a happy-path admin session", async () => { state.cookieValue = "valid-admin"; const future = new Date(Date.now() + 60_000); state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [ { id: "valid-admin", brandId: "b1", adminUserId: "au1", expiresAt: future, }, ], }), }), }); const { requireWaterAdminSession } = await import( "@/actions/water-log/auth" ); const result = await requireWaterAdminSession(); expect(result).toEqual({ ok: true, sessionId: "valid-admin", brandId: "b1", adminUserId: "au1", }); }); }); // ── getWaterAdminSession (the "return null on miss" variant) ──────────── describe("getWaterAdminSession()", () => { it("returns null when cookie is missing", async () => { state.cookieValue = undefined; const { getWaterAdminSession } = await import( "@/actions/water-log/auth" ); expect(await getWaterAdminSession()).toBeNull(); }); it("returns null when session row is missing", async () => { state.cookieValue = "missing"; state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [] }), }), }); const { getWaterAdminSession } = await import( "@/actions/water-log/auth" ); expect(await getWaterAdminSession()).toBeNull(); }); it("returns null when past expires_at", async () => { state.cookieValue = "expired"; const past = new Date(Date.now() - 1); state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [ { id: "expired", brandId: "b1", adminUserId: "au1", expiresAt: past, }, ], }), }), }); const { getWaterAdminSession } = await import( "@/actions/water-log/auth" ); expect(await getWaterAdminSession()).toBeNull(); }); it("returns the session shape on a valid row", async () => { state.cookieValue = "valid"; const future = new Date(Date.now() + 60_000); state.mockDb.select.mockReturnValueOnce({ from: () => ({ where: () => ({ limit: async () => [ { id: "valid", brandId: "b1", adminUserId: "au1", expiresAt: future, }, ], }), }), }); const { getWaterAdminSession } = await import( "@/actions/water-log/auth" ); expect(await getWaterAdminSession()).toEqual({ sessionId: "valid", brandId: "b1", adminUserId: "au1", role: "water_admin", }); }); }); // ── 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 }); }); });