import { describe, it, expect, vi, beforeEach } from 'vitest'; import RedisMock from 'ioredis-mock'; const mocks = vi.hoisted(() => ({ redis: null as any })); vi.mock('../redis.js', () => ({ isRedisEnabled: () => true, getRedis: () => mocks.redis, })); import { MemoryLoginLockout, RedisLoginLockout, MAX_LOGIN_ATTEMPTS, } from './loginLockout.js'; describe('MemoryLoginLockout', () => { it('locks after MAX_LOGIN_ATTEMPTS failures with retryAfter', async () => { const lockout = new MemoryLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) { await lockout.recordFailure('user@example.com'); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); } await lockout.recordFailure('user@example.com'); const status = await lockout.isLocked('user@example.com'); expect(status.locked).toBe(true); expect(status.retryAfter).toBeGreaterThan(0); }); it('clear removes the lockout', async () => { const lockout = new MemoryLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { await lockout.recordFailure('user@example.com'); } await lockout.clear('user@example.com'); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); }); it('treats emails case-insensitively', async () => { const lockout = new MemoryLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { await lockout.recordFailure('User@Example.com'); } expect((await lockout.isLocked('user@example.com')).locked).toBe(true); }); it('expires after the lockout window', async () => { vi.useFakeTimers(); try { const lockout = new MemoryLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { await lockout.recordFailure('user@example.com'); } expect((await lockout.isLocked('user@example.com')).locked).toBe(true); vi.advanceTimersByTime(16 * 60 * 1000); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); } finally { vi.useRealTimers(); } }); }); describe('RedisLoginLockout', () => { beforeEach(async () => { mocks.redis = new RedisMock(); // ioredis-mock shares data between instances by connection string. await mocks.redis.flushall(); }); it('locks after MAX_LOGIN_ATTEMPTS failures shared via redis', async () => { const lockout = new RedisLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS - 1; i++) { await lockout.recordFailure('user@example.com'); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); } await lockout.recordFailure('user@example.com'); const status = await lockout.isLocked('user@example.com'); expect(status.locked).toBe(true); expect(status.retryAfter).toBeGreaterThan(0); }); it('sets the lockout window TTL on the first failure', async () => { const lockout = new RedisLoginLockout(); await lockout.recordFailure('user@example.com'); const ttl = await mocks.redis.pttl('lockout:user@example.com'); expect(ttl).toBeGreaterThan(0); }); it('clear removes the lockout', async () => { const lockout = new RedisLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { await lockout.recordFailure('user@example.com'); } await lockout.clear('user@example.com'); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); }); it('treats emails case-insensitively', async () => { const lockout = new RedisLoginLockout(); for (let i = 0; i < MAX_LOGIN_ATTEMPTS; i++) { await lockout.recordFailure('User@Example.com'); } expect((await lockout.isLocked('user@example.com')).locked).toBe(true); }); it('fails open when the client is not initialized', async () => { mocks.redis = null; const lockout = new RedisLoginLockout(); await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined(); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); }); it('fails open when the backend errors', async () => { mocks.redis = { get: () => Promise.reject(new Error('connection refused')), pttl: () => Promise.reject(new Error('connection refused')), del: () => Promise.reject(new Error('connection refused')), lockoutRecordFailure: () => Promise.reject(new Error('connection refused')), }; const lockout = new RedisLoginLockout(); await expect(lockout.recordFailure('user@example.com')).resolves.toBeUndefined(); expect((await lockout.isLocked('user@example.com')).locked).toBe(false); await expect(lockout.clear('user@example.com')).resolves.toBeUndefined(); }); });