137 lines
5.5 KiB
TypeScript
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();
|
|
});
|
|
});
|