diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..cf3f673b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci --only=production + +COPY dist ./dist + +EXPOSE 3000 + +CMD ["node", "dist/main"] diff --git a/facilpay-api b/facilpay-api new file mode 160000 index 00000000..0ebcd696 --- /dev/null +++ b/facilpay-api @@ -0,0 +1 @@ +Subproject commit 0ebcd696d54027d2398114d86472feadbafbbe7c diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..9b58549e --- /dev/null +++ b/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + collectCoverageFrom: [ + '**/*.(t|j)s', + ], + coverageDirectory: '../coverage', + testEnvironment: 'node', +}; diff --git a/src/app.service.spec.ts b/src/app.service.spec.ts new file mode 100644 index 00000000..20579327 --- /dev/null +++ b/src/app.service.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppService } from './app.service'; + +describe('AppService', () => { + let service: AppService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AppService], + }).compile(); + + service = module.get(AppService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return "Hello World!"', () => { + expect(service.getHello()).toBe('Hello World!'); + }); +}); diff --git a/src/common/filters/global-exception.filter.spec.ts b/src/common/filters/global-exception.filter.spec.ts new file mode 100644 index 00000000..9ba01482 --- /dev/null +++ b/src/common/filters/global-exception.filter.spec.ts @@ -0,0 +1,407 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpException, HttpStatus, BadRequestException, UnauthorizedException, ConflictException } from '@nestjs/common'; +import { GlobalExceptionFilter } from './global-exception.filter'; +import { Request, Response } from 'express'; +import { QueryFailedError } from 'typeorm'; + +describe('GlobalExceptionFilter', () => { + let filter: GlobalExceptionFilter; + + beforeEach(() => { + filter = new GlobalExceptionFilter(); + }); + + describe('HttpException handling', () => { + it('should handle BadRequestException with proper format', () => { + const mockRequest = { + url: '/test', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const exception = new BadRequestException('Name is required'); + + filter.catch(exception, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.BAD_REQUEST, + message: expect.any(String), + error: expect.any(String), + timestamp: expect.any(String), + path: '/test', + }), + ); + }); + + it('should handle UnauthorizedException with 401 status', () => { + const mockRequest = { + url: '/protected', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const exception = new UnauthorizedException('Invalid credentials'); + + filter.catch(exception, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.UNAUTHORIZED, + error: expect.any(String), + timestamp: expect.any(String), + path: '/protected', + }), + ); + }); + + it('should handle ConflictException with 409 status', () => { + const mockRequest = { + url: '/api/users', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const exception = new ConflictException('Email already exists'); + + filter.catch(exception, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: HttpStatus.CONFLICT, + error: expect.any(String), + path: '/api/users', + }), + ); + }); + + it('should handle validation errors array format', () => { + const mockRequest = { + url: '/api/data', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const validationErrors = ['name must not be empty', 'email must be valid']; + const exception = new BadRequestException({ + message: validationErrors, + error: 'Bad Request', + }); + + filter.catch(exception, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.message).toContain('name must not be empty'); + expect(jsonCall.message).toContain('email must be valid'); + }); + }); + + describe('QueryFailedError handling', () => { + it('should handle duplicate key error safely', () => { + const mockRequest = { + url: '/api/users', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const queryError = new QueryFailedError( + 'SELECT * FROM users WHERE email = ?', + ['test@email.com'], + new Error('Duplicate entry for key email_unique'), + ); + + filter.catch(queryError, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.error).toBe('QueryFailedError'); + expect(jsonCall.message).toBe('A resource with this value already exists'); + expect(jsonCall.statusCode).toBe(HttpStatus.BAD_REQUEST); + // Ensure raw SQL is NOT exposed + expect(jsonCall.message).not.toContain('SELECT'); + }); + + it('should handle constraint error safely', () => { + const mockRequest = { + url: '/api/data', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const queryError = new QueryFailedError( + 'UPDATE users SET email = ? WHERE id = ?', + ['newemail@email.com', '1'], + new Error('Foreign key constraint failed'), + ); + + filter.catch(queryError, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.message).toBe('Invalid data provided'); + // Ensure raw SQL is NOT exposed + expect(jsonCall.message).not.toContain('UPDATE'); + }); + + it('should not expose raw SQL queries in error messages', () => { + const mockRequest = { + url: '/api/data', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const queryError = new QueryFailedError( + 'DELETE FROM users WHERE id = ? AND age > ?', + ['5', '18'], + new Error('Syntax error in query'), + ); + + filter.catch(queryError, mockArgumentsHost); + + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.message).not.toContain('DELETE'); + expect(jsonCall.message).not.toContain('query'); + }); + }); + + describe('Unknown error handling', () => { + it('should handle unknown errors with 500 status', () => { + const mockRequest = { + url: '/api/unknown', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const error = new Error('Something went wrong'); + + filter.catch(error, mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.statusCode).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(jsonCall.message).toBe('An unexpected error occurred'); + expect(jsonCall.error).toBe('Error'); + }); + + it('should return generic message for unexpected errors', () => { + const mockRequest = { + url: '/api/error', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const error = new Error('Internal database connection lost'); + + filter.catch(error, mockArgumentsHost); + + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.message).toBe('An unexpected error occurred'); + // Ensure internal error details are NOT exposed + expect(jsonCall.message).not.toContain('database'); + expect(jsonCall.message).not.toContain('connection'); + }); + + it('should handle non-Error objects thrown as errors', () => { + const mockRequest = { + url: '/api/error', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + filter.catch('String error', mockArgumentsHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.statusCode).toBe(HttpStatus.INTERNAL_SERVER_ERROR); + expect(jsonCall.message).toBe('An unexpected error occurred'); + }); + }); + + describe('Error response format validation', () => { + it('should include all required fields in response', () => { + const mockRequest = { + url: '/api/test', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const exception = new BadRequestException('Test error'); + + filter.catch(exception, mockArgumentsHost); + + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall).toHaveProperty('statusCode'); + expect(jsonCall).toHaveProperty('message'); + expect(jsonCall).toHaveProperty('error'); + expect(jsonCall).toHaveProperty('timestamp'); + expect(jsonCall).toHaveProperty('path'); + }); + + it('should include valid ISO timestamp', () => { + const mockRequest = { + url: '/api/test', + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const exception = new BadRequestException('Test error'); + + filter.catch(exception, mockArgumentsHost); + + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + const timestamp = new Date(jsonCall.timestamp); + expect(timestamp).toBeInstanceOf(Date); + expect(timestamp.getTime()).not.toBeNaN(); + }); + + it('should match requested path in response', () => { + const testPath = '/api/users/123'; + const mockRequest = { + url: testPath, + } as Request; + + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockArgumentsHost = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + getResponse: jest.fn().mockReturnValue(mockResponse), + }), + } as any; + + const exception = new BadRequestException('Test error'); + + filter.catch(exception, mockArgumentsHost); + + const jsonCall = (mockResponse.json as jest.Mock).mock.calls[0][0]; + expect(jsonCall.path).toBe(testPath); + }); + }); +}); diff --git a/src/common/filters/global-exception.filter.ts b/src/common/filters/global-exception.filter.ts new file mode 100644 index 00000000..600066a5 --- /dev/null +++ b/src/common/filters/global-exception.filter.ts @@ -0,0 +1,145 @@ +import { + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, + ArgumentsHost, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { QueryFailedError } from 'typeorm'; +import { ErrorResponse } from '../interfaces/error-response.interface'; + +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(GlobalExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const path = request.url; + const timestamp = new Date().toISOString(); + + let errorResponse: ErrorResponse; + + if (exception instanceof HttpException) { + errorResponse = this.handleHttpException( + exception, + path, + timestamp, + ); + } else if (exception instanceof QueryFailedError) { + errorResponse = this.handleQueryFailedError( + exception, + path, + timestamp, + ); + } else { + errorResponse = this.handleUnknownError( + exception, + path, + timestamp, + ); + } + + response.status(errorResponse.statusCode).json(errorResponse); + } + + private handleHttpException( + exception: HttpException, + path: string, + timestamp: string, + ): ErrorResponse { + const statusCode = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + let message = 'An error occurred'; + let error = exception.name; + + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + const responseObj = exceptionResponse as Record; + message = + responseObj.message || + (Array.isArray(responseObj.message) + ? responseObj.message.join(', ') + : message); + error = responseObj.error || error; + } else if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } + + const errorPayload: ErrorResponse = { + statusCode, + message: Array.isArray(message) ? message.join(', ') : message, + error, + timestamp, + path, + }; + + this.logger.warn( + `HttpException - ${statusCode}: ${errorPayload.message}`, + ); + + return errorPayload; + } + + private handleQueryFailedError( + exception: QueryFailedError, + path: string, + timestamp: string, + ): ErrorResponse { + const statusCode = HttpStatus.BAD_REQUEST; + const lowerMessage = exception.message.toLowerCase(); + const isDuplicate = lowerMessage.includes('duplicate'); + const isConstraint = lowerMessage.includes('constraint'); + + let message = 'Database error occurred'; + + if (isDuplicate) { + message = 'A resource with this value already exists'; + } else if (isConstraint) { + message = 'Invalid data provided'; + } + + const errorPayload: ErrorResponse = { + statusCode, + message, + error: 'QueryFailedError', + timestamp, + path, + }; + + this.logger.error( + `QueryFailedError - Database operation failed: ${exception.message}`, + exception.stack, + ); + + return errorPayload; + } + + private handleUnknownError( + exception: unknown, + path: string, + timestamp: string, + ): ErrorResponse { + const statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + const message = 'An unexpected error occurred'; + const error = exception instanceof Error ? exception.name : 'UnknownError'; + + const errorPayload: ErrorResponse = { + statusCode, + message, + error, + timestamp, + path, + }; + + this.logger.error( + `UnknownError - An unexpected error occurred`, + exception instanceof Error ? exception.stack : String(exception), + ); + + return errorPayload; + } +}