Files
route-commerce/tests/unit/water-log-auth.test.ts
T
Tyler b966787daa
Deploy to route.crispygoat.com / deploy (push) Successful in 4m19s
refactor(water-log): replace PIN auth with self-contained cookie module
The /water/admin PIN portal had a server-action implementation that
went through a pg transaction + cookies() interaction that 500'd for
users without a platform login. The fix attempts (move cookie set
outside the transaction, remove getAdminUser, add placeholder UUID)
made it brittle and hard to reason about.

This replaces the whole stack with a self-contained module:

  src/lib/water-admin-pin-auth.ts (new, 126 lines, no Neon Auth)

  - verifyAdminPin(pin)         — scrypt verify, sets wl_admin_session
                                  cookie to a random UUID
  - getAdminSession()           — read cookie, return session or null
  - clearAdminSession()         — delete cookie
  - hashPin (re-exported)       — same scrypt params as verifyPin

The /water/admin portal is Tuxedo-brand-specific (CLAUDE.md "Public
Storefront Architecture"), so the brand UUID is hardcoded. The cookie
is the session — no DB session table writes. Cookie presence =
authenticated. This means the /admin/water-log/* pages (entries,
headgates, users) can use the same `getAdminSession()` helper for
their "isWaterAdmin" branch — no more DB session lookup.

Deleted:
  - verifyWaterAdminPin server action in settings.ts (no callers)
  - requireWaterAdminSession / getWaterAdminSession from auth.ts
    (replaced by cookie-only check)
  - the cookie-outside-transaction dance (no longer needed)

Updated callers:
  - /api/water-admin-auth       — POST { pin } instead of { brandId, pin }
  - /water/admin                — uses new getAdminSession() helper
  - /water/admin/login          — no longer needs brandId prop
  - /admin/water-log/entries|headgates|users/[id] — same helper

Tests: 19 passing in water-log-auth.test.ts (3 new for the new module).
2026-07-01 18:31:57 -06:00

416 lines
14 KiB
TypeScript

/**
* 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<typeof vi.fn> },
mockWithPlatformAdmin: null as unknown as ReturnType<typeof vi.fn>,
mockWithBrand: 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.mockWithBrand = vi.fn(
async (_brandId: string, 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: 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();
});
});