Files
2026-07-13 13:32:57 +08:00

125 lines
4.7 KiB
TypeScript

import { redisTest } from "@internal/testcontainers";
import { type RedisOptions } from "ioredis";
import { describe, expect, it, vi } from "vitest";
import { type RedisWithClusterOptions } from "../app/redis.server.js";
import {
checkMfaRateLimit,
createMfaRateLimiters,
MFA_DAILY_ATTEMPTS,
MFA_PER_MINUTE_ATTEMPTS,
MfaRateLimitError,
} from "../app/services/mfa/mfaRateLimiter.server.js";
// redisTest spins up a container per test; give startup + the cumulative
// 30-attempt loop room.
vi.setConfig({ testTimeout: 60_000 });
// The container speaks plaintext; without tlsDisabled the client tries TLS,
// the connection fails, and @upstash/ratelimit fails open (allowing every
// attempt). Map the fixture options onto the shape createMfaRateLimiters
// expects, with TLS off.
const toRedisOptions = (redisOptions: RedisOptions): RedisWithClusterOptions => ({
host: redisOptions.host,
port: redisOptions.port,
username: redisOptions.username,
password: redisOptions.password,
tlsDisabled: true,
});
// A unique user id per test so sliding-window state never bleeds between
// cases (Redis is shared within a container).
let seq = 0;
const userId = (label: string) => `mfa-${label}-${seq++}`;
describe("checkMfaRateLimit", () => {
redisTest(
"allows up to the per-minute cap then blocks the next attempt",
async ({ redisOptions }) => {
const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) });
const id = userId("per-min");
// The first MFA_PER_MINUTE_ATTEMPTS (5) succeed.
for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) {
await expect(checkMfaRateLimit(id, limiters)).resolves.toBeUndefined();
}
// The 6th within the same minute is rejected.
await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
}
);
redisTest(
"caps cumulative attempts at the daily limit even when the per-minute window would allow them",
async ({ redisOptions }) => {
// Raise the per-minute cap out of the way so this test isolates the
// 24h cumulative window.
const limiters = createMfaRateLimiters({
redisOptions: toRedisOptions(redisOptions),
perMinuteAttempts: 100_000,
});
const id = userId("daily");
for (let i = 0; i < MFA_DAILY_ATTEMPTS; i++) {
await expect(checkMfaRateLimit(id, limiters)).resolves.toBeUndefined();
}
await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
}
);
redisTest("rate limits are scoped per user id", async ({ redisOptions }) => {
const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) });
const victim = userId("victim");
const bystander = userId("bystander");
// Exhaust the per-minute window for the victim.
for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) {
await checkMfaRateLimit(victim, limiters);
}
await expect(checkMfaRateLimit(victim, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
// A different user is unaffected.
await expect(checkMfaRateLimit(bystander, limiters)).resolves.toBeUndefined();
});
redisTest("the thrown error carries a positive retry-after", async ({ redisOptions }) => {
const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) });
const id = userId("retry-after");
for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) {
await checkMfaRateLimit(id, limiters);
}
const error = await checkMfaRateLimit(id, limiters).catch((e) => e);
expect(error).toBeInstanceOf(MfaRateLimitError);
expect((error as MfaRateLimitError).retryAfter).toBeGreaterThan(0);
});
redisTest(
"the daily cap is checked before the per-minute cap, so an exhausted day blocks the very first attempt of a fresh minute",
async ({ redisOptions }) => {
// perMinute high enough that only the daily window can trip.
const limiters = createMfaRateLimiters({
redisOptions: toRedisOptions(redisOptions),
perMinuteAttempts: 100_000,
dailyAttempts: 3,
});
const id = userId("daily-first");
await checkMfaRateLimit(id, limiters);
await checkMfaRateLimit(id, limiters);
await checkMfaRateLimit(id, limiters);
// Daily budget (3) is spent; the next attempt is rejected even though
// the per-minute window is nowhere near full.
await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError);
}
);
it("pins the production policy to the documented values", () => {
// Guards against a silent loosening of the caps in future edits.
expect(MFA_PER_MINUTE_ATTEMPTS).toBe(5);
expect(MFA_DAILY_ATTEMPTS).toBe(30);
});
});