1132 lines
42 KiB
TypeScript
1132 lines
42 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,
|
|
};
|
|
});
|
|
|
|
// Spy on the canonical `signOut()` from the shared auth-client so we can
|
|
// assert the no-access screen's logout button uses the SAME handler as
|
|
// `AdminLayout`. We mock at the module boundary — the no-access screen is
|
|
// the one authenticated surface that renders without the portal chrome, so
|
|
// a regression here would trap the user. We do NOT use `importActual`
|
|
// because the real `createAuthClient()` requires a runtime `baseURL`
|
|
// (Better Auth) that the JSDOM test environment can't supply.
|
|
const signOutSpy = vi.hoisted(() => vi.fn(async () => undefined));
|
|
vi.mock("../lib/auth-client.js", () => ({
|
|
signOut: signOutSpy,
|
|
}));
|
|
|
|
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(),
|
|
},
|
|
];
|
|
|
|
// ─── Shared test fixtures ───────────────────────────────────────────────────
|
|
|
|
// `brandingResponse` is the mock /api/branding payload used by every test
|
|
// in this file. Hoisted to module scope so the SSO bridge and the OOBE
|
|
// describe blocks can both reach it without redefining the same body.
|
|
const brandingResponse = {
|
|
ok: true,
|
|
json: async () => ({
|
|
businessName: "GroomBook",
|
|
primaryColor: "#4f8a6f",
|
|
accentColor: "#8b7355",
|
|
logoBase64: null,
|
|
logoMimeType: null,
|
|
}),
|
|
} as Response;
|
|
|
|
// ─── 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();
|
|
// Reset shared signOut() spy so per-test counts are deterministic
|
|
signOutSpy.mockClear();
|
|
});
|
|
|
|
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("routes to /onboarding when session-from-auth returns 404 (GRO-2359)", async () => {
|
|
// GRO-2359 replaces the P1 no-access fallback for the new-user path.
|
|
// The post-auth handler must now navigate to /onboarding so the OOBE
|
|
// component can drive portal creation. The no-access card itself is
|
|
// reserved for the deep-link deleted-portal case (see the next two
|
|
// tests, which exercise ?noAccess=deleted-portal).
|
|
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", name: "Stranger", 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;
|
|
|
|
// MemoryRouter is required for the React Router context used by
|
|
// useNavigate inside CustomerPortal. We pass `initialEntries=["/"]`
|
|
// and let the post-auth handler navigate the router to /onboarding.
|
|
const { CustomerPortal } = await import("../portal/CustomerPortal.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/"]}>
|
|
<CustomerPortal />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
// The bridge 404 must NOT render the legacy no-access card. The OOBE
|
|
// form is the new-user surface.
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/set up your portal/i)).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("calls the shared signOut() handler and navigates to /login from the no-access screen (GRO-2358)", async () => {
|
|
// Reset the spy so previous tests don't leak into this assertion.
|
|
signOutSpy.mockClear();
|
|
|
|
// JSDOM throws on window.location.href assignment by default; swap in a
|
|
// writable stub so the navigation is observable, then restore after.
|
|
const originalLocation = window.location;
|
|
Object.defineProperty(window, "location", {
|
|
value: { href: "" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
// GRO-2359: the post-auth bridge 404 now routes to /onboarding (OOBE)
|
|
// on the new-user path. The no-access card itself is reserved for the
|
|
// deep-link deleted-portal case, which is signalled via
|
|
// ?noAccess=deleted-portal. A server-side "client disabled" check
|
|
// (future GRO) is the natural trigger.
|
|
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);
|
|
}
|
|
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
|
// NOT be 404 (which would route to /onboarding). A 500 models a
|
|
// server-side portal-disabled check; the no-access card is mounted
|
|
// because of the URL param, not because of the bridge.
|
|
if (url === "/api/portal/session-from-auth") {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 500,
|
|
json: async () => ({ error: "Portal disabled" }),
|
|
} 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={["/?noAccess=deleted-portal"]}>
|
|
<CustomerPortal />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
|
});
|
|
|
|
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
|
// no-access screen is mounted because of the deleted-portal signal, not
|
|
// because the user clicked anything.
|
|
expect(signOutSpy).not.toHaveBeenCalled();
|
|
|
|
// Drive the click. The handler is the SAME `signOut()` exported from
|
|
// auth-client that AdminLayout uses, so verifying this call is enough to
|
|
// prove the no-access screen reaches the canonical sign-out surface.
|
|
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
|
fireEvent.click(signOutButton);
|
|
|
|
await waitFor(() => {
|
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// The handler always navigates to /login — even if the network call to
|
|
// /api/auth/sign-out fails — so a transient auth-server hiccup never
|
|
// leaves the user trapped on an authenticated screen.
|
|
await waitFor(() => {
|
|
expect(window.location.href).toBe("/login");
|
|
});
|
|
|
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
});
|
|
|
|
it("reaches the same shared signOut() on a deep-link no-access screen (GRO-2358)", async () => {
|
|
// AC requires verifying the SAME logout handler is reachable from at
|
|
// least one other authenticated surface — here a deep link to a portal
|
|
// sub-route (e.g. /appointments) for a user with a Better Auth session
|
|
// whose portal was deleted. The no-access screen is the only
|
|
// authenticated surface without a route guard, so the handler must
|
|
// fire identically.
|
|
//
|
|
// GRO-2359: the bridge 404 now routes to /onboarding (OOBE) on the
|
|
// new-user path; ?noAccess=deleted-portal is the surviving trigger.
|
|
signOutSpy.mockClear();
|
|
|
|
const originalLocation = window.location;
|
|
Object.defineProperty(window, "location", {
|
|
value: { href: "" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
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);
|
|
}
|
|
// The bridge must NOT succeed (so portalSessionId stays null) and must
|
|
// NOT be 404 (which would route to /onboarding). A 500 models a
|
|
// server-side portal-disabled check; the no-access card is mounted
|
|
// because of the URL param, not because of the bridge.
|
|
if (url === "/api/portal/session-from-auth") {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 500,
|
|
json: async () => ({ error: "Portal disabled" }),
|
|
} 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={["/appointments?noAccess=deleted-portal"]}>
|
|
<CustomerPortal />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Portal access not configured/i)).toBeInTheDocument();
|
|
});
|
|
|
|
const signOutButton = screen.getByRole("button", { name: /Sign out/i });
|
|
fireEvent.click(signOutButton);
|
|
|
|
await waitFor(() => {
|
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(window.location.href).toBe("/login");
|
|
});
|
|
|
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("OOBE portal-creation flow (GRO-2359)", () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
// The OOBE is mounted both from the post-auth callback (CustomerPortal
|
|
// navigates to /onboarding on bridge 404) and from a direct deep-link.
|
|
// This set of tests exercises the direct-link mount, the form submit, and
|
|
// the shared signOut() handler. The post-auth routing is covered by the
|
|
// "routes to /onboarding when session-from-auth returns 404" test above.
|
|
|
|
function setupOOBEAuthMock(opts: { role?: string } = {}) {
|
|
const role = opts.role ?? "customer";
|
|
return 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: "new-sso@example.com", name: "New SSO", role },
|
|
}),
|
|
} as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
}) as unknown as typeof fetch;
|
|
}
|
|
|
|
it("renders the OOBE form when navigated to /onboarding directly (GRO-2359)", async () => {
|
|
global.fetch = setupOOBEAuthMock();
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("heading", { name: /set up your portal/i })).toBeInTheDocument();
|
|
});
|
|
// All three primary form fields are present.
|
|
expect(screen.getByLabelText(/your name/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/phone/i)).toBeInTheDocument();
|
|
expect(screen.getByLabelText(/address/i)).toBeInTheDocument();
|
|
// Submit and shared signOut are both present.
|
|
expect(screen.getByRole("button", { name: /Create my portal/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("prefills the name field from the Better Auth session (GRO-2359)", async () => {
|
|
global.fetch = setupOOBEAuthMock();
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
|
});
|
|
});
|
|
|
|
it("calls POST /api/portal/clients-from-auth and navigates to / on success (GRO-2359)", async () => {
|
|
const fetchMock = 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: "new-sso@example.com", name: "New SSO", role: "customer" },
|
|
}),
|
|
} as Response);
|
|
}
|
|
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({
|
|
id: "new-client-id",
|
|
name: "New SSO",
|
|
email: "new-sso@example.com",
|
|
}),
|
|
} as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
}) as unknown as typeof fetch;
|
|
global.fetch = fetchMock;
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
|
});
|
|
|
|
// Fill phone + address and submit.
|
|
fireEvent.change(screen.getByLabelText(/phone/i), {
|
|
target: { value: "555-1234" },
|
|
});
|
|
fireEvent.change(screen.getByLabelText(/address/i), {
|
|
target: { value: "1 Main St" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
|
|
|
// The endpoint must have been called with the form values, normalised
|
|
// (phone/address trimmed). We don't assert navigation here because the
|
|
// MemoryRouter would need a history prop to assert a URL change — the
|
|
// internal `navigate("/")` call is the contract.
|
|
await waitFor(() => {
|
|
const calls = vi.mocked(fetchMock).mock.calls;
|
|
const onboardCall = calls.find(([u]) =>
|
|
typeof u === "string" && (u as string).endsWith("/api/portal/clients-from-auth"),
|
|
);
|
|
expect(onboardCall).toBeDefined();
|
|
const body = JSON.parse(((onboardCall?.[1] as RequestInit | undefined)?.body as string) ?? "{}");
|
|
expect(body).toEqual({
|
|
name: "New SSO",
|
|
phone: "555-1234",
|
|
address: "1 Main St",
|
|
notes: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("shows the portal-selection message when the API returns 409 (GRO-2359)", 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: "new-sso@example.com", name: "New SSO", role: "customer" },
|
|
}),
|
|
} as Response);
|
|
}
|
|
if (url === "/api/portal/clients-from-auth" && init?.method === "POST") {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 409,
|
|
json: async () => ({ error: "A customer record with this email already exists" }),
|
|
} as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
}) as unknown as typeof fetch;
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("New SSO");
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/already exists/i)).toBeInTheDocument();
|
|
});
|
|
// The submit button is re-enabled after the error so the user can retry.
|
|
expect(screen.getByRole("button", { name: /Create my portal/i })).not.toBeDisabled();
|
|
});
|
|
|
|
it("requires the name field before submitting (GRO-2359)", async () => {
|
|
// Use a session WITHOUT a name so the OOBE starts with an empty form.
|
|
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: "noname@example.com", role: "customer" } }),
|
|
} as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
}) as unknown as typeof fetch;
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByLabelText(/your name/i)).toHaveValue("");
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: /Create my portal/i }));
|
|
|
|
// The name-required error is shown; no API call was made.
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/tell us your name/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
it("uses the shared signOut() handler on the OOBE Sign out button (GRO-2359)", async () => {
|
|
signOutSpy.mockClear();
|
|
const originalLocation = window.location;
|
|
Object.defineProperty(window, "location", {
|
|
value: { href: "" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
global.fetch = setupOOBEAuthMock();
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /Sign out/i })).toBeInTheDocument();
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: /Sign out/i }));
|
|
|
|
// Same canonical handler as AdminLayout and the no-access card, per
|
|
// GRO-2358 — never a raw fetch("/api/auth/sign-out").
|
|
await waitFor(() => {
|
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(window.location.href).toBe("/login");
|
|
});
|
|
|
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
});
|
|
|
|
it("redirects to /login when no Better Auth session is present (GRO-2359)", async () => {
|
|
const originalLocation = window.location;
|
|
Object.defineProperty(window, "location", {
|
|
value: { href: "" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
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: false, status: 401, json: async () => ({}) } as Response);
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({}) } as Response);
|
|
}) as unknown as typeof fetch;
|
|
|
|
const { OOBE } = await import("../portal/OOBE.js");
|
|
render(
|
|
<MemoryRouter initialEntries={["/onboarding"]}>
|
|
<OOBE />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(window.location.href).toBe("/login");
|
|
});
|
|
|
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
});
|
|
|
|
it("reaches the shared signOut() handler from the in-portal chrome sidebar (GRO-2373)", async () => {
|
|
// Pre-GRO-2373, the customer portal chrome (Home, Appointments, My Pets,
|
|
// Report Cards, Billing, Messages, Settings) had no visible sign-out
|
|
// control — only the OOBE and the no-access card exposed one. This
|
|
// leaves users signed-in with no escape hatch. The fix lands a
|
|
// "Sign out" button in the sidebar footer that wires to the same
|
|
// canonical `signOut()` already used by OOBE / no-access / AdminLayout.
|
|
signOutSpy.mockClear();
|
|
|
|
const originalLocation = window.location;
|
|
Object.defineProperty(window, "location", {
|
|
value: { href: "" },
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
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: "uat-customer@groombook.dev", role: "customer" } }),
|
|
} as Response);
|
|
}
|
|
if (url === "/api/portal/session-from-auth" && init?.method === "POST") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 201,
|
|
json: async () => ({ sessionId: "chrome-sess-1", clientId: "client-1", clientName: "Jane Doe" }),
|
|
} 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>
|
|
);
|
|
|
|
// Land on the chrome (proof: customer greeting is rendered, no
|
|
// no-access card, no OOBE).
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Hi, Jane/)).toBeInTheDocument();
|
|
});
|
|
expect(screen.queryByText(/Portal access not configured/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/set up your portal/i)).not.toBeInTheDocument();
|
|
|
|
// The new chrome sign-out is scoped by data-testid so it doesn't
|
|
// collide with other surfaces that may also render "Sign out" labels
|
|
// (e.g. the impersonation banner uses "End Session").
|
|
const signOutButton = screen.getByTestId("portal-chrome-signout");
|
|
expect(signOutButton).toHaveTextContent(/Sign out/i);
|
|
fireEvent.click(signOutButton);
|
|
|
|
// Same canonical handler as OOBE / no-access / AdminLayout — never
|
|
// a raw fetch("/api/auth/sign-out") and never a navigate() without
|
|
// signOut() (the OOBE/no-access surface uses window.location.href
|
|
// for a hard reload so cached state is reset).
|
|
await waitFor(() => {
|
|
expect(signOutSpy).toHaveBeenCalledTimes(1);
|
|
});
|
|
await waitFor(() => {
|
|
expect(window.location.href).toBe("/login");
|
|
});
|
|
|
|
Object.defineProperty(window, "location", { value: originalLocation, configurable: true });
|
|
});
|
|
});
|