Files
math2-platform/backend/tests/redis.client.test.ts
Renato bc43c9e772
Some checks failed
Test Suite / test-backend (push) Has been cancelled
Test Suite / test-frontend (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / coverage-check (push) Has been cancelled
🎓 Initial commit: Math2 Platform - Plataforma de Álgebra Lineal PRO
 Características:
- 45 ejercicios universitarios (Basic → Advanced)
- Renderizado LaTeX profesional
- IA generativa (Z.ai/DashScope)
- Docker 9 servicios
- Tests 123/123 pasando
- Seguridad enterprise (JWT, XSS, Rate limiting)

🐳 Infraestructura:
- Next.js 14 + Node.js 20
- PostgreSQL 15 + Redis 7
- Docker Compose completo
- Nginx + SSL ready

📚 Documentación:
- 5 informes técnicos completos
- README profesional
- Scripts de deployment automatizados

Estado: Producción lista 
2026-03-31 11:27:11 -03:00

331 lines
10 KiB
TypeScript

/**
* 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);
});
});