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
+72
View File
@@ -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" });