fix(water-log): remove platform-login dependency from PIN flows
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user