Files
route-commerce/tests/unit/water-log-pin.test.ts
T
Tyler 11cd2fd01a
Deploy to route.crispygoat.com / deploy (push) Successful in 4m33s
Water Log: production-ready module (schema, auth, CRUD, UI, tests, docs)
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).
2026-06-17 11:36:00 -06:00

157 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 48 digits", () => {
expect(PIN_MIN).toBe(4);
expect(PIN_MAX).toBe(8);
});
});