98fc1d3bdd
Deploy to route.crispygoat.com / deploy (push) Successful in 4m3s
The previous PR wrapped the getAdminUser() call in try/catch as a
best-effort audit hook. In practice this still 500s users without a
platform login: getSession() inside getAdminUser() touches the
Next.js cookie store, and any failure leaves the store in a state
where the subsequent cookies().set('wl_admin_session', ...) throws.
The /water/admin/login page is the Tuxedo-brand-specific entry
point for brand staff in the field. It must work PIN-only, with no
Neon Auth dependency.
Fix:
- Drop the getAdminUser() call inside verifyWaterAdminPin entirely.
- adminUserId on the new water_admin_sessions row is always the
zero-UUID placeholder. Audit attribution is intentionally deferred.
- Document the Tuxedo-only/PIN-only contract on the page itself and
in the spec so future maintainers don't accidentally re-add the
Neon Auth dependency.
Guards (TDD, red → green):
- tests/unit/water-log-auth.test.ts: 3 new tests scoped to the
verifyWaterAdminPin function source — asserts it does not call
getAdminUser() or getSession() and that the zero-UUID placeholder
is present. These fail if a future refactor re-introduces the
dependency.
470 lines
15 KiB
TypeScript
470 lines
15 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/,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── 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 });
|
|
});
|
|
});
|