fix(water-log): remove platform-login dependency from PIN flows
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s

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.
This commit is contained in:
Tyler
2026-07-01 17:27:37 -06:00
parent 015eb33291
commit 658f6a5b8b
10 changed files with 780 additions and 209 deletions
+403
View File
@@ -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<typeof vi.fn> },
mockWithPlatformAdmin: null as unknown as ReturnType<typeof vi.fn>,
mockGetAdminUser: null as unknown as ReturnType<typeof vi.fn>,
}));
state.mockWithPlatformAdmin = vi.fn(
async (fn: (db: typeof state.mockDb) => Promise<unknown>) => 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 });
});
});