From 585b8d1f915dbf18aa74c926948892bf923d3a7f Mon Sep 17 00:00:00 2001 From: jango-blockchained Date: Thu, 30 Jan 2025 09:27:22 +0100 Subject: [PATCH] Enhance security middleware and package dependencies - Updated security headers configuration with stricter settings - Modified rate limiting and helmet middleware setup - Added TypeScript type definitions for Express, rate-limit, and Helmet - Refined referrer policy and HSTS configuration - Improved security middleware chain for better protection --- __tests__/context/index.test.ts | 272 ++++++++++++ __tests__/performance/index.test.ts | 189 +++++++++ __tests__/security/index.test.ts | 212 ++++++++++ __tests__/websocket/client.test.ts | 1 + coverage/lcov-report/index.html | 85 +++- coverage/lcov.info | 622 ++++++++++++++++++++++------ package.json | 6 +- src/security/index.ts | 40 +- 8 files changed, 1277 insertions(+), 150 deletions(-) create mode 100644 __tests__/context/index.test.ts create mode 100644 __tests__/performance/index.test.ts create mode 100644 __tests__/security/index.test.ts create mode 100644 __tests__/websocket/client.test.ts diff --git a/__tests__/context/index.test.ts b/__tests__/context/index.test.ts new file mode 100644 index 0000000..c8fcd90 --- /dev/null +++ b/__tests__/context/index.test.ts @@ -0,0 +1,272 @@ +import { jest } from '@jest/globals'; +import { ContextManager, ResourceType, RelationType, ResourceState, ResourceRelationship } from '../../src/context/index.js'; + +describe('Context Manager', () => { + let contextManager: ContextManager; + + beforeEach(() => { + contextManager = new ContextManager(); + }); + + describe('Resource Management', () => { + const testResource: ResourceState = { + id: 'test1', + type: ResourceType.DEVICE, + state: 'on', + attributes: { name: 'Test Device' }, + lastUpdated: Date.now() + }; + + it('should add resources', () => { + const addHandler = jest.fn(); + contextManager.on('resource_added', addHandler); + + contextManager.addResource(testResource); + + const resource = contextManager.getResource('test1'); + expect(resource).toEqual(testResource); + expect(addHandler).toHaveBeenCalledWith(testResource); + }); + + it('should update resources', () => { + const updateHandler = jest.fn(); + contextManager.on('resource_updated', updateHandler); + + contextManager.addResource(testResource); + contextManager.updateResource('test1', { + state: 'off', + attributes: { ...testResource.attributes, brightness: 50 } + }); + + const resource = contextManager.getResource('test1'); + expect(resource?.state).toBe('off'); + expect(resource?.attributes.brightness).toBe(50); + expect(updateHandler).toHaveBeenCalled(); + }); + + it('should remove resources', () => { + const removeHandler = jest.fn(); + contextManager.on('resource_removed', removeHandler); + + contextManager.addResource(testResource); + contextManager.removeResource('test1'); + + expect(contextManager.getResource('test1')).toBeUndefined(); + expect(removeHandler).toHaveBeenCalledWith(testResource); + }); + + it('should get resources by type', () => { + const resources = [ + testResource, + { + id: 'test2', + type: ResourceType.DEVICE, + state: 'off', + attributes: {}, + lastUpdated: Date.now() + }, + { + id: 'area1', + type: ResourceType.AREA, + state: 'active', + attributes: {}, + lastUpdated: Date.now() + } + ]; + + resources.forEach(r => contextManager.addResource(r)); + + const devices = contextManager.getResourcesByType(ResourceType.DEVICE); + expect(devices).toHaveLength(2); + expect(devices.every((d: ResourceState) => d.type === ResourceType.DEVICE)).toBe(true); + }); + }); + + describe('Relationship Management', () => { + const testRelationship: ResourceRelationship = { + sourceId: 'device1', + targetId: 'area1', + type: RelationType.CONTAINS + }; + + beforeEach(() => { + // Add test resources + contextManager.addResource({ + id: 'device1', + type: ResourceType.DEVICE, + state: 'on', + attributes: {}, + lastUpdated: Date.now() + }); + contextManager.addResource({ + id: 'area1', + type: ResourceType.AREA, + state: 'active', + attributes: {}, + lastUpdated: Date.now() + }); + }); + + it('should add relationships', () => { + const addHandler = jest.fn(); + contextManager.on('relationship_added', addHandler); + + contextManager.addRelationship(testRelationship); + + const related = contextManager.getRelatedResources('device1'); + expect(related).toHaveLength(2); + expect(related.some(r => r.id === 'area1')).toBe(true); + expect(addHandler).toHaveBeenCalledWith(testRelationship); + }); + + it('should remove relationships', () => { + const removeHandler = jest.fn(); + contextManager.on('relationship_removed', removeHandler); + + contextManager.addRelationship(testRelationship); + contextManager.removeRelationship( + 'device1', + 'area1', + RelationType.CONTAINS + ); + + const related = contextManager.getRelatedResources('device1'); + expect(related).toHaveLength(0); + expect(removeHandler).toHaveBeenCalled(); + }); + + it('should get related resources with depth', () => { + // Create a chain: device1 -> area1 -> area2 + contextManager.addResource({ + id: 'area2', + type: ResourceType.AREA, + state: 'active', + attributes: {}, + lastUpdated: Date.now() + }); + + contextManager.addRelationship({ + sourceId: 'device1', + targetId: 'area1', + type: RelationType.CONTAINS + }); + contextManager.addRelationship({ + sourceId: 'area1', + targetId: 'area2', + type: RelationType.CONTAINS + }); + + const relatedDepth1 = contextManager.getRelatedResources('device1', undefined, 1); + expect(relatedDepth1).toHaveLength(3); + + const relatedDepth2 = contextManager.getRelatedResources('device1', undefined, 2); + expect(relatedDepth2).toHaveLength(3); + }); + }); + + describe('Resource Analysis', () => { + beforeEach(() => { + // Setup test resources and relationships + const resources = [ + { + id: 'device1', + type: ResourceType.DEVICE, + state: 'on', + attributes: {}, + lastUpdated: Date.now() + }, + { + id: 'automation1', + type: ResourceType.AUTOMATION, + state: 'on', + attributes: {}, + lastUpdated: Date.now() + }, + { + id: 'group1', + type: ResourceType.GROUP, + state: 'on', + attributes: {}, + lastUpdated: Date.now() + } + ]; + + resources.forEach(r => contextManager.addResource(r)); + + const relationships = [ + { + sourceId: 'automation1', + targetId: 'device1', + type: RelationType.CONTROLS + }, + { + sourceId: 'device1', + targetId: 'group1', + type: RelationType.DEPENDS_ON + }, + { + sourceId: 'group1', + targetId: 'device1', + type: RelationType.GROUPS + } + ]; + + relationships.forEach(r => contextManager.addRelationship(r)); + }); + + it('should analyze resource usage', () => { + const analysis = contextManager.analyzeResourceUsage('device1'); + + expect(analysis.dependencies).toHaveLength(1); + expect(analysis.dependencies[0]).toBe('group1'); + expect(analysis.dependents).toHaveLength(0); + expect(analysis.groups).toHaveLength(1); + expect(analysis.usage.controlCount).toBe(0); + expect(analysis.usage.triggerCount).toBe(0); + expect(analysis.usage.groupCount).toBe(1); + }); + }); + + describe('Event Subscriptions', () => { + it('should handle resource subscriptions', () => { + const callback = jest.fn(); + const unsubscribe = contextManager.subscribeToResource('test1', callback); + + contextManager.addResource({ + id: 'test1', + type: ResourceType.DEVICE, + state: 'on', + attributes: {}, + lastUpdated: Date.now() + }); + + contextManager.updateResource('test1', { state: 'off' }); + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + contextManager.updateResource('test1', { state: 'on' }); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should handle type subscriptions', () => { + const callback = jest.fn(); + const unsubscribe = contextManager.subscribeToType(ResourceType.DEVICE, callback); + + const resource = { + id: 'test1', + type: ResourceType.DEVICE, + state: 'on', + attributes: {}, + lastUpdated: Date.now() + }; + + contextManager.addResource(resource); + contextManager.updateResource('test1', { state: 'off' }); + expect(callback).toHaveBeenCalled(); + + unsubscribe(); + contextManager.updateResource('test1', { state: 'on' }); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/performance/index.test.ts b/__tests__/performance/index.test.ts new file mode 100644 index 0000000..4ba40d0 --- /dev/null +++ b/__tests__/performance/index.test.ts @@ -0,0 +1,189 @@ +import { PerformanceMonitor, PerformanceOptimizer, Metric } from '../../src/performance/index.js'; +import type { MemoryUsage } from 'node:process'; + +describe('Performance Module', () => { + describe('PerformanceMonitor', () => { + let monitor: PerformanceMonitor; + + beforeEach(() => { + monitor = new PerformanceMonitor({ + responseTime: 500, + memoryUsage: 1024 * 1024 * 512, // 512MB + cpuUsage: 70 + }); + }); + + afterEach(() => { + monitor.stop(); + }); + + it('should collect metrics', () => { + const metricHandler = jest.fn(); + monitor.on('metric', metricHandler); + + monitor.start(); + + // Wait for first collection + return new Promise(resolve => setTimeout(() => { + expect(metricHandler).toHaveBeenCalled(); + const calls = metricHandler.mock.calls; + + // Verify memory metrics + expect(calls.some(([metric]: [Metric]) => + metric.name === 'memory.heapUsed' + )).toBe(true); + expect(calls.some(([metric]: [Metric]) => + metric.name === 'memory.heapTotal' + )).toBe(true); + expect(calls.some(([metric]: [Metric]) => + metric.name === 'memory.rss' + )).toBe(true); + + // Verify CPU metrics + expect(calls.some(([metric]: [Metric]) => + metric.name === 'cpu.user' + )).toBe(true); + expect(calls.some(([metric]: [Metric]) => + metric.name === 'cpu.system' + )).toBe(true); + + resolve(true); + }, 100)); + }); + + it('should emit threshold exceeded events', () => { + const thresholdHandler = jest.fn(); + monitor = new PerformanceMonitor({ + memoryUsage: 1, // Ensure threshold is exceeded + cpuUsage: 1 + }); + monitor.on('threshold_exceeded', thresholdHandler); + + monitor.start(); + + return new Promise(resolve => setTimeout(() => { + expect(thresholdHandler).toHaveBeenCalled(); + resolve(true); + }, 100)); + }); + + it('should clean old metrics', () => { + const now = Date.now(); + const oldMetric: Metric = { + name: 'test', + value: 1, + timestamp: now - 25 * 60 * 60 * 1000 // 25 hours old + }; + const newMetric: Metric = { + name: 'test', + value: 2, + timestamp: now - 1000 // 1 second old + }; + + monitor.addMetric(oldMetric); + monitor.addMetric(newMetric); + + const metrics = monitor.getMetrics(now - 24 * 60 * 60 * 1000); + expect(metrics).toHaveLength(1); + expect(metrics[0]).toEqual(newMetric); + }); + + it('should calculate metric averages', () => { + const now = Date.now(); + const metrics: Metric[] = [ + { name: 'test', value: 1, timestamp: now - 3000 }, + { name: 'test', value: 2, timestamp: now - 2000 }, + { name: 'test', value: 3, timestamp: now - 1000 } + ]; + + metrics.forEach(metric => monitor.addMetric(metric)); + + const average = monitor.calculateAverage( + 'test', + now - 5000, + now + ); + expect(average).toBe(2); + }); + }); + + describe('PerformanceOptimizer', () => { + it('should process batches correctly', async () => { + const items = [1, 2, 3, 4, 5]; + const batchSize = 2; + const processor = jest.fn(async (batch: number[]) => + batch.map(n => n * 2) + ); + + const results = await PerformanceOptimizer.processBatch( + items, + batchSize, + processor + ); + + expect(results).toEqual([2, 4, 6, 8, 10]); + expect(processor).toHaveBeenCalledTimes(3); // 2 + 2 + 1 items + }); + + it('should debounce function calls', (done) => { + const fn = jest.fn(); + const debounced = PerformanceOptimizer.debounce(fn, 100); + + debounced(); + debounced(); + debounced(); + + setTimeout(() => { + expect(fn).not.toHaveBeenCalled(); + }, 50); + + setTimeout(() => { + expect(fn).toHaveBeenCalledTimes(1); + done(); + }, 150); + }); + + it('should throttle function calls', (done) => { + const fn = jest.fn(); + const throttled = PerformanceOptimizer.throttle(fn, 100); + + throttled(); + throttled(); + throttled(); + + expect(fn).toHaveBeenCalledTimes(1); + + setTimeout(() => { + throttled(); + expect(fn).toHaveBeenCalledTimes(2); + done(); + }, 150); + }); + + it('should optimize memory when threshold is exceeded', async () => { + const originalGc = global.gc; + global.gc = jest.fn(); + + const memoryUsage = process.memoryUsage; + process.memoryUsage = jest.fn().mockImplementation((): MemoryUsage => ({ + heapUsed: 900, + heapTotal: 1000, + rss: 2000, + external: 0, + arrayBuffers: 0 + })); + + await PerformanceOptimizer.optimizeMemory(); + + expect(global.gc).toHaveBeenCalled(); + + // Cleanup + process.memoryUsage = memoryUsage; + if (originalGc) { + global.gc = originalGc; + } else { + delete global.gc; + } + }); + }); +}); \ No newline at end of file diff --git a/__tests__/security/index.test.ts b/__tests__/security/index.test.ts new file mode 100644 index 0000000..52a21e4 --- /dev/null +++ b/__tests__/security/index.test.ts @@ -0,0 +1,212 @@ +import { TokenManager, validateRequest, sanitizeInput, errorHandler } from '../../src/security'; +import { Request, Response } from 'express'; + +describe('Security Module', () => { + describe('TokenManager', () => { + const testToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + const encryptionKey = 'test_encryption_key'; + + it('should encrypt and decrypt tokens', () => { + const encrypted = TokenManager.encryptToken(testToken, encryptionKey); + const decrypted = TokenManager.decryptToken(encrypted, encryptionKey); + + expect(decrypted).toBe(testToken); + }); + + it('should validate tokens correctly', () => { + expect(TokenManager.validateToken(testToken)).toBe(true); + expect(TokenManager.validateToken('invalid_token')).toBe(false); + expect(TokenManager.validateToken('')).toBe(false); + }); + + it('should handle expired tokens', () => { + const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + expect(TokenManager.validateToken(expiredToken)).toBe(false); + }); + }); + + describe('Request Validation', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + mockRequest = { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer validToken' + }, + is: jest.fn().mockReturnValue(true), + body: { test: 'data' } + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + mockNext = jest.fn(); + }); + + it('should pass valid requests', () => { + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalled(); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + it('should reject invalid content type', () => { + mockRequest.is = jest.fn().mockReturnValue(false); + + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(415); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Unsupported Media Type - Content-Type must be application/json' + }); + }); + + it('should reject missing token', () => { + mockRequest.headers = {}; + + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid or expired token' + }); + }); + + it('should reject invalid request body', () => { + mockRequest.body = null; + + validateRequest( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid request body' + }); + }); + }); + + describe('Input Sanitization', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: jest.Mock; + + beforeEach(() => { + mockRequest = { + body: {} + }; + mockResponse = {}; + mockNext = jest.fn(); + }); + + it('should sanitize HTML tags from request body', () => { + mockRequest.body = { + text: 'Test ', + nested: { + html: '' + } + }; + + sanitizeInput( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockRequest.body).toEqual({ + text: 'Test alert("xss")', + nested: { + html: 'img src="x" onerror="alert(1)"' + } + }); + expect(mockNext).toHaveBeenCalled(); + }); + + it('should handle non-object body', () => { + mockRequest.body = 'string body'; + + sanitizeInput( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockRequest.body).toBe('string body'); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('Error Handler', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: jest.Mock; + const originalEnv = process.env.NODE_ENV; + + beforeEach(() => { + mockRequest = {}; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + mockNext = jest.fn(); + }); + + afterAll(() => { + process.env.NODE_ENV = originalEnv; + }); + + it('should handle errors in production mode', () => { + process.env.NODE_ENV = 'production'; + const error = new Error('Test error'); + + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Internal Server Error', + message: undefined + }); + }); + + it('should include error message in development mode', () => { + process.env.NODE_ENV = 'development'; + const error = new Error('Test error'); + + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockResponse.status).toHaveBeenCalledWith(500); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Internal Server Error', + message: 'Test error' + }); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/websocket/client.test.ts b/__tests__/websocket/client.test.ts new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/__tests__/websocket/client.test.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/coverage/lcov-report/index.html b/coverage/lcov-report/index.html index c94782f..a0474fd 100644 --- a/coverage/lcov-report/index.html +++ b/coverage/lcov-report/index.html @@ -23,30 +23,30 @@
- 38.18% + 45.71% Statements - 42/110 + 128/280
- 12.96% + 40.19% Branches - 7/54 + 41/102
- 28.57% + 40.74% Functions - 2/7 + 33/81
- 38.18% + 46.29% Lines - 42/110 + 125/270
@@ -79,18 +79,18 @@ - src - -
+ src + +
- 33% - 33/100 - 2.43% - 1/41 - 20% - 1/5 - 33% - 33/100 + 100% + 33/33 + 100% + 1/1 + 100% + 1/1 + 100% + 33/33 @@ -108,6 +108,21 @@ 2/2 + + src/context + +
+ + 95.55% + 86/90 + 85% + 34/40 + 91.17% + 31/34 + 95.4% + 83/87 + + src/hass @@ -123,6 +138,36 @@ 7/8 + + src/performance + +
+ + 0% + 0/67 + 0% + 0/22 + 0% + 0/21 + 0% + 0/60 + + + + src/websocket + +
+ + 0% + 0/80 + 0% + 0/26 + 0% + 0/23 + 0% + 0/80 + +
@@ -131,7 +176,7 @@