ca79896d5f
Deploy to route.crispygoat.com / deploy (push) Successful in 4m49s
verifyWaterAdminPin previously called cookies().set(...) from inside the withBrand(...) pg transaction. The pg transaction's AsyncLocalStorage context masks Next.js's request-scoped cookie store, so for users without a platform login the cookie set threw and the route handler returned 500. Move the cookie write to the outer function frame, after withBrand resolves. The DB session row is still inserted inside the transaction (so it's atomic with the PIN verification); only the cookie write is hoisted out. The prior fix removed getAdminUser() but left the cookie set inside the transaction, so valid PINs still 500'd. This is the missing half. Adds a static regression test that asserts const cookieStore is declared AFTER the matching close-paren of withBrand(...).
515 lines
17 KiB
TypeScript
515 lines
17 KiB
TypeScript
/**
|
|
* 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*\(/);
|
|
});
|
|
});
|
|
|
|
// ── 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<string> {
|
|
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/,
|
|
);
|
|
});
|
|
|
|
it("writes the wl_admin_session cookie OUTSIDE the withBrand callback", async () => {
|
|
// Regression: calling `cookies().set(...)` from inside the
|
|
// withBrand(...) pg transaction corrupts Next.js's request-scoped
|
|
// AsyncLocalStorage and the cookie set throws for users without a
|
|
// platform login (the route handler returns 500). The cookie must
|
|
// be written in the OUTER function frame, after withBrand resolves.
|
|
//
|
|
// Distinguishing OLD vs NEW: in the old code, `const cookieStore
|
|
// = await cookies()` was declared INSIDE the withBrand callback
|
|
// (between `withBrand(` and its matching closing `)`). In the new
|
|
// code, it's declared AFTER that closing. We find the matching
|
|
// `)` that closes withBrand (depth-tracked, ignoring string
|
|
// contents) and assert that `const cookieStore` comes after it.
|
|
const stripped = stripComments(await getVerifyWaterAdminPinSource());
|
|
const wbStart = stripped.indexOf("withBrand(");
|
|
expect(wbStart).toBeGreaterThan(-1);
|
|
const wbOpenParen = wbStart + "withBrand".length; // position of the `(` after `withBrand`
|
|
let depth = 1; // we are inside withBrand(
|
|
let i = wbOpenParen + 1;
|
|
let inString: string | null = null;
|
|
let escaped = false;
|
|
let wbEnd = -1;
|
|
while (i < stripped.length) {
|
|
const c = stripped[i];
|
|
if (inString) {
|
|
if (escaped) { escaped = false; i++; continue; }
|
|
if (c === "\\") { escaped = true; i++; continue; }
|
|
if (c === inString) inString = null;
|
|
i++;
|
|
continue;
|
|
}
|
|
if (c === '"' || c === "'" || c === "`") { inString = c; i++; continue; }
|
|
if (c === "(" || c === "{") depth++;
|
|
else if (c === ")" || c === "}") {
|
|
depth--;
|
|
if (depth === 0) { wbEnd = i; break; }
|
|
}
|
|
i++;
|
|
}
|
|
expect(wbEnd).toBeGreaterThan(-1);
|
|
const cookieStoreIdx = stripped.indexOf("const cookieStore");
|
|
expect(cookieStoreIdx).toBeGreaterThan(-1);
|
|
expect(cookieStoreIdx).toBeGreaterThan(wbEnd);
|
|
});
|
|
});
|
|
|
|
// ── 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 });
|
|
});
|
|
});
|