// @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) { 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); 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>); 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); vi.mocked(extractAdminSsoClaims).mockReturnValue({ ok: true, claims } as ReturnType); vi.mocked(jitProvisionAdmin).mockResolvedValue({ ok: true, admin: { userId: "user_1", role: "admin", created: false, linkedExisting: true }, } as Awaited>); vi.mocked(getUser).mockResolvedValue({ id: "user_1", email: "admin@example.test", defaultTenantId: null, locale: "de", } as Awaited>); vi.mocked(getUserInfo).mockResolvedValue({ userId: "old-user", lng: "en" } as Awaited>); vi.mocked(setLoggedUser).mockResolvedValue({ userId: "user_1" } as Awaited>); 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>); 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>); 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); 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); 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); }); });