diff --git a/package-lock.json b/package-lock.json index 17dcd7b..77868f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -821,6 +821,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3939,6 +3940,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -4228,6 +4230,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -8420,6 +8423,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8877,6 +8881,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -9739,6 +9744,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12332,7 +12338,6 @@ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "debug": "4" }, @@ -12346,7 +12351,6 @@ "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^5.0.0", @@ -12357,28 +12361,12 @@ "node": ">=12" } }, - "node_modules/mongoose/node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/mongoose/node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "agent-base": "6", "debug": "4" diff --git a/tests/unit/core/errors.test.js b/tests/unit/core/errors.test.js new file mode 100644 index 0000000..81eb54c --- /dev/null +++ b/tests/unit/core/errors.test.js @@ -0,0 +1,148 @@ +import { describe, it, expect } from '@jest/globals'; +import ErrorResponse from '../../../src/core/errors.js'; + +describe('ErrorResponse', () => { + describe('constructor', () => { + it('should create an error with message and status code', () => { + const error = new ErrorResponse('Test error', 400); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('Test error'); + expect(error.statusCode).toBe(400); + expect(error.status).toBe(400); + expect(error.code).toBeNull(); + }); + + it('should create an error with message, status code, and error code', () => { + const error = new ErrorResponse('Invalid email', 400, 'INVALID_EMAIL'); + + expect(error.message).toBe('Invalid email'); + expect(error.statusCode).toBe(400); + expect(error.status).toBe(400); + expect(error.code).toBe('INVALID_EMAIL'); + }); + + it('should create a 404 error', () => { + const error = new ErrorResponse('User not found', 404, 'USER_NOT_FOUND'); + + expect(error.message).toBe('User not found'); + expect(error.statusCode).toBe(404); + expect(error.code).toBe('USER_NOT_FOUND'); + }); + + it('should create a 401 unauthorized error', () => { + const error = new ErrorResponse('Unauthorized', 401, 'UNAUTHORIZED'); + + expect(error.statusCode).toBe(401); + expect(error.code).toBe('UNAUTHORIZED'); + }); + + it('should create a 403 forbidden error', () => { + const error = new ErrorResponse('Access denied', 403, 'FORBIDDEN'); + + expect(error.statusCode).toBe(403); + expect(error.code).toBe('FORBIDDEN'); + }); + + it('should create a 409 conflict error', () => { + const error = new ErrorResponse('Email already exists', 409, 'EMAIL_EXISTS'); + + expect(error.statusCode).toBe(409); + expect(error.code).toBe('EMAIL_EXISTS'); + }); + + it('should create a 500 internal server error', () => { + const error = new ErrorResponse('Internal server error', 500, 'INTERNAL_ERROR'); + + expect(error.statusCode).toBe(500); + expect(error.code).toBe('INTERNAL_ERROR'); + }); + + it('should have null code when not provided', () => { + const error = new ErrorResponse('Some error', 400); + + expect(error.code).toBeNull(); + }); + + it('should extend Error class', () => { + const error = new ErrorResponse('Test', 400); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('Error'); + }); + + it('should have stack trace', () => { + const error = new ErrorResponse('Test', 400); + + expect(error.stack).toBeDefined(); + expect(typeof error.stack).toBe('string'); + }); + }); + + describe('error usage', () => { + it('should be throwable', () => { + expect(() => { + throw new ErrorResponse('Test error', 400, 'TEST_ERROR'); + }).toThrow(ErrorResponse); + }); + + it('should be catchable', () => { + try { + throw new ErrorResponse('Test error', 400, 'TEST_ERROR'); + } catch (err) { + expect(err).toBeInstanceOf(ErrorResponse); + expect(err.message).toBe('Test error'); + expect(err.statusCode).toBe(400); + expect(err.code).toBe('TEST_ERROR'); + } + }); + + it('should preserve message in catch block', () => { + let caughtError; + try { + throw new ErrorResponse('Custom message', 404, 'NOT_FOUND'); + } catch (err) { + caughtError = err; + } + + expect(caughtError.message).toBe('Custom message'); + }); + }); + + describe('common error scenarios', () => { + it('should create validation error', () => { + const error = new ErrorResponse('Validation failed', 400, 'VALIDATION_ERROR'); + + expect(error.statusCode).toBe(400); + expect(error.code).toBe('VALIDATION_ERROR'); + }); + + it('should create authentication error', () => { + const error = new ErrorResponse('Invalid credentials', 401, 'INVALID_CREDENTIALS'); + + expect(error.statusCode).toBe(401); + expect(error.code).toBe('INVALID_CREDENTIALS'); + }); + + it('should create authorization error', () => { + const error = new ErrorResponse('Insufficient permissions', 403, 'INSUFFICIENT_PERMISSIONS'); + + expect(error.statusCode).toBe(403); + expect(error.code).toBe('INSUFFICIENT_PERMISSIONS'); + }); + + it('should create resource not found error', () => { + const error = new ErrorResponse('Resource not found', 404, 'RESOURCE_NOT_FOUND'); + + expect(error.statusCode).toBe(404); + expect(error.code).toBe('RESOURCE_NOT_FOUND'); + }); + + it('should create duplicate resource error', () => { + const error = new ErrorResponse('Resource already exists', 409, 'DUPLICATE_RESOURCE'); + + expect(error.statusCode).toBe(409); + expect(error.code).toBe('DUPLICATE_RESOURCE'); + }); + }); +}); diff --git a/tests/unit/middlewares/auth.middleware.test.js b/tests/unit/middlewares/auth.middleware.test.js new file mode 100644 index 0000000..6c5bfde --- /dev/null +++ b/tests/unit/middlewares/auth.middleware.test.js @@ -0,0 +1,363 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import jwt from 'jsonwebtoken'; +import { requireAuth, requireServiceToken, requireAdmin, requireRoles } from '../../../src/middlewares/auth.middleware.js'; +import User from '../../../src/models/user.model.js'; +import ErrorResponse from '../../../src/core/errors.js'; + +// Mock dependencies +jest.mock('jsonwebtoken'); +jest.mock('../../../src/models/user.model.js'); +jest.mock('../../../src/core/logger.js', () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +describe('Auth Middleware', () => { + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + jest.clearAllMocks(); + + // Setup environment variables + process.env.JWT_SECRET = 'test-secret'; + process.env.SERVICE_AUTH_TOKEN = 'auth-service-token'; + process.env.SERVICE_ADMIN_TOKEN = 'admin-service-token'; + + mockReq = { + headers: {}, + cookies: {}, + }; + + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + mockNext = jest.fn(); + }); + + describe('requireAuth', () => { + it('should authenticate user with valid JWT in Authorization header', async () => { + const token = 'valid.jwt.token'; + mockReq.headers.authorization = `Bearer ${token}`; + + const decodedToken = { + sub: 'user-123', + email: 'test@example.com', + roles: ['customer'], + }; + + const mockUser = { + _id: 'user-123', + email: 'test@example.com', + isActive: true, + roles: ['customer'], + }; + + jwt.verify = jest.fn().mockReturnValue(decodedToken); + User.findById = jest.fn().mockResolvedValue(mockUser); + + await requireAuth(mockReq, mockRes, mockNext); + + expect(jwt.verify).toHaveBeenCalledWith(token, 'test-secret', expect.any(Object)); + expect(User.findById).toHaveBeenCalledWith('user-123'); + expect(mockReq.user).toEqual(mockUser); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should authenticate user with valid JWT in cookies', async () => { + const token = 'valid.jwt.token'; + mockReq.cookies.jwt = token; + + const decodedToken = { + sub: 'user-123', + email: 'test@example.com', + roles: ['customer'], + }; + + const mockUser = { + _id: 'user-123', + email: 'test@example.com', + isActive: true, + }; + + jwt.verify = jest.fn().mockReturnValue(decodedToken); + User.findById = jest.fn().mockResolvedValue(mockUser); + + await requireAuth(mockReq, mockRes, mockNext); + + expect(mockReq.user).toEqual(mockUser); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should return 401 if no token provided', async () => { + await requireAuth(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(401); + expect(mockNext.mock.calls[0][0].message).toContain('No token found'); + }); + + it('should return 401 if token is invalid', async () => { + mockReq.headers.authorization = 'Bearer invalid.token'; + + jwt.verify = jest.fn().mockImplementation(() => { + throw new Error('Invalid token'); + }); + + await requireAuth(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(401); + }); + + it('should return 401 if token is expired', async () => { + mockReq.headers.authorization = 'Bearer expired.token'; + + const expiredError = new Error('Token expired'); + expiredError.name = 'TokenExpiredError'; + jwt.verify = jest.fn().mockImplementation(() => { + throw expiredError; + }); + + await requireAuth(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].message).toContain('expired'); + }); + + it('should continue with JWT claims if user not found in database', async () => { + mockReq.headers.authorization = 'Bearer valid.token'; + + const decodedToken = { + sub: 'non-existent-user', + email: 'test@example.com', + roles: ['customer'], + }; + + jwt.verify = jest.fn().mockReturnValue(decodedToken); + User.findById = jest.fn().mockResolvedValue(null); + + await requireAuth(mockReq, mockRes, mockNext); + + // Should continue with JWT claims + expect(mockReq.user).toBeDefined(); + expect(mockReq.user._id).toBe('non-existent-user'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should return 403 if user account is deactivated', async () => { + mockReq.headers.authorization = 'Bearer valid.token'; + + const decodedToken = { + sub: 'user-123', + email: 'test@example.com', + roles: ['customer'], + }; + + const mockUser = { + _id: 'user-123', + email: 'test@example.com', + isActive: false, + }; + + jwt.verify = jest.fn().mockReturnValue(decodedToken); + User.findById = jest.fn().mockResolvedValue(mockUser); + + await requireAuth(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(403); + expect(mockNext.mock.calls[0][0].message).toContain('deactivated'); + }); + }); + + describe('requireServiceToken', () => { + it('should authenticate valid service token', async () => { + mockReq.headers['x-service-token'] = 'auth-service-token'; + mockReq.path = '/internal/users/test@example.com'; + mockReq.method = 'GET'; + + await requireServiceToken(mockReq, mockRes, mockNext); + + expect(mockReq.callingService).toBe('auth-service'); + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should return 401 if no service token provided', async () => { + mockReq.path = '/internal/users/test@example.com'; + mockReq.method = 'GET'; + + await requireServiceToken(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(401); + expect(mockNext.mock.calls[0][0].message).toContain('No service token'); + }); + + it('should return 401 if service token is invalid', async () => { + mockReq.headers['x-service-token'] = 'invalid-token'; + mockReq.path = '/internal/users/test@example.com'; + mockReq.method = 'GET'; + + await requireServiceToken(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(401); + expect(mockNext.mock.calls[0][0].message).toContain('Invalid service token'); + }); + + it('should authenticate admin service token', async () => { + mockReq.headers['x-service-token'] = 'admin-service-token'; + mockReq.path = '/internal/admin/users'; + mockReq.method = 'GET'; + + await requireServiceToken(mockReq, mockRes, mockNext); + + expect(mockReq.callingService).toBe('admin-service'); + expect(mockNext).toHaveBeenCalledWith(); + }); + }); + + describe('requireAdmin', () => { + it('should allow access for admin user', async () => { + mockReq.user = { + _id: 'admin-123', + roles: ['admin'], + }; + + await requireAdmin(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should allow access for user with admin role among multiple roles', async () => { + mockReq.user = { + _id: 'user-123', + roles: ['customer', 'admin'], + }; + + await requireAdmin(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should return 403 for non-admin user', async () => { + mockReq.user = { + _id: 'user-123', + roles: ['customer'], + }; + + await requireAdmin(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(403); + expect(mockNext.mock.calls[0][0].message).toContain('admin'); + }); + + it('should return 403 for user with no roles', async () => { + mockReq.user = { + _id: 'user-123', + roles: [], + }; + + await requireAdmin(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(403); + }); + }); + + describe('requireRoles', () => { + it('should allow access for user with required role', async () => { + mockReq.user = { + _id: 'user-123', + roles: ['premium'], + }; + + const middleware = requireRoles('premium', 'admin'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should allow access for user with one of multiple required roles', async () => { + mockReq.user = { + _id: 'user-123', + roles: ['customer'], + }; + + const middleware = requireRoles('customer', 'premium', 'admin'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should allow access for admin if admin is in required roles', async () => { + mockReq.user = { + _id: 'admin-123', + roles: ['admin'], + }; + + const middleware = requireRoles('premium', 'admin'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(); + }); + + it('should return 403 for user without required roles', async () => { + mockReq.user = { + _id: 'user-123', + roles: ['customer'], + }; + + const middleware = requireRoles('premium', 'gold'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(403); + expect(mockNext.mock.calls[0][0].message).toContain('Required roles'); + }); + + it('should return 403 for empty roles array', async () => { + mockReq.user = { + _id: 'user-123', + roles: [], + }; + + const middleware = requireRoles('premium'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(403); + }); + + it('should return 403 for user without roles property', async () => { + mockReq.user = { + _id: 'user-123', + }; + + const middleware = requireRoles('premium'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(403); + }); + + it('should return 401 if no user authenticated', async () => { + mockReq.user = null; + + const middleware = requireRoles('premium'); + await middleware(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalledWith(expect.any(ErrorResponse)); + expect(mockNext.mock.calls[0][0].statusCode).toBe(401); + }); + }); +});