diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index dace647..48c5d54 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -283,6 +283,7 @@ This means: | TC-API-8.13 | Portal pet update — owner success + persistence (GRO-2187, fixes [GRO-1480](/GRO/issues/GRO-1480) §5.23) | With a portal session for the pet's owner, `PATCH /api/portal/pets/{petId}` with body `{ "name": "...", "breed": "...", "weightKg": 18.25, "healthAlerts": "...", "coatType": "double", "petSizeCategory": "xlarge", "preferredCuts": ["teddy bear"], "medicalAlerts": [{"type":"allergy","description":"oatmeal","severity":"medium"}] }` | 200 OK; response reflects the update with `petSizeCategory: "extra_large"` (web `xlarge` → DB `extra_large`). A follow-up `GET /api/portal/pets` shows the persisted values | | TC-API-8.14 | Portal pet update — non-owner blocked (GRO-2187) | `PATCH /api/portal/pets/{petId}` for a pet owned by a different client, using another client's portal session | 403 Forbidden (or 404 if pet id is unknown); no mutation persisted | | TC-API-8.15 | Portal pet update — invalid enum rejected (GRO-2187) | `PATCH /api/portal/pets/{petId}` with `coatType: "fluffy"` or `petSizeCategory: "gigantic"` | 422 Unprocessable Entity; pet unchanged | +| TC-API-8.16 | Portal pet update — malformed (non-UUID) petId returns 404 (GRO-2203) | With a valid portal session, `PATCH /api/portal/pets/not-a-uuid` with header `X-Impersonation-Session-Id` and body `{"coatType":"short"}` | 404 Not Found with body `{"error":"Not found"}` (was an unhandled 500 from the Postgres uuid cast in GRO-2203; mirrors the GRO-2014 guard). No mutation persisted | ### 4.9 Waitlist diff --git a/src/__tests__/portalPets.test.ts b/src/__tests__/portalPets.test.ts index c23bca0..a9e41c2 100644 --- a/src/__tests__/portalPets.test.ts +++ b/src/__tests__/portalPets.test.ts @@ -280,6 +280,23 @@ describe("PATCH /portal/pets/:petId", () => { expect(res.status).toBe(404); }); + it("returns 404 for a malformed (non-UUID) petId without hitting the db (GRO-2203)", async () => { + selectSessionRow = ACTIVE_SESSION; + // A non-UUID petId previously reached `where(eq(pets.id, ...))` and made + // Postgres throw "invalid input syntax for type uuid" → unhandled 500. + // It must now short-circuit to 404 before any select/update. + selectPetRow = PET; + + const res = await jsonPatch( + `/portal/pets/not-a-uuid`, + { coatType: "short" }, + { "X-Impersonation-Session-Id": SESSION_ID } + ); + + expect(res.status).toBe(404); + expect(updatedValues).toHaveLength(0); + }); + it("returns 422 for an invalid coatType", async () => { selectSessionRow = ACTIVE_SESSION; selectPetRow = PET; diff --git a/src/__tests__/waitlist.test.ts b/src/__tests__/waitlist.test.ts index 383bc80..5c4d209 100644 --- a/src/__tests__/waitlist.test.ts +++ b/src/__tests__/waitlist.test.ts @@ -184,6 +184,66 @@ describe("POST /portal/waitlist", () => { 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 () => { const res = await jsonRequest("POST", "/portal/waitlist", { petId: VALID_UUID_3, @@ -258,6 +318,16 @@ describe("PATCH /portal/waitlist/:id", () => { 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 () => { const res = await jsonRequest("PATCH", `/portal/waitlist/${VALID_UUID_1}`, { status: "cancelled", diff --git a/src/routes/portal.ts b/src/routes/portal.ts index 0b106fb..aa1593a 100644 --- a/src/routes/portal.ts +++ b/src/routes/portal.ts @@ -296,6 +296,14 @@ portalRouter.patch( const body = c.req.valid("json"); const clientId = c.get("portalClientId"); + // GRO-2203: validate UUID format before hitting Postgres. Passing a non-UUID + // string to a uuid column makes the driver throw ("invalid input syntax for + // type uuid"), which previously surfaced as an unhandled 500. Mirror the + // GRO-2014 fix in pets.ts and treat a malformed id as Not found. + if (!z.string().uuid().safeParse(petId).success) { + return c.json({ error: "Not found" }, 404); + } + const [pet] = await db .select() .from(pets) @@ -551,17 +559,33 @@ portalRouter.post("/appointments/:id/cancel", async (c) => { // ─── 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({ petId: z.string().uuid(), serviceId: z.string().uuid(), - preferredDate: z.string(), - preferredTime: z.string(), + preferredDate: preferredDateSchema, + preferredTime: preferredTimeSchema, }); const updateWaitlistEntrySchema = z.object({ status: z.literal("cancelled").optional(), - preferredDate: z.string().optional(), - preferredTime: z.string().optional(), + preferredDate: preferredDateSchema.optional(), + preferredTime: preferredTimeSchema.optional(), }); portalRouter.post( @@ -579,7 +603,7 @@ portalRouter.post( petId: body.petId, serviceId: body.serviceId, preferredDate: body.preferredDate, - preferredTime: body.preferredTime, + preferredTime: normalizeTime(body.preferredTime), }) .returning(); @@ -610,7 +634,7 @@ portalRouter.patch( const updateData: Record = { updatedAt: new Date() }; if (body.status !== undefined) updateData.status = body.status; 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 .update(waitlistEntries)