✨ 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 ✅
331 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|