/** * Tests for Redis Client - Token Blacklist Security * * Verifies fail-closed behavior when Redis is unavailable. */ import { describe, it, expect, beforeEach, vi, afterEach, beforeAll } from 'vitest'; // Hoist the mock so it applies before module imports const mockRedisFns = vi.hoisted(() => ({ exists: vi.fn(), setex: vi.fn(), on: vi.fn(), quit: vi.fn() })); // Mock ioredis before any imports vi.mock('ioredis', async () => { return { default: class MockRedis { status = 'ready'; on = mockRedisFns.on; exists = mockRedisFns.exists; setex = mockRedisFns.setex; quit = mockRedisFns.quit; constructor() { // Simulate async connection setTimeout(() => { this.on('connect', () => {}); }, 0); } } }; }); // Mock logger vi.mock('../src/shared/utils/logger', () => ({ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } })); // Import after mocks are set up let redisModule: typeof import('../src/shared/database/redis.client'); describe('Token Blacklist - Security (Fail-Closed)', () => { beforeAll(async () => { // Dynamic import after mocks are established redisModule = await import('../src/shared/database/redis.client'); }); beforeEach(() => { redisModule.resetBlacklistMetrics(); vi.clearAllMocks(); // Reset mock implementations mockRedisFns.exists.mockReset(); mockRedisFns.setex.mockReset(); }); afterEach(() => { vi.restoreAllMocks(); }); it('should return true when token is blacklisted in Redis', async () => { mockRedisFns.exists.mockResolvedValue(1); const result = await redisModule.isTokenBlacklisted('blacklisted-token'); expect(result).toBe(true); expect(mockRedisFns.exists).toHaveBeenCalledWith('token_blacklist:blacklisted-token'); }); it('should return false when token is not blacklisted in Redis', async () => { mockRedisFns.exists.mockResolvedValue(0); const result = await redisModule.isTokenBlacklisted('valid-token'); expect(result).toBe(false); }); it('should FAIL-CLOSED when Redis throws error on exists check', async () => { mockRedisFns.exists.mockRejectedValue(new Error('Connection lost')); // Should throw AuthenticationError instead of returning false (fail-open) await expect(redisModule.isTokenBlacklisted('any-token')) .rejects .toThrow('Unable to verify token status. Service temporarily unavailable.'); // Verify metrics tracked the failure const metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1); expect(metrics.redisBlacklistConsecutiveFailures).toBeGreaterThanOrEqual(1); }); it('should FAIL-CLOSED when Redis is unavailable (multiple consecutive failures)', async () => { mockRedisFns.exists.mockRejectedValue(new Error('Redis unavailable')); // Reset metrics to start fresh redisModule.resetBlacklistMetrics(); // Simulate multiple failures to trigger circuit breaker for (let i = 0; i < 5; i++) { try { await redisModule.isTokenBlacklisted('token-' + i); } catch (e) { // Expected to fail } } // After 5 failures, circuit breaker should have opened const metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistConsecutiveFailures).toBeGreaterThanOrEqual(1); }); it('should retry Redis operation with exponential backoff before failing', async () => { // First two calls fail, third succeeds mockRedisFns.exists .mockRejectedValueOnce(new Error('Temporary error')) .mockRejectedValueOnce(new Error('Temporary error')) .mockResolvedValueOnce(0); const result = await redisModule.isTokenBlacklisted('test-token'); // Should succeed on third attempt after retries expect(result).toBe(false); expect(mockRedisFns.exists).toHaveBeenCalledTimes(3); }); it('should track success metrics when Redis check succeeds', async () => { mockRedisFns.exists.mockResolvedValue(0); redisModule.resetBlacklistMetrics(); await redisModule.isTokenBlacklisted('test-token'); const metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(1); }); }); describe('Blacklist Token Operations', () => { beforeAll(async () => { if (!redisModule) { redisModule = await import('../src/shared/database/redis.client'); } }); beforeEach(() => { redisModule.resetBlacklistMetrics(); vi.clearAllMocks(); mockRedisFns.setex.mockReset(); }); it('should blacklist token successfully in Redis', async () => { mockRedisFns.setex.mockResolvedValue('OK'); await redisModule.blacklistToken('token-to-blacklist'); expect(mockRedisFns.setex).toHaveBeenCalledWith( 'token_blacklist:token-to-blacklist', 604800, // 7 days in seconds '1' ); const metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(1); }); it('should store token in memory cache when Redis fails', async () => { mockRedisFns.setex.mockRejectedValue(new Error('Redis down')); // Should not throw - graceful degradation to memory cache await expect(redisModule.blacklistToken('token-to-blacklist')).resolves.not.toThrow(); const metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1); }); }); describe('Circuit Breaker Behavior', () => { beforeAll(async () => { if (!redisModule) { redisModule = await import('../src/shared/database/redis.client'); } }); beforeEach(() => { redisModule.resetBlacklistMetrics(); vi.clearAllMocks(); mockRedisFns.exists.mockReset(); }); it('should open circuit breaker after 5 consecutive failures', async () => { mockRedisFns.exists.mockRejectedValue(new Error('Redis unavailable')); // Attempt 5 times for (let i = 0; i < 5; i++) { try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) { // Expected } } const metrics = redisModule.getBlacklistMetrics(); expect(metrics.circuitBreakerOpens).toBeGreaterThanOrEqual(1); }); it('should reset consecutive failures counter on successful operation', async () => { mockRedisFns.exists .mockRejectedValueOnce(new Error('Error 1')) .mockRejectedValueOnce(new Error('Error 2')) .mockResolvedValueOnce(0); // First two should fail try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) {} try { await redisModule.isTokenBlacklisted('test-token'); } catch (e) {} // Third should succeed after retries and reset counter const result = await redisModule.isTokenBlacklisted('test-token'); expect(result).toBe(false); const metrics = redisModule.getBlacklistMetrics(); // After success, consecutive failures should be reset to 0 expect(metrics.redisBlacklistConsecutiveFailures).toBe(0); }); }); describe('Security - Preventing Authentication Bypass', () => { beforeAll(async () => { if (!redisModule) { redisModule = await import('../src/shared/database/redis.client'); } }); beforeEach(() => { redisModule.resetBlacklistMetrics(); vi.clearAllMocks(); mockRedisFns.exists.mockReset(); }); it('should never return false (allow access) when Redis is unavailable', async () => { mockRedisFns.exists.mockRejectedValue(new Error('Redis connection lost')); // Even with a normally valid token, Redis failure should cause rejection try { await redisModule.isTokenBlacklisted('potentially-valid-token'); // If we reach here, the test fails - we should have thrown expect(false).toBe(true); } catch (error) { expect((error as Error).message).toContain('Unable to verify token status'); } }); it('should log security events with proper token prefix (never full token)', async () => { const { logger } = await import('../src/shared/utils/logger'); mockRedisFns.exists.mockRejectedValue(new Error('Redis down')); const longToken = 'this-is-a-very-long-secret-token-that-should-not-be-logged'; try { await redisModule.isTokenBlacklisted(longToken); } catch (e) { // Expected } // Check that logger.error was called expect(logger.error).toHaveBeenCalled(); // Get all calls to logger.error const errorCalls = vi.mocked(logger.error).mock.calls; expect(errorCalls.length).toBeGreaterThan(0); // Verify no full token in logs - check all arguments const allArgs = JSON.stringify(errorCalls); expect(allArgs).not.toContain(longToken); }); }); describe('Metrics and Observability', () => { beforeAll(async () => { if (!redisModule) { redisModule = await import('../src/shared/database/redis.client'); } }); beforeEach(() => { redisModule.resetBlacklistMetrics(); vi.clearAllMocks(); mockRedisFns.exists.mockReset(); mockRedisFns.setex.mockReset(); }); it('should track all relevant metrics', async () => { mockRedisFns.exists.mockResolvedValue(0); mockRedisFns.setex.mockResolvedValue('OK'); await redisModule.isTokenBlacklisted('test-1'); await redisModule.isTokenBlacklisted('test-2'); await redisModule.blacklistToken('token-1'); const metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistSuccesses).toBeGreaterThanOrEqual(3); expect(metrics.redisBlacklistFailures).toBe(0); expect(metrics.redisBlacklistConsecutiveFailures).toBe(0); }); it('should reset metrics when resetBlacklistMetrics is called', async () => { // First, accumulate some metrics with a failure mockRedisFns.exists.mockRejectedValue(new Error('Error')); try { await redisModule.isTokenBlacklisted('test'); } catch (e) {} let metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistFailures).toBeGreaterThanOrEqual(1); // Reset redisModule.resetBlacklistMetrics(); metrics = redisModule.getBlacklistMetrics(); expect(metrics.redisBlacklistFailures).toBe(0); expect(metrics.redisBlacklistSuccesses).toBe(0); expect(metrics.redisBlacklistConsecutiveFailures).toBe(0); expect(metrics.circuitBreakerOpens).toBe(0); }); });