151 lines
5.5 KiB
TypeScript
151 lines
5.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { enforceAdminMfa } from "~/utils/auth/mfa/enforcement.server";
|
|
import { checkMfaStepUpState } from "~/utils/auth/mfa/step-up.server";
|
|
import { getUserSession } from "~/utils/session.server";
|
|
import { getUser, getUserTotpStatus } from "~/utils/db/users.db.server";
|
|
import { writeAdminSsoAuditEvent } from "~/modules/admin-sso/audit.server";
|
|
import {
|
|
requireAdminMfaForAction,
|
|
SENSITIVE_ADMIN_ACTIONS,
|
|
isSensitiveAdminAction,
|
|
} from "./admin-mfa-gate.server";
|
|
|
|
vi.mock("@toolctrl/config", () => ({
|
|
loadRequiredWebConfig: vi.fn(() => ({ routing: { appUrl: "https://app.example.test" } })),
|
|
}));
|
|
|
|
vi.mock("~/utils/auth/mfa/enforcement.server", () => ({
|
|
enforceAdminMfa: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/auth/mfa/step-up.server", () => ({
|
|
checkMfaStepUpState: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/app/CrossHostUrls", () => ({
|
|
absoluteAppRootPath: vi.fn(() => "https://app.example.test"),
|
|
}));
|
|
|
|
vi.mock("~/utils/session.server", () => ({
|
|
getUserSession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/users.db.server", () => ({
|
|
getUser: vi.fn(),
|
|
getUserTotpStatus: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/modules/admin-sso/audit.server", () => ({
|
|
writeAdminSsoAuditEvent: vi.fn(),
|
|
}));
|
|
|
|
const enforceAdminMfaMock = vi.mocked(enforceAdminMfa);
|
|
const checkMfaStepUpStateMock = vi.mocked(checkMfaStepUpState);
|
|
const getUserSessionMock = vi.mocked(getUserSession);
|
|
const getUserMock = vi.mocked(getUser);
|
|
const getUserTotpStatusMock = vi.mocked(getUserTotpStatus);
|
|
const writeAdminSsoAuditEventMock = vi.mocked(writeAdminSsoAuditEvent);
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getUserSessionMock.mockResolvedValue({ get: vi.fn() } as never);
|
|
getUserMock.mockResolvedValue({ id: "user_1", admin: true } as never);
|
|
getUserTotpStatusMock.mockResolvedValue({ enabled: true } as never);
|
|
checkMfaStepUpStateMock.mockReturnValue({
|
|
verified: true,
|
|
state: { userId: "user_1", method: "totp", verifiedAt: "2026-05-25T12:00:00.000Z" },
|
|
ageMs: 1000,
|
|
});
|
|
enforceAdminMfaMock.mockReturnValue({ allowed: true, reason: "allowed", redirectTo: "/admin" });
|
|
writeAdminSsoAuditEventMock.mockResolvedValue(undefined);
|
|
});
|
|
|
|
describe("SENSITIVE_ADMIN_ACTIONS taxonomy", () => {
|
|
it("includes the spec-required actions", () => {
|
|
expect(SENSITIVE_ADMIN_ACTIONS).toContain("impersonation.start");
|
|
expect(SENSITIVE_ADMIN_ACTIONS).toContain("tenant.delete");
|
|
expect(SENSITIVE_ADMIN_ACTIONS).toContain("permission.grant");
|
|
expect(SENSITIVE_ADMIN_ACTIONS).toContain("audit.export");
|
|
expect(SENSITIVE_ADMIN_ACTIONS).toContain("data.export");
|
|
});
|
|
|
|
it("typeguard accepts known actions and rejects unknown", () => {
|
|
expect(isSensitiveAdminAction("impersonation.start")).toBe(true);
|
|
expect(isSensitiveAdminAction("tenant.delete")).toBe(true);
|
|
expect(isSensitiveAdminAction("totally-random")).toBe(false);
|
|
expect(isSensitiveAdminAction("")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("requireAdminMfaForAction", () => {
|
|
it("returns the actor and writes success audit when MFA enforcement allows the action", async () => {
|
|
const request = new Request("https://admin.example.test/admin/accounts");
|
|
|
|
await expect(requireAdminMfaForAction(request, "tenant.delete", "user_1")).resolves.toEqual({
|
|
userId: "user_1",
|
|
action: "tenant.delete",
|
|
});
|
|
|
|
expect(getUserSessionMock).toHaveBeenCalledWith(request);
|
|
expect(getUserMock).toHaveBeenCalledWith("user_1");
|
|
expect(getUserTotpStatusMock).toHaveBeenCalledWith("user_1");
|
|
expect(enforceAdminMfaMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
requestUrl: request.url,
|
|
userId: "user_1",
|
|
isAdmin: true,
|
|
redirectMode: "host-aware",
|
|
appFallbackPath: "https://app.example.test",
|
|
}),
|
|
);
|
|
expect(writeAdminSsoAuditEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "admin.mfa.app.challenge.success",
|
|
actorAdminUserId: "user_1",
|
|
request,
|
|
meta: { action: "tenant.delete" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("audits and throws a redirect response when enforcement returns a Response", async () => {
|
|
const request = new Request("https://admin.example.test/admin/accounts");
|
|
const redirectResponse = new Response(null, {
|
|
status: 302,
|
|
headers: { Location: "/admin/security/mfa-challenge" },
|
|
});
|
|
enforceAdminMfaMock.mockReturnValue(redirectResponse as never);
|
|
|
|
await expect(requireAdminMfaForAction(request, "audit.export", "user_1")).rejects.toBe(redirectResponse);
|
|
expect(writeAdminSsoAuditEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "admin.mfa.app.challenge.failure",
|
|
actorAdminUserId: "user_1",
|
|
request,
|
|
meta: { action: "audit.export", reason: "redirect-response" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("audits denied enforcement and throws a redirect to the challenge path", async () => {
|
|
const request = new Request("https://admin.example.test/admin/accounts");
|
|
enforceAdminMfaMock.mockReturnValue({
|
|
allowed: false,
|
|
reason: "step-up-missing",
|
|
redirectTo: "/admin/security/mfa-challenge?redirectTo=%2Fadmin%2Faccounts",
|
|
});
|
|
|
|
await expect(requireAdminMfaForAction(request, "permission.grant", "user_1")).rejects.toMatchObject({
|
|
status: 302,
|
|
});
|
|
expect(writeAdminSsoAuditEventMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "admin.mfa.app.challenge.failure",
|
|
actorAdminUserId: "user_1",
|
|
request,
|
|
meta: { action: "permission.grant", reason: "step-up-missing" },
|
|
}),
|
|
);
|
|
});
|
|
});
|