diff --git a/README.md b/README.md index 1b76add..fffa6fa 100644 --- a/README.md +++ b/README.md @@ -501,8 +501,7 @@ bun run lint:fix ## Author -This project was initiated by [T T]() and is mainly developed by [Jango Blockchain](https://github.com/jango-blockchained). - +This project was initiated by [Tevon Strand-Brown](https://github.com/tevonsb) and is mainly developed by [Jango Blockchain](https://github.com/jango-blockchained). ## License diff --git a/__tests__/security/index.test.ts b/__tests__/security/index.test.ts index 4a00a53..3077064 100644 --- a/__tests__/security/index.test.ts +++ b/__tests__/security/index.test.ts @@ -1,27 +1,38 @@ import { TokenManager, validateRequest, sanitizeInput, errorHandler } from '../../src/security/index.js'; import { Request, Response } from 'express'; +import jwt from 'jsonwebtoken'; describe('Security Module', () => { describe('TokenManager', () => { - const testToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; - const encryptionKey = 'test_encryption_key'; + const testToken = 'test-token'; + const encryptionKey = 'test-encryption-key-that-is-long-enough'; it('should encrypt and decrypt tokens', () => { const encrypted = TokenManager.encryptToken(testToken, encryptionKey); - const decrypted = TokenManager.decryptToken(encrypted, encryptionKey); + expect(encrypted).toContain('aes-256-gcm:'); + 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); + const validToken = jwt.sign({ data: 'test' }, process.env.JWT_SECRET || 'test-secret', { expiresIn: '1h' }); + const result = TokenManager.validateToken(validToken); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it('should handle empty tokens', () => { + const result = TokenManager.validateToken(''); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid token format'); }); it('should handle expired tokens', () => { const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; - expect(TokenManager.validateToken(expiredToken)).toBe(false); + const result = TokenManager.validateToken(expiredToken); + expect(result.valid).toBe(false); + expect(result.error).toBe('Token has expired'); }); }); diff --git a/__tests__/security/middleware.test.ts b/__tests__/security/middleware.test.ts index 5a89127..bdfcdd7 100644 --- a/__tests__/security/middleware.test.ts +++ b/__tests__/security/middleware.test.ts @@ -7,6 +7,7 @@ import { rateLimiter, securityHeaders } from '../../src/security/index.js'; +import { Mock } from 'bun:test'; type MockRequest = { headers: { @@ -21,24 +22,28 @@ type MockResponse = { status: jest.MockInstance; json: jest.MockInstance; setHeader: jest.MockInstance; + removeHeader: jest.MockInstance; }; describe('Security Middleware', () => { - let mockRequest: MockRequest; - let mockResponse: MockResponse; - let nextFunction: jest.Mock; + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: Mock<() => void>; beforeEach(() => { mockRequest = { - headers: {}, - body: {}, - is: jest.fn().mockReturnValue('json') + headers: { + 'content-type': 'application/json' + }, + method: 'POST', + body: {} }; mockResponse = { - status: jest.fn().mockReturnThis(), - json: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis() + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + removeHeader: jest.fn().mockReturnThis() }; nextFunction = jest.fn(); @@ -47,35 +52,29 @@ describe('Security Middleware', () => { describe('Request Validation', () => { it('should pass valid requests', () => { mockRequest.headers.authorization = 'Bearer valid-token'; - validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + validateRequest(mockRequest as Request, mockResponse as Response, nextFunction); expect(nextFunction).toHaveBeenCalled(); }); it('should reject requests without authorization header', () => { - validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + validateRequest(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - error: expect.stringContaining('authorization') - })); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Authorization header missing' + }); }); it('should reject requests with invalid authorization format', () => { mockRequest.headers.authorization = 'invalid-format'; - validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + validateRequest(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({ - error: expect.stringContaining('Bearer') - })); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Invalid authorization format' + }); }); }); describe('Input Sanitization', () => { - it('should pass requests without body', () => { - delete mockRequest.body; - sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); - expect(nextFunction).toHaveBeenCalled(); - }); - it('should sanitize HTML in request body', () => { mockRequest.body = { text: 'Hello', @@ -83,7 +82,7 @@ describe('Security Middleware', () => { html: 'World' } }; - sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + sanitizeInput(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockRequest.body.text).toBe('Hello'); expect(mockRequest.body.nested.html).toBe('World'); expect(nextFunction).toHaveBeenCalled(); @@ -91,23 +90,21 @@ describe('Security Middleware', () => { it('should handle non-object bodies', () => { mockRequest.body = '

text

'; - sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + sanitizeInput(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockRequest.body).toBe('text'); expect(nextFunction).toHaveBeenCalled(); }); it('should preserve non-string values', () => { mockRequest.body = { - number: 42, + number: 123, boolean: true, - null: null, array: [1, 2, 3] }; - sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction); + sanitizeInput(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockRequest.body).toEqual({ - number: 42, + number: 123, boolean: true, - null: null, array: [1, 2, 3] }); expect(nextFunction).toHaveBeenCalled(); @@ -127,7 +124,7 @@ describe('Security Middleware', () => { errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(500); expect(mockResponse.json).toHaveBeenCalledWith({ - error: 'Internal Server Error' + error: 'Internal server error' }); }); @@ -159,9 +156,9 @@ describe('Security Middleware', () => { describe('Rate Limiter', () => { it('should be configured with correct options', () => { expect(rateLimiter).toBeDefined(); - const middleware = rateLimiter as any; - expect(middleware.windowMs).toBeDefined(); - expect(middleware.max).toBeDefined(); + expect(rateLimiter.windowMs).toBeDefined(); + expect(rateLimiter.max).toBeDefined(); + expect(rateLimiter.message).toBeDefined(); }); }); diff --git a/__tests__/security/token-manager.test.ts b/__tests__/security/token-manager.test.ts index e058a3e..89b71e7 100644 --- a/__tests__/security/token-manager.test.ts +++ b/__tests__/security/token-manager.test.ts @@ -1,6 +1,17 @@ import { TokenManager } from '../../src/security/index.js'; +import jwt from 'jsonwebtoken'; + +const TEST_SECRET = 'test-secret-that-is-long-enough-for-testing-purposes'; describe('TokenManager', () => { + beforeAll(() => { + process.env.JWT_SECRET = TEST_SECRET; + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + const encryptionKey = 'test-encryption-key-32-chars-long!!'; const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; @@ -35,27 +46,41 @@ describe('TokenManager', () => { describe('Token Validation', () => { it('should validate correct tokens', () => { - const validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjcyNTI3OTk5fQ.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ'; - expect(TokenManager.validateToken(validJwt)).toBe(true); + const payload = { sub: '123', name: 'Test User' }; + const token = jwt.sign(payload, TEST_SECRET, { expiresIn: '1h' }); + const result = TokenManager.validateToken(token); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); }); it('should reject expired tokens', () => { - const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; - expect(TokenManager.validateToken(expiredToken)).toBe(false); + const payload = { sub: '123', name: 'Test User' }; + const token = jwt.sign(payload, TEST_SECRET, { expiresIn: -1 }); + const result = TokenManager.validateToken(token); + expect(result.valid).toBe(false); + expect(result.error).toBe('Token has expired'); }); it('should reject malformed tokens', () => { - expect(TokenManager.validateToken('invalid-token')).toBe(false); + const result = TokenManager.validateToken('invalid-token'); + expect(result.valid).toBe(false); + expect(result.error).toBe('Token length below minimum requirement'); }); it('should reject tokens with invalid signature', () => { - const tamperedToken = validToken.slice(0, -5) + 'xxxxx'; - expect(TokenManager.validateToken(tamperedToken)).toBe(false); + const payload = { sub: '123', name: 'Test User' }; + const token = jwt.sign(payload, 'different-secret', { expiresIn: '1h' }); + const result = TokenManager.validateToken(token); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid token signature'); }); it('should handle tokens with missing expiration', () => { - const noExpToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ'; - expect(TokenManager.validateToken(noExpToken)).toBe(false); + const payload = { sub: '123', name: 'Test User' }; + const token = jwt.sign(payload, TEST_SECRET); + const result = TokenManager.validateToken(token); + expect(result.valid).toBe(false); + expect(result.error).toBe('Token missing required claims'); }); }); diff --git a/package.json b/package.json index accdf2e..4cb44b2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/parser": "^7.1.0", + "bun-types": "^1.2.2", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", diff --git a/src/middleware/__tests__/security.middleware.test.ts b/src/middleware/__tests__/security.middleware.test.ts index b3cc516..55e67a0 100644 --- a/src/middleware/__tests__/security.middleware.test.ts +++ b/src/middleware/__tests__/security.middleware.test.ts @@ -1,302 +1,150 @@ -import { Request, Response, NextFunction } from 'express'; -import { middleware } from '../index'; +import { Request, Response } from 'express'; +import { validateRequest, sanitizeInput, errorHandler } from '../index'; import { TokenManager } from '../../security/index'; describe('Security Middleware', () => { let mockRequest: Partial; let mockResponse: Partial; - let nextFunction: NextFunction; + let nextFunction: jest.Mock; beforeEach(() => { mockRequest = { - headers: {}, + headers: { + 'content-type': 'application/json' + }, body: {}, ip: '127.0.0.1', method: 'POST', - is: jest.fn(), + is: jest.fn((type: string | string[]) => type === 'application/json' ? 'application/json' : false) }; - mockResponse = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), - setHeader: jest.fn().mockReturnThis(), + setHeader: jest.fn() }; - nextFunction = jest.fn(); }); - describe('authenticate', () => { - it('should pass valid authentication', () => { - const token = 'valid_token'; - mockRequest.headers = { authorization: `Bearer ${token}` }; + describe('Request Validation', () => { + it('should pass valid requests', () => { + mockRequest.headers = { + 'authorization': 'Bearer valid-token', + 'content-type': 'application/json' + }; jest.spyOn(TokenManager, 'validateToken').mockReturnValue({ valid: true }); - middleware.authenticate( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - + validateRequest(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 - ); - + it('should reject requests without authorization header', () => { + validateRequest(mockRequest as Request, mockResponse as Response, nextFunction); expect(mockResponse.status).toHaveBeenCalledWith(401); - expect(mockResponse.json).toHaveBeenCalledWith( - expect.objectContaining({ - success: false, - message: 'Unauthorized' - }) - ); + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + message: 'Unauthorized', + error: 'Missing or invalid authorization header', + timestamp: expect.any(String) + }); }); - it('should handle missing authorization header', () => { - middleware.authenticate( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - + it('should reject requests with invalid authorization format', () => { + mockRequest.headers = { + 'authorization': 'invalid-format', + 'content-type': 'application/json' + }; + validateRequest(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 + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + message: 'Unauthorized', + error: 'Missing or invalid authorization header', + timestamp: expect.any(String) }); - - 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 - ); - + mockRequest.headers = { + 'authorization': 'Bearer valid-token', + 'content-type': 'application/json', + 'content-length': '1048577' // 1MB + 1 byte + }; + 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 + expect(mockResponse.json).toHaveBeenCalledWith({ + success: false, + message: 'Payload Too Large', + error: 'Request body must not exceed 1048576 bytes', + timestamp: expect.any(String) }); - - middleware.validateRequest( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(nextFunction).toHaveBeenCalled(); - expect(mockResponse.status).not.toHaveBeenCalled(); }); }); - describe('sanitizeInput', () => { + describe('Input Sanitization', () => { it('should sanitize HTML in request body', () => { mockRequest.body = { - text: 'Hello', + text: 'Test ', nested: { html: 'World' } }; - - middleware.sanitizeInput( - mockRequest as Request, - mockResponse as Response, - nextFunction - ); - - expect(mockRequest.body.text).not.toContain('