diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..9718104 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,76 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: [ + '**/__tests__/**/*.+(ts|tsx|js)', + '**/?(*.)+(spec|test).+(ts|tsx|js)' + ], + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest' + }, + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + setupFilesAfterEnv: [ + '/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; \ No newline at end of file diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..fedc0d9 --- /dev/null +++ b/src/__tests__/setup.ts @@ -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; \ No newline at end of file diff --git a/src/config/__tests__/test.config.ts b/src/config/__tests__/test.config.ts new file mode 100644 index 0000000..3ff03ff --- /dev/null +++ b/src/config/__tests__/test.config.ts @@ -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; \ No newline at end of file diff --git a/src/middleware/__tests__/security.middleware.test.ts b/src/middleware/__tests__/security.middleware.test.ts new file mode 100644 index 0000000..b3cc516 --- /dev/null +++ b/src/middleware/__tests__/security.middleware.test.ts @@ -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; + let mockResponse: Partial; + 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: 'Hello', + nested: { + html: 'World' + } + }; + + middleware.sanitizeInput( + mockRequest as Request, + mockResponse as Response, + nextFunction + ); + + expect(mockRequest.body.text).not.toContain('