From 658f6a5b8b4dab6099222797d15355e39507ff63 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 1 Jul 2026 17:27:37 -0600 Subject: [PATCH] fix(water-log): remove platform-login dependency from PIN flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field users at /water were blocked because every server action called getSession() (Neon Auth) even though the field path is PIN-authenticated. A missing or expired platform session hung or rejected PIN submissions, so users without a platform login could never reach the water log entry screen. Centralize water-log auth in src/actions/water-log/auth.ts with three helpers — requireFieldSession, requireWaterAdminSession, and requireWaterAdminPermission — and migrate all server actions off the stray getSession() calls. Auth.ts deliberately does NOT import getSession; a static-source unit test enforces that. Changes: - src/actions/water-log/auth.ts (new): dedicated auth layer, no Neon Auth - src/actions/water-log/field.ts: -10 await getSession(), use helpers - src/actions/water-log/admin.ts: -18 await getSession(), use helpers - src/actions/water-log/settings.ts: -4 await getSession(), resilient to missing platform admin - 3x admin pages: update getWaterAdminSession import path - tests/unit/water-log-auth.test.ts (new): 17 tests incl. regression guard that auth.ts never imports getSession - tests/water-log.spec.ts: 3 E2E regression tests (no platform login) - vitest.config.ts: alias for deep @/db/schema/water-log path Rollback = revert this commit. No DB migration; no schema change. Existing rows/cookies unchanged. --- src/actions/water-log/admin.ts | 100 ++--- src/actions/water-log/auth.ts | 243 +++++++++++ src/actions/water-log/field.ts | 139 +----- src/actions/water-log/settings.ts | 24 +- src/app/admin/water-log/entries/[id]/page.tsx | 2 +- .../admin/water-log/headgates/[id]/page.tsx | 2 +- src/app/admin/water-log/users/[id]/page.tsx | 2 +- tests/unit/water-log-auth.test.ts | 403 ++++++++++++++++++ tests/water-log.spec.ts | 72 ++++ vitest.config.ts | 2 + 10 files changed, 780 insertions(+), 209 deletions(-) create mode 100644 src/actions/water-log/auth.ts create mode 100644 tests/unit/water-log-auth.test.ts diff --git a/src/actions/water-log/admin.ts b/src/actions/water-log/admin.ts index 4828d06..ec311f3 100644 --- a/src/actions/water-log/admin.ts +++ b/src/actions/water-log/admin.ts @@ -16,22 +16,21 @@ */ "use server"; -import { cookies } from "next/headers"; -import { and, desc, eq, gte, lte, sql, SQL } from "drizzle-orm"; -import { getAdminUser } from "@/lib/admin-permissions"; +import { and, desc, eq, sql } from "drizzle-orm"; import { withBrand } from "@/db/client"; import { waterHeadgates, waterIrrigators, waterLogEntries, - waterAdminSessions, type WaterHeadgate, type WaterIrrigator, type WaterLogEntry, } from "@/db/schema/water-log"; -import { hashPin, generatePin, verifyPin } from "@/lib/water-log-pin"; -import { logAuditEvent, logAlert } from "@/lib/water-log-audit"; -import { getSession } from "@/lib/auth"; +import { hashPin, generatePin } from "@/lib/water-log-pin"; +import { logAuditEvent } from "@/lib/water-log-audit"; +import { + requireWaterAdminPermission, +} from "@/actions/water-log/auth"; // ── Types used by both server and client ──────────────────────────────────── @@ -108,54 +107,11 @@ export type AlertLogEntry = { }; // ── Auth helpers ─────────────────────────────────────────────────────────── - -async function requireWaterAdminPermission() { - const adminUser = await getAdminUser(); - if (!adminUser) return { ok: false as const, error: "Not authenticated" }; - if ( - !adminUser.can_manage_water_log && - adminUser.role !== "platform_admin" - ) { - return { ok: false as const, error: "Not authorized" }; - } - return { ok: true as const, adminUser }; -} - -/** - * For the `/water/admin` (PIN) portal. Returns the admin + brand if a - * valid session cookie is present. Falls back to a 401-shaped error. - */ -async function requireWaterAdminSession(): Promise< - | { ok: true; adminUserId: string; brandId: string } - | { ok: false; error: string } -> { - -await getSession(); const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_admin_session")?.value; - if (!sessionId) return { ok: false, error: "Not signed in" }; - - return withBrand(globalThis.__TUXEDO_BRAND_ID__ ?? "", async () => { - // session lookup must use platform-admin to read across brands - const { withPlatformAdmin } = await import("@/db/client"); - return withPlatformAdmin(async (db) => { - const rows = await db - .select() - .from(waterAdminSessions) - .where(eq(waterAdminSessions.id, sessionId)) - .limit(1); - const row = rows[0]; - if (!row) return { ok: false as const, error: "Session not found" }; - if (row.expiresAt.getTime() < Date.now()) { - return { ok: false as const, error: "Session expired" }; - } - return { - ok: true as const, - adminUserId: row.adminUserId, - brandId: row.brandId, - }; - }); - }); -} +// +// `requireWaterAdminPermission` and `requireWaterAdminSession` live in +// `@/actions/water-log/auth`. The site-admin gate calls `getAdminUser()`; +// the brand-admin-PIN gate reads `wl_admin_session` directly. Neither +// calls the platform-level `getSession()` from `@/lib/auth`. // Global hint used by the field admin portal: water log is currently // scoped to the Tuxedo brand only. Surfacing this in one place makes it @@ -229,7 +185,7 @@ export async function createWaterHeadgate( options: { highThreshold?: number | null; lowThreshold?: number | null; notes?: string | null } = {}, ): Promise<{ success: boolean; headgate?: AdminHeadgate; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const trimmed = name?.trim(); if (!trimmed) return { success: false, error: "Name is required" }; @@ -283,7 +239,7 @@ export async function updateWaterHeadgate( status?: AdminHeadgate["status"], ): Promise<{ success: boolean; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; // Headgates aren't directly brand-scoped in the URL — look up brand first. @@ -337,7 +293,7 @@ export async function deleteWaterHeadgate( headgateId: string, ): Promise<{ success: boolean; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOf(headgateId); if (!brand) return { success: false, error: "Headgate not found" }; @@ -363,7 +319,7 @@ export async function regenerateHeadgateToken( headgateId: string, ): Promise<{ success: boolean; token?: string; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOf(headgateId); if (!brand) return { success: false, error: "Headgate not found" }; @@ -391,7 +347,7 @@ export async function getWaterHeadgatesAdmin( brandId: string, ): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; return withBrand(brandId, async (db) => { const rows = await db @@ -407,7 +363,7 @@ await getSession(); const auth = await requireWaterAdminPermission(); export async function getWaterIrrigators(brandId: string): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; return withBrand(brandId, async (db) => { const rows = await db @@ -434,7 +390,7 @@ export async function createWaterUser( phone?: string | null, ): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const trimmed = name?.trim(); if (!trimmed) return { success: false, error: "Name is required" }; @@ -494,7 +450,7 @@ export async function createWaterIrrigator( language: string = "en", ): Promise { -await getSession(); return createWaterUser(brandId, name, "irrigator", language); +return createWaterUser(brandId, name, "irrigator", language); } export async function updateWaterIrrigator( @@ -507,7 +463,7 @@ export async function updateWaterIrrigator( notes?: string | null, ): Promise<{ success: boolean; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfIrrigator(irrigatorId); if (!brand) return { success: false, error: "User not found" }; @@ -547,7 +503,7 @@ export async function deleteWaterUser( userId: string, ): Promise<{ success: boolean; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfIrrigator(userId); if (!brand) return { success: false, error: "User not found" }; @@ -573,7 +529,7 @@ export async function resetWaterIrrigatorPin( userId: string, ): Promise<{ success: boolean; pin?: string; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfIrrigator(userId); if (!brand) return { success: false, error: "User not found" }; @@ -609,7 +565,7 @@ export async function getWaterEntries( limit: number = 50, ): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; const safeLimit = Math.min(Math.max(limit, 1), 1000); return withBrand(brandId, async (db) => { @@ -664,7 +620,7 @@ await getSession(); const auth = await requireWaterAdminPermission(); export async function getWaterEntryById(entryId: string): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return null; const brand = await withPlatformAdminBrandOfEntry(entryId); if (!brand) return null; @@ -727,7 +683,7 @@ export async function updateWaterEntry( method?: AdminEntry["method"], ): Promise<{ success: boolean; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; if (!Number.isFinite(measurement) || measurement < 0) { return { success: false, error: "Measurement must be a non-negative number" }; @@ -763,7 +719,7 @@ export async function deleteWaterEntry( entryId: string, ): Promise<{ success: boolean; error?: string }> { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return { success: false, error: auth.error }; const brand = await withPlatformAdminBrandOfEntry(entryId); if (!brand) return { success: false, error: "Entry not found" }; @@ -791,7 +747,7 @@ export async function getWaterDisplaySummary( brandId: string, ): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return null; return withBrand(brandId, async (db) => { // Headgates with their latest entry @@ -923,7 +879,7 @@ export async function getWaterAlertLog( limit: number = 50, ): Promise { -await getSession(); const auth = await requireWaterAdminPermission(); +const auth = await requireWaterAdminPermission(); if (!auth.ok) return []; return withBrand(brandId, async (db) => { const rows = await db.execute<{ diff --git a/src/actions/water-log/auth.ts b/src/actions/water-log/auth.ts new file mode 100644 index 0000000..6b424fc --- /dev/null +++ b/src/actions/water-log/auth.ts @@ -0,0 +1,243 @@ +/** + * Water Log — auth layer. + * + * Centralizes the three auth gates the water-log module uses: + * + * 1. `requireFieldSession()` — read wl_session cookie, look up + * the session row in `water_sessions`, + * return user/brand/role. Gates every + * irrigator PIN action (/water). + * 2. `requireWaterAdminSession()` — read wl_admin_session cookie, look + * up the row in `water_admin_sessions`. + * Gates brand-admin PIN paths + * (/water/admin/*). + * 3. `requireWaterAdminPermission()`— site-admin gate (Neon Auth + + * admin_users.can_manage_water_log). + * Gates /admin/water-log/*. + * + * **Crucially, the two PIN-only helpers (1) and (2) never call + * `getSession()` from `@/lib/auth` and never call `getAdminUser()`.** + * The water log module is intentionally PIN-based and self-contained; + * a prior version of this code carried spurious `getSession()` calls + * that hung PIN submission for users with no platform login. The static + * "does not import getSession" test in `tests/unit/water-log-auth.test.ts` + * guards against that regression. + */ +"use server"; + +import { cookies } from "next/headers"; +import { eq } from "drizzle-orm"; +import { withPlatformAdmin } from "@/db/client"; +import { + waterSessions, + waterIrrigators, + waterAdminSessions, +} from "@/db/schema/water-log"; +import { getAdminUser } from "@/lib/admin-permissions"; + +// ── Result types ──────────────────────────────────────────────────────── + +export type FieldSession = + | { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" } + | { ok: false; error: string }; + +export type AdminPinSession = + | { ok: true; sessionId: string; brandId: string; adminUserId: string } + | { ok: false; error: string }; + +export type AdminPinSessionInfo = { + sessionId: string; + brandId: string; + adminUserId: string; + role: "water_admin"; +}; + +export type SiteAdminPermission = + | { ok: true; adminUser: NonNullable>> } + | { ok: false; error: string }; + +// ── Helpers ───────────────────────────────────────────────────────────── + +/** + * Field session gate — /water (irrigator PIN). + * + * Reads the `wl_session` cookie, joins it to `water_sessions` and the + * linked `water_irrigators` row, and returns the user/brand/role. + * + * Returns `{ ok: false, error }` for: + * - missing cookie → "Not logged in" + * - cookie with no matching row → "Session not found" + * - row past `expires_at` → "Session expired" (best-effort + * cleanup of the stale row) + * - irrigator marked `active = false`→ "User is inactive" + * + * Never calls `getSession()`. Never calls `getAdminUser()`. The only + * network round-trip is the local DB query. + */ +export async function requireFieldSession(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("wl_session")?.value; + if (!sessionId) return { ok: false, error: "Not logged in" }; + + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ + session: waterSessions, + irrigator: waterIrrigators, + }) + .from(waterSessions) + .innerJoin( + waterIrrigators, + eq(waterIrrigators.id, waterSessions.irrigatorId), + ) + .where(eq(waterSessions.id, sessionId)) + .limit(1); + const row = rows[0]; + if (!row) return { ok: false as const, error: "Session not found" }; + if (row.session.expiresAt.getTime() < Date.now()) { + // Best-effort cleanup + try { + await db.delete(waterSessions).where(eq(waterSessions.id, sessionId)); + } catch { + // ignore — the row may have been deleted concurrently + } + return { ok: false as const, error: "Session expired" }; + } + if (!row.irrigator.active) { + return { ok: false as const, error: "User is inactive" }; + } + return { + ok: true as const, + userId: row.irrigator.id, + brandId: row.irrigator.brandId, + role: + (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator", + }; + }); +} + +/** + * Brand-admin PIN session gate — /water/admin/*. + * + * Reads the `wl_admin_session` cookie, joins it to + * `water_admin_sessions`, and returns brand/admin info. Same error + * semantics as `requireFieldSession` but for the admin-PIN cookie. + */ +export async function requireWaterAdminSession(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("wl_admin_session")?.value; + if (!sessionId) return { ok: false, error: "Not signed in" }; + + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ + id: waterAdminSessions.id, + brandId: waterAdminSessions.brandId, + adminUserId: waterAdminSessions.adminUserId, + expiresAt: waterAdminSessions.expiresAt, + }) + .from(waterAdminSessions) + .where(eq(waterAdminSessions.id, sessionId)) + .limit(1); + const row = rows[0]; + if (!row) return { ok: false as const, error: "Session not found" }; + if (row.expiresAt.getTime() < Date.now()) { + return { ok: false as const, error: "Session expired" }; + } + return { + ok: true as const, + sessionId: row.id, + brandId: row.brandId, + adminUserId: row.adminUserId, + }; + }); +} + +/** + * "Return null on miss" variant of `requireWaterAdminSession`. Used by + * pages that want to render conditionally based on whether the user + * has a valid admin-PIN session, without forcing a hard error. + */ +export async function getWaterAdminSession(): Promise { + const cookieStore = await cookies(); + const sessionId = cookieStore.get("wl_admin_session")?.value; + if (!sessionId) return null; + + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ + id: waterAdminSessions.id, + brandId: waterAdminSessions.brandId, + adminUserId: waterAdminSessions.adminUserId, + expiresAt: waterAdminSessions.expiresAt, + }) + .from(waterAdminSessions) + .where(eq(waterAdminSessions.id, sessionId)) + .limit(1); + const row = rows[0]; + if (!row) return null; + if (row.expiresAt.getTime() <= Date.now()) return null; + return { + sessionId: row.id, + brandId: row.brandId, + adminUserId: row.adminUserId, + role: "water_admin" as const, + }; + }); +} + +/** + * Read the current field session user (or null). Cheap, idempotent. + * Used by the admin settings page to render the "logged in as" badge + * without forcing a hard error when the session is gone. + */ +export async function getFieldSessionUser(): Promise<{ + userId: string; + name: string; + brandId: string; + role: "irrigator" | "water_admin"; +} | null> { + const s = await requireFieldSession(); + if (!s.ok) return null; + return withPlatformAdmin(async (db) => { + const rows = await db + .select({ + name: waterIrrigators.name, + role: waterIrrigators.role, + }) + .from(waterIrrigators) + .where(eq(waterIrrigators.id, s.userId)) + .limit(1); + const row = rows[0]; + if (!row) return null; + return { + userId: s.userId, + name: row.name, + brandId: s.brandId, + role: (row.role as "irrigator" | "water_admin") ?? "irrigator", + }; + }); +} + +/** + * Site-admin gate — /admin/water-log/*. + * + * This is the only helper in this module that calls + * `getAdminUser()`, and that's intentional: it backs the + * `/admin/water-log/*` pages which sit behind Neon Auth in the + * middleware. The two PIN-only helpers above deliberately do not. + */ +export async function requireWaterAdminPermission(): Promise { + const adminUser = await getAdminUser(); + if (!adminUser) return { ok: false, error: "Not signed in" }; + if ( + !adminUser.can_manage_water_log && + adminUser.role !== "platform_admin" + ) { + return { ok: false, error: "Not authorized" }; + } + // `adminUser` is non-null here (the first guard returned). Cast keeps + // the success-branch type narrow (non-nullable) so callers can read + // `auth.adminUser.foo` without an extra null check. + return { ok: true, adminUser: adminUser as NonNullable }; +} diff --git a/src/actions/water-log/field.ts b/src/actions/water-log/field.ts index 30ad0b4..ceac627 100644 --- a/src/actions/water-log/field.ts +++ b/src/actions/water-log/field.ts @@ -30,7 +30,12 @@ import { } from "@/db/schema/water-log"; import { verifyPin, validatePin } from "@/lib/water-log-pin"; import { logAlert } from "@/lib/water-log-audit"; -import { getSession } from "@/lib/auth"; +import { + requireFieldSession, + type FieldSession, +} from "@/actions/water-log/auth"; + +export type { FieldSession }; // Field sessions last 8h — a working day in the field. Long enough // that a single sign-in covers a morning shift + afternoon shift. @@ -70,8 +75,7 @@ export async function getWaterHeadgates( _brandId: string, activeOnly: boolean = false, ): Promise { - -await getSession(); return withBrand(TUXEDO_BRAND_ID, async (db) => { + return withBrand(TUXEDO_BRAND_ID, async (db) => { const where = activeOnly ? and( eq(waterHeadgates.brandId, TUXEDO_BRAND_ID), @@ -101,8 +105,7 @@ export async function verifyWaterPin( _brandId: string, pin: string, ): Promise { - -await getSession(); // Validate format first (cheap, no DB). + // Validate format first (cheap, no DB). const formatError = validatePin(pin); if (formatError) return { success: false, error: formatError }; @@ -185,8 +188,7 @@ export async function submitWaterEntry( longitude?: number, _headgateLocked?: boolean, ): Promise { - -await getSession(); // ── Auth ── + // ── Auth ── const session = await requireFieldSession(); if (!session.ok) return { success: false, error: session.error }; @@ -281,78 +283,10 @@ await getSession(); // ── Auth ── }); } -// ── Session helpers (shared with admin.ts via requireFieldSession) ──────── - -type FieldSession = - | { ok: true; userId: string; brandId: string; role: "irrigator" | "water_admin" } - | { ok: false; error: string }; - -async function requireFieldSession(): Promise { - -await getSession(); const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_session")?.value; - if (!sessionId) return { ok: false, error: "Not logged in" }; - - return withPlatformAdmin(async (db) => { - const rows = await db - .select({ - session: waterSessions, - irrigator: waterIrrigators, - }) - .from(waterSessions) - .innerJoin(waterIrrigators, eq(waterIrrigators.id, waterSessions.irrigatorId)) - .where(eq(waterSessions.id, sessionId)) - .limit(1); - const row = rows[0]; - if (!row) return { ok: false as const, error: "Session not found" }; - if (row.session.expiresAt.getTime() < Date.now()) { - // Best-effort cleanup - await db.delete(waterSessions).where(eq(waterSessions.id, sessionId)); - return { ok: false as const, error: "Session expired" }; - } - if (!row.irrigator.active) { - return { ok: false as const, error: "User is inactive" }; - } - return { - ok: true as const, - userId: row.irrigator.id, - brandId: row.irrigator.brandId, - role: (row.irrigator.role as "irrigator" | "water_admin") ?? "irrigator", - }; - }); -} - -async function getFieldSessionUser(): Promise<{ - userId: string; - name: string; - brandId: string; - role: "irrigator" | "water_admin"; -} | null> { - -await getSession(); const s = await requireFieldSession(); - if (!s.ok) return null; - return withPlatformAdmin(async (db) => { - const rows = await db - .select({ name: waterIrrigators.name, role: waterIrrigators.role }) - .from(waterIrrigators) - .where(eq(waterIrrigators.id, s.userId)) - .limit(1); - const row = rows[0]; - if (!row) return null; - return { - userId: s.userId, - name: row.name, - brandId: s.brandId, - role: (row.role as "irrigator" | "water_admin") ?? "irrigator", - }; - }); -} - // ── Cookie/language helpers ─────────────────────────────────────────────── export async function logoutWater(): Promise { - -await getSession(); const cookieStore = await cookies(); + const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_session")?.value; if (sessionId) { // Best-effort DB cleanup @@ -368,8 +302,7 @@ await getSession(); const cookieStore = await cookies(); } export async function logoutWaterAdmin(): Promise { - -await getSession(); const cookieStore = await cookies(); + const cookieStore = await cookies(); const sessionId = cookieStore.get("wl_admin_session")?.value; if (sessionId) { try { @@ -385,58 +318,8 @@ await getSession(); const cookieStore = await cookies(); cookieStore.delete("wl_admin_session"); } -async function getWaterSession(): Promise { - -await getSession(); const cookieStore = await cookies(); - return cookieStore.get("wl_session")?.value ?? null; -} - -/** - * Resolves the current `/water/admin` session. - * - * Returns the admin role + brandId on success, or `null` if the cookie - * is missing / expired / points at a deleted session row. Used by - * admin pages (entries/[id], headgates/[id], users/[id]) as a *second* - * gate on top of `getAdminUser()` — a site admin can edit entries - * directly, but a PIN-authenticated water admin can too. - */ -export async function getWaterAdminSession(): Promise<{ - sessionId: string; - brandId: string; - adminUserId: string; - role: "water_admin"; -} | null> { - -await getSession(); const cookieStore = await cookies(); - const sessionId = cookieStore.get("wl_admin_session")?.value; - if (!sessionId) return null; - return withPlatformAdmin(async (db) => { - const rows = await db - .select({ - id: waterAdminSessions.id, - brandId: waterAdminSessions.brandId, - adminUserId: waterAdminSessions.adminUserId, - expiresAt: waterAdminSessions.expiresAt, - }) - .from(waterAdminSessions) - .where(eq(waterAdminSessions.id, sessionId)) - .limit(1); - const row = rows[0]; - if (!row) return null; - if (row.expiresAt.getTime() <= Date.now()) return null; - return { - sessionId: row.id, - brandId: row.brandId, - adminUserId: row.adminUserId, - role: "water_admin" as const, - }; - }); -} - export async function setWaterLang(lang: string): Promise { - if (!["en", "es"].includes(lang)) return; - await getSession(); const cookieStore = await cookies(); cookieStore.set("wl_lang", lang, { httpOnly: false, diff --git a/src/actions/water-log/settings.ts b/src/actions/water-log/settings.ts index 1d74b79..9f903ee 100644 --- a/src/actions/water-log/settings.ts +++ b/src/actions/water-log/settings.ts @@ -24,7 +24,10 @@ import { import { getAdminUser } from "@/lib/admin-permissions"; import { hashPin, verifyPin, validatePin, generatePin } from "@/lib/water-log-pin"; import { logAuditEvent } from "@/lib/water-log-audit"; -import { getSession } from "@/lib/auth"; + +// Note: we deliberately do NOT import `getSession` from `@/lib/auth`. +// The water-admin PIN entry flow (`verifyWaterAdminPin`) is PIN-based +// and self-contained — see docs/superpowers/specs/2026-07-01-water-log-no-platform-login-design.md. export type AdminSettings = { enabled: boolean; @@ -56,7 +59,7 @@ export async function getWaterAdminSettings( brandId: string, ): Promise { -await getSession(); const adminUser = await getAdminUser(); +const adminUser = await getAdminUser(); if (!adminUser) return null; if ( !adminUser.can_manage_water_log && @@ -88,7 +91,7 @@ export async function saveWaterAdminSettings( settings: Partial, ): Promise<{ success: boolean; settings?: AdminSettings; error?: string }> { -await getSession(); const adminUser = await getAdminUser(); +const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if ( !adminUser.can_manage_water_log && @@ -152,7 +155,7 @@ export async function regenerateAdminPin( brandId: string, ): Promise<{ success: boolean; pin?: string; error?: string }> { -await getSession(); const adminUser = await getAdminUser(); +const adminUser = await getAdminUser(); if (!adminUser) return { success: false, error: "Not authenticated" }; if ( !adminUser.can_manage_water_log && @@ -199,7 +202,7 @@ export async function verifyWaterAdminPin( pin: string, ): Promise<{ success: boolean; session_id?: string; error?: string }> { -await getSession(); const formatError = validatePin(pin); +const formatError = validatePin(pin); if (formatError) return { success: false, error: formatError }; return withBrand(brandId, async (db) => { @@ -224,7 +227,16 @@ await getSession(); const formatError = validatePin(pin); } // Tie the session to the calling site admin (best-effort). - const adminUser = await getAdminUser(); + // The `wl_admin_session` is valid whether or not a platform admin + // is signed in — this call only attaches an `adminUserId` for + // audit. If `getAdminUser()` fails or returns null, we fall back + // to a zero-UUID placeholder. + let adminUser: Awaited> = null; + try { + adminUser = await getAdminUser(); + } catch { + // ignore — the session is valid regardless + } const expiresAt = new Date( Date.now() + settings.sessionDurationHours * 60 * 60 * 1000, diff --git a/src/app/admin/water-log/entries/[id]/page.tsx b/src/app/admin/water-log/entries/[id]/page.tsx index 791b4f9..82901b4 100644 --- a/src/app/admin/water-log/entries/[id]/page.tsx +++ b/src/app/admin/water-log/entries/[id]/page.tsx @@ -1,5 +1,5 @@ import { getAdminUser } from "@/lib/admin-permissions"; -import { getWaterAdminSession } from "@/actions/water-log/field"; +import { getWaterAdminSession } from "@/actions/water-log/auth"; import { getWaterEntryById } from "@/actions/water-log/admin"; import WaterLogEntryEditForm from "@/components/admin/WaterLogEntryEditForm"; import Link from "next/link"; diff --git a/src/app/admin/water-log/headgates/[id]/page.tsx b/src/app/admin/water-log/headgates/[id]/page.tsx index e7b4599..28b227c 100644 --- a/src/app/admin/water-log/headgates/[id]/page.tsx +++ b/src/app/admin/water-log/headgates/[id]/page.tsx @@ -1,6 +1,6 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { redirect } from "next/navigation"; -import { getWaterAdminSession } from "@/actions/water-log/field"; +import { getWaterAdminSession } from "@/actions/water-log/auth"; import { getWaterHeadgatesAdmin } from "@/actions/water-log/admin"; import HeadgateEditForm from "@/components/admin/HeadgateEditForm"; import Link from "next/link"; diff --git a/src/app/admin/water-log/users/[id]/page.tsx b/src/app/admin/water-log/users/[id]/page.tsx index de7a8c0..b97fcf2 100644 --- a/src/app/admin/water-log/users/[id]/page.tsx +++ b/src/app/admin/water-log/users/[id]/page.tsx @@ -1,6 +1,6 @@ import { getAdminUser } from "@/lib/admin-permissions"; import { redirect } from "next/navigation"; -import { getWaterAdminSession } from "@/actions/water-log/field"; +import { getWaterAdminSession } from "@/actions/water-log/auth"; import { getWaterIrrigators } from "@/actions/water-log/admin"; import WaterUserEditForm from "@/components/admin/WaterUserEditForm"; import Link from "next/link"; diff --git a/tests/unit/water-log-auth.test.ts b/tests/unit/water-log-auth.test.ts new file mode 100644 index 0000000..345b3c7 --- /dev/null +++ b/tests/unit/water-log-auth.test.ts @@ -0,0 +1,403 @@ +/** + * 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*\(/); + }); +}); + +// ── 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 }); + }); +}); diff --git a/tests/water-log.spec.ts b/tests/water-log.spec.ts index f779b6c..67fa50e 100644 --- a/tests/water-log.spec.ts +++ b/tests/water-log.spec.ts @@ -66,6 +66,78 @@ test.describe("Water Log — public surfaces", () => { }); }); +test.describe("Water Log — no platform login required (regression)", () => { + // The water log module is PIN-based and self-contained. A prior + // version of the server actions called getSession() (Neon Auth) on + // every PIN-screen interaction, which caused `/water` to hang or + // fail for users with no platform login. These tests assert that + // a fresh, cookie-free browser context can complete the public + // surfaces without ever touching /login. + + test("/water full flow does not redirect to /login with no cookies", async ({ browser }) => { + // Fresh context: no storageState, no cookies, no dev_session. + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + const hits: string[] = []; + page.on("request", (r) => { + const u = r.url(); + if (u.startsWith(BASE)) hits.push(u); + }); + + const res = await page.goto(`${BASE}/water`, { waitUntil: "domcontentloaded" }); + expect(res?.status()).toBe(200); + + // Walk through language → role. The exact button labels depend on + // the i18n, but the Irrigator path is the green button labeled + // "Irrigator" and the language step is the first English/Español + // choice. + await page.getByRole("button", { name: /^english$/i }).click({ timeout: 5_000 }).catch(async () => { + // Some renderings show "English" as a label rather than exact + // text — fall back to the first button in the language picker. + await page.locator("button").filter({ hasText: /english/i }).first().click(); + }); + await page.getByRole("button", { name: /^irrigator$/i }).click({ timeout: 5_000 }); + + // The PIN input is now visible. + const pin = page.locator('input[type="password"][inputmode="numeric"]').first(); + await expect(pin).toBeVisible({ timeout: 10_000 }); + + // We must never have hit /login. + const loginHits = hits.filter((u) => u.includes("/login")); + expect(loginHits).toEqual([]); + + await ctx.close(); + }); + + test("GET /api/water-admin-auth works with no cookies (returns 401, not 302/500)", async () => { + const ctx = await request.newContext({ + baseURL: BASE, + storageState: { cookies: [], origins: [] }, + }); + // Any 4-digit PIN, no cookies — must NOT 302 to /login and must + // NOT 500 (server error). 401/403 are the correct gates. + const res = await ctx.post("/api/water-admin-auth", { + data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "0000" }, + maxRedirects: 0, + }); + expect([200, 401, 403]).toContain(res.status()); + await ctx.dispose(); + }); + + test("/water responds 200 to a no-cookie GET (no Neon Auth hang)", async () => { + const ctx = await request.newContext({ + baseURL: BASE, + storageState: { cookies: [], origins: [] }, + }); + // Bounded timeout — if the server-action pre-call is hanging on + // Neon Auth, this will throw. 8s is generous for a static page + // render and tight enough to fail fast on a hang. + const res = await ctx.get("/water", { timeout: 8_000 }); + expect(res.status()).toBe(200); + await ctx.dispose(); + }); +}); + test.describe("Water Log — site-admin gate", () => { test("/admin/water-log redirects unauthenticated users", async ({ page }) => { await page.goto(`${BASE}/admin/water-log`, { waitUntil: "domcontentloaded" }); diff --git a/vitest.config.ts b/vitest.config.ts index ee1af17..a12d55c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ resolve: { alias: [ { find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" }, + // Specific sub-paths must come before the general @/db catch-all. + { find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") }, { find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") }, { find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") }, { find: "@/db", replacement: path.resolve(__dirname, "db") },