Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8c6737ede | |||
| 3d7b247562 |
@@ -53,6 +53,7 @@ export const { signIn, signOut, useSession, changePassword } = authClient;
|
||||
| TC-WEB-5.1.2 | OIDC redirect | Click OIDC login button | Redirected to OIDC provider, then back to app with session established |
|
||||
| TC-WEB-5.1.3 | Logout | Click logout button | Session cleared, redirected to login page |
|
||||
| TC-WEB-5.1.4 | Session indicator | After successful login | User info/initials visible in UI indicating active session |
|
||||
| TC-WEB-5.1.5 | Unauthenticated `/login` renders the form (GRO-2011) | In a private/incognito window with no session cookie, navigate to UAT `/login` | React root mounts; the GroomBook sign-in card with the OIDC button is visible. Network tab shows `/api/auth/get-session` 200, `/api/setup/status` 200, and the login form is rendered (NOT a blank white viewport). |
|
||||
|
||||
### 5.2 Authentication — VITE_API_URL Set
|
||||
|
||||
@@ -371,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 `<RescheduleFlow>` (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=<picked>` 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/<id>/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=<active>`), 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:**
|
||||
|
||||
+8
-3
@@ -327,11 +327,16 @@ export function App() {
|
||||
.catch(() => setAuthDisabled(false));
|
||||
}, []);
|
||||
|
||||
// After session is confirmed, check if setup is needed
|
||||
// After session is confirmed, check if setup is needed.
|
||||
// Always run the setup/status fetch as soon as the auth state is known — even for
|
||||
// unauthenticated users, so the `needsSetup` value is in place if they sign in
|
||||
// mid-session. The unauth branch in the render below is handled before
|
||||
// `needsSetup` is consulted, so this is safe and avoids a stuck-`null` state.
|
||||
// See GRO-2011.
|
||||
useEffect(() => {
|
||||
if (authDisabled === null || sessionLoading) return;
|
||||
// Skip if no authenticated session (will redirect to login or dev selector)
|
||||
if (!authDisabled && !session) return;
|
||||
// In dev mode, only fetch when a dev user has been selected — otherwise the
|
||||
// user is mid-redirect to the dev login selector and we don't need setup state.
|
||||
if (authDisabled && !getDevUser()) return;
|
||||
|
||||
fetch("/api/setup/status")
|
||||
|
||||
@@ -121,6 +121,65 @@ describe("App navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GRO-2011 — setup/status fetch for unauthenticated users", () => {
|
||||
it("calls /api/setup/status for unauthenticated users so needsSetup is never stuck null", async () => {
|
||||
const setupStatusCalls: string[] = [];
|
||||
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
if (url === "/api/dev/config") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ authDisabled: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/auth/get-session") {
|
||||
// Better Auth returns 200 with null session for unauthenticated users.
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => null,
|
||||
} as unknown as Response);
|
||||
}
|
||||
if (url === "/api/setup/status") {
|
||||
setupStatusCalls.push(url);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ needsSetup: false }),
|
||||
} as Response);
|
||||
}
|
||||
if (url === "/api/branding") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
businessName: "GroomBook",
|
||||
primaryColor: "#4f8a6f",
|
||||
accentColor: "#8b7355",
|
||||
logoBase64: null,
|
||||
logoMimeType: null,
|
||||
}),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({ ok: true, json: async () => [] } as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/login"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// The login page should be rendered for the unauthenticated user.
|
||||
await screen.findByText("Sign in to continue");
|
||||
|
||||
// Crucially, /api/setup/status must be called even when the user is unauthenticated —
|
||||
// otherwise `needsSetup` stays null and a later code path can short-circuit to a
|
||||
// blank page (GRO-2011).
|
||||
await waitFor(() => {
|
||||
expect(setupStatusCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
expect(setupStatusCalls[0]).toBe("/api/setup/status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dev login selector", () => {
|
||||
it("redirects to /login when auth is disabled and no user selected", async () => {
|
||||
global.fetch = vi.fn((url: string) => {
|
||||
|
||||
@@ -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<typeof import("../portal/sections/Appointments.js")>(
|
||||
"../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(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<CustomerPortal />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -326,7 +326,7 @@ export function CustomerPortal() {
|
||||
<RescheduleFlow
|
||||
appointment={rescheduleAppointment}
|
||||
onClose={() => { setShowReschedule(false); setRescheduleAppointment(null); }}
|
||||
sessionId={session?.id ?? null}
|
||||
sessionId={session?.id ?? portalSessionId}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user