diff --git a/services/auth/__tests__/auth.middleware.test.ts b/services/auth/__tests__/auth.middleware.test.ts new file mode 100644 index 0000000..76e5ad1 --- /dev/null +++ b/services/auth/__tests__/auth.middleware.test.ts @@ -0,0 +1,442 @@ +import { Request, Response, NextFunction } from 'express'; +import * as jwt from 'jsonwebtoken'; +import { authenticate, authorize } from '../auth.middleware'; +import { JWT_SECRET } from '../auth.constants'; +import { AuthError } from '../auth.error'; + +// Mock dependencies +jest.mock('jsonwebtoken'); +jest.mock('../auth.constants', () => ({ + JWT_SECRET: 'test-secret-key', +})); +jest.mock('../auth.error'); + +const mockJwt = jwt as jest.Mocked; +const mockAuthError = AuthError as jest.MockedClass; +const mockHandleAuthError = require('../auth.error') + .handleAuthError as jest.Mock; + +describe('Auth Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: NextFunction; + + beforeEach(() => { + jest.clearAllMocks(); + + mockRequest = { + headers: {}, + user: undefined, + }; + + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + mockNext = jest.fn(); + + // Reset mocks + mockJwt.verify.mockReset(); + mockAuthError.mockReset(); + mockHandleAuthError.mockReset(); + }); + + describe('authenticate middleware', () => { + test('should authenticate valid Bearer token', () => { + const mockDecoded = { userId: '123', email: 'test@example.com' }; + mockRequest.headers = { + authorization: 'Bearer valid-token', + }; + mockJwt.verify.mockReturnValue(mockDecoded); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockJwt.verify).toHaveBeenCalledWith('valid-token', JWT_SECRET); + expect(mockRequest.user).toBe(mockDecoded); + expect(mockNext).toHaveBeenCalledWith(); + expect(mockHandleAuthError).not.toHaveBeenCalled(); + }); + + test('should reject request without authorization header', () => { + mockRequest.headers = {}; + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'No authentication token provided', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should reject request with empty authorization header', () => { + mockRequest.headers = { + authorization: '', + }; + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'No authentication token provided', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + }); + + test('should reject request without Bearer prefix', () => { + mockRequest.headers = { + authorization: 'Basic token123', + }; + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'No authentication token provided', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + }); + + test('should reject request with malformed Bearer token', () => { + mockRequest.headers = { + authorization: 'Bearer', + }; + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'No authentication token provided', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + }); + + test('should handle JWT verification errors', () => { + const jwtError = new Error('Invalid token'); + mockRequest.headers = { + authorization: 'Bearer invalid-token', + }; + mockJwt.verify.mockImplementation(() => { + throw jwtError; + }); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockJwt.verify).toHaveBeenCalledWith('invalid-token', JWT_SECRET); + expect(mockHandleAuthError).toHaveBeenCalledWith( + jwtError, + mockRequest, + mockResponse, + mockNext + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should handle expired JWT tokens', () => { + const expiredError = new jwt.TokenExpiredError( + 'Token expired', + new Date() + ); + mockRequest.headers = { + authorization: 'Bearer expired-token', + }; + mockJwt.verify.mockImplementation(() => { + throw expiredError; + }); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockHandleAuthError).toHaveBeenCalledWith( + expiredError, + mockRequest, + mockResponse, + mockNext + ); + }); + + test('should handle malformed JWT tokens', () => { + const malformedError = new jwt.JsonWebTokenError('Malformed token'); + mockRequest.headers = { + authorization: 'Bearer malformed-token', + }; + mockJwt.verify.mockImplementation(() => { + throw malformedError; + }); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockHandleAuthError).toHaveBeenCalledWith( + malformedError, + mockRequest, + mockResponse, + mockNext + ); + }); + + test('should extract token correctly from Bearer header with extra spaces', () => { + const mockDecoded = { userId: '123' }; + mockRequest.headers = { + authorization: ' Bearer token-with-spaces ', + }; + mockJwt.verify.mockReturnValue(mockDecoded); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'No authentication token provided', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + }); + + test('should handle case-sensitive Bearer prefix', () => { + mockRequest.headers = { + authorization: 'bearer lowercase-token', + }; + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'No authentication token provided', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + }); + + test('should work with different JWT payloads', () => { + const complexPayload = { + userId: '123', + email: 'test@example.com', + roles: ['user', 'admin'], + permissions: ['read', 'write'], + iat: 1234567890, + exp: 1234567900, + }; + mockRequest.headers = { + authorization: 'Bearer complex-token', + }; + mockJwt.verify.mockReturnValue(complexPayload); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockRequest.user).toBe(complexPayload); + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe('authorize middleware', () => { + test('should authorize request with authenticated user', () => { + mockRequest.user = { userId: '123', email: 'test@example.com' }; + + const middleware = authorize('admin', 'user'); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + expect(mockHandleAuthError).not.toHaveBeenCalled(); + }); + + test('should reject request without authenticated user', () => { + mockRequest.user = undefined; + + const middleware = authorize('admin'); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'Authentication required', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should handle null user', () => { + mockRequest.user = null; + + const middleware = authorize('user'); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockAuthError).toHaveBeenCalledWith( + 'Authentication required', + 401 + ); + expect(mockHandleAuthError).toHaveBeenCalled(); + }); + + test('should work with multiple allowed roles', () => { + mockRequest.user = { userId: '123', role: 'admin' }; + + const middleware = authorize('admin', 'moderator', 'user'); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should work with no roles specified', () => { + mockRequest.user = { userId: '123' }; + + const middleware = authorize(); + middleware(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should handle errors thrown during authorization', () => { + mockRequest.user = { userId: '123' }; + + // Mock next to throw an error + const errorMiddleware = authorize('admin'); + const throwingNext = jest.fn(() => { + throw new Error('Unexpected error'); + }); + + // This should be caught by the try-catch in authorize + errorMiddleware( + mockRequest as Request, + mockResponse as Response, + throwingNext + ); + + expect(throwingNext).toHaveBeenCalled(); + }); + + test('should return a function when called', () => { + const middleware = authorize('admin'); + expect(typeof middleware).toBe('function'); + expect(middleware.length).toBe(3); // Should accept req, res, next + }); + + test('should create different middleware instances for different role sets', () => { + const adminMiddleware = authorize('admin'); + const userMiddleware = authorize('user'); + + expect(adminMiddleware).not.toBe(userMiddleware); + }); + }); + + describe('middleware integration', () => { + test('should work together in a middleware chain', () => { + // First authenticate + const mockDecoded = { userId: '123', role: 'admin' }; + mockRequest.headers = { + authorization: 'Bearer valid-token', + }; + mockJwt.verify.mockReturnValue(mockDecoded); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockRequest.user).toBe(mockDecoded); + expect(mockNext).toHaveBeenCalledWith(); + + // Reset next mock + mockNext.mockClear(); + + // Then authorize + const authorizationMiddleware = authorize('admin'); + authorizationMiddleware( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + test('should fail authorization if authentication failed', () => { + // Authentication fails + mockRequest.headers = {}; + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockRequest.user).toBeUndefined(); + expect(mockHandleAuthError).toHaveBeenCalled(); + + // Authorization should fail + const authorizationMiddleware = authorize('admin'); + authorizationMiddleware( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + expect(mockAuthError).toHaveBeenCalledWith( + 'Authentication required', + 401 + ); + }); + }); + + describe('error handling', () => { + test('should handle unexpected errors in authenticate', () => { + const unexpectedError = new Error('Unexpected error'); + mockRequest.headers = { + authorization: 'Bearer token', + }; + + // Mock jwt.verify to throw unexpected error + mockJwt.verify.mockImplementation(() => { + throw unexpectedError; + }); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockHandleAuthError).toHaveBeenCalledWith( + unexpectedError, + mockRequest, + mockResponse, + mockNext + ); + }); + + test('should handle unexpected errors in authorize', () => { + // Setup a scenario where an error might occur + mockRequest.user = { userId: '123' }; + + // Mock the authorize middleware to have an internal error + const middleware = authorize('admin'); + + // This test ensures the try-catch block in authorize works + expect(() => { + middleware(mockRequest as Request, mockResponse as Response, mockNext); + }).not.toThrow(); + }); + }); + + describe('security considerations', () => { + test('should not expose sensitive information in errors', () => { + const jwtError = new Error('Invalid token'); + mockRequest.headers = { + authorization: 'Bearer sensitive-token-data', + }; + mockJwt.verify.mockImplementation(() => { + throw jwtError; + }); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockJwt.verify).toHaveBeenCalledWith( + 'sensitive-token-data', + JWT_SECRET + ); + expect(mockHandleAuthError).toHaveBeenCalledWith( + jwtError, + mockRequest, + mockResponse, + mockNext + ); + // The error message should not contain the actual token + }); + + test('should use constant-time comparison for token validation', () => { + // This is more of a documentation test since jwt.verify handles this + mockRequest.headers = { + authorization: 'Bearer token', + }; + mockJwt.verify.mockReturnValue({ userId: '123' }); + + authenticate(mockRequest as Request, mockResponse as Response, mockNext); + + expect(mockJwt.verify).toHaveBeenCalledWith('token', JWT_SECRET); + }); + }); +});