Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/auth/mfa/challenge-rate-limit.server.test.ts
T

117 lines
4.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createSharedStore } from "@toolctrl/shared-store";
import {
checkMfaChallengeRateLimit,
mfaChallengeRateLimitKey,
recordFailedMfaChallengeAttempt,
resetMfaChallengeRateLimit,
resetMfaChallengeRateLimiterForTests,
__resetMfaChallengeRateLimitStoreForTests,
__setMfaChallengeRateLimitStoreForTests,
} from "./challenge-rate-limit.server";
beforeEach(() => {
__setMfaChallengeRateLimitStoreForTests(createSharedStore({ namespace: "test-mfa-challenge" }));
});
afterEach(async () => {
await resetMfaChallengeRateLimiterForTests();
__resetMfaChallengeRateLimitStoreForTests();
});
describe("MFA challenge rate limiter", () => {
it("keys attempts by user and client IP", () => {
const request = new Request("https://admin.example.test/mfa-challenge", {
headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1" },
});
expect(mfaChallengeRateLimitKey(request, "user_1")).toBe("user_1:203.0.113.5");
});
it("falls back from blank forwarded headers to real-ip and then unknown", () => {
expect(
mfaChallengeRateLimitKey(
new Request("https://admin.example.test/mfa-challenge", {
headers: { "x-forwarded-for": " ", "x-real-ip": "198.51.100.7" },
}),
"user_1",
),
).toBe("user_1:198.51.100.7");
expect(mfaChallengeRateLimitKey(new Request("https://admin.example.test/mfa-challenge"), "user_1")).toBe(
"user_1:unknown",
);
});
it("blocks after repeated failed attempts and can be reset", async () => {
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
for (let index = 0; index < 5; index += 1) {
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
await recordFailedMfaChallengeAttempt(key, now);
}
const blocked = await checkMfaChallengeRateLimit(key, now);
expect(blocked.allowed).toBe(false);
if (!blocked.allowed) {
expect(blocked.retryAfterSeconds).toBe(300);
}
await resetMfaChallengeRateLimit(key);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
});
it("treats non-numeric stored counters as zero remaining attempts", async () => {
const store = createSharedStore({ namespace: "test-mfa-challenge-nan" });
__setMfaChallengeRateLimitStoreForTests(store);
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
await store.set(`rl:fixed:mfa-challenge:${key}`, "NaN", { ttlSeconds: 300 });
await expect(checkMfaChallengeRateLimit(key, now)).resolves.toEqual({
allowed: true,
remaining: 5,
resetAt: new Date("2026-05-11T12:05:00.000Z"),
});
});
it("resetMfaChallengeRateLimiterForTests clears cached fixed-window keys", async () => {
const key = "user_1:203.0.113.5";
const now = new Date("2026-05-11T12:00:00.000Z");
await recordFailedMfaChallengeAttempt(key, now);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
await resetMfaChallengeRateLimiterForTests();
const freshStore = createSharedStore({ namespace: "test-mfa-challenge" });
__setMfaChallengeRateLimitStoreForTests(freshStore);
await recordFailedMfaChallengeAttempt(key, now);
expect((await checkMfaChallengeRateLimit(key, now)).allowed).toBe(true);
});
it("uses the redis adapter when REDIS_URL is set", () => {
const originalRedisUrl = process.env.REDIS_URL;
try {
process.env.REDIS_URL = "redis://does-not-exist:6379";
const store = createSharedStore({ namespace: "mfa-redis-selection", redisUrl: process.env.REDIS_URL });
expect(store.adapter.constructor.name).toBe("RedisAdapter");
void store.close();
} finally {
if (originalRedisUrl === undefined) delete process.env.REDIS_URL;
else process.env.REDIS_URL = originalRedisUrl;
}
});
it("uses the memory adapter when REDIS_URL is unset", () => {
const originalRedisUrl = process.env.REDIS_URL;
delete process.env.REDIS_URL;
try {
const store = createSharedStore({ namespace: "mfa-memory-selection" });
expect(store.adapter.constructor.name).toBe("MemoryAdapter");
} finally {
if (originalRedisUrl !== undefined) process.env.REDIS_URL = originalRedisUrl;
}
});
});