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