233 lines
8.5 KiB
TypeScript
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);
|
|
});
|
|
});
|