117 lines
4.3 KiB
TypeScript
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;
|
|
}
|
|
});
|
|
});
|