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();
|
||||
|
||||
Reference in New Issue
Block a user