test: add comprehensive test suite for security and SSE components
- Implemented detailed Jest test configurations for project - Added test configuration with robust environment setup - Created comprehensive test suites for: * Security middleware * Token management * SSE security features - Configured test utilities with mock request/response objects - Implemented extensive test scenarios covering authentication, rate limiting, and error handling
This commit is contained in:
76
jest.config.ts
Normal file
76
jest.config.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.+(ts|tsx|js)',
|
||||
'**/?(*.)+(spec|test).+(ts|tsx|js)'
|
||||
],
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': 'ts-jest'
|
||||
},
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/__tests__/setup.ts'
|
||||
],
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: 'tsconfig.json',
|
||||
isolatedModules: true
|
||||
}
|
||||
},
|
||||
collectCoverage: true,
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/**/__tests__/**',
|
||||
'!src/**/__mocks__/**',
|
||||
'!src/**/types/**'
|
||||
],
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
statements: 80
|
||||
}
|
||||
},
|
||||
verbose: true,
|
||||
testTimeout: 10000,
|
||||
maxWorkers: '50%',
|
||||
errorOnDeprecated: true,
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
restoreMocks: true,
|
||||
testPathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/',
|
||||
'/.cursor/'
|
||||
],
|
||||
watchPathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/',
|
||||
'/.cursor/',
|
||||
'/coverage/'
|
||||
],
|
||||
modulePathIgnorePatterns: [
|
||||
'/dist/',
|
||||
'/.cursor/'
|
||||
],
|
||||
moduleFileExtensions: [
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'json',
|
||||
'node'
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
134
src/__tests__/setup.ts
Normal file
134
src/__tests__/setup.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { config } from 'dotenv';
|
||||
import path from 'path';
|
||||
import { TEST_CONFIG } from '../config/__tests__/test.config';
|
||||
|
||||
// Load test environment variables
|
||||
config({ path: path.resolve(process.cwd(), '.env.test') });
|
||||
|
||||
// Global test setup
|
||||
beforeAll(() => {
|
||||
// Set required environment variables
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.JWT_SECRET = TEST_CONFIG.TEST_JWT_SECRET;
|
||||
process.env.TEST_TOKEN = TEST_CONFIG.TEST_TOKEN;
|
||||
|
||||
// Configure console output for tests
|
||||
const originalConsoleError = console.error;
|
||||
const originalConsoleWarn = console.warn;
|
||||
const originalConsoleLog = console.log;
|
||||
|
||||
// Suppress console output during tests unless explicitly enabled
|
||||
if (!process.env.DEBUG) {
|
||||
console.error = jest.fn();
|
||||
console.warn = jest.fn();
|
||||
console.log = jest.fn();
|
||||
}
|
||||
|
||||
// Store original console methods for cleanup
|
||||
(global as any).__ORIGINAL_CONSOLE__ = {
|
||||
error: originalConsoleError,
|
||||
warn: originalConsoleWarn,
|
||||
log: originalConsoleLog
|
||||
};
|
||||
});
|
||||
|
||||
// Global test teardown
|
||||
afterAll(() => {
|
||||
// Restore original console methods
|
||||
const originalConsole = (global as any).__ORIGINAL_CONSOLE__;
|
||||
if (originalConsole) {
|
||||
console.error = originalConsole.error;
|
||||
console.warn = originalConsole.warn;
|
||||
console.log = originalConsole.log;
|
||||
delete (global as any).__ORIGINAL_CONSOLE__;
|
||||
}
|
||||
});
|
||||
|
||||
// Reset mocks between tests
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// Custom test environment setup
|
||||
const setupTestEnvironment = () => {
|
||||
return {
|
||||
// Mock WebSocket for SSE tests
|
||||
mockWebSocket: () => {
|
||||
const mockWs = {
|
||||
on: jest.fn(),
|
||||
send: jest.fn(),
|
||||
close: jest.fn()
|
||||
};
|
||||
return mockWs;
|
||||
},
|
||||
|
||||
// Mock HTTP response for API tests
|
||||
mockResponse: () => {
|
||||
const res: any = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
res.end = jest.fn().mockReturnValue(res);
|
||||
res.setHeader = jest.fn().mockReturnValue(res);
|
||||
res.writeHead = jest.fn().mockReturnValue(res);
|
||||
res.write = jest.fn().mockReturnValue(true);
|
||||
return res;
|
||||
},
|
||||
|
||||
// Mock HTTP request for API tests
|
||||
mockRequest: (overrides = {}) => {
|
||||
return {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: {},
|
||||
query: {},
|
||||
params: {},
|
||||
ip: TEST_CONFIG.TEST_CLIENT_IP,
|
||||
method: 'GET',
|
||||
path: '/api/test',
|
||||
is: jest.fn(type => type === 'application/json'),
|
||||
...overrides
|
||||
};
|
||||
},
|
||||
|
||||
// Create test client for SSE tests
|
||||
createTestClient: (id: string = 'test-client') => ({
|
||||
id,
|
||||
ip: TEST_CONFIG.TEST_CLIENT_IP,
|
||||
connectedAt: new Date(),
|
||||
send: jest.fn(),
|
||||
rateLimit: {
|
||||
count: 0,
|
||||
lastReset: Date.now()
|
||||
},
|
||||
connectionTime: Date.now()
|
||||
}),
|
||||
|
||||
// Create test event for SSE tests
|
||||
createTestEvent: (type: string = 'test_event', data: any = {}) => ({
|
||||
event_type: type,
|
||||
data,
|
||||
origin: 'test',
|
||||
time_fired: new Date().toISOString(),
|
||||
context: { id: 'test' }
|
||||
}),
|
||||
|
||||
// Create test entity for Home Assistant tests
|
||||
createTestEntity: (entityId: string = 'test.entity', state: string = 'on') => ({
|
||||
entity_id: entityId,
|
||||
state,
|
||||
attributes: {},
|
||||
last_changed: new Date().toISOString(),
|
||||
last_updated: new Date().toISOString()
|
||||
}),
|
||||
|
||||
// Helper to wait for async operations
|
||||
wait: (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
};
|
||||
};
|
||||
|
||||
// Export test utilities
|
||||
export const testUtils = setupTestEnvironment();
|
||||
|
||||
// Make test utilities available globally
|
||||
(global as any).testUtils = testUtils;
|
||||
135
src/config/__tests__/test.config.ts
Normal file
135
src/config/__tests__/test.config.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// Test configuration schema
|
||||
const testConfigSchema = z.object({
|
||||
// Test Environment
|
||||
TEST_PORT: z.number().default(3001),
|
||||
TEST_HOST: z.string().default('http://localhost'),
|
||||
TEST_WEBSOCKET_PORT: z.number().default(3002),
|
||||
|
||||
// Mock Authentication
|
||||
TEST_JWT_SECRET: z.string().default('test_jwt_secret_key_that_is_at_least_32_chars'),
|
||||
TEST_TOKEN: z.string().default('test_token_that_is_at_least_32_chars_long'),
|
||||
TEST_INVALID_TOKEN: z.string().default('invalid_token'),
|
||||
|
||||
// Mock Client Settings
|
||||
TEST_CLIENT_IP: z.string().default('127.0.0.1'),
|
||||
TEST_MAX_CLIENTS: z.number().default(10),
|
||||
TEST_PING_INTERVAL: z.number().default(100),
|
||||
TEST_CLEANUP_INTERVAL: z.number().default(200),
|
||||
TEST_MAX_CONNECTION_AGE: z.number().default(1000),
|
||||
|
||||
// Mock Rate Limiting
|
||||
TEST_RATE_LIMIT_WINDOW: z.number().default(60000), // 1 minute
|
||||
TEST_RATE_LIMIT_MAX_REQUESTS: z.number().default(100),
|
||||
TEST_RATE_LIMIT_WEBSOCKET: z.number().default(1000),
|
||||
|
||||
// Mock Events
|
||||
TEST_EVENT_TYPES: z.array(z.string()).default([
|
||||
'state_changed',
|
||||
'automation_triggered',
|
||||
'script_executed',
|
||||
'service_called'
|
||||
]),
|
||||
|
||||
// Mock Entities
|
||||
TEST_ENTITIES: z.array(z.object({
|
||||
entity_id: z.string(),
|
||||
state: z.string(),
|
||||
attributes: z.record(z.any()),
|
||||
last_changed: z.string(),
|
||||
last_updated: z.string()
|
||||
})).default([
|
||||
{
|
||||
entity_id: 'light.test_light',
|
||||
state: 'on',
|
||||
attributes: {
|
||||
brightness: 255,
|
||||
color_temp: 400
|
||||
},
|
||||
last_changed: new Date().toISOString(),
|
||||
last_updated: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
entity_id: 'switch.test_switch',
|
||||
state: 'off',
|
||||
attributes: {},
|
||||
last_changed: new Date().toISOString(),
|
||||
last_updated: new Date().toISOString()
|
||||
}
|
||||
]),
|
||||
|
||||
// Mock Services
|
||||
TEST_SERVICES: z.array(z.object({
|
||||
domain: z.string(),
|
||||
service: z.string(),
|
||||
data: z.record(z.any())
|
||||
})).default([
|
||||
{
|
||||
domain: 'light',
|
||||
service: 'turn_on',
|
||||
data: {
|
||||
entity_id: 'light.test_light',
|
||||
brightness: 255
|
||||
}
|
||||
},
|
||||
{
|
||||
domain: 'switch',
|
||||
service: 'turn_off',
|
||||
data: {
|
||||
entity_id: 'switch.test_switch'
|
||||
}
|
||||
}
|
||||
]),
|
||||
|
||||
// Mock Error Scenarios
|
||||
TEST_ERROR_SCENARIOS: z.array(z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
code: z.number()
|
||||
})).default([
|
||||
{
|
||||
type: 'authentication_error',
|
||||
message: 'Invalid token',
|
||||
code: 401
|
||||
},
|
||||
{
|
||||
type: 'rate_limit_error',
|
||||
message: 'Too many requests',
|
||||
code: 429
|
||||
},
|
||||
{
|
||||
type: 'validation_error',
|
||||
message: 'Invalid request body',
|
||||
code: 400
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
// Parse environment variables or use defaults
|
||||
const parseTestConfig = () => {
|
||||
const config = {
|
||||
TEST_PORT: parseInt(process.env.TEST_PORT || '3001'),
|
||||
TEST_HOST: process.env.TEST_HOST || 'http://localhost',
|
||||
TEST_WEBSOCKET_PORT: parseInt(process.env.TEST_WEBSOCKET_PORT || '3002'),
|
||||
TEST_JWT_SECRET: process.env.TEST_JWT_SECRET || 'test_jwt_secret_key_that_is_at_least_32_chars',
|
||||
TEST_TOKEN: process.env.TEST_TOKEN || 'test_token_that_is_at_least_32_chars_long',
|
||||
TEST_INVALID_TOKEN: process.env.TEST_INVALID_TOKEN || 'invalid_token',
|
||||
TEST_CLIENT_IP: process.env.TEST_CLIENT_IP || '127.0.0.1',
|
||||
TEST_MAX_CLIENTS: parseInt(process.env.TEST_MAX_CLIENTS || '10'),
|
||||
TEST_PING_INTERVAL: parseInt(process.env.TEST_PING_INTERVAL || '100'),
|
||||
TEST_CLEANUP_INTERVAL: parseInt(process.env.TEST_CLEANUP_INTERVAL || '200'),
|
||||
TEST_MAX_CONNECTION_AGE: parseInt(process.env.TEST_MAX_CONNECTION_AGE || '1000'),
|
||||
TEST_RATE_LIMIT_WINDOW: parseInt(process.env.TEST_RATE_LIMIT_WINDOW || '60000'),
|
||||
TEST_RATE_LIMIT_MAX_REQUESTS: parseInt(process.env.TEST_RATE_LIMIT_MAX_REQUESTS || '100'),
|
||||
TEST_RATE_LIMIT_WEBSOCKET: parseInt(process.env.TEST_RATE_LIMIT_WEBSOCKET || '1000'),
|
||||
};
|
||||
|
||||
return testConfigSchema.parse(config);
|
||||
};
|
||||
|
||||
// Export the validated test configuration
|
||||
export const TEST_CONFIG = parseTestConfig();
|
||||
|
||||
// Export types
|
||||
export type TestConfig = z.infer<typeof testConfigSchema>;
|
||||
302
src/middleware/__tests__/security.middleware.test.ts
Normal file
302
src/middleware/__tests__/security.middleware.test.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { middleware } from '../index';
|
||||
import { TokenManager } from '../../security/index';
|
||||
|
||||
describe('Security Middleware', () => {
|
||||
let mockRequest: Partial<Request>;
|
||||
let mockResponse: Partial<Response>;
|
||||
let nextFunction: NextFunction;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRequest = {
|
||||
headers: {},
|
||||
body: {},
|
||||
ip: '127.0.0.1',
|
||||
method: 'POST',
|
||||
is: jest.fn(),
|
||||
};
|
||||
|
||||
mockResponse = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
nextFunction = jest.fn();
|
||||
});
|
||||
|
||||
describe('authenticate', () => {
|
||||
it('should pass valid authentication', () => {
|
||||
const token = 'valid_token';
|
||||
mockRequest.headers = { authorization: `Bearer ${token}` };
|
||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: true });
|
||||
|
||||
middleware.authenticate(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid token', () => {
|
||||
const token = 'invalid_token';
|
||||
mockRequest.headers = { authorization: `Bearer ${token}` };
|
||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({
|
||||
valid: false,
|
||||
error: 'Invalid token'
|
||||
});
|
||||
|
||||
middleware.authenticate(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Unauthorized'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing authorization header', () => {
|
||||
middleware.authenticate(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('securityHeaders', () => {
|
||||
it('should set all required security headers', () => {
|
||||
middleware.securityHeaders(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'X-Content-Type-Options',
|
||||
'nosniff'
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'X-Frame-Options',
|
||||
'DENY'
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Strict-Transport-Security',
|
||||
expect.stringContaining('max-age=31536000')
|
||||
);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Security-Policy',
|
||||
expect.any(String)
|
||||
);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRequest', () => {
|
||||
it('should pass valid requests', () => {
|
||||
mockRequest.is = jest.fn().mockReturnValue('application/json');
|
||||
mockRequest.body = { test: 'data' };
|
||||
Object.defineProperty(mockRequest, 'path', {
|
||||
get: () => '/api/test',
|
||||
configurable: true
|
||||
});
|
||||
|
||||
middleware.validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid content type', () => {
|
||||
mockRequest.is = jest.fn().mockReturnValue(false);
|
||||
Object.defineProperty(mockRequest, 'path', {
|
||||
get: () => '/api/test',
|
||||
configurable: true
|
||||
});
|
||||
|
||||
middleware.validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(415);
|
||||
});
|
||||
|
||||
it('should reject oversized requests', () => {
|
||||
mockRequest.headers = { 'content-length': '2097152' }; // 2MB
|
||||
mockRequest.is = jest.fn().mockReturnValue('application/json');
|
||||
Object.defineProperty(mockRequest, 'path', {
|
||||
get: () => '/api/test',
|
||||
configurable: true
|
||||
});
|
||||
|
||||
middleware.validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(413);
|
||||
});
|
||||
|
||||
it('should skip validation for health check endpoints', () => {
|
||||
Object.defineProperty(mockRequest, 'path', {
|
||||
get: () => '/health',
|
||||
configurable: true
|
||||
});
|
||||
|
||||
middleware.validateRequest(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
expect(mockResponse.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeInput', () => {
|
||||
it('should sanitize HTML in request body', () => {
|
||||
mockRequest.body = {
|
||||
text: '<script>alert("xss")</script>Hello',
|
||||
nested: {
|
||||
html: '<img src="x" onerror="alert(1)">World'
|
||||
}
|
||||
};
|
||||
|
||||
middleware.sanitizeInput(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockRequest.body.text).not.toContain('<script>');
|
||||
expect(mockRequest.body.nested.html).not.toContain('<img');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle non-object bodies', () => {
|
||||
mockRequest.body = '<p>text</p>';
|
||||
|
||||
middleware.sanitizeInput(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockRequest.body).not.toContain('<p>');
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should preserve non-string values', () => {
|
||||
const body = {
|
||||
number: 42,
|
||||
boolean: true,
|
||||
null: null,
|
||||
array: [1, 2, 3]
|
||||
};
|
||||
mockRequest.body = { ...body };
|
||||
|
||||
middleware.sanitizeInput(
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockRequest.body).toEqual(body);
|
||||
expect(nextFunction).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorHandler', () => {
|
||||
it('should handle ValidationError', () => {
|
||||
const error = new Error('Validation failed');
|
||||
error.name = 'ValidationError';
|
||||
|
||||
middleware.errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(400);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Validation Error'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle UnauthorizedError', () => {
|
||||
const error = new Error('Unauthorized access');
|
||||
error.name = 'UnauthorizedError';
|
||||
|
||||
middleware.errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('should handle generic errors', () => {
|
||||
const error = new Error('Something went wrong');
|
||||
|
||||
middleware.errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.status).toHaveBeenCalledWith(500);
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
success: false,
|
||||
message: 'Internal Server Error'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide error details in production', () => {
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
const error = new Error('Sensitive error details');
|
||||
|
||||
middleware.errorHandler(
|
||||
error,
|
||||
mockRequest as Request,
|
||||
mockResponse as Response,
|
||||
nextFunction
|
||||
);
|
||||
|
||||
expect(mockResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: 'An unexpected error occurred'
|
||||
})
|
||||
);
|
||||
|
||||
process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
});
|
||||
});
|
||||
116
src/security/__tests__/security.test.ts
Normal file
116
src/security/__tests__/security.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { TokenManager } from '../index';
|
||||
import { SECURITY_CONFIG } from '../../config/security.config';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
describe('TokenManager', () => {
|
||||
const validSecret = 'test_secret_key_that_is_at_least_32_chars_long';
|
||||
const validToken = 'valid_token_that_is_at_least_32_chars_long';
|
||||
const testIp = '127.0.0.1';
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.JWT_SECRET = validSecret;
|
||||
// Reset rate limiting
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('Token Validation', () => {
|
||||
it('should validate a properly formatted token', () => {
|
||||
const payload = { userId: '123', role: 'user' };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
const result = TokenManager.validateToken(token, testIp);
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject an invalid token', () => {
|
||||
const result = TokenManager.validateToken('invalid_token', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject a token that is too short', () => {
|
||||
const result = TokenManager.validateToken('short', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('minimum requirement');
|
||||
});
|
||||
|
||||
it('should reject an expired token', () => {
|
||||
const payload = { userId: '123', role: 'user' };
|
||||
const token = jwt.sign(payload, validSecret, { expiresIn: -1 });
|
||||
const result = TokenManager.validateToken(token, testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('expired');
|
||||
});
|
||||
|
||||
it('should implement rate limiting for failed attempts', () => {
|
||||
// Simulate multiple failed attempts
|
||||
for (let i = 0; i < SECURITY_CONFIG.MAX_FAILED_ATTEMPTS; i++) {
|
||||
TokenManager.validateToken('invalid_token', testIp);
|
||||
}
|
||||
|
||||
// Next attempt should be blocked
|
||||
const result = TokenManager.validateToken('invalid_token', testIp);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toContain('Too many failed attempts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Generation', () => {
|
||||
it('should generate a valid JWT token', () => {
|
||||
const payload = { userId: '123', role: 'user' };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe('string');
|
||||
|
||||
// Verify the token can be decoded
|
||||
const decoded = jwt.verify(token, validSecret) as any;
|
||||
expect(decoded.userId).toBe(payload.userId);
|
||||
expect(decoded.role).toBe(payload.role);
|
||||
});
|
||||
|
||||
it('should include required claims in generated tokens', () => {
|
||||
const payload = { userId: '123' };
|
||||
const token = TokenManager.generateToken(payload);
|
||||
const decoded = jwt.verify(token, validSecret) as any;
|
||||
|
||||
expect(decoded.iat).toBeDefined();
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.exp - decoded.iat).toBe(
|
||||
Math.floor(SECURITY_CONFIG.JWT_EXPIRY / 1000)
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when JWT secret is not configured', () => {
|
||||
delete process.env.JWT_SECRET;
|
||||
const payload = { userId: '123' };
|
||||
expect(() => TokenManager.generateToken(payload)).toThrow('JWT secret not configured');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Token Encryption', () => {
|
||||
const encryptionKey = 'encryption_key_that_is_at_least_32_chars_long';
|
||||
|
||||
it('should encrypt and decrypt a token successfully', () => {
|
||||
const originalToken = 'test_token_to_encrypt';
|
||||
const encrypted = TokenManager.encryptToken(originalToken, encryptionKey);
|
||||
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
|
||||
expect(decrypted).toBe(originalToken);
|
||||
});
|
||||
|
||||
it('should throw error for invalid encryption inputs', () => {
|
||||
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow('Invalid token');
|
||||
expect(() => TokenManager.encryptToken(validToken, '')).toThrow('Invalid encryption key');
|
||||
});
|
||||
|
||||
it('should throw error for invalid decryption inputs', () => {
|
||||
expect(() => TokenManager.decryptToken('', encryptionKey)).toThrow('Invalid encrypted token');
|
||||
expect(() => TokenManager.decryptToken('invalid:format', encryptionKey)).toThrow('Invalid encrypted token format');
|
||||
});
|
||||
|
||||
it('should generate different ciphertexts for same plaintext', () => {
|
||||
const token = 'test_token';
|
||||
const encrypted1 = TokenManager.encryptToken(token, encryptionKey);
|
||||
const encrypted2 = TokenManager.encryptToken(token, encryptionKey);
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
});
|
||||
});
|
||||
});
|
||||
215
src/sse/__tests__/sse.security.test.ts
Normal file
215
src/sse/__tests__/sse.security.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { SSEManager } from '../index';
|
||||
import { TokenManager } from '../../security/index';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
describe('SSE Security Features', () => {
|
||||
let sseManager: SSEManager;
|
||||
const validToken = 'valid_token';
|
||||
const testIp = '127.0.0.1';
|
||||
|
||||
const createTestClient = (id: string) => ({
|
||||
id,
|
||||
ip: testIp,
|
||||
connectedAt: new Date(),
|
||||
send: jest.fn(),
|
||||
rateLimit: {
|
||||
count: 0,
|
||||
lastReset: Date.now()
|
||||
},
|
||||
connectionTime: Date.now()
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sseManager = new SSEManager({
|
||||
maxClients: 10,
|
||||
pingInterval: 100,
|
||||
cleanupInterval: 200,
|
||||
maxConnectionAge: 1000
|
||||
});
|
||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Client Authentication', () => {
|
||||
it('should authenticate valid clients', () => {
|
||||
const client = createTestClient('test-client-1');
|
||||
const result = sseManager.addClient(client, validToken);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
expect(result?.authenticated).toBe(true);
|
||||
expect(TokenManager.validateToken).toHaveBeenCalledWith(validToken, testIp);
|
||||
});
|
||||
|
||||
it('should reject invalid tokens', () => {
|
||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({
|
||||
valid: false,
|
||||
error: 'Invalid token'
|
||||
});
|
||||
|
||||
const client = createTestClient('test-client-2');
|
||||
const result = sseManager.addClient(client, 'invalid_token');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(TokenManager.validateToken).toHaveBeenCalledWith('invalid_token', testIp);
|
||||
});
|
||||
|
||||
it('should enforce maximum client limit', () => {
|
||||
const sseManager = new SSEManager({ maxClients: 2 });
|
||||
|
||||
// Add maximum number of clients
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const client = createTestClient(`test-client-${i}`);
|
||||
const result = sseManager.addClient(client, validToken);
|
||||
expect(result).toBeTruthy();
|
||||
}
|
||||
|
||||
// Try to add one more client
|
||||
const extraClient = createTestClient('extra-client');
|
||||
const result = sseManager.addClient(extraClient, validToken);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Client Management', () => {
|
||||
it('should track client connections', () => {
|
||||
const client = createTestClient('test-client');
|
||||
sseManager.addClient(client, validToken);
|
||||
const stats = sseManager.getStatistics();
|
||||
|
||||
expect(stats.totalClients).toBe(1);
|
||||
expect(stats.authenticatedClients).toBe(1);
|
||||
expect(stats.clientStats).toHaveLength(1);
|
||||
expect(stats.clientStats[0].ip).toBe(testIp);
|
||||
});
|
||||
|
||||
it('should remove disconnected clients', () => {
|
||||
const client = createTestClient('test-client');
|
||||
sseManager.addClient(client, validToken);
|
||||
sseManager.removeClient(client.id);
|
||||
const stats = sseManager.getStatistics();
|
||||
|
||||
expect(stats.totalClients).toBe(0);
|
||||
});
|
||||
|
||||
it('should cleanup inactive clients', async () => {
|
||||
const sseManager = new SSEManager({
|
||||
maxClients: 10,
|
||||
pingInterval: 100,
|
||||
cleanupInterval: 200,
|
||||
maxConnectionAge: 300
|
||||
});
|
||||
|
||||
const client = createTestClient('test-client');
|
||||
client.connectedAt = new Date(Date.now() - 400); // Older than maxConnectionAge
|
||||
sseManager.addClient(client, validToken);
|
||||
|
||||
// Wait for cleanup interval
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
|
||||
const stats = sseManager.getStatistics();
|
||||
expect(stats.totalClients).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rate Limiting', () => {
|
||||
it('should enforce rate limits for message sending', () => {
|
||||
const client = createTestClient('test-client');
|
||||
const sseClient = sseManager.addClient(client, validToken);
|
||||
expect(sseClient).toBeTruthy();
|
||||
|
||||
// Send messages up to rate limit
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: i });
|
||||
}
|
||||
|
||||
// Next message should trigger rate limit
|
||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: 'overflow' });
|
||||
|
||||
const lastCall = client.send.mock.calls[client.send.mock.calls.length - 1][0];
|
||||
expect(JSON.parse(lastCall)).toMatchObject({
|
||||
type: 'error',
|
||||
error: 'rate_limit_exceeded'
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset rate limits after window expires', async () => {
|
||||
const client = createTestClient('test-client');
|
||||
const sseClient = sseManager.addClient(client, validToken);
|
||||
expect(sseClient).toBeTruthy();
|
||||
|
||||
// Send messages up to rate limit
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: i });
|
||||
}
|
||||
|
||||
// Wait for rate limit window to expire
|
||||
await new Promise(resolve => setTimeout(resolve, 1100));
|
||||
|
||||
// Should be able to send messages again
|
||||
sseManager['sendToClient'](sseClient!, { type: 'test', data: 'new message' });
|
||||
const lastCall = client.send.mock.calls[client.send.mock.calls.length - 1][0];
|
||||
expect(JSON.parse(lastCall)).toMatchObject({
|
||||
type: 'test',
|
||||
data: 'new message'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Broadcasting', () => {
|
||||
it('should only send events to authenticated clients', () => {
|
||||
const client1 = createTestClient('client1');
|
||||
const client2 = createTestClient('client2');
|
||||
|
||||
const sseClient1 = sseManager.addClient(client1, validToken);
|
||||
jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: false });
|
||||
const sseClient2 = sseManager.addClient(client2, 'invalid_token');
|
||||
|
||||
expect(sseClient1).toBeTruthy();
|
||||
expect(sseClient2).toBeNull();
|
||||
|
||||
sseManager.broadcastEvent({
|
||||
event_type: 'test_event',
|
||||
data: { test: true },
|
||||
origin: 'test',
|
||||
time_fired: new Date().toISOString(),
|
||||
context: { id: 'test' }
|
||||
});
|
||||
|
||||
expect(client1.send).toHaveBeenCalled();
|
||||
expect(client2.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect subscription filters', () => {
|
||||
const client = createTestClient('test-client');
|
||||
const sseClient = sseManager.addClient(client, validToken);
|
||||
expect(sseClient).toBeTruthy();
|
||||
|
||||
sseManager.subscribeToEvent(client.id, 'test_event');
|
||||
|
||||
// Send matching event
|
||||
sseManager.broadcastEvent({
|
||||
event_type: 'test_event',
|
||||
data: { test: true },
|
||||
origin: 'test',
|
||||
time_fired: new Date().toISOString(),
|
||||
context: { id: 'test' }
|
||||
});
|
||||
|
||||
// Send non-matching event
|
||||
sseManager.broadcastEvent({
|
||||
event_type: 'other_event',
|
||||
data: { test: true },
|
||||
origin: 'test',
|
||||
time_fired: new Date().toISOString(),
|
||||
context: { id: 'test' }
|
||||
});
|
||||
|
||||
expect(client.send).toHaveBeenCalledTimes(1);
|
||||
const sentMessage = JSON.parse(client.send.mock.calls[0][0]);
|
||||
expect(sentMessage.type).toBe('test_event');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user