From f29f1828c8aad98f54d03173e56cda29ac1f41b9 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Mon, 1 Jun 2026 17:28:43 +0000 Subject: [PATCH] fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix(GRO-2012): pass portalSessionId to RescheduleFlow for SSO bridge customers (closes #38) - src/portal/CustomerPortal.tsx:329 - use portalSessionId fallback for RescheduleFlow - src/__tests__/portal.test.tsx - new regression test - UAT_PLAYBOOK.md §5.26 - new test cases cc @cpfarhood Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 16 +++++++ src/__tests__/portal.test.tsx | 85 +++++++++++++++++++++++++++++++++++ src/portal/CustomerPortal.tsx | 2 +- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 271b295..2a2faae 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -372,6 +372,22 @@ These cases cover the `CustomerPortal` initialisation path that bridges an Authe | TC-WEB-5.25.10 | Unauthenticated user is sent to login (no infinite loop) | Without signing in, navigate directly to `/`. | `App.tsx` renders the LoginPage. `CustomerPortal` does not render. No `session-from-auth` request is made. | | TC-WEB-5.25.11 | Session persists across reload via Better Auth cookie | After TC-WEB-5.25.1 succeeds, reload the page. | Portal dashboard re-renders. A fresh `GET /api/auth/get-session` + `POST /api/portal/session-from-auth` pair runs and yields 200/201. Greeting still reads "Hi, <FirstName>". | +### 5.26 Customer Portal — RescheduleFlow under SSO Bridge (GRO-2012) + +These cases guard against the regression where an SSO-bridge customer (no `?sessionId=` URL param, no impersonation session) could trigger the RescheduleFlow and have `RescheduleFlow` receive `sessionId={null}`, which caused the internal `/api/book/availability` call to send `X-Impersonation-Session-Id: ` (empty) and return 401. The fix: `CustomerPortal` now passes `sessionId={session?.id ?? portalSessionId}` to `` (matching the fallback `renderSection()` already used). + +**Pre-conditions:** + +- TC-WEB-5.25.1 — TC-WEB-5.25.3 must pass on the build under test. +- The seeded customer used has at least one upcoming, non-cancelled appointment with `status` ∈ {`pending`, `confirmed`}. + +| # | Scenario | Steps | Expected | +|---|----------|-------|----------| +| TC-WEB-5.26.1 | RescheduleFlow receives portalSessionId (no 401) | 1. Complete TC-WEB-5.25.1 (SSO sign-in as a customer). 2. From the dashboard, click **Reschedule** on the next-upcoming appointment. 3. In the RescheduleFlow modal, pick a future date. 4. Open DevTools → Network and filter to `/api/`. | The `GET /api/book/availability?date=` request includes an `X-Impersonation-Session-Id` header whose value equals the `sessionId` from `session-from-auth`. The request returns 200. The time-slot list populates. No 401. | +| TC-WEB-5.26.2 | RescheduleFlow submit succeeds | From TC-WEB-5.26.1, pick a time slot and confirm. | `POST /api/portal/appointments//reschedule` (or the equivalent) includes the same `X-Impersonation-Session-Id` value. Returns 200. The modal closes and the appointment card reflects the new time. | +| TC-WEB-5.26.3 | Impersonation flow reschedule is unchanged (no regression) | 1. With an active impersonation session (`?sessionId=`), load `/`. 2. Click **Reschedule** on an appointment. 3. Pick a date. | `GET /api/book/availability` includes `X-Impersonation-Session-Id` equal to the impersonation `sessionId` (not `portalSessionId`). Returns 200. Behaves identically to the pre-fix build. | +| TC-WEB-5.26.4 | No `X-Impersonation-Session-Id` is empty / null | From TC-WEB-5.26.1, inspect every `/api/portal/*` and `/api/book/*` request. | No request has an empty or `null` `X-Impersonation-Session-Id` header. | + ## 6. Pass/Fail Criteria **Pass:** diff --git a/src/__tests__/portal.test.tsx b/src/__tests__/portal.test.tsx index 5161aa1..96f1adf 100644 --- a/src/__tests__/portal.test.tsx +++ b/src/__tests__/portal.test.tsx @@ -5,6 +5,22 @@ import { ImpersonationBanner } from "../portal/ImpersonationBanner.js"; import { AuditLogViewer } from "../portal/AuditLogViewer.js"; import type { ImpersonationSession, ImpersonationAuditLog } from "@groombook/types"; +// Spy on the RescheduleFlow so we can assert the sessionId prop it receives +// from CustomerPortal without rendering the full flow UI. The real module is +// still loaded via importActual; only RescheduleFlow is swapped. +const rescheduleFlowSpy = vi.hoisted(() => + vi.fn((_props: { sessionId: string | null; appointment: { id: string } }) => null) +); +vi.mock("../portal/sections/Appointments.js", async () => { + const actual = await vi.importActual( + "../portal/sections/Appointments.js" + ); + return { + ...actual, + RescheduleFlow: rescheduleFlowSpy, + }; +}); + const SESSION: ImpersonationSession = { id: "sess-1", staffId: "staff-1", @@ -473,4 +489,73 @@ describe("CustomerPortal SSO bridge", () => { ); expect(bridgeCalls).toHaveLength(0); }); + + it("passes portalSessionId (not null) to RescheduleFlow for SSO bridge customers (GRO-2012)", async () => { + rescheduleFlowSpy.mockClear(); + + global.fetch = vi.fn((input: RequestInfo, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + if (url === "/api/branding") return Promise.resolve(brandingResponse); + if (url === "/api/auth/get-session") { + return Promise.resolve({ + ok: true, + json: async () => ({ user: { email: "customer@example.com", role: "customer" } }), + } as Response); + } + if (url === "/api/portal/session-from-auth" && init?.method === "POST") { + return Promise.resolve({ + ok: true, + status: 201, + json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }), + } as Response); + } + // Dashboard data — return an upcoming appointment so the Reschedule + // button is rendered on the dashboard card. + if (url === "/api/portal/appointments") { + return Promise.resolve({ + ok: true, + json: async () => ({ + appointments: [ + { + id: "appt-1", + date: "2099-01-01", + time: "10:00", + petName: "Buddy", + serviceName: "Bath & Brush", + status: "confirmed", + }, + ], + }), + } as Response); + } + if (url === "/api/portal/pets") { + return Promise.resolve({ ok: true, json: async () => ({ pets: [] }) } as Response); + } + if (url === "/api/portal/invoices") { + return Promise.resolve({ ok: true, json: async () => ({ invoices: [] }) } as Response); + } + return Promise.resolve({ ok: true, json: async () => ({}) } as Response); + }) as unknown as typeof fetch; + + const { CustomerPortal } = await import("../portal/CustomerPortal.js"); + render( + + + + ); + + // Wait for the Reschedule button to appear on the dashboard card + const rescheduleBtn = await screen.findByRole("button", { name: /^Reschedule$/i }); + fireEvent.click(rescheduleBtn); + + // RescheduleFlow should have been invoked with the bridged portalSessionId, + // NOT null. Pre-fix, the call would be sessionId={null} for SSO customers. + await waitFor(() => { + expect(rescheduleFlowSpy).toHaveBeenCalled(); + }); + const lastProps = rescheduleFlowSpy.mock.lastCall?.[0]; + expect(lastProps).toBeDefined(); + expect(lastProps!.sessionId).toBe("sso-sess-1"); + expect(lastProps!.appointment.id).toBe("appt-1"); + }); }); diff --git a/src/portal/CustomerPortal.tsx b/src/portal/CustomerPortal.tsx index db0dcbd..3b284b7 100644 --- a/src/portal/CustomerPortal.tsx +++ b/src/portal/CustomerPortal.tsx @@ -326,7 +326,7 @@ export function CustomerPortal() { { setShowReschedule(false); setRescheduleAppointment(null); }} - sessionId={session?.id ?? null} + sessionId={session?.id ?? portalSessionId} /> )}