From 915a310e0ae8022159041d01a1d46dab620b6b36 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 18:55:00 +0000 Subject: [PATCH 1/2] fix(GRO-2234): transparent re-mint on 401 for portal Book New submit A deliberately-paced Book New wizard could outlive the portal impersonation session, so the final POST /api/portal/waitlist returned 401 and the UI showed "Failed to book appointment. Please try again." BookingFlow now retries once on a 401: it re-mints a fresh portal session via POST /api/portal/session-from-auth (the customer's Better Auth cookie is still valid) and resubmits the waitlist request with the new X-Impersonation-Session-Id. Falls through to the existing error if no Better Auth session is available (staff/dev impersonation paths). - Appointments.tsx: remintPortalSession() helper; handleConfirmBooking submits via submitWaitlist(id) and retries once after a 401 re-mint. - Test: first waitlist POST 401 -> re-mint -> retry with fresh id -> success; asserts exactly one re-mint and the header sequence. - UAT_PLAYBOOK.md 5.12e: TC-WEB-5.12.25 slow-wizard submit succeeds. Companion to groombook/api GRO-2234 (bounded sliding expiration). Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 10 ++++ src/__tests__/Appointments.test.tsx | 71 ++++++++++++++++++++++++++++ src/portal/sections/Appointments.tsx | 64 +++++++++++++++++++------ 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index c1452e9..582f81e 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -244,6 +244,16 @@ export const { signIn, signOut, useSession, changePassword } = authClient; | TC-WEB-5.12.22 | Slot buttons show formatted label | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", select a pet and service, pick a date with availability | Each time-slot button shows a human-readable label like `10:00 AM` (UTC), never a raw ISO timestamp (e.g. not `2026-06-09T10:00:00.000Z`) | | TC-WEB-5.12.23 | Confirmation review shows formatted label | Continue the Book New wizard to the Review step | The "Date & Time" summary and the final confirmation both display the formatted slot label (e.g. `10:00 AM`), not a raw ISO string | | TC-WEB-5.12.24 | Booking submit succeeds (regression) | Complete the Book New wizard and submit the request | Request succeeds with no `500` / `invalid input syntax for type time` error; the booking POST sends `preferredTime` as `HH:MM:SS` (e.g. `10:00:00`); the new appointment appears in the Upcoming list | +| TC-WEB-5.12.25 | Slow-wizard submit succeeds (GRO-2234) | Sign in as `uat-customer@groombook.dev`, open `Appointments`, click "Book New", then deliberately pace the wizard (pet → service → groomer → date/slot → review) so that **>2 minutes** elapse before clicking "Confirm Booking". | Submit returns success — **no** "Failed to book appointment. Please try again." error. In DevTools → Network, if the first `POST /api/portal/waitlist` returns `401`, a `POST /api/portal/session-from-auth` fires immediately after and the booking is retried once with the fresh `X-Impersonation-Session-Id`, then returns 201. The appointment appears in the Upcoming list. | + +> **GRO-2234 note:** A deliberately-paced Book New wizard could outlive the +> portal impersonation session, so the final `POST /api/portal/waitlist` returned +> `401 {"error":"Unauthorized"}` ("Failed to book appointment"). The web fix adds +> a transparent one-shot re-mint: on a `401` from the waitlist submit, +> `BookingFlow` calls `POST /api/portal/session-from-auth` (the Better Auth +> cookie is still valid) and retries the submit once with the fresh session id. +> The companion API fix (groombook/api GRO-2234) adds bounded sliding expiration +> so active sessions rarely lapse in the first place. > **GRO-2211/GRO-2213 note:** The Book New wizard previously rendered the raw > UTC ISO slot string as the button/confirmation label and submitted that same diff --git a/src/__tests__/Appointments.test.tsx b/src/__tests__/Appointments.test.tsx index c00bb01..72c4678 100644 --- a/src/__tests__/Appointments.test.tsx +++ b/src/__tests__/Appointments.test.tsx @@ -801,4 +801,75 @@ describe("BookingFlow Book New funnel (GRO-2213)", () => { expect(body.preferredTime).toBe("10:00:00"); expect(body.preferredDate).toBe("2026-06-09"); }); + + it("re-mints the portal session and retries once when waitlist returns 401 (GRO-2234)", async () => { + const calls = { waitlist: 0, remint: 0 }; + const waitlistHeaders: string[] = []; + const routed = (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url.includes("/api/portal/pets")) { + return Promise.resolve({ + ok: true, + json: async () => ({ pets: [{ id: "pet-1", name: "Buddy", breed: "Lab" }] }), + } as Response); + } + if (url.includes("/api/portal/services")) { + return Promise.resolve({ + ok: true, + json: async () => ({ + services: [{ id: "service-1", name: "Bath & Brush", isAddOn: false, duration: 60, price: 50 }], + }), + } as Response); + } + if (url.includes("/api/book/availability")) { + return Promise.resolve({ + ok: true, + json: async () => ["2026-06-09T10:00:00.000Z"], + } as Response); + } + if (url.includes("/api/portal/session-from-auth")) { + calls.remint += 1; + return Promise.resolve({ + ok: true, + json: async () => ({ sessionId: "fresh-session-id", clientId: "c1", clientName: "Jane" }), + } as Response); + } + if (url.includes("/api/portal/waitlist")) { + calls.waitlist += 1; + const headers = (init?.headers ?? {}) as Record; + waitlistHeaders.push(headers["X-Impersonation-Session-Id"] ?? ""); + // First attempt: session lapsed → 401. Retry after re-mint: success. + if (calls.waitlist === 1) { + return Promise.resolve({ ok: false, status: 401, json: async () => ({ error: "Unauthorized" }) } as Response); + } + return Promise.resolve({ ok: true, status: 201, json: async () => ({}) } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }; + global.fetch = vi.fn().mockImplementation(routed as typeof fetch); + + render( {}} sessionId="stale-session-id" />); + + await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Buddy")); + await waitFor(() => expect(screen.getByText("Bath & Brush")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Bath & Brush")); + fireEvent.click(screen.getByRole("button", { name: /^Next$/ })); + await waitFor(() => expect(screen.getByText("First Available")).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: /^Next$/ })); + await waitFor(() => expect(screen.getByLabelText(/date/i)).toBeInTheDocument()); + fireEvent.change(screen.getByLabelText(/date/i), { target: { value: "2026-06-09" } }); + await waitFor(() => expect(screen.getByText("10:00 AM")).toBeInTheDocument()); + fireEvent.click(screen.getByText("10:00 AM")); + fireEvent.click(screen.getByRole("button", { name: /^Next$/ })); + await waitFor(() => expect(screen.getByText(/Review & Confirm/i)).toBeInTheDocument()); + fireEvent.click(screen.getByRole("button", { name: /Confirm Booking/i })); + + // Re-mint happened exactly once, waitlist retried with the fresh id, and the + // booking succeeded (no error surfaced). + await waitFor(() => expect(calls.waitlist).toBe(2)); + expect(calls.remint).toBe(1); + expect(waitlistHeaders).toEqual(["stale-session-id", "fresh-session-id"]); + expect(screen.queryByText(/Failed to book appointment/i)).not.toBeInTheDocument(); + }); }); diff --git a/src/portal/sections/Appointments.tsx b/src/portal/sections/Appointments.tsx index 7ed22e3..fd1b4fc 100644 --- a/src/portal/sections/Appointments.tsx +++ b/src/portal/sections/Appointments.tsx @@ -8,6 +8,28 @@ import { ANALYTICS_EVENTS, fireAnalyticsEvent } from '../../lib/analytics'; // responds with `{error: "..."}` on 4xx, and we must not treat that as slots. const AVAILABILITY_ERROR_MESSAGE = 'Failed to load time slots'; +/** + * Re-mint an SSO-bridge portal session from the active Better Auth session. + * Defense-in-depth for GRO-2234: if a portal call returns 401 mid-flow (the + * impersonation session lapsed during a slow wizard), the customer's Better + * Auth cookie is still valid, so we can transparently obtain a fresh portal + * session id and retry once. Returns the new session id, or null if no Better + * Auth session is available (e.g. staff/dev impersonation paths). + */ +async function remintPortalSession(): Promise { + try { + const res = await fetch('/api/portal/session-from-auth', { + method: 'POST', + credentials: 'include', + }); + if (!res.ok) return null; + const data = (await res.json().catch(() => ({}))) as { sessionId?: string }; + return data.sessionId ?? null; + } catch { + return null; + } +} + async function fetchAvailability( params: { serviceId: string; date: string }, sessionId: string | null, @@ -993,26 +1015,40 @@ export function BookingFlow({ onClose, sessionId }: BookingFlowProps) { setSubmitting(true); setError(null); - try { - const response = await fetch('/api/portal/waitlist', { + const payload = JSON.stringify({ + petId: selectedPet.id, + serviceId: selectedServices[0]?.id, + serviceIds: selectedServices.map((s) => s.id), + addOnIds: selectedAddOns.map((s) => s.id), + groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer, + preferredDate: selectedDate, + preferredTime: slotToTime(selectedTime), + notes: notes || undefined, + recurring: recurring || undefined, + }); + const submitWaitlist = (id: string) => + fetch('/api/portal/waitlist', { method: 'POST', headers: { 'Content-Type': 'application/json', - 'X-Impersonation-Session-Id': sessionId ?? '', + 'X-Impersonation-Session-Id': id, }, - body: JSON.stringify({ - petId: selectedPet.id, - serviceId: selectedServices[0]?.id, - serviceIds: selectedServices.map((s) => s.id), - addOnIds: selectedAddOns.map((s) => s.id), - groomerId: selectedGroomer === 'first-available' ? null : selectedGroomer, - preferredDate: selectedDate, - preferredTime: slotToTime(selectedTime), - notes: notes || undefined, - recurring: recurring || undefined, - }), + body: payload, }); + try { + let response = await submitWaitlist(sessionId); + + // GRO-2234: a deliberately-paced wizard can outlive the portal session. + // The customer's Better Auth session is still valid, so transparently + // re-mint a fresh portal session and retry once before surfacing an error. + if (response.status === 401) { + const freshSessionId = await remintPortalSession(); + if (freshSessionId) { + response = await submitWaitlist(freshSessionId); + } + } + if (response.ok) { setConfirmed(true); fireAnalyticsEvent(ANALYTICS_EVENTS.BOOKING_STEP_SUBMIT, { step: "submit", flow: "portal" }); -- 2.52.0 From 0766332712f9e3af9322ab4d1197c2fcc5602027 Mon Sep 17 00:00:00 2001 From: Flea Flicker Date: Mon, 8 Jun 2026 19:11:26 +0000 Subject: [PATCH 2/2] ci: re-trigger checks (transient runner failure) Co-Authored-By: Paperclip -- 2.52.0