Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/auth.admin.logout.test.ts
T

137 lines
5.5 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
const { MockAdminSsoConfigError } = vi.hoisted(() => ({
MockAdminSsoConfigError: class MockAdminSsoConfigError extends Error {},
}));
vi.mock("~/modules/admin-sso", () => ({
AdminSsoConfigError: MockAdminSsoConfigError,
getAdminSsoClient: vi.fn(),
readAdminSsoEnv: vi.fn(),
}));
vi.mock("~/modules/admin-sso/logout.server", () => ({
clearIdTokenCookie: vi.fn(() => "admin_sso_id_token=; Path=/auth/admin; HttpOnly; SameSite=Lax; Max-Age=0"),
readIdTokenFromRequest: vi.fn(),
}));
vi.mock("~/modules/admin-sso/audit.server", () => ({
writeAdminSsoAuditEvent: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
commitSession: vi.fn(),
getUserInfo: vi.fn(),
getUserSession: vi.fn(),
}));
import { getAdminSsoClient, readAdminSsoEnv } from "~/modules/admin-sso";
import { clearIdTokenCookie, readIdTokenFromRequest } from "~/modules/admin-sso/logout.server";
import { writeAdminSsoAuditEvent } from "~/modules/admin-sso/audit.server";
import { commitSession, getUserInfo, getUserSession } from "~/utils/session.server";
import { action, loader } from "./auth.admin.logout";
type SessionMock = {
unset: ReturnType<typeof vi.fn>;
};
function sessionMock(): SessionMock {
return { unset: vi.fn() };
}
function request(init?: RequestInit) {
return new Request("https://admin.example.test/auth/admin/logout", init);
}
async function callAction(req = request({ method: "POST" })) {
return action({ request: req, params: {}, context: {} } as ActionFunctionArgs);
}
async function callLoader(req = request()) {
return loader({ request: req, params: {}, context: {} } as LoaderFunctionArgs);
}
describe("auth admin logout route", () => {
let session: SessionMock;
beforeEach(() => {
vi.clearAllMocks();
session = sessionMock();
vi.mocked(getUserInfo).mockResolvedValue({ userId: "admin-user-1" } as Awaited<ReturnType<typeof getUserInfo>>);
vi.mocked(getUserSession).mockResolvedValue(session as Awaited<ReturnType<typeof getUserSession>>);
vi.mocked(commitSession).mockResolvedValue("app_session=; Path=/; HttpOnly" as Awaited<ReturnType<typeof commitSession>>);
vi.mocked(readIdTokenFromRequest).mockReturnValue(null);
vi.mocked(readAdminSsoEnv).mockReturnValue({ postLogoutRedirectUri: "https://admin.example.test/admin/login?logged_out=1" } as ReturnType<typeof readAdminSsoEnv>);
vi.mocked(getAdminSsoClient).mockResolvedValue({
endSessionUrl: vi.fn(() => "https://idp.example.test/end-session?id_token_hint=id-token-value"),
} as Awaited<ReturnType<typeof getAdminSsoClient>>);
});
it("clears the local app session and admin id-token cookie without an IdP token", async () => {
const response = await callAction();
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/admin/login?logged_out=1");
expect(readAdminSsoEnv).not.toHaveBeenCalled();
expect(getAdminSsoClient).not.toHaveBeenCalled();
expect(session.unset).toHaveBeenCalledWith("userId");
expect(session.unset).toHaveBeenCalledWith("impersonatingFromUserId");
expect(commitSession).toHaveBeenCalledWith(session);
expect(clearIdTokenCookie).toHaveBeenCalledOnce();
const setCookie = response.headers.get("Set-Cookie") ?? "";
expect(setCookie).toContain("app_session=");
expect(setCookie).toContain("admin_sso_id_token=");
expect(setCookie).toContain("Max-Age=0");
expect(writeAdminSsoAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "admin.logout.sso",
actorAdminUserId: "admin-user-1",
meta: { hadIdToken: false },
})
);
});
it("uses RP-initiated logout when a readable id token is present", async () => {
vi.mocked(readIdTokenFromRequest).mockReturnValue("id-token-value");
const response = await callAction();
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://idp.example.test/end-session?id_token_hint=id-token-value");
const client = await vi.mocked(getAdminSsoClient).mock.results[0]?.value;
expect(client.endSessionUrl).toHaveBeenCalledWith({
id_token_hint: "id-token-value",
post_logout_redirect_uri: "https://admin.example.test/admin/login?logged_out=1",
});
expect(commitSession).toHaveBeenCalledWith(session);
expect(response.headers.get("Set-Cookie")).toContain("admin_sso_id_token=");
expect(writeAdminSsoAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ meta: { hadIdToken: true } }));
});
it("falls back to local logout if IdP configuration is unavailable", async () => {
vi.mocked(readIdTokenFromRequest).mockReturnValue("id-token-value");
vi.mocked(readAdminSsoEnv).mockImplementation(() => {
throw new MockAdminSsoConfigError("missing env");
});
const response = await callAction();
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/admin/login?logged_out=1");
expect(getAdminSsoClient).not.toHaveBeenCalled();
expect(commitSession).toHaveBeenCalledWith(session);
expect(response.headers.get("Set-Cookie")).toContain("Max-Age=0");
});
it("also clears session state for GET logout requests", async () => {
const response = await callLoader();
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/admin/login?logged_out=1");
expect(commitSession).toHaveBeenCalledWith(session);
expect(clearIdTokenCookie).toHaveBeenCalledOnce();
});
});