658f6a5b8b
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.
264 lines
12 KiB
TypeScript
264 lines
12 KiB
TypeScript
/**
|
|
* Water Log — end-to-end tests.
|
|
*
|
|
* These cover the user-visible surface of the Water Log feature across
|
|
* both PIN-authenticated portals and the site-admin view. The intent is
|
|
* to catch the most common production regressions: page no longer
|
|
* renders, PIN screen broken, admin gate missing, CSV export leaking
|
|
* unauthenticated data, etc.
|
|
*
|
|
* Heavy multi-actor flows (admin adds headgate → irrigator submits entry
|
|
* → admin exports CSV) require a working DB + a fully-provisioned
|
|
* Tuxedo brand, so they're behind a `WATER_LOG_E2E_DB=1` env flag. The
|
|
* default suite stays runnable against any deployment.
|
|
*
|
|
* Run locally:
|
|
* PLAYWRIGHT_URL=http://localhost:3000 npx playwright test water-log
|
|
* # Or against prod (default in playwright.config):
|
|
* npx playwright test water-log
|
|
*/
|
|
import { test, expect, request } from "@playwright/test";
|
|
|
|
const BASE = process.env.PLAYWRIGHT_URL ?? "http://localhost:3000";
|
|
|
|
test.describe("Water Log — public surfaces", () => {
|
|
test("/water renders the PIN entry form", async ({ page }) => {
|
|
const res = await page.goto(`${BASE}/water`);
|
|
expect(res?.status()).toBeLessThan(500);
|
|
// PIN input is a password input with inputMode=numeric
|
|
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
|
await expect(pin).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("/water PIN input strips non-digits and caps at 4 chars", async ({ page }) => {
|
|
await page.goto(`${BASE}/water`);
|
|
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
|
await expect(pin).toBeVisible({ timeout: 10_000 });
|
|
await pin.fill("12ab34cd56ef");
|
|
const value = await pin.inputValue();
|
|
expect(value).toMatch(/^\d{0,4}$/);
|
|
expect(value.length).toBeLessThanOrEqual(4);
|
|
});
|
|
|
|
test("/water/admin renders the admin PIN entry form", async ({ page }) => {
|
|
const res = await page.goto(`${BASE}/water/admin/login`);
|
|
expect(res?.status()).toBeLessThan(500);
|
|
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
|
await expect(pin).toBeVisible({ timeout: 10_000 });
|
|
});
|
|
|
|
test("/water/admin wrong PIN shows an error, does not navigate", async ({ page }) => {
|
|
await page.goto(`${BASE}/water/admin/login`);
|
|
const pin = page.locator('input[type="password"][inputmode="numeric"]').first();
|
|
await expect(pin).toBeVisible({ timeout: 10_000 });
|
|
// Use a clearly-invalid 4-digit PIN. We don't know the configured
|
|
// PIN, so 0000 is the canonical "wrong" value.
|
|
await pin.fill("0000");
|
|
const submit = page.getByRole("button", { name: /sign in|submit|enter/i }).first();
|
|
if (await submit.isVisible().catch(() => false)) {
|
|
await submit.click();
|
|
} else {
|
|
await pin.press("Enter");
|
|
}
|
|
// After a wrong PIN, the URL should still be the login page.
|
|
await page.waitForTimeout(500);
|
|
expect(page.url()).toMatch(/\/water\/admin\/login/);
|
|
});
|
|
});
|
|
|
|
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" });
|
|
// Should not stay on /admin/water-log if not signed in.
|
|
expect(page.url()).not.toMatch(/\/admin\/water-log$/);
|
|
});
|
|
|
|
test("/admin/water-log/settings redirects unauthenticated users", async ({ page }) => {
|
|
await page.goto(`${BASE}/admin/water-log/settings`, { waitUntil: "domcontentloaded" });
|
|
expect(page.url()).not.toMatch(/\/admin\/water-log\/settings$/);
|
|
});
|
|
|
|
test("/admin/water-log/headgates redirects unauthenticated users", async ({ page }) => {
|
|
await page.goto(`${BASE}/admin/water-log/headgates`, { waitUntil: "domcontentloaded" });
|
|
expect(page.url()).not.toMatch(/\/admin\/water-log\/headgates$/);
|
|
});
|
|
|
|
test("/admin/water-log/users redirects unauthenticated users", async ({ page }) => {
|
|
await page.goto(`${BASE}/admin/water-log/users`, { waitUntil: "domcontentloaded" });
|
|
expect(page.url()).not.toMatch(/\/admin\/water-log\/users$/);
|
|
});
|
|
|
|
test("/admin/water-log/entries redirects unauthenticated users", async ({ page }) => {
|
|
await page.goto(`${BASE}/admin/water-log/entries`, { waitUntil: "domcontentloaded" });
|
|
expect(page.url()).not.toMatch(/\/admin\/water-log\/entries$/);
|
|
});
|
|
});
|
|
|
|
test.describe("Water Log — API auth", () => {
|
|
test("/api/water-logs/export returns 401/403 for unauthenticated callers", async () => {
|
|
const ctx = await request.newContext({ baseURL: BASE });
|
|
const res = await ctx.get("/api/water-logs/export");
|
|
// Either 401 (no admin session) or 403 (signed in but not permitted)
|
|
// — both are correct, the point is the endpoint doesn't leak data.
|
|
expect([401, 403]).toContain(res.status());
|
|
await ctx.dispose();
|
|
});
|
|
|
|
test("/api/water-admin-auth rejects malformed PINs (length != 4)", async () => {
|
|
const ctx = await request.newContext({ baseURL: BASE });
|
|
const res = await ctx.post("/api/water-admin-auth", {
|
|
data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "12" },
|
|
});
|
|
// 400 (bad request) or 401/403 (no admin user) — both are valid
|
|
// gates. Critically, it must NOT be 200.
|
|
expect(res.status()).not.toBe(200);
|
|
await ctx.dispose();
|
|
});
|
|
|
|
test("/api/water-admin-auth rejects non-numeric PINs", async () => {
|
|
const ctx = await request.newContext({ baseURL: BASE });
|
|
const res = await ctx.post("/api/water-admin-auth", {
|
|
data: { brandId: "64294306-5f42-463d-a5e8-2ad6c81a96de", pin: "abcd" },
|
|
});
|
|
expect(res.status()).not.toBe(200);
|
|
await ctx.dispose();
|
|
});
|
|
});
|
|
|
|
test.describe("Water Log — full workflow (DB required)", () => {
|
|
// These tests need a live database with the Tuxedo brand provisioned
|
|
// and the migrations from 0090 applied. Skipped by default so the
|
|
// suite still runs against any deployment. Set WATER_LOG_E2E_DB=1
|
|
// (and ensure BASE points at a dev DB) to opt in.
|
|
test.skip(!process.env.WATER_LOG_E2E_DB, "requires WATER_LOG_E2E_DB=1 + a live DB");
|
|
|
|
test("site admin can add a headgate and an irrigator, then export CSV", async ({ page }) => {
|
|
// 1. Sign in as a platform_admin or Tuxedo brand_admin
|
|
await page.goto(`${BASE}/login`);
|
|
// The login flow is environment-specific (Google OAuth, email link,
|
|
// or local dev session). Try the dev session form first.
|
|
const emailInput = page.getByLabel(/email/i).first();
|
|
if (await emailInput.isVisible().catch(() => false)) {
|
|
await emailInput.fill(process.env.WATER_LOG_TEST_ADMIN_EMAIL ?? "admin@tuxedo.example");
|
|
const passwordInput = page.getByLabel(/password/i).first();
|
|
if (await passwordInput.isVisible().catch(() => false)) {
|
|
await passwordInput.fill(process.env.WATER_LOG_TEST_ADMIN_PASSWORD ?? "test-password");
|
|
}
|
|
await page.getByRole("button", { name: /sign in/i }).first().click();
|
|
}
|
|
|
|
// 2. Navigate to the Water Log admin
|
|
await page.goto(`${BASE}/admin/water-log`);
|
|
await expect(page.getByRole("heading", { name: /water log/i }).first()).toBeVisible({
|
|
timeout: 15_000,
|
|
});
|
|
|
|
// 3. Add a headgate
|
|
const hgName = `E2E Headgate ${Date.now()}`;
|
|
const addHg = page.getByRole("button", { name: /add headgate/i }).first();
|
|
if (await addHg.isVisible().catch(() => false)) {
|
|
await addHg.click();
|
|
const nameInput = page.getByPlaceholder(/north field gate|gate/i).first();
|
|
await nameInput.fill(hgName);
|
|
await page.getByRole("button", { name: /^save|create|add$/i }).first().click();
|
|
}
|
|
await expect(page.getByText(hgName).first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
// 4. Add an irrigator
|
|
const irrName = `E2E Irrigator ${Date.now()}`;
|
|
const addUser = page.getByRole("button", { name: /add (water )?user|add irrigator/i }).first();
|
|
if (await addUser.isVisible().catch(() => false)) {
|
|
await addUser.click();
|
|
const nameInput = page.getByPlaceholder(/full name/i).first();
|
|
await nameInput.fill(irrName);
|
|
await page.getByRole("button", { name: /^save|create|add$/i }).first().click();
|
|
}
|
|
await expect(page.getByText(irrName).first()).toBeVisible({ timeout: 10_000 });
|
|
|
|
// 5. Hit the CSV export endpoint and confirm it returns CSV
|
|
const ctx = await request.newContext({
|
|
baseURL: BASE,
|
|
storageState: await page.context().storageState(),
|
|
});
|
|
const res = await ctx.get("/api/water-logs/export?format=csv");
|
|
expect(res.status()).toBe(200);
|
|
const contentType = res.headers()["content-type"] ?? "";
|
|
expect(contentType).toContain("text/csv");
|
|
const body = await res.text();
|
|
expect(body.split("\n").length).toBeGreaterThanOrEqual(1);
|
|
await ctx.dispose();
|
|
});
|
|
});
|