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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user