Compare commits

..

3 Commits

Author SHA1 Message Date
Savannah Savings bd9866520b fix(portal): GRO-2203 validate petId as UUID before PATCH lookup (500→404)
CI / Test (pull_request) Successful in 23s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Images (pull_request) Successful in 1m2s
A non-UUID :petId passed straight into where(eq(pets.id, petId)) made
Postgres throw "invalid input syntax for type uuid", surfacing as an
unhandled 500. Guard the param with z.string().uuid() and return the
existing 404 {"error":"Not found"} for malformed ids, mirroring the
GRO-2014 fix in pets.ts. Valid-UUID-not-found already returned 404.

- Add regression test (non-UUID petId → 404, no mutation)
- Update UAT_PLAYBOOK.md §8 (TC-API-8.16)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-08 17:01:26 +00:00
Flea Flicker d0c0b1b646 feat(GRO-2155): route CRUD + optimization endpoint (Phase 2.1) (#175)
CI / Test (push) Successful in 25s
CI / Lint & Typecheck (push) Successful in 28s
CI / Test (pull_request) Successful in 24s
CI / Build & Push Docker Images (push) Successful in 35s
CI / Lint & Typecheck (pull_request) Successful in 26s
CI / Build & Push Docker Images (pull_request) Successful in 25s
2026-06-08 13:57:07 +00:00
Flea Flicker b9fc688769 fix(db): wait for/retry DB DNS resolution before drizzle-kit migrate (GRO-2163) (#161)
CI / Test (push) Successful in 28s
CI / Lint & Typecheck (push) Successful in 31s
CI / Build & Push Docker Images (push) Successful in 47s
2026-06-08 13:37:30 +00:00
3 changed files with 6 additions and 94 deletions
-2
View File
@@ -165,8 +165,6 @@ Geocoding turns a client's street address into `latitude`/`longitude` + `geocode
| TC-API-3.19b | Get pet profile summary — customer cross-tenant blocked (GRO-2013) | Sign in as `uat-customer@groombook.dev`; reuse the customer's sessionId from TC-API-3.19a; `GET /api/pets/{otherClientPetId}/profile-summary` for a pet owned by a different client (`c0000002-...` or any non-customer pet) | 403 Forbidden (owner-bypass requires session.clientId === pet.clientId) |
| TC-API-3.19c | Get pet profile summary — customer without portal session header | Same as TC-API-3.19a but omit the `X-Impersonation-Session-Id` header | 403 Forbidden (no owner-bypass without valid portal session) |
| TC-API-3.19d | Get pet profile summary — owner-bypass writes audit row (GRO-2063) | Same setup as TC-API-3.19a (sign in as `uat-customer@groombook.dev`, establish a portal session for the customer's own clientId, call `GET /api/pets/{ownPetId}/profile-summary` with `X-Impersonation-Session-Id: {sessionId}` and a 200 OK response). Then call `GET /api/impersonation/sessions/{sessionId}/audit-log` and confirm there is exactly one entry with `action === "read_profile_summary"`, `pageVisited` matching the profile-summary path, and `metadata` containing `petId` and `actorStaffId` for the customer. Repeat TC-API-3.19b (cross-tenant attempt) and confirm NO new `read_profile_summary` row was written for the cross-tenant attempt. | 200 OK on the profile-summary call AND an audit log entry is present with the correct shape (defense-in-depth audit row; bypass attempts against other clients must NOT log) |
| TC-UAT-2 | Groomer accesses linked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000002/profile-summary` (UAT Pup Alpha — linked via deterministic completed appointment `a0000001-0000-0000-0000-000000000001`, service `b0000001-…-0001` "Bath & Brush", `startTime` ~7 days ago) | 200 OK, `recentGroomingHistory[]` non-empty (>=1 entry), `visitCount >= 1`, `upcomingAppointment` null (the seeded appointment is in the past) |
| TC-UAT-3 | Groomer blocked from unlinked pet profile summary (GRO-2100) | Sign in as `uat-groomer@groombook.dev`; `GET /api/pets/c0000001-0000-0000-0000-000000000003/profile-summary` (UAT Pup Beta — intentionally UNLINKED; no appointment row references this pet's clientId+groomerId combo) | 403 Forbidden (RBAC `groomer` role lacks the appointment-linkage grant for this pet). NOTE: if 404 is returned instead of 403, file a separate RBAC defect (not against the seed) — see GRO-2100 verification note |
| TC-API-3.29 | Get pet profile summary — unknown UUID returns 404 (GRO-2014) | GET /api/pets/00000000-0000-0000-0000-000000000001/profile-summary while authenticated (any role) | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014) |
| TC-API-3.30 | Get pet profile summary — malformed UUID returns 404 (GRO-2014) | GET /api/pets/not-a-uuid/profile-summary while authenticated | 404 Not Found with body `{"error":"Not found"}` (was empty-body 500 in GRO-2014 — Postgres uuid cast failure) |
| TC-API-3.31 | Get pet profile summary — never empty-body 500 (GRO-2014) | GET /api/pets/{anyId}/profile-summary across the test sweep | No response has status 500 with an empty body. Any 500 must include a JSON body `{"error":"Internal Server Error"}` |
-70
View File
@@ -184,66 +184,6 @@ 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,
@@ -318,16 +258,6 @@ 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",
+6 -22
View File
@@ -559,33 +559,17 @@ 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: preferredDateSchema,
preferredTime: preferredTimeSchema,
preferredDate: z.string(),
preferredTime: z.string(),
});
const updateWaitlistEntrySchema = z.object({
status: z.literal("cancelled").optional(),
preferredDate: preferredDateSchema.optional(),
preferredTime: preferredTimeSchema.optional(),
preferredDate: z.string().optional(),
preferredTime: z.string().optional(),
});
portalRouter.post(
@@ -603,7 +587,7 @@ portalRouter.post(
petId: body.petId,
serviceId: body.serviceId,
preferredDate: body.preferredDate,
preferredTime: normalizeTime(body.preferredTime),
preferredTime: body.preferredTime,
})
.returning();
@@ -634,7 +618,7 @@ portalRouter.patch(
const updateData: Record<string, unknown> = { 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 = normalizeTime(body.preferredTime);
if (body.preferredTime !== undefined) updateData.preferredTime = body.preferredTime;
const [updated] = await db
.update(waitlistEntries)