Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Completes the Tuxedo Water Log feature end-to-end. Builds on the existing
water_* tables in 0001_init.sql; extends them with 0090 and rewrites the
server actions, settings page, export route, and admin panel to actually
function.
Schema (db/migrations/0090_water_log_completion.sql, db/schema/water-log.ts)
- headgate_token + status + max_flow_gpm + thresholds + notes on water_headgates
- role + phone + notes on water_irrigators
- method + total_gallons + photo_url + logged_date + lat/lon on water_log_entries
- new tables: water_admin_settings, water_admin_sessions, water_audit_log, water_alert_log
Auth (src/lib/water-log-pin.ts, src/lib/water-log-audit.ts)
- scrypt N=16384 per-PIN random salt, self-describing hash format
- weak-PIN detection (repeats, monotonic, palindromes)
- 200ms delay + timingSafeEqual on failed verify
- 4-8 digit PINs, generatePin() rejects weak patterns
Server actions (src/actions/water-log/{admin,field,settings}.ts)
- full Drizzle CRUD: headgates, irrigators, entries (with audit hooks)
- verifyPin + 8h field sessions, admin PIN + configurable 1-168h sessions
- regenerateAdminPin invalidates existing admin sessions
- threshold breach detection + alert log writes
- getWaterAdminSession() helper for admin pages
API routes
- /api/water-admin-auth: PIN-protected, generic error (no enum leak)
- /api/water-logs/export: admin-gated JSON/CSV, correct headers
UI (Field Almanac — cream + forest, Fraunces display, § section markers)
- src/components/admin/WaterLogAdminPanel.tsx: full rewrite
- src/components/water/WaterFieldClient.tsx: matches palette
- src/components/water/icons/{WaterGauge,SeasonMark}.tsx: custom SVGs
- src/app/admin/water-log/settings/page.tsx: full rewrite, Field Almanac
Tests
- tests/unit/water-log-pin.test.ts (21 cases): validate, hash, verify, generate
- tests/unit/water-log-reporting.test.ts (31 cases): season, filter, CSV, report
- tests/water-log.spec.ts (Playwright E2E): PIN form, auth gates, API gates
Docs
- docs/water-log.md: full admin guide, security model, data dictionary, checklist
- README: link to docs/water-log.md in admin modules list
- .env.example: Water Log section comment
Type-check: clean. Tests: 70 pass, 3 fail (3 pre-existing getAdminUser
failures on main, unrelated to this work).
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Tests for `src/lib/water-log-pin.ts` — PIN hashing/verification used
|
||||
* by the field portal. The hashing layer is critical: a 4-digit PIN is
|
||||
* essentially a 10,000-entry dictionary, so a slow KDF and a per-row
|
||||
* random salt are the only defense-in-depth that matters if the
|
||||
* database leaks.
|
||||
*
|
||||
* The `server-only` import is stubbed so the test runner can import
|
||||
* the module (the pattern matches `tests/unit/passwords.test.ts`).
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
import {
|
||||
hashPin,
|
||||
verifyPin,
|
||||
validatePin,
|
||||
isWeakPin,
|
||||
generatePin,
|
||||
PIN_MIN,
|
||||
PIN_MAX,
|
||||
} from "@/lib/water-log-pin";
|
||||
|
||||
describe("validatePin", () => {
|
||||
it("accepts a 4-digit numeric PIN", () => {
|
||||
expect(validatePin("1234")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts the full 8-digit range", () => {
|
||||
expect(validatePin("12345678")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects pins shorter than PIN_MIN", () => {
|
||||
expect(validatePin("12")).toMatch(/4.*8/);
|
||||
});
|
||||
|
||||
it("rejects pins longer than PIN_MAX", () => {
|
||||
expect(validatePin("123456789")).toMatch(/4.*8/);
|
||||
});
|
||||
|
||||
it("rejects non-numeric input", () => {
|
||||
expect(validatePin("123a")).toBe("PIN must be numeric");
|
||||
});
|
||||
|
||||
it("rejects non-string types", () => {
|
||||
expect(validatePin(1234)).toBe("PIN must be a string");
|
||||
expect(validatePin(null)).toBe("PIN must be a string");
|
||||
expect(validatePin(undefined)).toBe("PIN must be a string");
|
||||
});
|
||||
|
||||
it("accepts leading zeros", () => {
|
||||
expect(validatePin("0007")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWeakPin", () => {
|
||||
it("flags repeated digits", () => {
|
||||
expect(isWeakPin("1111")).toBe(true);
|
||||
expect(isWeakPin("0000")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags monotonic runs", () => {
|
||||
expect(isWeakPin("1234")).toBe(true);
|
||||
expect(isWeakPin("4321")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags palindromes", () => {
|
||||
expect(isWeakPin("1221")).toBe(true);
|
||||
expect(isWeakPin("3443")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag random-looking pins", () => {
|
||||
expect(isWeakPin("4739")).toBe(false);
|
||||
expect(isWeakPin("8201")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPin / verifyPin", () => {
|
||||
it("round-trips a 4-digit PIN", () => {
|
||||
const h = hashPin("4739");
|
||||
expect(verifyPin("4739", h)).toBe(true);
|
||||
expect(verifyPin("4740", h)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a random salt — same PIN produces different hashes", () => {
|
||||
const a = hashPin("4739");
|
||||
const b = hashPin("4739");
|
||||
expect(a).not.toBe(b);
|
||||
expect(verifyPin("4739", a)).toBe(true);
|
||||
expect(verifyPin("4739", b)).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a self-describing format (scrypt$N$r$p$salt$hash)", () => {
|
||||
const h = hashPin("4739");
|
||||
const parts = h.split("$");
|
||||
expect(parts[0]).toBe("scrypt");
|
||||
expect(Number.parseInt(parts[1], 10)).toBeGreaterThan(0);
|
||||
expect(Number.parseInt(parts[2], 10)).toBeGreaterThan(0);
|
||||
expect(Number.parseInt(parts[3], 10)).toBeGreaterThan(0);
|
||||
// Salt is 16 bytes base64 — length depends on padding but always > 16
|
||||
expect(parts[4].length).toBeGreaterThanOrEqual(16);
|
||||
// Hash is 32 bytes base64
|
||||
expect(parts[5].length).toBeGreaterThanOrEqual(40);
|
||||
});
|
||||
|
||||
it("returns false (no exception) for tampered hashes", () => {
|
||||
expect(verifyPin("4739", "not-a-real-hash")).toBe(false);
|
||||
expect(verifyPin("4739", "scrypt$1$1$1$xx$yy")).toBe(false);
|
||||
expect(verifyPin("4739", "")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for the wrong PIN (without revealing why)", () => {
|
||||
const h = hashPin("4739");
|
||||
expect(verifyPin("0000", h)).toBe(false);
|
||||
expect(verifyPin("9999", h)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the empty string (no bypass for missing PIN)", () => {
|
||||
const h = hashPin("4739");
|
||||
expect(verifyPin("", h)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generatePin", () => {
|
||||
it("returns a 4-digit string", () => {
|
||||
const pin = generatePin();
|
||||
expect(pin).toMatch(/^\d{4}$/);
|
||||
});
|
||||
|
||||
it("avoids obvious weak patterns (no 1111, 1234, etc.)", () => {
|
||||
// 1000 attempts — should never see a weak pin if the generator works.
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const pin = generatePin();
|
||||
expect(isWeakPin(pin)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("produces variety across many calls (statistical)", () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 200; i++) {
|
||||
seen.add(generatePin());
|
||||
}
|
||||
// With 10k possible 4-digit pins, we'd expect to see well over 100
|
||||
// distinct values in 200 draws. This is a sanity check, not a hard
|
||||
// guarantee — if it ever flakes, increase the iteration count.
|
||||
expect(seen.size).toBeGreaterThan(80);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PIN length constants", () => {
|
||||
it("PINs are 4–8 digits", () => {
|
||||
expect(PIN_MIN).toBe(4);
|
||||
expect(PIN_MAX).toBe(8);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* Tests for `src/lib/water-log-reporting.ts` — pure-function utilities
|
||||
* used by both the admin and field clients. Covers:
|
||||
* - irrigation season detection (in/out + year-wrapping)
|
||||
* - entry shaping for the report format
|
||||
* - filter by date range, headgate, user, submission source
|
||||
* - CSV encoding (commas, quotes, newlines, unicode)
|
||||
* - report formatter ("Daily Report" output for SMS/email)
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
isInIrrigationSeason,
|
||||
shapeWaterLogEntry,
|
||||
filterWaterLogEntries,
|
||||
waterLogToCSV,
|
||||
formatDailyWaterReport,
|
||||
getDisplayAgeLabel,
|
||||
getDisplayAgeColor,
|
||||
type IrrigationSeasonSettings,
|
||||
type WaterLogReportRow,
|
||||
} from "@/lib/water-log-reporting";
|
||||
|
||||
const ROW = (overrides: Partial<WaterLogReportRow> = {}): WaterLogReportRow => ({
|
||||
logged_at: "2026-06-15T14:30:00.000Z",
|
||||
headgate_name: "Upper Ditch",
|
||||
user_name: "Tyler",
|
||||
user_role: "irrigator",
|
||||
measurement: 3.2,
|
||||
unit: "CFS",
|
||||
notes: null,
|
||||
submitted_via: "field",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("isInIrrigationSeason", () => {
|
||||
const season: IrrigationSeasonSettings = {
|
||||
seasonStartMonth: 3,
|
||||
seasonStartDay: 15,
|
||||
seasonEndMonth: 10,
|
||||
seasonEndDay: 15,
|
||||
};
|
||||
|
||||
// Construct dates in *local* time (year, monthIndex, day) so the test
|
||||
// is timezone-independent. `new Date("2026-03-15")` would parse as
|
||||
// UTC midnight, which on a US TZ tester becomes the previous day
|
||||
// locally and makes boundary assertions brittle.
|
||||
const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d);
|
||||
|
||||
it("returns true inside the season (May 1)", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 5, 1), season)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false before the season (Feb 1)", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 2, 1), season)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false after the season (Dec 1)", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 12, 1), season)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on the boundary day (Mar 15)", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 3, 15), season)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true on the closing boundary (Oct 15)", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 10, 15), season)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false the day after closing (Oct 16)", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 10, 16), season)).toBe(false);
|
||||
});
|
||||
|
||||
it("handles a season that wraps the year (Nov → Feb)", () => {
|
||||
const wrap: IrrigationSeasonSettings = {
|
||||
seasonStartMonth: 11,
|
||||
seasonStartDay: 1,
|
||||
seasonEndMonth: 2,
|
||||
seasonEndDay: 28,
|
||||
};
|
||||
expect(isInIrrigationSeason(localDate(2026, 12, 25), wrap)).toBe(true);
|
||||
expect(isInIrrigationSeason(localDate(2026, 1, 15), wrap)).toBe(true);
|
||||
expect(isInIrrigationSeason(localDate(2026, 6, 1), wrap)).toBe(false);
|
||||
});
|
||||
|
||||
it("uses default settings (Mar 15 → Oct 15) when none provided", () => {
|
||||
expect(isInIrrigationSeason(localDate(2026, 4, 1))).toBe(true);
|
||||
expect(isInIrrigationSeason(localDate(2026, 12, 1))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shapeWaterLogEntry", () => {
|
||||
it("normalizes an entry, providing a default role", () => {
|
||||
const out = shapeWaterLogEntry({
|
||||
id: "x",
|
||||
logged_at: "2026-06-15T14:30:00.000Z",
|
||||
headgate_name: "Upper Ditch",
|
||||
user_name: "Tyler",
|
||||
user_role: "water_admin",
|
||||
measurement: 3.2,
|
||||
unit: "CFS",
|
||||
notes: null,
|
||||
submitted_via: "field",
|
||||
});
|
||||
expect(out).toEqual({
|
||||
logged_at: "2026-06-15T14:30:00.000Z",
|
||||
headgate_name: "Upper Ditch",
|
||||
user_name: "Tyler",
|
||||
user_role: "water_admin",
|
||||
measurement: 3.2,
|
||||
unit: "CFS",
|
||||
notes: null,
|
||||
submitted_via: "field",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults role to 'irrigator' when missing", () => {
|
||||
const out = shapeWaterLogEntry({
|
||||
id: "x",
|
||||
logged_at: "2026-06-15T14:30:00.000Z",
|
||||
headgate_name: "Upper Ditch",
|
||||
user_name: "Tyler",
|
||||
measurement: 3.2,
|
||||
unit: "CFS",
|
||||
notes: null,
|
||||
submitted_via: "field",
|
||||
});
|
||||
expect(out.user_role).toBe("irrigator");
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterWaterLogEntries", () => {
|
||||
const rows: WaterLogReportRow[] = [
|
||||
ROW({ logged_at: "2026-06-10T10:00:00.000Z", headgate_name: "A", user_name: "Tyler" }),
|
||||
ROW({ logged_at: "2026-06-15T10:00:00.000Z", headgate_name: "B", user_name: "Tyler" }),
|
||||
ROW({ logged_at: "2026-06-20T10:00:00.000Z", headgate_name: "A", user_name: "Sam" }),
|
||||
];
|
||||
|
||||
it("returns everything when no filters", () => {
|
||||
expect(filterWaterLogEntries(rows, {})).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("filters by dateFrom (inclusive of full day)", () => {
|
||||
const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15" });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by dateTo (inclusive of full day)", () => {
|
||||
const out = filterWaterLogEntries(rows, { dateTo: "2026-06-15" });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by date range", () => {
|
||||
const out = filterWaterLogEntries(rows, {
|
||||
dateFrom: "2026-06-15",
|
||||
dateTo: "2026-06-15",
|
||||
});
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by headgate", () => {
|
||||
const out = filterWaterLogEntries(rows, { headgateId: "A" });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("filters by user", () => {
|
||||
const out = filterWaterLogEntries(rows, { userId: "Sam" });
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters by submission source", () => {
|
||||
const rows2 = [
|
||||
ROW({ submitted_via: "field" }),
|
||||
ROW({ submitted_via: "admin" }),
|
||||
];
|
||||
expect(filterWaterLogEntries(rows2, { submittedVia: "admin" })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("combines multiple filters", () => {
|
||||
const out = filterWaterLogEntries(rows, {
|
||||
dateFrom: "2026-06-15",
|
||||
headgateId: "A",
|
||||
});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].user_name).toBe("Sam");
|
||||
});
|
||||
});
|
||||
|
||||
describe("waterLogToCSV", () => {
|
||||
it("emits a header row + one row per entry", () => {
|
||||
const csv = waterLogToCSV([
|
||||
ROW(),
|
||||
ROW({ measurement: 4.5, notes: "rain" }),
|
||||
]);
|
||||
const lines = csv.split("\n");
|
||||
expect(lines).toHaveLength(3);
|
||||
expect(lines[0]).toContain("When");
|
||||
expect(lines[0]).toContain("Headgate");
|
||||
});
|
||||
|
||||
it("quotes fields containing commas, quotes, or newlines", () => {
|
||||
const csv = waterLogToCSV([
|
||||
ROW({ notes: 'has, "quotes" and\nnewlines' }),
|
||||
]);
|
||||
// The cell value is properly quoted with internal quotes doubled.
|
||||
// An embedded newline splits the row across two physical lines,
|
||||
// so we read the full CSV string rather than a single line.
|
||||
expect(csv).toContain('"has, ""quotes"" and\nnewlines"');
|
||||
});
|
||||
|
||||
it("escapes unicode and emoji", () => {
|
||||
const csv = waterLogToCSV([
|
||||
ROW({ user_name: "José 🚜" }),
|
||||
]);
|
||||
expect(csv).toContain("José 🚜");
|
||||
});
|
||||
|
||||
it("emits a syntactically valid CSV (parses with a basic split)", () => {
|
||||
const csv = waterLogToCSV([ROW(), ROW()]);
|
||||
// 1 header + 2 data rows
|
||||
expect(csv.split("\n")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDailyWaterReport", () => {
|
||||
it("returns null when no rows and sendEvenIfEmpty is false", () => {
|
||||
expect(formatDailyWaterReport([])).toBeNull();
|
||||
});
|
||||
|
||||
it("emits an empty-day message when sendEvenIfEmpty is true", () => {
|
||||
const out = formatDailyWaterReport([], { sendEvenIfEmpty: true });
|
||||
expect(out).toContain("No entries reported today");
|
||||
});
|
||||
|
||||
it("includes the recipient name when provided", () => {
|
||||
const out = formatDailyWaterReport([ROW()], { recipientName: "David" });
|
||||
// We don't enforce the salutation format, but the row must be present.
|
||||
expect(out).toContain("Tyler");
|
||||
expect(out).toContain("Upper Ditch");
|
||||
});
|
||||
|
||||
it("sums the total measurement", () => {
|
||||
const out = formatDailyWaterReport([
|
||||
ROW({ measurement: 2 }),
|
||||
ROW({ measurement: 3.5 }),
|
||||
]);
|
||||
expect(out).toContain("5.50");
|
||||
});
|
||||
|
||||
it("includes notes when present", () => {
|
||||
const out = formatDailyWaterReport([ROW({ notes: "Ditch running heavy" })]);
|
||||
expect(out).toContain("Ditch running heavy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("display age helpers", () => {
|
||||
it("returns 'never' for null", () => {
|
||||
expect(getDisplayAgeLabel(null)).toBe("never");
|
||||
expect(getDisplayAgeColor(null)).toBe("red");
|
||||
});
|
||||
|
||||
it("returns 'just now' under 1 minute", () => {
|
||||
expect(getDisplayAgeLabel(0.5)).toBe("just now");
|
||||
expect(getDisplayAgeColor(0.5)).toBe("green");
|
||||
});
|
||||
|
||||
it("buckets by minutes / hours / days", () => {
|
||||
expect(getDisplayAgeLabel(15)).toBe("15m ago");
|
||||
expect(getDisplayAgeLabel(90)).toBe("1h ago");
|
||||
expect(getDisplayAgeLabel(120)).toBe("2h ago");
|
||||
expect(getDisplayAgeLabel(60 * 26)).toBe("1d ago");
|
||||
});
|
||||
|
||||
it("colors by recency", () => {
|
||||
expect(getDisplayAgeColor(15)).toBe("green"); // < 30m
|
||||
expect(getDisplayAgeColor(60)).toBe("yellow"); // < 2h
|
||||
expect(getDisplayAgeColor(180)).toBe("red"); // > 2h
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 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 — 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user