diff --git a/backend/TESTING_DOCUMENTATION.md b/backend/TESTING_DOCUMENTATION.md new file mode 100644 index 00000000..f56fc702 --- /dev/null +++ b/backend/TESTING_DOCUMENTATION.md @@ -0,0 +1,415 @@ +# Backend Testing Documentation + +## Overview + +This document outlines the comprehensive testing strategy implemented for the NEPA backend application. The testing suite has been designed to ensure code quality, reliability, and maintainability while achieving minimum 80% test coverage. + +## Test Structure + +### Directory Organization + +``` +backend/tests/ +├── unit/ # Unit tests +│ ├── services/ # Service layer tests +│ ├── controllers/ # Controller layer tests +│ ├── middleware/ # Middleware tests +│ └── blockchain/ # Blockchain-related tests +├── integration/ # Integration tests +├── e2e/ # End-to-end tests +├── performance/ # Performance tests +├── security/ # Security tests +├── visual/ # Visual regression tests +├── helpers.ts # Test helper functions +├── mocks.ts # Mock configurations +├── setup.ts # Test setup +├── globalSetup.ts # Global test setup +└── globalTeardown.ts # Global test teardown +``` + +## Testing Framework Configuration + +### Jest Configuration + +The project uses Jest as the primary testing framework with the following configuration: + +- **Preset**: `ts-jest` for TypeScript support +- **Test Environment**: `jsdom` for DOM testing capabilities +- **Coverage**: Collects from `src/**/*.{ts,tsx}`, `controllers/**/*.ts`, `services/**/*.ts` +- **Coverage Reporters**: `text`, `lcov`, `html` +- **Test Timeout**: 10 seconds + +### Test Scripts + +```bash +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch + +# Run tests with coverage +npm run test:coverage + +# Run only unit tests +npm run test:unit + +# Run only integration tests +npm run test:integration + +# Run E2E tests +npm run test:e2e + +# Run Cypress tests +npm run test:cypress +``` + +## Test Categories + +### 1. Unit Tests + +Unit tests focus on testing individual components in isolation. + +#### Service Layer Tests + +**Coverage**: All critical services including: +- `AuditService` - Audit logging and compliance +- `EmailService` - Email communication +- `RbacService` - Role-based access control +- `AuthenticationService` - User authentication +- `AnalyticsService` - Data analytics +- `BillingService` - Payment processing +- `FileStorageService` - File management +- `AdvancedRateLimitService` - Rate limiting + +**Key Test Patterns**: +```typescript +describe('ServiceName', () => { + beforeEach(() => { + // Setup mocks and test data + }); + + describe('methodName', () => { + it('should handle success case', async () => { + // Test successful execution + }); + + it('should handle error cases', async () => { + // Test error handling + }); + + it('should validate inputs', async () => { + // Test input validation + }); + }); +}); +``` + +#### Controller Layer Tests + +**Coverage**: All controllers including: +- `AuditController` - Audit log management +- `AuthenticationController` - Authentication endpoints +- `AnalyticsController` - Analytics endpoints +- `PaymentController` - Payment processing +- `UserController` - User management +- `WebhookController` - Webhook handling + +**Key Test Patterns**: +```typescript +describe('ControllerName', () => { + let req: Request; + let res: Response; + let next: NextFunction; + + beforeEach(() => { + req = mockRequest(); + res = mockResponse(); + next = mockNext(); + }); + + it('should return 200 for valid request', async () => { + // Test successful request handling + }); + + it('should return 400 for invalid input', async () => { + // Test validation errors + }); + + it('should return 403 for unauthorized access', async () => { + // Test authorization + }); +}); +``` + +#### Middleware Tests + +**Coverage**: All middleware components: +- `authentication` - JWT token validation +- `rateLimiter` - Rate limiting logic +- `inputSanitization` - Input validation +- `auditMiddleware` - Audit logging +- `webhookSecurity` - Webhook security + +**Key Test Patterns**: +```typescript +describe('MiddlewareName', () => { + it('should allow valid requests', async () => { + // Test middleware success path + }); + + it('should block invalid requests', async () => { + // Test middleware rejection + }); + + it('should handle errors gracefully', async () => { + // Test error handling + }); +}); +``` + +### 2. Integration Tests + +Integration tests verify that multiple components work together correctly. + +#### User Management Flow +- Complete registration → verification → login → profile management flow +- Password reset flow +- Session management +- Token refresh mechanisms + +#### Admin Operations +- User role management +- User suspension/activation +- Audit log access +- System administration tasks + +#### Security Integration +- Authentication and authorization flow +- Rate limiting effectiveness +- Input sanitization +- CSRF protection + +### 3. End-to-End Tests + +E2E tests verify the entire application stack from API to database. + +#### Authentication Flow +```typescript +describe('Authentication E2E', () => { + it('should complete full user lifecycle', async () => { + // Register → Verify → Login → Access → Logout + }); +}); +``` + +#### Payment Processing +```typescript +describe('Payment E2E', () => { + it('should process payment successfully', async () => { + // Create payment → Process → Verify → Update status + }); +}); +``` + +## Testing Best Practices + +### 1. Test Organization + +- **Descriptive Test Names**: Use clear, descriptive test names that explain what is being tested +- **Logical Grouping**: Group related tests using `describe` blocks +- **Setup/Teardown**: Use `beforeEach`/`afterEach` for test isolation +- **Test Data**: Use factories and helpers for consistent test data + +### 2. Mocking Strategy + +- **External Dependencies**: Mock all external services (database, email, payment gateways) +- **Consistent Mocks**: Use centralized mock configurations +- **Reset Mocks**: Clear mocks between tests to prevent test pollution + +### 3. Assertion Patterns + +- **Specific Assertions**: Use specific assertions rather than generic ones +- **Error Testing**: Test both success and error paths +- **Edge Cases**: Include boundary condition tests + +### 4. Test Coverage + +- **Critical Paths**: Ensure all critical business logic is tested +- **Error Handling**: Test all error conditions +- **Security**: Verify security controls work correctly + +## Test Data Management + +### Test Helpers + +The `helpers.ts` file provides utilities for: +- Creating test users +- Generating test data +- Cleaning up test data +- Database operations + +### Mock Data + +The `mocks.ts` file contains: +- Mock request/response objects +- Mock service implementations +- Test data factories + +### Database Setup + +Tests use a dedicated test database with: +- Automatic cleanup between tests +- Consistent seed data +- Isolated test environments + +## Coverage Requirements + +### Minimum Coverage Targets + +- **Overall Coverage**: 80% +- **Function Coverage**: 85% +- **Branch Coverage**: 75% +- **Line Coverage**: 80% + +### Coverage Exclusions + +- Type definition files (`*.d.ts`) +- Configuration files +- Test files themselves +- Migration files + +### Coverage Reporting + +Coverage reports are generated in multiple formats: +- **Console Output**: Summary during test runs +- **HTML Report**: Detailed coverage visualization +- **LCOV Format**: For CI/CD integration + +## Continuous Integration + +### CI/CD Pipeline + +Tests are automatically run in CI/CD with: +- **Unit Tests**: Fast feedback on code changes +- **Integration Tests**: Verify component interactions +- **Coverage Checks**: Enforce minimum coverage thresholds +- **Security Tests**: Verify security controls + +### Test Environment + +CI/CD uses: +- **Docker Containers**: Isolated test environments +- **Parallel Execution**: Fast test runs +- **Artifact Storage**: Test results and coverage reports + +## Performance Testing + +### Load Testing + +- **API Endpoints**: Verify performance under load +- **Database Queries**: Optimize slow queries +- **Memory Usage**: Monitor memory consumption + +### Benchmarking + +- **Response Times**: Measure API response times +- **Throughput**: Test concurrent request handling +- **Resource Usage**: Monitor CPU and memory usage + +## Security Testing + +### Authentication Tests + +- **JWT Validation**: Token verification and expiration +- **Password Security**: Hashing and validation +- **Session Management**: Secure session handling + +### Authorization Tests + +- **Role-Based Access**: Verify RBAC implementation +- **Permission Checks**: Test permission validation +- **Resource Access**: Verify resource-level security + +### Input Validation + +- **SQL Injection**: Prevent SQL injection attacks +- **XSS Prevention**: Verify input sanitization +- **CSRF Protection**: Test CSRF token validation + +## Debugging Tests + +### Test Debugging + +- **Console Logging**: Use debug logs for troubleshooting +- **Breakpoints**: Use debugger for complex tests +- **Test Isolation**: Run individual tests for debugging + +### Common Issues + +- **Async Problems**: Handle promises and async/await correctly +- **Mock Issues**: Verify mock configurations +- **Timing Issues**: Use proper async handling + +## Maintenance + +### Test Updates + +- **Refactoring**: Update tests when code changes +- **New Features**: Add tests for new functionality +- **Bug Fixes**: Add regression tests for bug fixes + +### Test Review + +- **Code Review**: Review test code changes +- **Coverage Review**: Monitor coverage trends +- **Performance Review**: Monitor test execution times + +## Running Tests Locally + +### Prerequisites + +- Node.js (version specified in package.json) +- Test database (PostgreSQL) +- Environment variables configured + +### Setup + +```bash +# Install dependencies +npm install + +# Setup test database +npm run db:setup + +# Run tests +npm test +``` + +### Troubleshooting + +- **Database Issues**: Verify database connection and migrations +- **Environment Issues**: Check environment variables +- **Dependency Issues**: Verify node_modules installation + +## Future Improvements + +### Planned Enhancements + +1. **Visual Testing**: Expand visual regression testing +2. **Contract Testing**: Add API contract tests +3. **Chaos Engineering**: Test system resilience +4. **Mutation Testing**: Verify test effectiveness + +### Tooling Improvements + +1. **Better Mocking**: Implement more sophisticated mocking +2. **Test Parallelization**: Improve test execution speed +3. **Coverage Visualization**: Enhanced coverage reporting +4. **Automated Test Generation**: AI-assisted test generation + +## Conclusion + +This comprehensive testing strategy ensures the NEPA backend application maintains high code quality, reliability, and security. The testing suite provides confidence in code changes and helps prevent regressions while supporting rapid development cycles. + +Regular review and maintenance of the test suite ensures it continues to provide value as the application evolves and grows. diff --git a/backend/tests/integration/user-management.test.ts b/backend/tests/integration/user-management.test.ts new file mode 100644 index 00000000..022d281c --- /dev/null +++ b/backend/tests/integration/user-management.test.ts @@ -0,0 +1,608 @@ +import request from 'supertest'; +import app from '../../app'; +import { TestHelpers } from '../helpers'; +import { prisma } from '../setup'; + +describe('User Management Integration Tests', () => { + let testUser: any; + let adminUser: any; + let authToken: string; + let adminToken: string; + + beforeEach(async () => { + await TestHelpers.cleanupTestData(); + + // Create test users + testUser = await TestHelpers.createTestUser({ + email: 'testuser@example.com', + username: 'testuser', + role: 'USER' + }); + + adminUser = await TestHelpers.createTestUser({ + email: 'admin@example.com', + username: 'admin', + role: 'ADMIN' + }); + + // Get auth tokens + const userLoginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: 'testuser@example.com', + password: 'password123' + }); + + authToken = userLoginResponse.body.token; + + const adminLoginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: 'admin@example.com', + password: 'password123' + }); + + adminToken = adminLoginResponse.body.token; + }); + + afterAll(async () => { + await TestHelpers.cleanupTestData(); + }); + + describe('User Registration and Authentication Flow', () => { + it('should complete full user registration and login flow', async () => { + const newUserEmail = 'newuser@example.com'; + + // Step 1: Register new user + const registerResponse = await request(app) + .post('/api/auth/register') + .send({ + email: newUserEmail, + password: 'password123', + username: 'newuser', + name: 'New User' + }) + .expect(201); + + expect(registerResponse.body).toMatchObject({ + message: 'Registration successful. Please verify your email.', + user: { + email: newUserEmail, + username: 'newuser', + name: 'New User', + status: 'PENDING_VERIFICATION' + } + }); + + const userId = registerResponse.body.user.id; + + // Step 2: Verify email (mock verification) + await prisma.user.update({ + where: { id: userId }, + data: { status: 'ACTIVE' } + }); + + // Step 3: Login with verified account + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: newUserEmail, + password: 'password123' + }) + .expect(200); + + expect(loginResponse.body).toMatchObject({ + message: 'Login successful', + token: expect.any(String), + refreshToken: expect.any(String), + user: { + email: newUserEmail, + username: 'newuser', + status: 'ACTIVE' + } + }); + + const userToken = loginResponse.body.token; + + // Step 4: Access protected endpoint + const profileResponse = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${userToken}`) + .expect(200); + + expect(profileResponse.body).toMatchObject({ + success: true, + data: { + email: newUserEmail, + username: 'newuser', + name: 'New User' + } + }); + + // Step 5: Update profile + const updateResponse = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${userToken}`) + .send({ + name: 'Updated Name', + bio: 'Updated bio' + }) + .expect(200); + + expect(updateResponse.body).toMatchObject({ + success: true, + data: { + name: 'Updated Name', + bio: 'Updated bio' + } + }); + + // Step 6: Logout + const logoutResponse = await request(app) + .post('/api/auth/logout') + .set('Authorization', `Bearer ${userToken}`) + .send({ + refreshToken: loginResponse.body.refreshToken + }) + .expect(200); + + expect(logoutResponse.body).toMatchObject({ + message: 'Logout successful' + }); + }); + + it('should prevent login with unverified email', async () => { + const newUserEmail = 'unverified@example.com'; + + // Register user but don't verify email + await request(app) + .post('/api/auth/register') + .send({ + email: newUserEmail, + password: 'password123', + username: 'unverified', + name: 'Unverified User' + }) + .expect(201); + + // Try to login without verification + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: newUserEmail, + password: 'password123' + }) + .expect(401); + + expect(loginResponse.body).toMatchObject({ + error: 'Please verify your email before logging in' + }); + }); + + it('should handle password reset flow', async () => { + const resetEmail = 'resetuser@example.com'; + + // Create user for password reset + const resetUser = await TestHelpers.createTestUser({ + email: resetEmail, + username: 'resetuser' + }); + + // Step 1: Request password reset + const resetRequestResponse = await request(app) + .post('/api/auth/forgot-password') + .send({ + email: resetEmail + }) + .expect(200); + + expect(resetRequestResponse.body).toMatchObject({ + message: 'Password reset instructions sent to your email' + }); + + // Step 2: Reset password with token (mock token) + const resetToken = 'mock-reset-token'; + await prisma.user.update({ + where: { id: resetUser.id }, + data: { + resetToken: resetToken, + resetTokenExpiry: new Date(Date.now() + 3600000) // 1 hour from now + } + }); + + // Step 3: Complete password reset + const resetCompleteResponse = await request(app) + .post('/api/auth/reset-password') + .send({ + token: resetToken, + newPassword: 'newpassword123' + }) + .expect(200); + + expect(resetCompleteResponse.body).toMatchObject({ + message: 'Password reset successful' + }); + + // Step 4: Login with new password + const newLoginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: resetEmail, + password: 'newpassword123' + }) + .expect(200); + + expect(newLoginResponse.body).toMatchObject({ + message: 'Login successful', + token: expect.any(String) + }); + }); + }); + + describe('Admin User Management', () => { + it('should allow admin to view all users', async () => { + const response = await request(app) + .get('/api/admin/users') + .set('Authorization', `Bearer ${adminToken}`) + .expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: expect.arrayContaining([ + expect.objectContaining({ + email: 'testuser@example.com', + username: 'testuser' + }), + expect.objectContaining({ + email: 'admin@example.com', + username: 'admin' + }) + ]) + }); + }); + + it('should deny regular users access to admin endpoints', async () => { + const response = await request(app) + .get('/api/admin/users') + .set('Authorization', `Bearer ${authToken}`) + .expect(403); + + expect(response.body).toMatchObject({ + error: 'Insufficient permissions' + }); + }); + + it('should allow admin to update user roles', async () => { + const response = await request(app) + .put(`/api/admin/users/${testUser.id}/role`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + role: 'PREMIUM_USER' + }) + .expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: { + role: 'PREMIUM_USER' + } + }); + }); + + it('should allow admin to suspend users', async () => { + const response = await request(app) + .put(`/api/admin/users/${testUser.id}/status`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ + status: 'SUSPENDED', + reason: 'Violation of terms' + }) + .expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: { + status: 'SUSPENDED' + } + }); + + // Verify suspended user cannot login + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: 'testuser@example.com', + password: 'password123' + }) + .expect(401); + + expect(loginResponse.body).toMatchObject({ + error: 'Account is suspended' + }); + }); + }); + + describe('User Profile Management', () => { + it('should allow users to update their profile', async () => { + const updateData = { + name: 'Updated Name', + bio: 'This is my updated bio', + avatar: 'https://example.com/avatar.jpg', + preferences: { + theme: 'dark', + notifications: true + } + }; + + const response = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${authToken}`) + .send(updateData) + .expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: updateData + }); + }); + + it('should allow users to change their password', async () => { + const response = await request(app) + .post('/api/users/change-password') + .set('Authorization', `Bearer ${authToken}`) + .send({ + currentPassword: 'password123', + newPassword: 'newpassword123' + }) + .expect(200); + + expect(response.body).toMatchObject({ + message: 'Password changed successfully' + }); + + // Verify login with new password + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: 'testuser@example.com', + password: 'newpassword123' + }) + .expect(200); + + expect(loginResponse.body).toMatchObject({ + message: 'Login successful' + }); + }); + + it('should reject password change with incorrect current password', async () => { + const response = await request(app) + .post('/api/users/change-password') + .set('Authorization', `Bearer ${authToken}`) + .send({ + currentPassword: 'wrongpassword', + newPassword: 'newpassword123' + }) + .expect(400); + + expect(response.body).toMatchObject({ + error: 'Current password is incorrect' + }); + }); + + it('should allow users to enable two-factor authentication', async () => { + const response = await request(app) + .post('/api/users/enable-2fa') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: { + qrCode: expect.any(String), + backupCodes: expect.any(Array) + } + }); + }); + }); + + describe('Session Management', () => { + it('should allow users to view their active sessions', async () => { + const response = await request(app) + .get('/api/users/sessions') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body).toMatchObject({ + success: true, + data: expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + createdAt: expect.any(String), + isActive: true + }) + ]) + }); + }); + + it('should allow users to revoke specific sessions', async () => { + // First get sessions + const sessionsResponse = await request(app) + .get('/api/users/sessions') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + const sessionId = sessionsResponse.body.data[0].id; + + // Revoke session + const revokeResponse = await request(app) + .delete(`/api/users/sessions/${sessionId}`) + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(revokeResponse.body).toMatchObject({ + message: 'Session revoked successfully' + }); + }); + + it('should allow users to revoke all sessions except current', async () => { + const response = await request(app) + .post('/api/users/revoke-all-sessions') + .set('Authorization', `Bearer ${authToken}`) + .expect(200); + + expect(response.body).toMatchObject({ + message: 'All other sessions revoked successfully' + }); + }); + }); + + describe('Security and Validation', () => { + it('should prevent duplicate email registration', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + email: 'testuser@example.com', // Already exists + password: 'password123', + username: 'differentuser' + }) + .expect(400); + + expect(response.body).toMatchObject({ + error: 'Email already registered' + }); + }); + + it('should prevent duplicate username registration', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + email: 'different@example.com', + password: 'password123', + username: 'testuser' // Already exists + }) + .expect(400); + + expect(response.body).toMatchObject({ + error: 'Username already taken' + }); + }); + + it('should validate email format', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + email: 'invalid-email', + password: 'password123', + username: 'testuser2' + }) + .expect(400); + + expect(response.body).toMatchObject({ + error: 'Invalid email format' + }); + }); + + it('should validate password strength', async () => { + const response = await request(app) + .post('/api/auth/register') + .send({ + email: 'weak@example.com', + password: '123', // Too weak + username: 'weakuser' + }) + .expect(400); + + expect(response.body).toMatchObject({ + error: 'Password does not meet security requirements' + }); + }); + + it('should rate limit authentication attempts', async () => { + // Make multiple failed login attempts + for (let i = 0; i < 5; i++) { + await request(app) + .post('/api/auth/login') + .send({ + email: 'testuser@example.com', + password: 'wrongpassword' + }) + .expect(401); + } + + // Sixth attempt should be rate limited + const response = await request(app) + .post('/api/auth/login') + .send({ + email: 'testuser@example.com', + password: 'wrongpassword' + }) + .expect(429); + + expect(response.body).toMatchObject({ + error: 'Too many failed login attempts' + }); + }); + }); + + describe('Token Management', () => { + it('should refresh access token with valid refresh token', async () => { + const loginResponse = await request(app) + .post('/api/auth/login') + .send({ + email: 'testuser@example.com', + password: 'password123' + }) + .expect(200); + + const refreshToken = loginResponse.body.refreshToken; + + const refreshResponse = await request(app) + .post('/api/auth/refresh') + .send({ + refreshToken: refreshToken + }) + .expect(200); + + expect(refreshResponse.body).toMatchObject({ + token: expect.any(String), + refreshToken: expect.any(String) + }); + }); + + it('should reject token refresh with invalid refresh token', async () => { + const response = await request(app) + .post('/api/auth/refresh') + .send({ + refreshToken: 'invalid-refresh-token' + }) + .expect(401); + + expect(response.body).toMatchObject({ + error: 'Invalid refresh token' + }); + }); + + it('should reject access to protected endpoints without token', async () => { + const response = await request(app) + .get('/api/users/profile') + .expect(401); + + expect(response.body).toMatchObject({ + error: 'Access token required' + }); + }); + + it('should reject access with expired token', async () => { + // Create an expired token (mock scenario) + const expiredToken = 'expired-token-mock'; + + const response = await request(app) + .get('/api/users/profile') + .set('Authorization', `Bearer ${expiredToken}`) + .expect(401); + + expect(response.body).toMatchObject({ + error: 'Token expired' + }); + }); + }); +}); diff --git a/backend/tests/unit/controllers/AuditController.test.ts b/backend/tests/unit/controllers/AuditController.test.ts new file mode 100644 index 00000000..756e33ee --- /dev/null +++ b/backend/tests/unit/controllers/AuditController.test.ts @@ -0,0 +1,470 @@ +import { Request, Response } from 'express'; +import { AuditController } from '../../../controllers/AuditController'; +import { auditService, AuditAction, AuditSeverity } from '../../../services/AuditService'; +import { mockRequest, mockResponse, mockNext } from '../mocks'; + +jest.mock('../../../services/AuditService'); +jest.mock('../../../services/logger'); + +const MockedAuditService = auditService as jest.Mocked; + +describe('AuditController Unit Tests', () => { + let req: Request; + let res: Response; + let next: any; + + beforeEach(() => { + jest.clearAllMocks(); + req = mockRequest(); + res = mockResponse(); + next = mockNext(); + }); + + describe('searchLogs', () => { + it('should allow admin to search all audit logs', async () => { + // Mock admin user + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { + userId: 'user-1', + action: AuditAction.USER_LOGIN, + limit: '50', + offset: '0' + }; + + const mockSearchResult = { + logs: [ + { + id: 'audit-1', + userId: 'user-1', + action: AuditAction.USER_LOGIN, + timestamp: new Date() + } + ], + total: 1 + }; + + MockedAuditService.searchAuditLogs.mockResolvedValue(mockSearchResult); + + await AuditController.searchLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockSearchResult, + pagination: { + limit: 50, + offset: 0, + total: 1 + } + }); + expect(MockedAuditService.searchAuditLogs).toHaveBeenCalledWith({ + userId: 'user-1', + action: AuditAction.USER_LOGIN, + limit: 50, + offset: 0 + }); + }); + + it('should restrict non-admin users to their own logs', async () => { + // Mock regular user + (req as any).user = { id: 'user-1', role: 'USER' }; + + req.query = { + userId: 'user-2', // Should be ignored for non-admin + action: AuditAction.USER_LOGIN + }; + + const mockSearchResult = { + logs: [ + { + id: 'audit-1', + userId: 'user-1', + action: AuditAction.USER_LOGIN, + timestamp: new Date() + } + ], + total: 1 + }; + + MockedAuditService.searchAuditLogs.mockResolvedValue(mockSearchResult); + + await AuditController.searchLogs(req, res); + + expect(MockedAuditService.searchAuditLogs).toHaveBeenCalledWith({ + userId: 'user-1', // Should be the current user's ID + action: AuditAction.USER_LOGIN, + limit: 100, + offset: 0 + }); + }); + + it('should handle search errors', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + MockedAuditService.searchAuditLogs.mockRejectedValue(new Error('Search failed')); + + await AuditController.searchLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Failed to search audit logs' + }); + }); + + it('should validate limit parameter', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { limit: '2000' }; // Exceeds max limit + + const mockSearchResult = { logs: [], total: 0 }; + MockedAuditService.searchAuditLogs.mockResolvedValue(mockSearchResult); + + await AuditController.searchLogs(req, res); + + expect(MockedAuditService.searchAuditLogs).toHaveBeenCalledWith({ + limit: 1000, // Should be capped at 1000 + offset: 0 + }); + }); + }); + + describe('getAuditStats', () => { + it('should return audit statistics for admin', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + const mockStats = [ + { action: AuditAction.USER_LOGIN, count: 100 }, + { action: AuditAction.USER_LOGOUT, count: 95 } + ]; + + MockedAuditService.getAuditStats.mockResolvedValue(mockStats); + + await AuditController.getAuditStats(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockStats + }); + expect(MockedAuditService.getAuditStats).toHaveBeenCalled(); + }); + + it('should deny access to non-admin users', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + await AuditController.getAuditStats(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Access denied' + }); + expect(MockedAuditService.getAuditStats).not.toHaveBeenCalled(); + }); + + it('should handle stats errors', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + MockedAuditService.getAuditStats.mockRejectedValue(new Error('Stats failed')); + + await AuditController.getAuditStats(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Failed to get audit statistics' + }); + }); + }); + + describe('exportLogs', () => { + it('should export logs for admin users', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { + format: 'csv', + startDate: '2024-01-01', + endDate: '2024-01-31' + }; + + const mockExportData = 'id,userId,action,timestamp\naudit-1,user-1,USER_LOGIN,2024-01-15T10:00:00Z'; + + MockedAuditService.exportAuditLogs.mockResolvedValue(mockExportData); + + await AuditController.exportLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/csv'); + expect(res.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="audit-logs.csv"'); + expect(res.send).toHaveBeenCalledWith(mockExportData); + }); + + it('should deny export access to non-admin users', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + await AuditController.exportLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Access denied' + }); + expect(MockedAuditService.exportAuditLogs).not.toHaveBeenCalled(); + }); + + it('should validate export format', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { format: 'xml' }; // Invalid format + + await AuditController.exportLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid export format' + }); + }); + + it('should handle export errors', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { format: 'csv' }; + + MockedAuditService.exportAuditLogs.mockRejectedValue(new Error('Export failed')); + + await AuditController.exportLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Failed to export audit logs' + }); + }); + }); + + describe('cleanupOldLogs', () => { + it('should cleanup old logs for admin users', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.body = { retentionDays: 90 }; + + const mockCleanupResult = { deletedCount: 100 }; + MockedAuditService.cleanupOldLogs.mockResolvedValue(mockCleanupResult); + + await AuditController.cleanupOldLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockCleanupResult, + message: 'Successfully cleaned up 100 old audit logs' + }); + expect(MockedAuditService.cleanupOldLogs).toHaveBeenCalledWith(90); + }); + + it('should deny cleanup access to non-admin users', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + await AuditController.cleanupOldLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Access denied' + }); + expect(MockedAuditService.cleanupOldLogs).not.toHaveBeenCalled(); + }); + + it('should validate retention days', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.body = { retentionDays: 30 }; // Too short + + await AuditController.cleanupOldLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Retention period must be at least 90 days' + }); + }); + + it('should handle cleanup errors', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.body = { retentionDays: 90 }; + + MockedAuditService.cleanupOldLogs.mockRejectedValue(new Error('Cleanup failed')); + + await AuditController.cleanupOldLogs(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Failed to cleanup old audit logs' + }); + }); + }); + + describe('getComplianceReport', () => { + it('should generate compliance report for admin users', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { + startDate: '2024-01-01', + endDate: '2024-01-31', + reportType: 'summary' + }; + + const mockReport = { + summary: { + totalEvents: 1000, + criticalEvents: 5, + userActions: 800, + adminActions: 200 + }, + details: [] + }; + + MockedAuditService.generateComplianceReport.mockResolvedValue(mockReport); + + await AuditController.getComplianceReport(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockReport + }); + expect(MockedAuditService.generateComplianceReport).toHaveBeenCalledWith({ + startDate: '2024-01-01', + endDate: '2024-01-31', + reportType: 'summary' + }); + }); + + it('should deny compliance report access to non-admin users', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + await AuditController.getComplianceReport(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Access denied' + }); + expect(MockedAuditService.generateComplianceReport).not.toHaveBeenCalled(); + }); + + it('should validate report parameters', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { + reportType: 'invalid' + }; + + await AuditController.getComplianceReport(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Invalid report type' + }); + }); + + it('should handle compliance report errors', async () => { + (req as any).user = { id: 'admin-1', role: 'ADMIN' }; + + req.query = { + reportType: 'summary' + }; + + MockedAuditService.generateComplianceReport.mockRejectedValue(new Error('Report generation failed')); + + await AuditController.getComplianceReport(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Failed to generate compliance report' + }); + }); + }); + + describe('logAuditEvent', () => { + it('should log audit event successfully', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + req.body = { + action: AuditAction.USER_LOGIN, + resource: 'auth', + severity: AuditSeverity.INFO, + details: { loginMethod: 'password' } + }; + + const mockLogResult = { + success: true, + auditLog: { + id: 'audit-1', + userId: 'user-1', + action: AuditAction.USER_LOGIN, + timestamp: new Date() + } + }; + + MockedAuditService.logAuditEvent.mockResolvedValue(mockLogResult); + + await AuditController.logAuditEvent(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: mockLogResult.auditLog + }); + expect(MockedAuditService.logAuditEvent).toHaveBeenCalledWith({ + userId: 'user-1', + action: AuditAction.USER_LOGIN, + resource: 'auth', + severity: AuditSeverity.INFO, + ipAddress: expect.any(String), + userAgent: expect.any(String), + details: { loginMethod: 'password' } + }); + }); + + it('should handle audit log errors', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + req.body = { + action: AuditAction.USER_LOGIN, + resource: 'auth' + }; + + MockedAuditService.logAuditEvent.mockRejectedValue(new Error('Log failed')); + + await AuditController.logAuditEvent(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Failed to log audit event' + }); + }); + + it('should validate required fields', async () => { + (req as any).user = { id: 'user-1', role: 'USER' }; + + req.body = {}; // Missing required fields + + await AuditController.logAuditEvent(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + success: false, + error: 'Missing required fields: action, resource' + }); + }); + }); +}); diff --git a/backend/tests/unit/middleware/authentication.test.ts b/backend/tests/unit/middleware/authentication.test.ts new file mode 100644 index 00000000..e49cada9 --- /dev/null +++ b/backend/tests/unit/middleware/authentication.test.ts @@ -0,0 +1,408 @@ +import { Request, Response, NextFunction } from 'express'; +import { authenticateToken, requireRole, requirePermission } from '../../../middleware/authentication'; +import jwt from 'jsonwebtoken'; +import { PrismaClient, UserRole } from '@prisma/client'; + +jest.mock('jsonwebtoken'); +jest.mock('@prisma/client'); + +const mockJwt = jwt as jest.Mocked; +const MockedPrisma = PrismaClient as jest.MockedClass; + +describe('Authentication Middleware Unit Tests', () => { + let req: Request; + let res: Response; + let next: NextFunction; + let mockPrismaClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + + req = { + headers: {}, + body: {}, + params: {}, + query: {} + } as Request; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis() + } as any; + + next = jest.fn(); + + mockPrismaClient = { + user: { + findUnique: jest.fn() + }, + userSession: { + findUnique: jest.fn(), + update: jest.fn() + } + }; + + MockedPrisma.mockImplementation(() => mockPrismaClient); + }); + + describe('authenticateToken', () => { + it('should authenticate user with valid token', async () => { + const mockToken = 'valid-jwt-token'; + const mockPayload = { userId: 'user-1', email: 'test@example.com' }; + const mockUser = { + id: 'user-1', + email: 'test@example.com', + role: UserRole.USER, + status: 'ACTIVE' + }; + + req.headers.authorization = `Bearer ${mockToken}`; + mockJwt.verify.mockReturnValue(mockPayload); + mockPrismaClient.user.findUnique.mockResolvedValue(mockUser); + mockPrismaClient.userSession.findUnique.mockResolvedValue({ + id: 'session-1', + userId: 'user-1', + isActive: true + }); + + await authenticateToken(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect((req as any).user).toEqual(mockUser); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should reject request with missing authorization header', async () => { + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Access token required' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject request with invalid token format', async () => { + req.headers.authorization = 'InvalidFormat token'; + + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Invalid token format' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject request with expired token', async () => { + const mockToken = 'expired-jwt-token'; + req.headers.authorization = `Bearer ${mockToken}`; + + mockJwt.verify.mockImplementation(() => { + throw new Error('Token expired'); + }); + + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Token expired' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject request for non-existent user', async () => { + const mockToken = 'valid-jwt-token'; + const mockPayload = { userId: 'user-1', email: 'test@example.com' }; + + req.headers.authorization = `Bearer ${mockToken}`; + mockJwt.verify.mockReturnValue(mockPayload); + mockPrismaClient.user.findUnique.mockResolvedValue(null); + + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'User not found' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject request for inactive user', async () => { + const mockToken = 'valid-jwt-token'; + const mockPayload = { userId: 'user-1', email: 'test@example.com' }; + const mockUser = { + id: 'user-1', + email: 'test@example.com', + role: UserRole.USER, + status: 'INACTIVE' + }; + + req.headers.authorization = `Bearer ${mockToken}`; + mockJwt.verify.mockReturnValue(mockPayload); + mockPrismaClient.user.findUnique.mockResolvedValue(mockUser); + + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Account is inactive' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject request with invalid session', async () => { + const mockToken = 'valid-jwt-token'; + const mockPayload = { userId: 'user-1', email: 'test@example.com' }; + const mockUser = { + id: 'user-1', + email: 'test@example.com', + role: UserRole.USER, + status: 'ACTIVE' + }; + + req.headers.authorization = `Bearer ${mockToken}`; + mockJwt.verify.mockReturnValue(mockPayload); + mockPrismaClient.user.findUnique.mockResolvedValue(mockUser); + mockPrismaClient.userSession.findUnique.mockResolvedValue(null); + + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Invalid session' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should handle database errors gracefully', async () => { + const mockToken = 'valid-jwt-token'; + req.headers.authorization = `Bearer ${mockToken}`; + + mockJwt.verify.mockReturnValue({ userId: 'user-1' }); + mockPrismaClient.user.findUnique.mockRejectedValue(new Error('Database error')); + + await authenticateToken(req, res, next); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication error' + }); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('requireRole', () => { + it('should allow access to users with required role', async () => { + const middleware = requireRole(UserRole.ADMIN); + (req as any).user = { id: 'user-1', role: UserRole.ADMIN }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should deny access to users without required role', async () => { + const middleware = requireRole(UserRole.ADMIN); + (req as any).user = { id: 'user-1', role: UserRole.USER }; + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should deny access to unauthenticated users', async () => { + const middleware = requireRole(UserRole.ADMIN); + // No user set on request + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication required' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should allow access to users with any of multiple roles', async () => { + const middleware = requireRole([UserRole.ADMIN, UserRole.SUPER_ADMIN]); + (req as any).user = { id: 'user-1', role: UserRole.SUPER_ADMIN }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should deny access to users with none of the required roles', async () => { + const middleware = requireRole([UserRole.ADMIN, UserRole.SUPER_ADMIN]); + (req as any).user = { id: 'user-1', role: UserRole.USER }; + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions' + }); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('requirePermission', () => { + it('should allow access to users with required permission', async () => { + const middleware = requirePermission('read_users'); + (req as any).user = { + id: 'user-1', + role: UserRole.ADMIN, + permissions: ['read_users', 'write_users'] + }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should deny access to users without required permission', async () => { + const middleware = requirePermission('delete_users'); + (req as any).user = { + id: 'user-1', + role: UserRole.USER, + permissions: ['read_users'] + }; + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should deny access to unauthenticated users', async () => { + const middleware = requirePermission('read_users'); + // No user set on request + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ + error: 'Authentication required' + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should allow access to admin users regardless of specific permissions', async () => { + const middleware = requirePermission('any_permission'); + (req as any).user = { + id: 'user-1', + role: UserRole.ADMIN, + permissions: [] + }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should allow access to super admin users regardless of specific permissions', async () => { + const middleware = requirePermission('any_permission'); + (req as any).user = { + id: 'user-1', + role: UserRole.SUPER_ADMIN, + permissions: [] + }; + + await middleware(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should handle users without permissions array', async () => { + const middleware = requirePermission('read_users'); + (req as any).user = { + id: 'user-1', + role: UserRole.USER + // No permissions property + }; + + await middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Insufficient permissions' + }); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('Token Refresh', () => { + it('should refresh token when close to expiry', async () => { + const mockToken = 'valid-jwt-token'; + const mockPayload = { + userId: 'user-1', + email: 'test@example.com', + exp: Math.floor(Date.now() / 1000) + 300 // 5 minutes from now + }; + const mockUser = { + id: 'user-1', + email: 'test@example.com', + role: UserRole.USER, + status: 'ACTIVE' + }; + + req.headers.authorization = `Bearer ${mockToken}`; + mockJwt.verify.mockReturnValue(mockPayload); + mockPrismaClient.user.findUnique.mockResolvedValue(mockUser); + mockPrismaClient.userSession.findUnique.mockResolvedValue({ + id: 'session-1', + userId: 'user-1', + isActive: true + }); + + await authenticateToken(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect((req as any).user).toEqual(mockUser); + expect((req as any).shouldRefreshToken).toBe(true); + }); + + it('should not refresh token when not close to expiry', async () => { + const mockToken = 'valid-jwt-token'; + const mockPayload = { + userId: 'user-1', + email: 'test@example.com', + exp: Math.floor(Date.now() / 1000) + 3600 // 1 hour from now + }; + const mockUser = { + id: 'user-1', + email: 'test@example.com', + role: UserRole.USER, + status: 'ACTIVE' + }; + + req.headers.authorization = `Bearer ${mockToken}`; + mockJwt.verify.mockReturnValue(mockPayload); + mockPrismaClient.user.findUnique.mockResolvedValue(mockUser); + mockPrismaClient.userSession.findUnique.mockResolvedValue({ + id: 'session-1', + userId: 'user-1', + isActive: true + }); + + await authenticateToken(req, res, next); + + expect(next).toHaveBeenCalledWith(); + expect((req as any).user).toEqual(mockUser); + expect((req as any).shouldRefreshToken).toBeUndefined(); + }); + }); +}); diff --git a/backend/tests/unit/middleware/rateLimiter.test.ts b/backend/tests/unit/middleware/rateLimiter.test.ts new file mode 100644 index 00000000..2ce0c0d6 --- /dev/null +++ b/backend/tests/unit/middleware/rateLimiter.test.ts @@ -0,0 +1,314 @@ +import { Request, Response, NextFunction } from 'express'; +import rateLimit from 'express-rate-limit'; +import { createRateLimiter } from '../../../middleware/rateLimiter'; + +jest.mock('express-rate-limit'); +jest.mock('../../../services/logger'); + +const mockRateLimit = rateLimit as jest.MockedFunction; + +describe('Rate Limiter Middleware Unit Tests', () => { + let req: Request; + let res: Response; + let next: NextFunction; + let mockRateLimitMiddleware: any; + + beforeEach(() => { + jest.clearAllMocks(); + + req = { + ip: '192.168.1.1', + headers: {}, + body: {}, + params: {}, + query: {} + } as Request; + + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis() + } as any; + + next = jest.fn(); + + mockRateLimitMiddleware = jest.fn((req, res, next) => { + // Mock successful rate limiting by default + next(); + }); + + mockRateLimit.mockReturnValue(mockRateLimitMiddleware); + }); + + describe('createRateLimiter', () => { + it('should create rate limiter with default options', () => { + const limiter = createRateLimiter(); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // 100 requests + message: expect.objectContaining({ + error: 'Too many requests' + }), + standardHeaders: true, + legacyHeaders: false + })); + }); + + it('should create rate limiter with custom options', () => { + const customOptions = { + windowMs: 5 * 60 * 1000, // 5 minutes + max: 50, + message: 'Custom rate limit message' + }; + + const limiter = createRateLimiter(customOptions); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + windowMs: 5 * 60 * 1000, + max: 50, + message: 'Custom rate limit message' + })); + }); + + it('should apply rate limiting to requests', async () => { + const limiter = createRateLimiter(); + + await limiter(req, res, next); + + expect(mockRateLimitMiddleware).toHaveBeenCalledWith(req, res, next); + }); + }); + + describe('Rate Limiting Behavior', () => { + it('should allow requests within limit', async () => { + const limiter = createRateLimiter({ max: 5 }); + + // Make 5 requests within limit + for (let i = 0; i < 5; i++) { + await limiter(req, res, next); + expect(next).toHaveBeenCalled(); + } + }); + + it('should block requests exceeding limit', async () => { + const limiter = createRateLimiter({ max: 2 }); + + // Mock the rate limiter to reject after 2 requests + let requestCount = 0; + mockRateLimitMiddleware.mockImplementation((req, res, next) => { + requestCount++; + if (requestCount <= 2) { + next(); + } else { + res.status(429).json({ error: 'Too many requests' }); + } + }); + + // First 2 requests should pass + await limiter(req, res, next); + expect(next).toHaveBeenCalled(); + jest.clearAllMocks(); + + await limiter(req, res, next); + expect(next).toHaveBeenCalled(); + jest.clearAllMocks(); + + // Third request should be blocked + await limiter(req, res, next); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ error: 'Too many requests' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should include rate limit headers', async () => { + const limiter = createRateLimiter(); + + // Mock rate limiter to set headers + mockRateLimitMiddleware.mockImplementation((req, res, next) => { + res.set('X-RateLimit-Limit', '100'); + res.set('X-RateLimit-Remaining', '99'); + res.set('X-RateLimit-Reset', '1640995200'); + next(); + }); + + await limiter(req, res, next); + + expect(res.set).toHaveBeenCalledWith('X-RateLimit-Limit', '100'); + expect(res.set).toHaveBeenCalledWith('X-RateLimit-Remaining', '99'); + expect(res.set).toHaveBeenCalledWith('X-RateLimit-Reset', '1640995200'); + }); + }); + + describe('Different Rate Limit Strategies', () => { + it('should create strict rate limiter for sensitive endpoints', () => { + const strictLimiter = createRateLimiter({ + windowMs: 60 * 1000, // 1 minute + max: 5, // 5 requests per minute + message: 'Rate limit exceeded for sensitive operation' + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + windowMs: 60 * 1000, + max: 5, + message: 'Rate limit exceeded for sensitive operation' + })); + }); + + it('should create lenient rate limiter for public endpoints', () => { + const lenientLimiter = createRateLimiter({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // 1000 requests per 15 minutes + skipSuccessfulRequests: false, + skipFailedRequests: true + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + windowMs: 15 * 60 * 1000, + max: 1000, + skipSuccessfulRequests: false, + skipFailedRequests: true + })); + }); + + it('should create rate limiter with custom key generator', () => { + const customKeyGenerator = (req: Request) => { + return req.headers['x-api-key'] as string || req.ip; + }; + + const limiter = createRateLimiter({ + keyGenerator: customKeyGenerator + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + keyGenerator: customKeyGenerator + })); + }); + }); + + describe('Error Handling', () => { + it('should handle rate limiter errors gracefully', async () => { + const limiter = createRateLimiter(); + + // Mock rate limiter to throw error + mockRateLimitMiddleware.mockImplementation((req, res, next) => { + throw new Error('Rate limiter error'); + }); + + // The middleware should not crash the application + expect(() => limiter(req, res, next)).not.toThrow(); + }); + + it('should work with missing IP address', async () => { + const limiter = createRateLimiter(); + req.ip = undefined; + + await limiter(req, res, next); + + expect(mockRateLimitMiddleware).toHaveBeenCalledWith( + expect.objectContaining({ ip: undefined }), + res, + next + ); + }); + }); + + describe('Rate Limiting by User', () => { + it('should rate limit by user ID when authenticated', async () => { + const limiter = createRateLimiter({ + keyGenerator: (req: Request) => { + const user = (req as any).user; + return user ? `user:${user.id}` : req.ip; + } + }); + + // Mock authenticated request + (req as any).user = { id: 'user-123' }; + + await limiter(req, res, next); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + keyGenerator: expect.any(Function) + })); + }); + + it('should rate limit by IP when not authenticated', async () => { + const limiter = createRateLimiter({ + keyGenerator: (req: Request) => { + const user = (req as any).user; + return user ? `user:${user.id}` : req.ip; + } + }); + + // Mock unauthenticated request + req.ip = '192.168.1.1'; + + await limiter(req, res, next); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + keyGenerator: expect.any(Function) + })); + }); + }); + + describe('Rate Limiting for Different HTTP Methods', () => { + it('should have stricter limits for POST requests', () => { + const postLimiter = createRateLimiter({ + windowMs: 15 * 60 * 1000, + max: 50, // Stricter for POST + skip: (req) => req.method !== 'POST' + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + max: 50, + skip: expect.any(Function) + })); + }); + + it('should have lenient limits for GET requests', () => { + const getLimiter = createRateLimiter({ + windowMs: 15 * 60 * 1000, + max: 200, // More lenient for GET + skip: (req) => req.method !== 'GET' + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + max: 200, + skip: expect.any(Function) + })); + }); + }); + + describe('Dynamic Rate Limiting', () => { + it('should adjust limits based on user role', () => { + const dynamicLimiter = createRateLimiter({ + max: (req) => { + const user = (req as any).user; + if (!user) return 10; // Unauthenticated users + if (user.role === 'ADMIN') return 1000; // Admins + if (user.role === 'PREMIUM') return 500; // Premium users + return 100; // Regular users + } + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + max: expect.any(Function) + })); + }); + + it('should adjust window size based on endpoint sensitivity', () => { + const sensitiveLimiter = createRateLimiter({ + windowMs: (req) => { + const path = req.path; + if (path.includes('/auth/login')) return 15 * 60 * 1000; // 15 minutes for login + if (path.includes('/api/')) return 5 * 60 * 1000; // 5 minutes for API + return 60 * 60 * 1000; // 1 hour for others + } + }); + + expect(mockRateLimit).toHaveBeenCalledWith(expect.objectContaining({ + windowMs: expect.any(Function) + })); + }); + }); +}); diff --git a/backend/tests/unit/services/AuditService.test.ts b/backend/tests/unit/services/AuditService.test.ts new file mode 100644 index 00000000..4d2519db --- /dev/null +++ b/backend/tests/unit/services/AuditService.test.ts @@ -0,0 +1,211 @@ +import { AuditService } from '../../../services/AuditService'; +import { AuditAction, AuditSeverity } from '../../../services/AuditService'; +import auditClient from '../../../databases/clients/auditClient'; + +jest.mock('../../../databases/clients/auditClient'); +jest.mock('../../../services/logger'); + +const mockAuditClient = auditClient as jest.Mocked; + +describe('AuditService Unit Tests', () => { + let auditService: AuditService; + + beforeEach(() => { + jest.clearAllMocks(); + auditService = new AuditService(); + }); + + describe('logAuditEvent', () => { + it('should log audit event successfully', async () => { + const eventData = { + userId: 'user-123', + action: AuditAction.USER_LOGIN, + resource: 'auth', + severity: AuditSeverity.INFO, + ipAddress: '192.168.1.1', + userAgent: 'Mozilla/5.0', + details: { loginMethod: 'password' } + }; + + mockAuditClient.auditLog.create.mockResolvedValue({ + id: 'audit-1', + ...eventData, + timestamp: new Date() + }); + + const result = await auditService.logAuditEvent(eventData); + + expect(result.success).toBe(true); + expect(mockAuditClient.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining(eventData) + }); + }); + + it('should handle audit logging errors gracefully', async () => { + const eventData = { + userId: 'user-123', + action: AuditAction.USER_LOGIN, + resource: 'auth', + severity: AuditSeverity.INFO + }; + + mockAuditClient.auditLog.create.mockRejectedValue(new Error('Database error')); + + const result = await auditService.logAuditEvent(eventData); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to log audit event'); + }); + + it('should auto-generate trace ID if not provided', async () => { + const eventData = { + userId: 'user-123', + action: AuditAction.USER_LOGIN, + resource: 'auth', + severity: AuditSeverity.INFO + }; + + mockAuditClient.auditLog.create.mockResolvedValue({ + id: 'audit-1', + ...eventData, + traceId: expect.any(String) + }); + + await auditService.logAuditEvent(eventData); + + expect(mockAuditClient.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + traceId: expect.any(String) + }) + }); + }); + }); + + describe('searchAuditLogs', () => { + it('should search audit logs with filters', async () => { + const searchParams = { + userId: 'user-123', + action: AuditAction.USER_LOGIN, + limit: 10, + offset: 0 + }; + + const mockLogs = [ + { + id: 'audit-1', + userId: 'user-123', + action: AuditAction.USER_LOGIN, + timestamp: new Date() + } + ]; + + mockAuditClient.auditLog.findMany.mockResolvedValue(mockLogs); + mockAuditClient.auditLog.count.mockResolvedValue(1); + + const result = await auditService.searchAuditLogs(searchParams); + + expect(result.logs).toEqual(mockLogs); + expect(result.total).toBe(1); + expect(mockAuditClient.auditLog.findMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + userId: 'user-123', + action: AuditAction.USER_LOGIN + }), + orderBy: { timestamp: 'desc' }, + take: 10, + skip: 0 + }); + }); + + it('should handle date range filters', async () => { + const searchParams = { + startDate: new Date('2024-01-01'), + endDate: new Date('2024-01-31'), + limit: 10 + }; + + mockAuditClient.auditLog.findMany.mockResolvedValue([]); + mockAuditClient.auditLog.count.mockResolvedValue(0); + + await auditService.searchAuditLogs(searchParams); + + expect(mockAuditClient.auditLog.findMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ + timestamp: { + gte: new Date('2024-01-01'), + lte: new Date('2024-01-31') + } + }), + take: 10, + skip: 0 + }); + }); + + it('should handle search errors', async () => { + const searchParams = { limit: 10 }; + + mockAuditClient.auditLog.findMany.mockRejectedValue(new Error('Search error')); + + const result = await auditService.searchAuditLogs(searchParams); + + expect(result.logs).toEqual([]); + expect(result.total).toBe(0); + }); + }); + + describe('getAuditStats', () => { + it('should return audit statistics', async () => { + const mockStats = [ + { action: AuditAction.USER_LOGIN, count: 100 }, + { action: AuditAction.USER_LOGOUT, count: 95 } + ]; + + mockAuditClient.auditLog.groupBy.mockResolvedValue(mockStats); + + const result = await auditService.getAuditStats(); + + expect(result).toEqual(mockStats); + expect(mockAuditClient.auditLog.groupBy).toHaveBeenCalledWith({ + by: ['action'], + _count: true + }); + }); + + it('should handle stats errors', async () => { + mockAuditClient.auditLog.groupBy.mockRejectedValue(new Error('Stats error')); + + const result = await auditService.getAuditStats(); + + expect(result).toEqual([]); + }); + }); + + describe('cleanupOldLogs', () => { + it('should cleanup old audit logs', async () => { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - 90); + + mockAuditClient.auditLog.deleteMany.mockResolvedValue({ count: 100 }); + + const result = await auditService.cleanupOldLogs(90); + + expect(result.deletedCount).toBe(100); + expect(mockAuditClient.auditLog.deleteMany).toHaveBeenCalledWith({ + where: { + timestamp: { + lt: cutoffDate + } + } + }); + }); + + it('should handle cleanup errors', async () => { + mockAuditClient.auditLog.deleteMany.mockRejectedValue(new Error('Cleanup error')); + + const result = await auditService.cleanupOldLogs(90); + + expect(result.deletedCount).toBe(0); + expect(result.error).toContain('Failed to cleanup old logs'); + }); + }); +}); diff --git a/backend/tests/unit/services/EmailService.test.ts b/backend/tests/unit/services/EmailService.test.ts new file mode 100644 index 00000000..ef5fc034 --- /dev/null +++ b/backend/tests/unit/services/EmailService.test.ts @@ -0,0 +1,258 @@ +import { EmailService, EmailOptions, EmailTemplate } from '../../../services/EmailService'; +import nodemailer from 'nodemailer'; +import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses'; + +jest.mock('nodemailer'); +jest.mock('@aws-sdk/client-ses'); +jest.mock('../../../services/logger'); + +const mockNodemailer = nodemailer as jest.Mocked; +const mockSES = { SESClient, SendEmailCommand } as jest.Mocked; + +describe('EmailService Unit Tests', () => { + let emailService: EmailService; + let mockTransporter: jest.Mocked; + let mockSesClient: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + + // Mock nodemailer transporter + mockTransporter = { + sendMail: jest.fn().mockResolvedValue({ messageId: 'test-message-id' }), + verify: jest.fn().mockResolvedValue(true) + } as any; + + mockNodemailer.createTransport.mockReturnValue(mockTransporter); + + // Mock SES client + mockSesClient = { + send: jest.fn().mockResolvedValue({ MessageId: 'ses-message-id' }) + } as any; + + mockSES.SESClient.mockImplementation(() => mockSesClient); + + emailService = EmailService.getInstance(); + }); + + describe('sendEmail', () => { + it('should send email using nodemailer when SES is not configured', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: 'Test Subject', + html: '

Test HTML

', + text: 'Test text' + }; + + // Mock environment without SES + delete process.env.AWS_ACCESS_KEY_ID; + delete process.env.AWS_SECRET_ACCESS_KEY; + + const result = await emailService.sendEmail(emailOptions); + + expect(result.success).toBe(true); + expect(result.messageId).toBe('test-message-id'); + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + to: 'test@example.com', + subject: 'Test Subject', + html: '

Test HTML

', + text: 'Test text' + })); + }); + + it('should send email using SES when configured', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: 'Test Subject', + html: '

Test HTML

' + }; + + // Mock SES environment + process.env.AWS_ACCESS_KEY_ID = 'test-key'; + process.env.AWS_SECRET_ACCESS_KEY = 'test-secret'; + process.env.AWS_REGION = 'us-east-1'; + + // Create new instance to trigger SES initialization + emailService = new EmailService(); + + const result = await emailService.sendEmail(emailOptions); + + expect(result.success).toBe(true); + expect(mockSesClient.send).toHaveBeenCalledWith(expect.any(SendEmailCommand)); + }); + + it('should handle multiple recipients', async () => { + const emailOptions: EmailOptions = { + to: ['test1@example.com', 'test2@example.com'], + subject: 'Test Subject', + html: '

Test HTML

' + }; + + const result = await emailService.sendEmail(emailOptions); + + expect(result.success).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + to: ['test1@example.com', 'test2@example.com'] + })); + }); + + it('should handle email sending failures', async () => { + const emailOptions: EmailOptions = { + to: 'test@example.com', + subject: 'Test Subject', + html: '

Test HTML

' + }; + + mockTransporter.sendMail.mockRejectedValue(new Error('SMTP Error')); + + const result = await emailService.sendEmail(emailOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to send email'); + }); + + it('should validate email options', async () => { + const invalidOptions = { + subject: 'Test Subject', + html: '

Test HTML

' + } as EmailOptions; // Missing 'to' field + + const result = await emailService.sendEmail(invalidOptions); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid email options'); + }); + }); + + describe('sendTemplateEmail', () => { + it('should send email using template', async () => { + const template: EmailTemplate = { + subject: 'Welcome {{name}}', + html: '

Hello {{name}}, welcome to our platform!

', + text: 'Hello {{name}}, welcome to our platform!' + }; + + const templateData = { name: 'John Doe' }; + const emailOptions: EmailOptions = { + to: 'john@example.com' + }; + + const result = await emailService.sendTemplateEmail(emailOptions, template, templateData); + + expect(result.success).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + to: 'john@example.com', + subject: 'Welcome John Doe', + html: '

Hello John Doe, welcome to our platform!

', + text: 'Hello John Doe, welcome to our platform!' + })); + }); + + it('should handle template variable replacement', async () => { + const template: EmailTemplate = { + subject: 'Order #{{orderId}} Confirmation', + html: '

Your order #{{orderId}} has been {{status}}

' + }; + + const templateData = { orderId: '12345', status: 'confirmed' }; + const emailOptions: EmailOptions = { + to: 'customer@example.com' + }; + + await emailService.sendTemplateEmail(emailOptions, template, templateData); + + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + subject: 'Order #12345 Confirmation', + html: '

Your order #12345 has been confirmed

' + })); + }); + + it('should handle missing template variables gracefully', async () => { + const template: EmailTemplate = { + subject: 'Hello {{name}}', + html: '

Welcome {{name}}!

' + }; + + const templateData = {}; // Missing 'name' + const emailOptions: EmailOptions = { + to: 'test@example.com' + }; + + const result = await emailService.sendTemplateEmail(emailOptions, template, templateData); + + expect(result.success).toBe(true); + expect(mockTransporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({ + subject: 'Hello {{name}}', + html: '

Welcome {{name}}!

' + })); + }); + }); + + describe('verifyConnection', () => { + it('should verify nodemailer connection', async () => { + const result = await emailService.verifyConnection(); + + expect(result.success).toBe(true); + expect(mockTransporter.verify).toHaveBeenCalled(); + }); + + it('should handle verification failures', async () => { + mockTransporter.verify.mockRejectedValue(new Error('Connection failed')); + + const result = await emailService.verifyConnection(); + + expect(result.success).toBe(false); + expect(result.error).toContain('Failed to verify email connection'); + }); + }); + + describe('Singleton Pattern', () => { + it('should return the same instance', () => { + const instance1 = EmailService.getInstance(); + const instance2 = EmailService.getInstance(); + + expect(instance1).toBe(instance2); + }); + }); + + describe('Email Validation', () => { + it('should validate email addresses', async () => { + const validEmails = [ + 'test@example.com', + 'user.name@domain.co.uk', + 'user+tag@example.org' + ]; + + for (const email of validEmails) { + const emailOptions: EmailOptions = { + to: email, + subject: 'Test', + html: '

Test

' + }; + + const result = await emailService.sendEmail(emailOptions); + expect(result.success).toBe(true); + } + }); + + it('should reject invalid email addresses', async () => { + const invalidEmails = [ + 'invalid-email', + '@domain.com', + 'user@', + 'user..name@domain.com' + ]; + + for (const email of invalidEmails) { + const emailOptions: EmailOptions = { + to: email, + subject: 'Test', + html: '

Test

' + }; + + const result = await emailService.sendEmail(emailOptions); + expect(result.success).toBe(false); + } + }); + }); +}); diff --git a/backend/tests/unit/services/RbacService.test.ts b/backend/tests/unit/services/RbacService.test.ts new file mode 100644 index 00000000..7eff5c51 --- /dev/null +++ b/backend/tests/unit/services/RbacService.test.ts @@ -0,0 +1,521 @@ +import { RbacService } from '../../../services/RbacService'; +import { PrismaClient, Role, Permission, UserRoleAssignment, RolePermission, ResourceType, PermissionScope } from '@prisma/client'; +import { AuditAction, AuditSeverity } from '../../../services/AuditService'; + +jest.mock('@prisma/client'); +jest.mock('../../../services/AuditService'); + +const mockPrisma = PrismaClient as jest.MockedClass; +const mockAuditService = { + logAuditEvent: jest.fn().mockResolvedValue({ success: true }) +}; + +describe('RbacService Unit Tests', () => { + let rbacService: RbacService; + let mockPrismaClient: any; + + beforeEach(() => { + jest.clearAllMocks(); + + mockPrismaClient = { + role: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn() + }, + permission: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn() + }, + userRoleAssignment: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findFirst: jest.fn() + }, + rolePermission: { + create: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn() + }, + user: { + findUnique: jest.fn(), + findMany: jest.fn() + }, + $transaction: jest.fn() + }; + + mockPrisma.mockImplementation(() => mockPrismaClient); + rbacService = new RbacService(); + }); + + describe('createRole', () => { + it('should create a new role successfully', async () => { + const roleData = { + name: 'Test Role', + description: 'A test role', + scope: PermissionScope.GLOBAL, + isSystem: false + }; + + const mockRole = { + id: 'role-1', + ...roleData, + isActive: true, + createdAt: new Date(), + updatedAt: new Date() + }; + + mockPrismaClient.role.create.mockResolvedValue(mockRole); + + const result = await rbacService.createRole(roleData); + + expect(result.success).toBe(true); + expect(result.role).toEqual(mockRole); + expect(mockPrismaClient.role.create).toHaveBeenCalledWith({ + data: roleData + }); + expect(mockAuditService.logAuditEvent).toHaveBeenCalledWith({ + userId: expect.any(String), + action: AuditAction.ADMIN_SYSTEM_CONFIG, + resource: 'role', + severity: AuditSeverity.INFO, + details: expect.objectContaining({ roleName: roleData.name }) + }); + }); + + it('should handle duplicate role names', async () => { + const roleData = { + name: 'Existing Role', + description: 'A test role' + }; + + mockPrismaClient.role.create.mockRejectedValue(new Error('Unique constraint failed')); + + const result = await rbacService.createRole(roleData); + + expect(result.success).toBe(false); + expect(result.error).toContain('Role already exists'); + }); + }); + + describe('assignRoleToUser', () => { + it('should assign role to user successfully', async () => { + const assignmentData = { + userId: 'user-1', + roleId: 'role-1', + assignedBy: 'admin-1', + expiresAt: new Date('2024-12-31') + }; + + const mockAssignment = { + id: 'assignment-1', + ...assignmentData, + isActive: true, + createdAt: new Date() + }; + + mockPrismaClient.userRoleAssignment.create.mockResolvedValue(mockAssignment); + mockPrismaClient.role.findUnique.mockResolvedValue({ id: 'role-1', name: 'Test Role' }); + mockPrismaClient.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' }); + + const result = await rbacService.assignRoleToUser(assignmentData); + + expect(result.success).toBe(true); + expect(result.assignment).toEqual(mockAssignment); + expect(mockAuditService.logAuditEvent).toHaveBeenCalledWith({ + userId: assignmentData.assignedBy, + action: AuditAction.ADMIN_UPDATE_USER_ROLE, + resource: 'user_role', + severity: AuditSeverity.INFO, + details: expect.objectContaining({ + userId: assignmentData.userId, + roleId: assignmentData.roleId + }) + }); + }); + + it('should prevent duplicate role assignments', async () => { + const assignmentData = { + userId: 'user-1', + roleId: 'role-1', + assignedBy: 'admin-1' + }; + + mockPrismaClient.userRoleAssignment.findFirst.mockResolvedValue({ id: 'existing-assignment' }); + + const result = await rbacService.assignRoleToUser(assignmentData); + + expect(result.success).toBe(false); + expect(result.error).toContain('User already has this role'); + }); + }); + + describe('checkPermission', () => { + it('should return true for user with required permission', async () => { + const userId = 'user-1'; + const permissionCheck = { + resource: ResourceType.USER, + action: 'read', + scope: PermissionScope.GLOBAL + }; + + // Mock user role assignments + mockPrismaClient.userRoleAssignment.findMany.mockResolvedValue([ + { + id: 'assignment-1', + userId, + roleId: 'role-1', + isActive: true + } + ]); + + // Mock role permissions + mockPrismaClient.rolePermission.findMany.mockResolvedValue([ + { + id: 'role-perm-1', + roleId: 'role-1', + permission: { + id: 'perm-1', + resource: ResourceType.USER, + action: 'read', + scope: PermissionScope.GLOBAL, + isActive: true + } + } + ]); + + const result = await rbacService.checkPermission(userId, permissionCheck); + + expect(result).toBe(true); + }); + + it('should return false for user without required permission', async () => { + const userId = 'user-1'; + const permissionCheck = { + resource: ResourceType.USER, + action: 'delete', + scope: PermissionScope.GLOBAL + }; + + mockPrismaClient.userRoleAssignment.findMany.mockResolvedValue([ + { + id: 'assignment-1', + userId, + roleId: 'role-1', + isActive: true + } + ]); + + mockPrismaClient.rolePermission.findMany.mockResolvedValue([ + { + id: 'role-perm-1', + roleId: 'role-1', + permission: { + id: 'perm-1', + resource: ResourceType.USER, + action: 'read', // Different action + scope: PermissionScope.GLOBAL, + isActive: true + } + } + ]); + + const result = await rbacService.checkPermission(userId, permissionCheck); + + expect(result).toBe(false); + }); + + it('should handle expired role assignments', async () => { + const userId = 'user-1'; + const permissionCheck = { + resource: ResourceType.USER, + action: 'read', + scope: PermissionScope.GLOBAL + }; + + mockPrismaClient.userRoleAssignment.findMany.mockResolvedValue([ + { + id: 'assignment-1', + userId, + roleId: 'role-1', + isActive: true, + expiresAt: new Date('2023-01-01') // Expired + } + ]); + + const result = await rbacService.checkPermission(userId, permissionCheck); + + expect(result).toBe(false); + }); + }); + + describe('createPermission', () => { + it('should create a new permission successfully', async () => { + const permissionData = { + name: 'read_users', + description: 'Read user data', + resource: ResourceType.USER, + action: 'read', + scope: PermissionScope.GLOBAL, + isSystem: false + }; + + const mockPermission = { + id: 'perm-1', + ...permissionData, + isActive: true, + createdAt: new Date(), + updatedAt: new Date() + }; + + mockPrismaClient.permission.create.mockResolvedValue(mockPermission); + + const result = await rbacService.createPermission(permissionData); + + expect(result.success).toBe(true); + expect(result.permission).toEqual(mockPermission); + expect(mockPrismaClient.permission.create).toHaveBeenCalledWith({ + data: permissionData + }); + }); + + it('should handle duplicate permission names', async () => { + const permissionData = { + name: 'read_users', + resource: ResourceType.USER, + action: 'read' + }; + + mockPrismaClient.permission.create.mockRejectedValue(new Error('Unique constraint failed')); + + const result = await rbacService.createPermission(permissionData); + + expect(result.success).toBe(false); + expect(result.error).toContain('Permission already exists'); + }); + }); + + describe('assignPermissionToRole', () => { + it('should assign permission to role successfully', async () => { + const roleId = 'role-1'; + const permissionId = 'perm-1'; + + const mockRolePermission = { + id: 'role-perm-1', + roleId, + permissionId, + createdAt: new Date() + }; + + mockPrismaClient.rolePermission.create.mockResolvedValue(mockRolePermission); + mockPrismaClient.role.findUnique.mockResolvedValue({ id: roleId, name: 'Test Role' }); + mockPrismaClient.permission.findUnique.mockResolvedValue({ id: permissionId, name: 'read_users' }); + + const result = await rbacService.assignPermissionToRole(roleId, permissionId); + + expect(result.success).toBe(true); + expect(result.rolePermission).toEqual(mockRolePermission); + expect(mockAuditService.logAuditEvent).toHaveBeenCalledWith({ + userId: expect.any(String), + action: AuditAction.ADMIN_SYSTEM_CONFIG, + resource: 'role_permission', + severity: AuditSeverity.INFO, + details: expect.objectContaining({ + roleId, + permissionId + }) + }); + }); + + it('should prevent duplicate permission assignments', async () => { + const roleId = 'role-1'; + const permissionId = 'perm-1'; + + mockPrismaClient.rolePermission.findUnique.mockResolvedValue({ id: 'existing-assignment' }); + + const result = await rbacService.assignPermissionToRole(roleId, permissionId); + + expect(result.success).toBe(false); + expect(result.error).toContain('Role already has this permission'); + }); + }); + + describe('removeRoleFromUser', () => { + it('should remove role from user successfully', async () => { + const userId = 'user-1'; + const roleId = 'role-1'; + const removedBy = 'admin-1'; + + const mockAssignment = { + id: 'assignment-1', + userId, + roleId, + isActive: true + }; + + mockPrismaClient.userRoleAssignment.findFirst.mockResolvedValue(mockAssignment); + mockPrismaClient.userRoleAssignment.update.mockResolvedValue({ ...mockAssignment, isActive: false }); + + const result = await rbacService.removeRoleFromUser(userId, roleId, removedBy); + + expect(result.success).toBe(true); + expect(mockPrismaClient.userRoleAssignment.update).toHaveBeenCalledWith({ + where: { id: 'assignment-1' }, + data: { isActive: false } + }); + expect(mockAuditService.logAuditEvent).toHaveBeenCalledWith({ + userId: removedBy, + action: AuditAction.ADMIN_UPDATE_USER_ROLE, + resource: 'user_role', + severity: AuditSeverity.INFO, + details: expect.objectContaining({ + userId, + roleId, + action: 'removed' + }) + }); + }); + + it('should handle non-existent role assignment', async () => { + const userId = 'user-1'; + const roleId = 'role-1'; + const removedBy = 'admin-1'; + + mockPrismaClient.userRoleAssignment.findFirst.mockResolvedValue(null); + + const result = await rbacService.removeRoleFromUser(userId, roleId, removedBy); + + expect(result.success).toBe(false); + expect(result.error).toContain('Role assignment not found'); + }); + }); + + describe('getUserRoles', () => { + it('should return all active roles for a user', async () => { + const userId = 'user-1'; + + const mockRoleAssignments = [ + { + id: 'assignment-1', + userId, + roleId: 'role-1', + isActive: true, + role: { + id: 'role-1', + name: 'Admin', + description: 'Administrator role', + isActive: true + } + }, + { + id: 'assignment-2', + userId, + roleId: 'role-2', + isActive: true, + role: { + id: 'role-2', + name: 'User', + description: 'Regular user role', + isActive: true + } + } + ]; + + mockPrismaClient.userRoleAssignment.findMany.mockResolvedValue(mockRoleAssignments); + + const result = await rbacService.getUserRoles(userId); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('Admin'); + expect(result[1].name).toBe('User'); + }); + + it('should exclude inactive roles', async () => { + const userId = 'user-1'; + + const mockRoleAssignments = [ + { + id: 'assignment-1', + userId, + roleId: 'role-1', + isActive: true, + role: { + id: 'role-1', + name: 'Admin', + isActive: true + } + }, + { + id: 'assignment-2', + userId, + roleId: 'role-2', + isActive: false, // Inactive assignment + role: { + id: 'role-2', + name: 'User', + isActive: true + } + } + ]; + + mockPrismaClient.userRoleAssignment.findMany.mockResolvedValue(mockRoleAssignments); + + const result = await rbacService.getUserRoles(userId); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('Admin'); + }); + }); + + describe('getRolePermissions', () => { + it('should return all permissions for a role', async () => { + const roleId = 'role-1'; + + const mockRolePermissions = [ + { + id: 'role-perm-1', + roleId, + permissionId: 'perm-1', + permission: { + id: 'perm-1', + name: 'read_users', + resource: ResourceType.USER, + action: 'read', + isActive: true + } + }, + { + id: 'role-perm-2', + roleId, + permissionId: 'perm-2', + permission: { + id: 'perm-2', + name: 'write_users', + resource: ResourceType.USER, + action: 'write', + isActive: true + } + } + ]; + + mockPrismaClient.rolePermission.findMany.mockResolvedValue(mockRolePermissions); + + const result = await rbacService.getRolePermissions(roleId); + + expect(result).toHaveLength(2); + expect(result[0].name).toBe('read_users'); + expect(result[1].name).toBe('write_users'); + }); + }); +});