Promote dev → uat: GRO-2358 logout on no-access screen (#73)
This commit was merged in pull request #73.
This commit is contained in:
@@ -21,6 +21,18 @@ vi.mock("../portal/sections/Appointments.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// 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",
|
||||
@@ -336,6 +348,8 @@ 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();
|
||||
});
|
||||
|
||||
const brandingResponse = {
|
||||
@@ -429,6 +443,132 @@ describe("CustomerPortal SSO bridge", () => {
|
||||
expect(screen.getByRole("button", { name: /Sign out/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,
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Pre-condition: the shared signOut() must NOT have been called yet — the
|
||||
// no-access screen is mounted because the bridge failed, 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
|
||||
// but no client record. The no-access screen is the only authenticated
|
||||
// surface without a route guard, so the handler must fire identically.
|
||||
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);
|
||||
}
|
||||
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={["/appointments"]}>
|
||||
<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();
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ImpersonationBanner } from "./ImpersonationBanner.js";
|
||||
import { AuditLogViewer } from "./AuditLogViewer.js";
|
||||
import { useBranding } from "../BrandingContext.js";
|
||||
import { getDevUser } from "../pages/DevLoginSelector.js";
|
||||
import { signOut } from "../lib/auth-client.js";
|
||||
import type { ImpersonationSession } from "@groombook/types";
|
||||
import type { Appointment as PortalAppointment } from "./sections/Appointments.js";
|
||||
|
||||
@@ -193,6 +194,19 @@ export function CustomerPortal() {
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Shared sign-out handler — wires the canonical Better Auth `signOut()` so
|
||||
// every authenticated surface (no-access screen, portal chrome, etc.) uses
|
||||
// the same implementation as `AdminLayout`. Failure to reach the server
|
||||
// still leaves the SPA free to navigate to /login.
|
||||
const handleSignOut = useCallback(async () => {
|
||||
try {
|
||||
await signOut();
|
||||
} catch {
|
||||
// Best-effort; navigate to /login regardless so the user is never trapped.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
const logPageView = useCallback((page: string) => {
|
||||
if (!session) return;
|
||||
void fetch(`/api/impersonation/sessions/${session.id}/log`, {
|
||||
@@ -281,14 +295,7 @@ export function CustomerPortal() {
|
||||
<h1 className="text-lg font-semibold text-stone-800 mb-2">Portal access not configured</h1>
|
||||
<p className="text-sm text-stone-600 mb-6">{authError}</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/api/auth/sign-out", { method: "POST", credentials: "include" });
|
||||
} catch {
|
||||
// Best-effort sign-out; redirect to /login regardless.
|
||||
}
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
onClick={() => { void handleSignOut(); }}
|
||||
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-stone-700 bg-stone-100 hover:bg-stone-200 transition-colors"
|
||||
>
|
||||
<LogOut size={14} />
|
||||
|
||||
Reference in New Issue
Block a user