16ad0955f2
Deploy to route.crispygoat.com / deploy (push) Successful in 4m12s
The two per-domain worker tables (water_irrigators, time_tracking_workers)
both carried a pin_hash, a brand, a role, and almost-identical columns.
Migrating a Tuxedo worker from one app to the other meant a duplicate
PIN. Field and admin staff complained; ops got inconsistent identity
records per domain.
Unify into a single field_workers table. Cycle 10 migration
0097_field_workers.sql:
- creates field_workers (id, brand_id, name, pin_hash, role with CHECK
IN ('worker','time_admin','irrigator','water_admin'),
language_preference, phone, notes, active, last_used_at, …)
- adds brand-scoped RLS, brand index, active index
- nullable field_worker_id on downstream tables, backfilled via
UPDATE … FROM, NULL count verified with RAISE EXCEPTION before
SET NOT NULL
- drops the two old tables and renames FK columns
(irrigator_id → field_worker_id, worker_id → field_worker_id)
- reissues the one-open-clock-in partial unique index against
field_worker_id
Application surface:
- All actions/services now read/write fieldWorkers. Drizzle's enum
literal infers tighter role types than the prior ad-hoc casts.
- The verify paths still filter to their respective domain roles so a
water-only worker can't be matched against a time-tracking PIN.
- API surface preserved: action function names didn't change.
- Seed file updated so QA reseeds work post-deploy.
Verified:
- npx tsc --noEmit clean
- npx vitest: 218 pass; 29 failures are pre-existing on main
- npm run lint: exit 0 (no new violations from cycle 10)
- npm run build: exit 0
- /water and /tuxedo/time-clock both 200 on dev
- DB verified via direct query: 20 field_workers, 200 water entries
with zero NULL field_worker_id, index rebuilt.
PR-reviewer: APPROVED.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
/**
|
|
* Unit tests for the water-log auth layer.
|
|
*
|
|
* Two modules are exercised here:
|
|
*
|
|
* - `src/actions/water-log/auth.ts`
|
|
* The platform-side auth helpers (`requireFieldSession`,
|
|
* `getFieldSessionUser`, `requireWaterAdminPermission`). These
|
|
* are used by `/water` (irrigator PIN) and `/admin/water-log/*`
|
|
* (Neon Auth + admin_users gate).
|
|
*
|
|
* - `src/lib/water-admin-pin-auth.ts`
|
|
* The brand-side PIN-only auth for `/water/admin/*`. Cookie
|
|
* presence = authenticated, no DB session table, no Neon Auth.
|
|
*
|
|
* Both modules are tested for the property that they never call
|
|
* `getSession()` from `@/lib/auth`. The static check at the top of
|
|
* the file enforces this on the platform-side module; the
|
|
* brand-side module has no `@/lib/auth` import at all by design.
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
// ── Hoisted state (visible inside vi.mock factory closures) ────────────
|
|
|
|
const state = vi.hoisted(() => ({
|
|
cookieValue: undefined as string | undefined,
|
|
mockDb: { select: vi.fn() } as { select: ReturnType<typeof vi.fn> },
|
|
mockWithPlatformAdmin: null as unknown as ReturnType<typeof vi.fn>,
|
|
mockWithBrand: null as unknown as ReturnType<typeof vi.fn>,
|
|
mockGetAdminUser: null as unknown as ReturnType<typeof vi.fn>,
|
|
}));
|
|
|
|
state.mockWithPlatformAdmin = vi.fn(
|
|
async (fn: (db: typeof state.mockDb) => Promise<unknown>) => fn(state.mockDb),
|
|
);
|
|
state.mockWithBrand = vi.fn(
|
|
async (_brandId: string, fn: (db: typeof state.mockDb) => Promise<unknown>) =>
|
|
fn(state.mockDb),
|
|
);
|
|
state.mockGetAdminUser = vi.fn();
|
|
|
|
vi.mock("server-only", () => ({}));
|
|
|
|
vi.mock("@/db/client", () => ({
|
|
withPlatformAdmin: state.mockWithPlatformAdmin,
|
|
withBrand: state.mockWithBrand,
|
|
withDb: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/admin-permissions", () => ({
|
|
getAdminUser: state.mockGetAdminUser,
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
cookies: () =>
|
|
Promise.resolve({
|
|
get: () =>
|
|
state.cookieValue !== undefined
|
|
? { value: state.cookieValue }
|
|
: undefined,
|
|
set: vi.fn(),
|
|
delete: vi.fn(),
|
|
}),
|
|
headers: () => Promise.resolve(new Headers()),
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
state.cookieValue = undefined;
|
|
state.mockDb.select.mockReset();
|
|
state.mockWithPlatformAdmin.mockClear();
|
|
state.mockWithBrand.mockClear();
|
|
state.mockGetAdminUser.mockReset();
|
|
});
|
|
|
|
// ── Module guards (the regression this whole PR exists for) ─────────────
|
|
|
|
describe("water-log/auth — module surface", () => {
|
|
it("does not import getSession from @/lib/auth", async () => {
|
|
const fs = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const raw = await fs.readFile(
|
|
path.resolve(process.cwd(), "src/actions/water-log/auth.ts"),
|
|
"utf8",
|
|
);
|
|
const stripped = raw
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
.replace(/^\s*\/\/.*$/gm, "")
|
|
.replace(/\s+/g, " ");
|
|
expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/);
|
|
expect(stripped).not.toMatch(/\bgetSession\s*\(/);
|
|
});
|
|
});
|
|
|
|
describe("water-admin-pin-auth — module surface", () => {
|
|
it("does not import getSession from @/lib/auth", async () => {
|
|
const fs = await import("node:fs/promises");
|
|
const path = await import("node:path");
|
|
const raw = await fs.readFile(
|
|
path.resolve(process.cwd(), "src/lib/water-admin-pin-auth.ts"),
|
|
"utf8",
|
|
);
|
|
const stripped = raw
|
|
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
.replace(/^\s*\/\/.*$/gm, "")
|
|
.replace(/\s+/g, " ");
|
|
expect(stripped).not.toMatch(/from\s+["']@\/lib\/auth["']/);
|
|
expect(stripped).not.toMatch(/\bgetSession\s*\(/);
|
|
expect(stripped).not.toMatch(/\bgetAdminUser\s*\(/);
|
|
});
|
|
});
|
|
|
|
// ── requireFieldSession ─────────────────────────────────────────────────
|
|
|
|
describe("requireFieldSession()", () => {
|
|
it("returns { ok: false, error: 'Not logged in' } when wl_session cookie is missing", async () => {
|
|
state.cookieValue = undefined;
|
|
const { requireFieldSession } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireFieldSession();
|
|
expect(result).toEqual({ ok: false, error: "Not logged in" });
|
|
expect(state.mockWithPlatformAdmin).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns { ok: false, error: 'Session not found' } when cookie has no matching row", async () => {
|
|
state.cookieValue = "missing-session-id";
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
innerJoin: () => ({
|
|
where: () => ({
|
|
limit: async () => [],
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
const { requireFieldSession } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireFieldSession();
|
|
expect(result).toEqual({ ok: false, error: "Session not found" });
|
|
});
|
|
|
|
it("returns { ok: false, error: 'Session expired' } and best-effort deletes the row when past expires_at", async () => {
|
|
state.cookieValue = "expired-id";
|
|
const past = new Date(Date.now() - 60_000);
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
innerJoin: () => ({
|
|
where: () => ({
|
|
limit: async () => [
|
|
{
|
|
session: { id: "expired-id", expiresAt: past },
|
|
worker: {
|
|
id: "u1",
|
|
brandId: "b1",
|
|
role: "irrigator",
|
|
active: true,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
const mockDelete = vi.fn().mockReturnValue({
|
|
where: async () => undefined,
|
|
});
|
|
(state.mockDb as unknown as { select: typeof vi.fn; delete: typeof vi.fn }).delete =
|
|
mockDelete;
|
|
const { requireFieldSession } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireFieldSession();
|
|
expect(result).toEqual({ ok: false, error: "Session expired" });
|
|
expect(mockDelete).toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns { ok: false, error: 'User is inactive' } when irrigator is soft-deleted", async () => {
|
|
state.cookieValue = "valid-id";
|
|
const future = new Date(Date.now() + 60_000);
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
innerJoin: () => ({
|
|
where: () => ({
|
|
limit: async () => [
|
|
{
|
|
session: { id: "valid-id", expiresAt: future },
|
|
worker: {
|
|
id: "u1",
|
|
brandId: "b1",
|
|
role: "irrigator",
|
|
active: false,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
const { requireFieldSession } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireFieldSession();
|
|
expect(result).toEqual({ ok: false, error: "User is inactive" });
|
|
});
|
|
|
|
it("returns { ok: true, userId, brandId, role } on a happy-path session", async () => {
|
|
state.cookieValue = "valid-id";
|
|
const future = new Date(Date.now() + 60_000);
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
innerJoin: () => ({
|
|
where: () => ({
|
|
limit: async () => [
|
|
{
|
|
session: { id: "valid-id", expiresAt: future },
|
|
worker: {
|
|
id: "u1",
|
|
brandId: "b1",
|
|
role: "irrigator",
|
|
active: true,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
}),
|
|
}),
|
|
});
|
|
const { requireFieldSession } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireFieldSession();
|
|
expect(result).toEqual({
|
|
ok: true,
|
|
userId: "u1",
|
|
brandId: "b1",
|
|
role: "irrigator",
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── getFieldSessionUser ─────────────────────────────────────────────────
|
|
|
|
describe("getFieldSessionUser()", () => {
|
|
it("returns null when there is no active field session", async () => {
|
|
state.cookieValue = undefined;
|
|
const { getFieldSessionUser } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
expect(await getFieldSessionUser()).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── requireWaterAdminPermission (site-admin gate) ───────────────────────
|
|
|
|
describe("requireWaterAdminPermission()", () => {
|
|
it("returns { ok: false, error: 'Not signed in' } when getAdminUser is null", async () => {
|
|
state.mockGetAdminUser.mockResolvedValue(null);
|
|
const { requireWaterAdminPermission } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireWaterAdminPermission();
|
|
expect(result).toEqual({ ok: false, error: "Not signed in" });
|
|
});
|
|
|
|
it("returns { ok: false, error: 'Not authorized' } when admin lacks can_manage_water_log and is not platform_admin", async () => {
|
|
state.mockGetAdminUser.mockResolvedValue({
|
|
user_id: "u1",
|
|
role: "brand_admin",
|
|
can_manage_water_log: false,
|
|
});
|
|
const { requireWaterAdminPermission } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireWaterAdminPermission();
|
|
expect(result).toEqual({ ok: false, error: "Not authorized" });
|
|
});
|
|
|
|
it("returns { ok: true, adminUser } when platform_admin", async () => {
|
|
const adminUser = { user_id: "u1", role: "platform_admin" };
|
|
state.mockGetAdminUser.mockResolvedValue(adminUser);
|
|
const { requireWaterAdminPermission } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireWaterAdminPermission();
|
|
expect(result).toEqual({ ok: true, adminUser });
|
|
});
|
|
|
|
it("returns { ok: true, adminUser } when brand_admin with can_manage_water_log", async () => {
|
|
const adminUser = {
|
|
user_id: "u1",
|
|
role: "brand_admin",
|
|
can_manage_water_log: true,
|
|
};
|
|
state.mockGetAdminUser.mockResolvedValue(adminUser);
|
|
const { requireWaterAdminPermission } = await import(
|
|
"@/actions/water-log/auth"
|
|
);
|
|
const result = await requireWaterAdminPermission();
|
|
expect(result).toEqual({ ok: true, adminUser });
|
|
});
|
|
});
|
|
|
|
// ── getAdminSession (cookie-only, the new simple auth) ──────────────────
|
|
|
|
describe("getAdminSession()", () => {
|
|
it("returns null when wl_admin_session cookie is missing", async () => {
|
|
state.cookieValue = undefined;
|
|
const { getAdminSession } = await import(
|
|
"@/lib/water-admin-pin-auth"
|
|
);
|
|
expect(await getAdminSession()).toBeNull();
|
|
});
|
|
|
|
it("returns the session shape on a valid cookie value", async () => {
|
|
state.cookieValue = "abc-123";
|
|
const { getAdminSession, TUXEDO_BRAND_ID } = await import(
|
|
"@/lib/water-admin-pin-auth"
|
|
);
|
|
expect(await getAdminSession()).toEqual({
|
|
sessionId: "abc-123",
|
|
brandId: TUXEDO_BRAND_ID,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── verifyAdminPin (PIN verify + cookie set, no DB session) ─────────────
|
|
|
|
describe("verifyAdminPin()", () => {
|
|
function makeSettingsRow(overrides: Partial<{
|
|
enabled: boolean;
|
|
pinHash: string | null;
|
|
sessionDurationHours: number;
|
|
}> = {}) {
|
|
return {
|
|
enabled: overrides.enabled ?? true,
|
|
pinHash: overrides.pinHash ?? "scrypt$16384$8$1$AAAAAAAAAAAAAAAAAAAAAA==$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
|
|
sessionDurationHours: overrides.sessionDurationHours ?? 8,
|
|
};
|
|
}
|
|
|
|
it("returns ok:false 'No PIN configured' when stored hash is missing", async () => {
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: async () => [{ enabled: true, pinHash: null, sessionDurationHours: 8 }],
|
|
}),
|
|
}),
|
|
});
|
|
const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth");
|
|
const result = await verifyAdminPin("1234");
|
|
expect(result).toEqual({
|
|
ok: false,
|
|
error: "No PIN configured — generate one in /admin/water-log/settings",
|
|
});
|
|
});
|
|
|
|
it("returns ok:false 'Admin portal is disabled' when settings.enabled is false", async () => {
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: async () => [{ enabled: false, pinHash: "any", sessionDurationHours: 8 }],
|
|
}),
|
|
}),
|
|
});
|
|
const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth");
|
|
const result = await verifyAdminPin("1234");
|
|
expect(result).toEqual({ ok: false, error: "Admin portal is disabled" });
|
|
});
|
|
|
|
it("returns ok:false 'Invalid PIN' on PIN format failure (no DB read)", async () => {
|
|
const { verifyAdminPin } = await import("@/lib/water-admin-pin-auth");
|
|
const result = await verifyAdminPin("12ab");
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toMatch(/PIN/);
|
|
}
|
|
expect(state.mockWithBrand).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns ok:true when PIN matches the stored hash", async () => {
|
|
// Pre-compute a real scrypt hash for "1234" so verifyPin succeeds.
|
|
const { hashPin } = await import("@/lib/water-log-pin");
|
|
const realHash = hashPin("1234");
|
|
state.mockDb.select.mockReturnValueOnce({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: async () => [
|
|
makeSettingsRow({ pinHash: realHash, sessionDurationHours: 4 }),
|
|
],
|
|
}),
|
|
}),
|
|
});
|
|
const { verifyAdminPin, TUXEDO_BRAND_ID } = await import(
|
|
"@/lib/water-admin-pin-auth"
|
|
);
|
|
const result = await verifyAdminPin("1234");
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.session.brandId).toBe(TUXEDO_BRAND_ID);
|
|
expect(typeof result.session.sessionId).toBe("string");
|
|
expect(result.session.sessionId.length).toBeGreaterThan(8);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── clearAdminSession ───────────────────────────────────────────────────
|
|
|
|
describe("clearAdminSession()", () => {
|
|
it("returns without throwing", async () => {
|
|
const { clearAdminSession } = await import(
|
|
"@/lib/water-admin-pin-auth"
|
|
);
|
|
await expect(clearAdminSession()).resolves.toBeUndefined();
|
|
});
|
|
}); |