Compare commits

...

1 Commits

Author SHA1 Message Date
Flea Flicker 2f20ef7287 fix(portal): validate waitlist preferredTime/preferredDate, return 400 on bad input (GRO-2211)
CI / Test (pull_request) Successful in 26s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Images (pull_request) Successful in 1m9s
createWaitlistEntrySchema/updateWaitlistEntrySchema declared preferredTime/
preferredDate as bare z.string(), so a malformed value (e.g. a full ISO
datetime) was inserted straight into the Postgres time/date columns, throwing
a DateTimeParseError that surfaced as an unhandled 500.

Constrain both fields with regexes (HH:MM[:SS] / YYYY-MM-DD) so zValidator
rejects bad input with 400 before it hits the DB, and normalize HH:MM to
HH:MM:SS on insert/update. Adds waitlist tests covering the 400 paths and
HH:MM normalization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 17:17:23 +00:00
2 changed files with 92 additions and 6 deletions
+70
View File
@@ -184,6 +184,66 @@ describe("POST /portal/waitlist", () => {
expect(insertedValues).toHaveLength(1); expect(insertedValues).toHaveLength(1);
}); });
it("normalizes HH:MM:SS preferredTime and returns 201 (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(201);
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
});
it("normalizes HH:MM preferredTime to HH:MM:SS before insert (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "10:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(201);
expect(insertedValues[0]?.preferredTime).toBe("10:00:00");
});
it("returns 400 (not 500) for a full ISO datetime preferredTime (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "2026-06-09T10:00:00.000Z",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(insertedValues).toHaveLength(0);
});
it("returns 400 for a malformed preferredDate (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "03/25/2026",
preferredTime: "10:00",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(insertedValues).toHaveLength(0);
});
it("returns 400 for an out-of-range preferredTime (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3,
serviceId: VALID_UUID_4,
preferredDate: "2026-03-25",
preferredTime: "25:99",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(insertedValues).toHaveLength(0);
});
it("returns 401 without session", async () => { it("returns 401 without session", async () => {
const res = await jsonRequest("POST", "/portal/waitlist", { const res = await jsonRequest("POST", "/portal/waitlist", {
petId: VALID_UUID_3, petId: VALID_UUID_3,
@@ -258,6 +318,16 @@ describe("PATCH /portal/waitlist/:id", () => {
expect(updatedValues[0]?.status).toBe("cancelled"); expect(updatedValues[0]?.status).toBe("cancelled");
}); });
it("returns 400 (not 500) for a full ISO datetime preferredTime on update (GRO-2211)", async () => {
selectSessionRow = ACTIVE_SESSION;
selectRows = [WAITLIST_ENTRY];
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
preferredTime: "2026-06-09T10:00:00.000Z",
}, { "X-Impersonation-Session-Id": VALID_UUID_5 });
expect(res.status).toBe(400);
expect(updatedValues).toHaveLength(0);
});
it("returns 401 without session", async () => { it("returns 401 without session", async () => {
const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, {
status: "cancelled", status: "cancelled",
+22 -6
View File
@@ -559,17 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => {
// ─── Client-facing waitlist routes ──────────────────────────────────────────── // ─── Client-facing waitlist routes ────────────────────────────────────────────
// Postgres `date` / `time` columns reject arbitrary strings (e.g. a full ISO
// datetime), throwing a DateTimeParseError that surfaces as an unhandled 500.
// Constrain client input here so malformed values are rejected with a 400 by
// zValidator before they ever reach the DB (GRO-2211 defense-in-depth).
const preferredDateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "preferredDate must be YYYY-MM-DD");
const preferredTimeSchema = z
.string()
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, "preferredTime must be HH:MM or HH:MM:SS");
// Normalize HH:MM → HH:MM:SS so it matches the Postgres `time` column format.
function normalizeTime(value: string): string {
return value.length === 5 ? `${value}:00` : value;
}
const createWaitlistEntrySchema = z.object({ const createWaitlistEntrySchema = z.object({
petId: z.string().uuid(), petId: z.string().uuid(),
serviceId: z.string().uuid(), serviceId: z.string().uuid(),
preferredDate: z.string(), preferredDate: preferredDateSchema,
preferredTime: z.string(), preferredTime: preferredTimeSchema,
}); });
const updateWaitlistEntrySchema = z.object({ const updateWaitlistEntrySchema = z.object({
status: z.literal("cancelled").optional(), status: z.literal("cancelled").optional(),
preferredDate: z.string().optional(), preferredDate: preferredDateSchema.optional(),
preferredTime: z.string().optional(), preferredTime: preferredTimeSchema.optional(),
}); });
portalRouter.post( portalRouter.post(
@@ -587,7 +603,7 @@ portalRouter.post(
petId: body.petId, petId: body.petId,
serviceId: body.serviceId, serviceId: body.serviceId,
preferredDate: body.preferredDate, preferredDate: body.preferredDate,
preferredTime: body.preferredTime, preferredTime: normalizeTime(body.preferredTime),
}) })
.returning(); .returning();
@@ -618,7 +634,7 @@ portalRouter.patch(
const updateData: Record<string, unknown> = { updatedAt: new Date() }; const updateData: Record<string, unknown> = { updatedAt: new Date() };
if (body.status !== undefined) updateData.status = body.status; if (body.status !== undefined) updateData.status = body.status;
if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate; if (body.preferredDate !== undefined) updateData.preferredDate = body.preferredDate;
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime; if (body.preferredTime !== undefined) updateData.preferredTime = normalizeTime(body.preferredTime);
const [updated] = await db const [updated] = await db
.update(waitlistEntries) .update(waitlistEntries)