151 lines
5.3 KiB
TypeScript
151 lines
5.3 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
|
|
vi.mock("~/utils/session.server", async () => {
|
|
const { createCookieSessionStorage } = await import("react-router");
|
|
return {
|
|
storage: createCookieSessionStorage({
|
|
cookie: { name: "test_session", secrets: ["test-session-secret"], path: "/", httpOnly: true, sameSite: "lax" },
|
|
}),
|
|
};
|
|
});
|
|
|
|
import { storage } from "~/utils/session.server";
|
|
import {
|
|
checkMfaStepUpState,
|
|
clearMfaStepUpState,
|
|
getMfaStepUpState,
|
|
MFA_STEP_UP_MAX_AGE_MS,
|
|
MFA_STEP_UP_SESSION_KEY,
|
|
setMfaStepUpState,
|
|
} from "./step-up.server";
|
|
|
|
async function createSession() {
|
|
return storage.getSession();
|
|
}
|
|
|
|
describe("step-up.server", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test("records and verifies fresh MFA step-up state for the same user", async () => {
|
|
const session = await createSession();
|
|
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
|
|
|
|
const state = setMfaStepUpState(session, {
|
|
userId: "admin-1",
|
|
method: "totp",
|
|
redirectTo: "/admin?tab=security",
|
|
now: verifiedAt,
|
|
});
|
|
const result = checkMfaStepUpState(session, "admin-1", { now: new Date(verifiedAt.getTime() + 30_000) });
|
|
|
|
expect(state).toEqual({
|
|
userId: "admin-1",
|
|
method: "totp",
|
|
verifiedAt: verifiedAt.toISOString(),
|
|
redirectTo: "/admin?tab=security",
|
|
});
|
|
expect(getMfaStepUpState(session)).toEqual(state);
|
|
expect(result).toEqual({ verified: true, state, ageMs: 30_000 });
|
|
});
|
|
|
|
test("records step-up state without optional redirect metadata", async () => {
|
|
const session = await createSession();
|
|
const verifiedAt = new Date("2026-05-08T14:00:00.000Z");
|
|
|
|
const state = setMfaStepUpState(session, {
|
|
userId: "admin-1",
|
|
method: "recovery-code",
|
|
now: verifiedAt,
|
|
});
|
|
|
|
expect(state).toEqual({
|
|
userId: "admin-1",
|
|
method: "recovery-code",
|
|
verifiedAt: verifiedAt.toISOString(),
|
|
});
|
|
expect(getMfaStepUpState(session)).toEqual(state);
|
|
});
|
|
|
|
test("fails closed for stale and future timestamps", async () => {
|
|
const staleSession = await createSession();
|
|
const futureSession = await createSession();
|
|
const now = new Date("2026-05-08T14:10:00.000Z");
|
|
|
|
setMfaStepUpState(staleSession, {
|
|
userId: "admin-1",
|
|
method: "recovery-code",
|
|
now: new Date(now.getTime() - MFA_STEP_UP_MAX_AGE_MS - 1),
|
|
});
|
|
setMfaStepUpState(futureSession, {
|
|
userId: "admin-1",
|
|
method: "totp",
|
|
now: new Date(now.getTime() + 1),
|
|
});
|
|
|
|
expect(checkMfaStepUpState(staleSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
|
|
expect(checkMfaStepUpState(futureSession, "admin-1", { now })).toEqual({ verified: false, reason: "stale" });
|
|
});
|
|
|
|
test("does not authorize a different user", async () => {
|
|
const session = await createSession();
|
|
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
|
|
|
|
expect(checkMfaStepUpState(session, "admin-2", { now: new Date("2026-05-08T14:01:00.000Z") })).toEqual({
|
|
verified: false,
|
|
reason: "wrong-user",
|
|
});
|
|
});
|
|
|
|
test("treats missing and malformed state as unverified without throwing", async () => {
|
|
const missingSession = await createSession();
|
|
const malformedPayloads = [
|
|
{ method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
|
{ userId: "", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
|
"not-json-object",
|
|
{ userId: "admin-1", method: "sms", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
|
{ userId: "admin-1", verifiedAt: "2026-05-08T14:00:00.000Z" },
|
|
{ userId: "admin-1", method: "totp", verifiedAt: "not-a-date" },
|
|
{ userId: "admin-1", method: "totp", verifiedAt: "2026-05-08T14:00:00.000Z", redirectTo: 123 },
|
|
];
|
|
|
|
expect(checkMfaStepUpState(missingSession, "admin-1")).toEqual({ verified: false, reason: "missing" });
|
|
|
|
for (const payload of malformedPayloads) {
|
|
const session = await createSession();
|
|
session.set(MFA_STEP_UP_SESSION_KEY, payload);
|
|
expect(() => getMfaStepUpState(session)).not.toThrow();
|
|
expect(getMfaStepUpState(session)).toBeNull();
|
|
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "malformed" });
|
|
}
|
|
});
|
|
|
|
test("clears existing MFA step-up state", async () => {
|
|
const session = await createSession();
|
|
setMfaStepUpState(session, { userId: "admin-1", method: "totp", now: new Date("2026-05-08T14:00:00.000Z") });
|
|
|
|
clearMfaStepUpState(session);
|
|
|
|
expect(getMfaStepUpState(session)).toBeNull();
|
|
expect(checkMfaStepUpState(session, "admin-1")).toEqual({ verified: false, reason: "missing" });
|
|
});
|
|
|
|
test("serialized step-up state contains no cookies, tokens, hashes, passwords, or factor secrets", async () => {
|
|
const session = await createSession();
|
|
const state = setMfaStepUpState(session, {
|
|
userId: "admin-1",
|
|
method: "recovery-code",
|
|
redirectTo: "/admin",
|
|
now: new Date("2026-05-08T14:00:00.000Z"),
|
|
});
|
|
|
|
const serialized = JSON.stringify(state);
|
|
|
|
expect(serialized).toContain("admin-1");
|
|
expect(serialized).toContain("recovery-code");
|
|
expect(serialized).not.toMatch(/cookie|csrf|token|secret|hash|password|encrypted|totpSecret|recoveryCodes/i);
|
|
});
|
|
});
|