Files
web/src/__tests__/portal.test.tsx
T
Flea Flicker f1cf58dc56
CI / Test (pull_request) Successful in 24s
CI / Lint & Typecheck (pull_request) Successful in 29s
CI / Build & Push Docker Image (pull_request) Successful in 45s
fix(GRO-2099): show loading state during CustomerPortal SSO bridge bootstrap
Root cause: `Dashboard.tsx:194` runs its own `!sessionId && !isImpersonating &&
!getDevUser()` auth guard, redirecting to `/login` if `sessionId` is null. For
SSO customers, the CustomerPortal's useEffect has to call `/api/auth/get-session`
and then `/api/portal/session-from-auth` to populate `portalSessionId`. During
that bootstrap window (typically 100-300ms), `sessionId` is null and the guard
fires — redirecting the user to `/login` and breaking the post-sign-in flow.
App.tsx additionally returned `null` at `/login` for authenticated users
(`showCustomerPortal` is false at `/login`), leaving a blank React root even
if the redirect target was /login itself.

Fix:
- `CustomerPortal.tsx`: show a 'Loading…' state (`role=status`) while
  `!initComplete`. The portal chrome and its child sections only mount once
  the bootstrap has resolved, so child auth guards don't fire prematurely.
- `App.tsx`: at `/login` with a valid session, redirect to `/` so the
  customer lands on the portal instead of seeing a blank page.
- `App.tsx`: only return `LoginPage` when at `/login` — other portal
  routes defer the auth check to `CustomerPortal` (the customer SSO bridge
  resolves `portalSessionId` on mount).
- `UAT_PLAYBOOK.md`: add §5.27 with 8 cases covering the bug, the loading
  state, the /login auto-redirect, the unauth fallback, and the groomer /
  impersonation non-regressions.
- `src/__tests__/portal.test.tsx`: add a regression test that asserts the
  loading state is shown during the bridge and the portal nav is NOT in the
  DOM mid-bootstrap.

Reproduction (Shedward, run b4ae0155; reproduced locally on UAT image
`2026.06.01-ec29f71`):
1. From `about:blank`, complete customer SSO as `uat-customer`.
2. `browser_navigate` to `/portal`.
3. Pre-fix: redirected to `/login` with blank React root.
4. Post-fix: URL stays at `/portal`, dashboard renders with customer name.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 16:35:16 +00:00

616 lines
22 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
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",
clientId: "client-1",
reason: "Customer reported missing appointment",
status: "active",
startedAt: new Date(Date.now() - 5 * 60_000).toISOString(),
endedAt: null,
expiresAt: new Date(Date.now() + 25 * 60_000).toISOString(),
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
};
const AUDIT_LOGS: ImpersonationAuditLog[] = [
{
id: "log-1",
sessionId: "sess-1",
action: "session_started",
pageVisited: null,
metadata: { reason: "Customer reported missing appointment" },
createdAt: new Date(Date.now() - 5 * 60_000).toISOString(),
},
{
id: "log-2",
sessionId: "sess-1",
action: "page_view",
pageVisited: "appointments",
metadata: null,
createdAt: new Date(Date.now() - 3 * 60_000).toISOString(),
},
];
// ─── ImpersonationBanner ────────────────────────────────────────────────────
describe("ImpersonationBanner", () => {
it("renders STAFF VIEW label", () => {
render(
<ImpersonationBanner
session={SESSION}
isExtended={false}
onEnd={vi.fn()}
onExtend={vi.fn()}
onShowAudit={vi.fn()}
/>
);
expect(screen.getByText("STAFF VIEW")).toBeInTheDocument();
});
it("displays the session reason", () => {
render(
<ImpersonationBanner
session={SESSION}
isExtended={false}
onEnd={vi.fn()}
onExtend={vi.fn()}
onShowAudit={vi.fn()}
/>
);
expect(screen.getByText(/Customer reported missing appointment/)).toBeInTheDocument();
});
it("calls onEnd when End Session is clicked", () => {
const onEnd = vi.fn();
render(
<ImpersonationBanner
session={SESSION}
isExtended={false}
onEnd={onEnd}
onExtend={vi.fn()}
onShowAudit={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
expect(onEnd).toHaveBeenCalledOnce();
});
it("calls onShowAudit when Audit is clicked", () => {
const onShowAudit = vi.fn();
render(
<ImpersonationBanner
session={SESSION}
isExtended={false}
onEnd={vi.fn()}
onExtend={vi.fn()}
onShowAudit={onShowAudit}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Audit/i }));
expect(onShowAudit).toHaveBeenCalledOnce();
});
it("shows Extend button when less than 5 minutes remain and not yet extended", async () => {
const nearlyExpiredSession: ImpersonationSession = {
...SESSION,
expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(), // 3 min left
};
render(
<ImpersonationBanner
session={nearlyExpiredSession}
isExtended={false}
onEnd={vi.fn()}
onExtend={vi.fn()}
onShowAudit={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Extend/i })).toBeInTheDocument();
});
});
it("does not show Extend button when already extended", async () => {
const nearlyExpiredSession: ImpersonationSession = {
...SESSION,
expiresAt: new Date(Date.now() + 3 * 60_000).toISOString(),
};
render(
<ImpersonationBanner
session={nearlyExpiredSession}
isExtended={true}
onEnd={vi.fn()}
onExtend={vi.fn()}
onShowAudit={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.queryByRole("button", { name: /Extend/i })).not.toBeInTheDocument();
});
});
});
// ─── AuditLogViewer ─────────────────────────────────────────────────────────
describe("AuditLogViewer", () => {
beforeEach(() => {
global.fetch = vi.fn();
});
it("fetches and displays audit log entries", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => [...AUDIT_LOGS].reverse(), // API returns newest-first
} as Response);
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
await waitFor(() => {
// "session started" appears in both the filter dropdown option and the log entry span
expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
});
expect(screen.getByText("appointments")).toBeInTheDocument();
expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1/audit-log");
});
it("shows error state when fetch fails", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: false,
status: 403,
} as Response);
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/Failed to load audit log/i)).toBeInTheDocument();
});
});
it("shows loading state initially", () => {
vi.mocked(global.fetch).mockReturnValue(new Promise(() => {}));
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
expect(screen.getByText(/Loading audit log/i)).toBeInTheDocument();
});
it("calls onClose when X button is clicked", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => [],
} as Response);
const onClose = vi.fn();
render(<AuditLogViewer sessionId="sess-1" onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText(/No audit entries/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "" }));
expect(onClose).toHaveBeenCalledOnce();
});
it("filters entries by action type", async () => {
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => [...AUDIT_LOGS].reverse(),
} as Response);
render(<AuditLogViewer sessionId="sess-1" onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getAllByText("session started").length).toBeGreaterThanOrEqual(1);
});
// Filter to page_view only
const select = screen.getByRole("combobox");
fireEvent.change(select, { target: { value: "page_view" } });
expect(screen.getByText("appointments")).toBeInTheDocument();
// After filtering, the "session started" span (log entry) should be gone
// The option in the select still has the text but the log entry span does not
const spans = document.querySelectorAll("span.inline-block");
expect(Array.from(spans).every((s) => s.textContent !== "session started")).toBe(true);
});
});
// ─── CustomerPortal — session loading ──────────────────────────────────────
describe("CustomerPortal session loading", () => {
beforeEach(() => {
global.fetch = vi.fn((url: string) => {
if (url === "/api/branding") {
return Promise.resolve({
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response);
}
if (url.startsWith("/api/impersonation/sessions/")) {
return Promise.resolve({
ok: true,
json: async () => SESSION,
} as Response);
}
return Promise.resolve({ ok: true, json: async () => [] } as Response);
}) as unknown as typeof fetch;
});
it("loads and displays impersonation banner when sessionId is in URL", async () => {
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
<CustomerPortal />
</MemoryRouter>
);
// Wait for the session fetch and banner to appear
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/impersonation/sessions/sess-1");
});
// Banner "End Session" button is unique to the active impersonation banner
await waitFor(() => {
expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
});
});
it("does not show banner when no sessionId in URL", async () => {
vi.mocked(global.fetch).mockClear();
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/"]}>
<CustomerPortal />
</MemoryRouter>
);
// No impersonation session fetch should happen
await new Promise((r) => setTimeout(r, 50));
const impersonationFetches = vi.mocked(global.fetch).mock.calls.filter(
([url]) => typeof url === "string" && url.startsWith("/api/impersonation/")
);
expect(impersonationFetches).toHaveLength(0);
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("redirects to /admin/clients after ending impersonation session", async () => {
// Mock window.location.href
const originalLocation = window.location;
Object.defineProperty(window, "location", {
value: { href: "" },
writable: true,
});
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
render(
<MemoryRouter initialEntries={["/?sessionId=sess-1"]}>
<CustomerPortal />
</MemoryRouter>
);
// Wait for banner to appear
await waitFor(() => {
expect(screen.getByRole("button", { name: /End Session/i })).toBeInTheDocument();
});
// Click "End Session" — this triggers handleEnd which calls the API then redirects
fireEvent.click(screen.getByRole("button", { name: /End Session/i }));
await waitFor(() => {
expect(window.location.href).toBe("/admin/clients");
});
// Restore
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
});
});
// ─── CustomerPortal — Better Auth SSO bridge (GRO-1867) ────────────────────
describe("CustomerPortal SSO bridge", () => {
beforeEach(() => {
// Make sure no dev-user leaks across tests
window.localStorage.clear();
});
const brandingResponse = {
ok: true,
json: async () => ({
businessName: "GroomBook",
primaryColor: "#4f8a6f",
accentColor: "#8b7355",
logoBase64: null,
logoMimeType: null,
}),
} as Response;
it("bridges Better Auth session via /api/portal/session-from-auth and uses returned sessionId", async () => {
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);
}
// Subsequent portal API calls — surface them so we can assert the header
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>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
"/api/portal/session-from-auth",
expect.objectContaining({ method: "POST", credentials: "include" })
);
});
// Client greeting reflects the bridged customer name (proof the response was consumed)
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
// The impersonation banner must NOT appear — this is the customer themselves
expect(screen.queryByRole("button", { name: /End Session/i })).not.toBeInTheDocument();
});
it("shows a friendly fallback when session-from-auth returns 404 (no client record)", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
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: "stranger@example.com", role: "customer" } }),
} as Response);
}
if (url === "/api/portal/session-from-auth") {
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({ error: "No client record found for this user" }),
} 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>
);
await waitFor(() => {
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
});
expect(screen.getByText(/not linked to a customer record/i)).toBeInTheDocument();
// Sign-out escape hatch is present so the user is not stuck in a loop
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
});
it("does not call session-from-auth when there is no Better Auth session", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
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 () => null,
} 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>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
// Wait one tick to ensure no subsequent bridge call is queued
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
expect(bridgeCalls).toHaveLength(0);
});
it("skips the bridge for staff Better Auth sessions", async () => {
global.fetch = vi.fn((input: RequestInfo) => {
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: "staff@example.com", role: "staff" } }),
} 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>
);
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/auth/get-session", expect.objectContaining({ credentials: "include" }));
});
await new Promise((r) => setTimeout(r, 30));
const bridgeCalls = vi.mocked(global.fetch).mock.calls.filter(
([u]) => typeof u === "string" && u === "/api/portal/session-from-auth"
);
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");
});
// GRO-2099 regression: the portal chrome (and Dashboard's `!sessionId` guard)
// must NOT render before the SSO bridge resolves. A loading state must be
// shown instead. Previously, the Dashboard's redirect-to-/login guard fired
// mid-bootstrap, leaving the user with a blank page after sign-in.
it("renders a loading state during the SSO bridge (does not flash portal chrome)", async () => {
// Slow bridge: resolve get-session and session-from-auth after a tick so
// we can observe the loading state mid-bootstrap.
let resolveBridge!: (value: Response) => void;
const bridgePromise = new Promise<Response>((resolve) => {
resolveBridge = resolve;
});
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 bridgePromise;
}
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>
);
// Loading state is visible while the bridge is in flight. The portal nav
// (Home / Appointments / etc.) must NOT be present — its presence would
// indicate the chrome is rendering with a null session, which is the
// pre-GRO-2099 bug.
expect(await screen.findByRole("status")).toHaveTextContent(/Loading/i);
expect(screen.queryByText("Home")).not.toBeInTheDocument();
expect(screen.queryByText("Appointments")).not.toBeInTheDocument();
// Resolve the bridge and confirm the portal renders normally.
resolveBridge({
ok: true,
status: 201,
json: async () => ({ sessionId: "sso-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
} as Response);
await waitFor(() => {
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
});
});
});