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

233 lines
8.5 KiB
TypeScript

// @vitest-environment node
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LoaderFunctionArgs } from "react-router";
const { MockAdminSsoConfigError } = vi.hoisted(() => ({
MockAdminSsoConfigError: class MockAdminSsoConfigError extends Error {
code: "missing-env" | "discovery-failed" | "client-init-failed";
constructor(code: "missing-env" | "discovery-failed" | "client-init-failed") {
super(code);
this.name = "AdminSsoConfigError";
this.code = code;
}
},
}));
vi.mock("~/utils/db.server", () => ({
db: {},
}));
vi.mock("~/modules/admin-sso", () => ({
AdminSsoConfigError: MockAdminSsoConfigError,
consumeAdminSsoChallenge: vi.fn(),
extractAdminSsoClaims: vi.fn(),
getAdminSsoClient: vi.fn(),
jitProvisionAdmin: vi.fn(),
readAdminSsoEnv: vi.fn(),
}));
vi.mock("~/modules/admin-sso/logout.server", () => ({
serializeIdTokenCookie: vi.fn((idToken: string) => `admin_sso_id_token=signed:${idToken}; Path=/auth/admin; HttpOnly`),
}));
vi.mock("~/modules/admin-sso/audit.server", () => ({
writeAdminSsoAuditEvent: vi.fn(),
}));
vi.mock("~/utils/session.server", () => ({
createUserSession: vi.fn(),
getUserInfo: vi.fn(),
setLoggedUser: vi.fn(),
}));
vi.mock("~/utils/db/users.db.server", () => ({
getUser: vi.fn(),
}));
vi.mock("~/utils/db/permissions/userRoles.db.server", () => ({
ensureAdminUserRoleByName: vi.fn(),
}));
vi.mock("@toolctrl/config", () => ({
loadRequiredWebConfig: vi.fn(() => ({ routing: { adminAppHost: "admin.example.test" } })),
}));
vi.mock("~/utils/app/CrossHostUrls", () => ({
absoluteAdminPath: vi.fn(() => "https://admin.example.test/dashboard"),
}));
import {
consumeAdminSsoChallenge,
extractAdminSsoClaims,
getAdminSsoClient,
jitProvisionAdmin,
readAdminSsoEnv,
} from "~/modules/admin-sso";
import { serializeIdTokenCookie } from "~/modules/admin-sso/logout.server";
import { writeAdminSsoAuditEvent } from "~/modules/admin-sso/audit.server";
import { createUserSession, getUserInfo, setLoggedUser } from "~/utils/session.server";
import { getUser } from "~/utils/db/users.db.server";
import { ensureAdminUserRoleByName } from "~/utils/db/permissions/userRoles.db.server";
import * as CallbackRoute from "./auth.admin.callback";
const env = { redirectUri: "https://admin.example.test/auth/admin/callback" };
const claims = {
subject: "sso-subject-1",
email: "admin@example.test",
name: "Ada Admin",
groups: ["toolctrl-admin"],
};
function callbackRequest(url = "https://admin.example.test/auth/admin/callback?code=abc&state=state_1") {
return new Request(url);
}
async function callLoader(request = callbackRequest()) {
return CallbackRoute.loader({ request, params: {}, context: {} } as LoaderFunctionArgs);
}
async function expectRedirect(promise: Promise<Response>) {
try {
await promise;
} catch (error) {
expect(error).toBeInstanceOf(Response);
return error as Response;
}
throw new Error("Expected redirect response");
}
describe("auth admin callback route", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(readAdminSsoEnv).mockReturnValue(env as ReturnType<typeof readAdminSsoEnv>);
vi.mocked(getAdminSsoClient).mockResolvedValue({
callbackParams: vi.fn(() => ({ code: "abc", state: "state_1" })),
callback: vi.fn(async () => ({
id_token: "id-token-value",
claims: () => claims,
})),
} as Awaited<ReturnType<typeof getAdminSsoClient>>);
vi.mocked(consumeAdminSsoChallenge).mockReturnValue({
ok: true,
payload: {
state: "state_1",
nonce: "nonce_1",
codeVerifier: "verifier_1",
returnTo: "/dashboard",
},
clearCookie: "admin_sso_challenge=; Path=/auth/admin; Max-Age=0",
} as ReturnType<typeof consumeAdminSsoChallenge>);
vi.mocked(extractAdminSsoClaims).mockReturnValue({ ok: true, claims } as ReturnType<typeof extractAdminSsoClaims>);
vi.mocked(jitProvisionAdmin).mockResolvedValue({
ok: true,
admin: { userId: "user_1", role: "admin", created: false, linkedExisting: true },
} as Awaited<ReturnType<typeof jitProvisionAdmin>>);
vi.mocked(getUser).mockResolvedValue({
id: "user_1",
email: "admin@example.test",
defaultTenantId: null,
locale: "de",
} as Awaited<ReturnType<typeof getUser>>);
vi.mocked(getUserInfo).mockResolvedValue({ userId: "old-user", lng: "en" } as Awaited<ReturnType<typeof getUserInfo>>);
vi.mocked(setLoggedUser).mockResolvedValue({ userId: "user_1" } as Awaited<ReturnType<typeof setLoggedUser>>);
vi.mocked(createUserSession).mockImplementation(
async (_session, redirectTo) => new Response(null, { status: 302, headers: { Location: redirectTo, "Set-Cookie": "app_session=fresh; HttpOnly" } })
);
});
it("grants the SuperAdmin RBAC role on a super_admin SSO login", async () => {
vi.mocked(jitProvisionAdmin).mockResolvedValue({
ok: true,
admin: { userId: "user_1", email: "admin@example.test", role: "super_admin", created: true, linkedExisting: false },
} as Awaited<ReturnType<typeof jitProvisionAdmin>>);
await callLoader();
expect(ensureAdminUserRoleByName).toHaveBeenCalledWith("user_1", "SuperAdmin");
});
it("does not grant an RBAC role when the SSO role is not super_admin", async () => {
// default jit mock returns role "admin" (non-super_admin)
await callLoader();
expect(ensureAdminUserRoleByName).not.toHaveBeenCalled();
});
it("still completes login when the RBAC role grant throws (non-fatal)", async () => {
vi.mocked(jitProvisionAdmin).mockResolvedValue({
ok: true,
admin: { userId: "user_1", email: "admin@example.test", role: "super_admin", created: true, linkedExisting: false },
} as Awaited<ReturnType<typeof jitProvisionAdmin>>);
vi.mocked(ensureAdminUserRoleByName).mockRejectedValueOnce(new Error("db down"));
const response = await callLoader();
expect(response.status).toBe(302);
expect(createUserSession).toHaveBeenCalled();
});
it("fails an invalid state challenge before token exchange or session creation", async () => {
vi.mocked(consumeAdminSsoChallenge).mockReturnValue({ ok: false, reason: "missing" } as ReturnType<typeof consumeAdminSsoChallenge>);
const response = await expectRedirect(callLoader());
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/admin/login?error=sso_state_invalid");
const client = await vi.mocked(getAdminSsoClient).mock.results[0]?.value;
expect(client.callback).not.toHaveBeenCalled();
expect(jitProvisionAdmin).not.toHaveBeenCalled();
expect(getUser).not.toHaveBeenCalled();
expect(createUserSession).not.toHaveBeenCalled();
expect(writeAdminSsoAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
action: "admin.login.sso.failure",
meta: { stage: "consume-challenge", reason: "missing" },
})
);
});
it("sanitizes unsafe return targets and appends cleanup cookies after session rotation", async () => {
vi.mocked(consumeAdminSsoChallenge).mockReturnValue({
ok: true,
payload: {
state: "state_1",
nonce: "nonce_1",
codeVerifier: "verifier_1",
returnTo: "https://evil.example.test/steal",
},
clearCookie: "admin_sso_challenge=; Path=/auth/admin; Max-Age=0",
} as ReturnType<typeof consumeAdminSsoChallenge>);
const response = await callLoader();
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("https://admin.example.test/dashboard");
expect(createUserSession).toHaveBeenCalledWith(
{
userId: "user_1",
lng: "de",
},
"https://admin.example.test/dashboard"
);
const setCookie = response.headers.get("Set-Cookie") ?? "";
expect(setCookie).toContain("app_session=fresh");
expect(setCookie).toContain("admin_sso_challenge=");
expect(setCookie).toContain("admin_sso_id_token=signed:id-token-value");
expect(serializeIdTokenCookie).toHaveBeenCalledWith("id-token-value");
expect(writeAdminSsoAuditEvent).toHaveBeenCalledWith(expect.objectContaining({ action: "admin.login.sso.success" }));
});
it("keeps safe relative return targets for successful callbacks", async () => {
const response = await callLoader();
expect(response.status).toBe(302);
expect(response.headers.get("Location")).toBe("/dashboard");
expect(createUserSession).toHaveBeenCalledWith(expect.any(Object), "/dashboard");
});
it("has no action export, so non-GET callback methods are not handled here", () => {
expect("action" in CallbackRoute).toBe(false);
});
});