diff --git a/middleware/package.json b/middleware/package.json index 0ba0c3a3..54eb426e 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -25,10 +25,12 @@ "express": "^5.1.0", "jsonwebtoken": "^9.0.2", "micromatch": "^4.0.8", - "stellar-sdk": "^13.1.0" + "stellar-sdk": "^13.1.0", + "uuid": "^9.0.0" }, "devDependencies": { "@types/express": "^5.0.0", + "@types/uuid": "^9.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", "@typescript-eslint/eslint-plugin": "^8.20.0", diff --git a/middleware/src/errors/constants/error-codes.ts b/middleware/src/errors/constants/error-codes.ts new file mode 100644 index 00000000..2fc55e83 --- /dev/null +++ b/middleware/src/errors/constants/error-codes.ts @@ -0,0 +1,141 @@ +/** + * Standardized error codes for programmatic handling + */ +export enum ErrorCode { + // Authentication errors (AUTH_*) + AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED', + AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID', + AUTH_TOKEN_MISSING = 'AUTH_TOKEN_MISSING', + AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS', + AUTH_SESSION_EXPIRED = 'AUTH_SESSION_EXPIRED', + + // Authorization errors (AUTHZ_*) + INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', + ROLE_NOT_ALLOWED = 'ROLE_NOT_ALLOWED', + ACCESS_DENIED = 'ACCESS_DENIED', + + // Validation errors (VALIDATION_*) + VALIDATION_FAILED = 'VALIDATION_FAILED', + INVALID_INPUT = 'INVALID_INPUT', + MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD', + INVALID_FORMAT = 'INVALID_FORMAT', + + // Resource errors (RESOURCE_*) + RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', + DUPLICATE_RESOURCE = 'DUPLICATE_RESOURCE', + RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', + RESOURCE_LOCKED = 'RESOURCE_LOCKED', + + // Rate limiting + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + + // Database errors (DB_*) + DB_CONNECTION_ERROR = 'DB_CONNECTION_ERROR', + DB_QUERY_ERROR = 'DB_QUERY_ERROR', + DB_CONSTRAINT_VIOLATION = 'DB_CONSTRAINT_VIOLATION', + DB_UNIQUE_VIOLATION = 'DB_UNIQUE_VIOLATION', + DB_FOREIGN_KEY_VIOLATION = 'DB_FOREIGN_KEY_VIOLATION', + DB_TIMEOUT = 'DB_TIMEOUT', + + // External service errors + EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR', + EXTERNAL_SERVICE_TIMEOUT = 'EXTERNAL_SERVICE_TIMEOUT', + EXTERNAL_SERVICE_UNAVAILABLE = 'EXTERNAL_SERVICE_UNAVAILABLE', + + // Server errors + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', + UNKNOWN_ERROR = 'UNKNOWN_ERROR', +} + +/** + * HTTP status code mapping for error codes + */ +export const ErrorCodeHttpStatus: Record = { + // 401 Unauthorized + [ErrorCode.AUTH_TOKEN_EXPIRED]: 401, + [ErrorCode.AUTH_TOKEN_INVALID]: 401, + [ErrorCode.AUTH_TOKEN_MISSING]: 401, + [ErrorCode.AUTH_INVALID_CREDENTIALS]: 401, + [ErrorCode.AUTH_SESSION_EXPIRED]: 401, + + // 403 Forbidden + [ErrorCode.INSUFFICIENT_PERMISSIONS]: 403, + [ErrorCode.ROLE_NOT_ALLOWED]: 403, + [ErrorCode.ACCESS_DENIED]: 403, + + // 400 Bad Request + [ErrorCode.VALIDATION_FAILED]: 400, + [ErrorCode.INVALID_INPUT]: 400, + [ErrorCode.MISSING_REQUIRED_FIELD]: 400, + [ErrorCode.INVALID_FORMAT]: 400, + + // 404 Not Found + [ErrorCode.RESOURCE_NOT_FOUND]: 404, + + // 409 Conflict + [ErrorCode.DUPLICATE_RESOURCE]: 409, + [ErrorCode.RESOURCE_CONFLICT]: 409, + [ErrorCode.RESOURCE_LOCKED]: 423, + + // 429 Too Many Requests + [ErrorCode.RATE_LIMIT_EXCEEDED]: 429, + + // 500+ Server Errors + [ErrorCode.DB_CONNECTION_ERROR]: 503, + [ErrorCode.DB_QUERY_ERROR]: 500, + [ErrorCode.DB_CONSTRAINT_VIOLATION]: 409, + [ErrorCode.DB_UNIQUE_VIOLATION]: 409, + [ErrorCode.DB_FOREIGN_KEY_VIOLATION]: 409, + [ErrorCode.DB_TIMEOUT]: 504, + + [ErrorCode.EXTERNAL_SERVICE_ERROR]: 502, + [ErrorCode.EXTERNAL_SERVICE_TIMEOUT]: 504, + [ErrorCode.EXTERNAL_SERVICE_UNAVAILABLE]: 503, + + [ErrorCode.INTERNAL_SERVER_ERROR]: 500, + [ErrorCode.SERVICE_UNAVAILABLE]: 503, + [ErrorCode.UNKNOWN_ERROR]: 500, +}; + +/** + * Default user-friendly messages for error codes + */ +export const ErrorCodeMessages: Record = { + [ErrorCode.AUTH_TOKEN_EXPIRED]: 'Your session has expired. Please log in again.', + [ErrorCode.AUTH_TOKEN_INVALID]: 'Invalid authentication token.', + [ErrorCode.AUTH_TOKEN_MISSING]: 'Authentication required.', + [ErrorCode.AUTH_INVALID_CREDENTIALS]: 'Invalid email or password.', + [ErrorCode.AUTH_SESSION_EXPIRED]: 'Your session has expired.', + + [ErrorCode.INSUFFICIENT_PERMISSIONS]: 'You do not have permission to perform this action.', + [ErrorCode.ROLE_NOT_ALLOWED]: 'Your role does not allow this action.', + [ErrorCode.ACCESS_DENIED]: 'Access denied.', + + [ErrorCode.VALIDATION_FAILED]: 'Validation failed. Please check your input.', + [ErrorCode.INVALID_INPUT]: 'Invalid input provided.', + [ErrorCode.MISSING_REQUIRED_FIELD]: 'Required field is missing.', + [ErrorCode.INVALID_FORMAT]: 'Invalid format.', + + [ErrorCode.RESOURCE_NOT_FOUND]: 'The requested resource was not found.', + [ErrorCode.DUPLICATE_RESOURCE]: 'A resource with the same identifier already exists.', + [ErrorCode.RESOURCE_CONFLICT]: 'Resource conflict detected.', + [ErrorCode.RESOURCE_LOCKED]: 'Resource is currently locked.', + + [ErrorCode.RATE_LIMIT_EXCEEDED]: 'Too many requests. Please try again later.', + + [ErrorCode.DB_CONNECTION_ERROR]: 'Service temporarily unavailable. Please try again.', + [ErrorCode.DB_QUERY_ERROR]: 'An error occurred while processing your request.', + [ErrorCode.DB_CONSTRAINT_VIOLATION]: 'Operation violates data constraints.', + [ErrorCode.DB_UNIQUE_VIOLATION]: 'A record with this value already exists.', + [ErrorCode.DB_FOREIGN_KEY_VIOLATION]: 'Referenced record does not exist.', + [ErrorCode.DB_TIMEOUT]: 'Request timed out. Please try again.', + + [ErrorCode.EXTERNAL_SERVICE_ERROR]: 'External service error. Please try again.', + [ErrorCode.EXTERNAL_SERVICE_TIMEOUT]: 'External service timed out.', + [ErrorCode.EXTERNAL_SERVICE_UNAVAILABLE]: 'External service is unavailable.', + + [ErrorCode.INTERNAL_SERVER_ERROR]: 'An unexpected error occurred.', + [ErrorCode.SERVICE_UNAVAILABLE]: 'Service is temporarily unavailable.', + [ErrorCode.UNKNOWN_ERROR]: 'An unknown error occurred.', +}; diff --git a/middleware/src/errors/exceptions/base.exception.ts b/middleware/src/errors/exceptions/base.exception.ts new file mode 100644 index 00000000..ca185521 --- /dev/null +++ b/middleware/src/errors/exceptions/base.exception.ts @@ -0,0 +1,57 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { ErrorCode, ErrorCodeHttpStatus, ErrorCodeMessages } from '../constants/error-codes'; +import { ValidationErrorDetail } from '../interfaces/error.interface'; + +/** + * Base exception class for all application errors + */ +export class BaseException extends HttpException { + public readonly errorCode: ErrorCode; + public readonly details?: ValidationErrorDetail[]; + public readonly context?: Record; + + constructor( + errorCode: ErrorCode, + message?: string, + details?: ValidationErrorDetail[], + context?: Record, + ) { + const statusCode = ErrorCodeHttpStatus[errorCode] ?? HttpStatus.INTERNAL_SERVER_ERROR; + const errorMessage = message ?? ErrorCodeMessages[errorCode] ?? 'An error occurred'; + + super( + { + statusCode, + errorCode, + message: errorMessage, + details, + }, + statusCode, + ); + + this.errorCode = errorCode; + this.details = details; + this.context = context; + } + + /** + * Get the error code + */ + getErrorCode(): ErrorCode { + return this.errorCode; + } + + /** + * Get validation error details + */ + getDetails(): ValidationErrorDetail[] | undefined { + return this.details; + } + + /** + * Get additional context + */ + getContext(): Record | undefined { + return this.context; + } +} diff --git a/middleware/src/errors/exceptions/database.exceptions.ts b/middleware/src/errors/exceptions/database.exceptions.ts new file mode 100644 index 00000000..48029e26 --- /dev/null +++ b/middleware/src/errors/exceptions/database.exceptions.ts @@ -0,0 +1,86 @@ +import { ErrorCode } from '../constants/error-codes'; +import { BaseException } from './base.exception'; + +/** + * Database connection error + */ +export class DatabaseConnectionException extends BaseException { + constructor(message?: string) { + super( + ErrorCode.DB_CONNECTION_ERROR, + message ?? 'Database connection failed', + ); + } +} + +/** + * Database query error + */ +export class DatabaseQueryException extends BaseException { + constructor(message?: string, context?: Record) { + super( + ErrorCode.DB_QUERY_ERROR, + message ?? 'Database query failed', + undefined, + context, + ); + } +} + +/** + * Database unique constraint violation + */ +export class UniqueConstraintException extends BaseException { + public readonly constraintName?: string; + public readonly columnName?: string; + + constructor(constraintName?: string, columnName?: string) { + const message = columnName + ? `A record with this ${columnName} already exists` + : 'Duplicate entry violates unique constraint'; + super(ErrorCode.DB_UNIQUE_VIOLATION, message); + this.constraintName = constraintName; + this.columnName = columnName; + } +} + +/** + * Database foreign key constraint violation + */ +export class ForeignKeyConstraintException extends BaseException { + public readonly constraintName?: string; + public readonly referencedTable?: string; + + constructor(constraintName?: string, referencedTable?: string) { + const message = referencedTable + ? `Referenced ${referencedTable} does not exist` + : 'Foreign key constraint violation'; + super(ErrorCode.DB_FOREIGN_KEY_VIOLATION, message); + this.constraintName = constraintName; + this.referencedTable = referencedTable; + } +} + +/** + * Database timeout error + */ +export class DatabaseTimeoutException extends BaseException { + constructor(message?: string) { + super(ErrorCode.DB_TIMEOUT, message ?? 'Database operation timed out'); + } +} + +/** + * General database constraint violation + */ +export class ConstraintViolationException extends BaseException { + public readonly constraintName?: string; + + constructor(constraintName?: string, message?: string) { + super( + ErrorCode.DB_CONSTRAINT_VIOLATION, + message ?? 'Database constraint violation', + ); + this.constraintName = constraintName; + } +} diff --git a/middleware/src/errors/exceptions/http.exceptions.ts b/middleware/src/errors/exceptions/http.exceptions.ts new file mode 100644 index 00000000..88236515 --- /dev/null +++ b/middleware/src/errors/exceptions/http.exceptions.ts @@ -0,0 +1,171 @@ +import { ErrorCode } from '../constants/error-codes'; +import { ValidationErrorDetail } from '../interfaces/error.interface'; +import { BaseException } from './base.exception'; + +/** + * 400 Bad Request - Validation errors + */ +export class ValidationException extends BaseException { + constructor( + details: ValidationErrorDetail[], + message: string = 'Validation failed', + ) { + super(ErrorCode.VALIDATION_FAILED, message, details); + } + + static fromFieldErrors(errors: Record): ValidationException { + const details: ValidationErrorDetail[] = Object.entries(errors).map( + ([field, messages]) => ({ + field, + message: messages.join(', '), + constraints: messages.reduce( + (acc, msg, idx) => ({ ...acc, [`constraint${idx}`]: msg }), + {}, + ), + }), + ); + return new ValidationException(details); + } +} + +/** + * 401 Unauthorized - Authentication errors + */ +export class UnauthorizedException extends BaseException { + constructor( + errorCode: ErrorCode = ErrorCode.AUTH_TOKEN_INVALID, + message?: string, + ) { + super(errorCode, message); + } + + static tokenExpired(): UnauthorizedException { + return new UnauthorizedException(ErrorCode.AUTH_TOKEN_EXPIRED); + } + + static tokenInvalid(): UnauthorizedException { + return new UnauthorizedException(ErrorCode.AUTH_TOKEN_INVALID); + } + + static tokenMissing(): UnauthorizedException { + return new UnauthorizedException(ErrorCode.AUTH_TOKEN_MISSING); + } + + static invalidCredentials(): UnauthorizedException { + return new UnauthorizedException(ErrorCode.AUTH_INVALID_CREDENTIALS); + } +} + +/** + * 403 Forbidden - Authorization errors + */ +export class ForbiddenException extends BaseException { + constructor( + errorCode: ErrorCode = ErrorCode.INSUFFICIENT_PERMISSIONS, + message?: string, + ) { + super(errorCode, message); + } + + static insufficientPermissions(): ForbiddenException { + return new ForbiddenException(ErrorCode.INSUFFICIENT_PERMISSIONS); + } + + static roleNotAllowed(role?: string): ForbiddenException { + return new ForbiddenException( + ErrorCode.ROLE_NOT_ALLOWED, + role ? `Role '${role}' is not allowed to perform this action` : undefined, + ); + } +} + +/** + * 404 Not Found - Resource not found errors + */ +export class NotFoundException extends BaseException { + constructor(resource?: string, identifier?: string) { + const message = resource + ? `${resource}${identifier ? ` with identifier '${identifier}'` : ''} was not found` + : undefined; + super(ErrorCode.RESOURCE_NOT_FOUND, message); + } +} + +/** + * 409 Conflict - Duplicate resource errors + */ +export class ConflictException extends BaseException { + constructor( + errorCode: ErrorCode = ErrorCode.DUPLICATE_RESOURCE, + message?: string, + ) { + super(errorCode, message); + } + + static duplicate(resource: string, field?: string): ConflictException { + const message = field + ? `${resource} with this ${field} already exists` + : `${resource} already exists`; + return new ConflictException(ErrorCode.DUPLICATE_RESOURCE, message); + } + + static conflict(message: string): ConflictException { + return new ConflictException(ErrorCode.RESOURCE_CONFLICT, message); + } +} + +/** + * 429 Too Many Requests - Rate limiting + */ +export class RateLimitException extends BaseException { + public readonly retryAfter?: number; + + constructor(retryAfter?: number, message?: string) { + super( + ErrorCode.RATE_LIMIT_EXCEEDED, + message ?? `Rate limit exceeded${retryAfter ? `. Retry after ${retryAfter} seconds` : ''}`, + ); + this.retryAfter = retryAfter; + } +} + +/** + * 500 Internal Server Error + */ +export class InternalServerException extends BaseException { + constructor(message?: string, context?: Record) { + super(ErrorCode.INTERNAL_SERVER_ERROR, message, undefined, context); + } +} + +/** + * 502/503/504 External Service Errors + */ +export class ExternalServiceException extends BaseException { + constructor( + errorCode: ErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR, + serviceName?: string, + message?: string, + ) { + const errorMessage = serviceName + ? `External service '${serviceName}' error: ${message ?? 'unavailable'}` + : message; + super(errorCode, errorMessage); + } + + static timeout(serviceName: string): ExternalServiceException { + return new ExternalServiceException( + ErrorCode.EXTERNAL_SERVICE_TIMEOUT, + serviceName, + 'request timed out', + ); + } + + static unavailable(serviceName: string): ExternalServiceException { + return new ExternalServiceException( + ErrorCode.EXTERNAL_SERVICE_UNAVAILABLE, + serviceName, + 'service unavailable', + ); + } +} diff --git a/middleware/src/errors/filters/global-exception.filter.ts b/middleware/src/errors/filters/global-exception.filter.ts new file mode 100644 index 00000000..33e0cdd1 --- /dev/null +++ b/middleware/src/errors/filters/global-exception.filter.ts @@ -0,0 +1,173 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ErrorResponse, DevErrorResponse, ErrorContext, ErrorHandlingConfig } from '../interfaces/error.interface'; +import { ErrorCode } from '../constants/error-codes'; +import { mapError } from '../utils/error-mapper'; +import { getCorrelationId, getCorrelationIdHeader } from '../utils/correlation-id'; +import { ErrorLoggerService } from '../services/error-logger.service'; + +/** + * Default configuration + */ +const DEFAULT_CONFIG: ErrorHandlingConfig = { + environment: process.env.NODE_ENV ?? 'development', + includeStackTrace: true, + logErrors: true, + sensitiveHeaders: ['authorization', 'cookie', 'x-api-key'], + sensitiveFields: ['password', 'token', 'secret'], +}; + +/** + * Global exception filter that catches all errors and formats them consistently + */ +@Catch() +export class GlobalExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger('GlobalExceptionFilter'); + private readonly errorLogger: ErrorLoggerService; + private readonly config: ErrorHandlingConfig; + private readonly isProduction: boolean; + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.isProduction = this.config.environment === 'production'; + this.errorLogger = new ErrorLoggerService({ + sensitiveHeaders: this.config.sensitiveHeaders, + sensitiveFields: this.config.sensitiveFields, + }); + } + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + // Generate or extract correlation ID + const correlationId = getCorrelationId(request); + + // Map the error to a standardized format + const mappedError = mapError(exception, this.isProduction); + + // Build error context for logging + const errorContext = this.buildErrorContext(request, correlationId); + + // Log the error + if (this.config.logErrors) { + const logEntry = this.errorLogger.createLogEntry( + exception, + errorContext, + mappedError.statusCode, + mappedError.errorCode, + mappedError.message, + ); + this.errorLogger.logError(logEntry); + } + + // Build and send response + const errorResponse = this.buildErrorResponse( + mappedError, + correlationId, + request.url, + exception, + ); + + // Set correlation ID header in response + response.setHeader(getCorrelationIdHeader(), correlationId); + + response.status(mappedError.statusCode).json(errorResponse); + } + + /** + * Build error context from request + */ + private buildErrorContext(request: Request, correlationId: string): ErrorContext { + // Extract user ID from request if available (set by auth middleware) + const userId = (request as Record).userId as string | undefined; + + // Get relevant headers (exclude sensitive ones for context) + const safeHeaders: Record = {}; + const headerKeys = ['content-type', 'user-agent', 'accept', 'accept-language']; + + for (const key of headerKeys) { + const value = request.headers[key]; + if (typeof value === 'string') { + safeHeaders[key] = value; + } + } + + return { + correlationId, + path: request.url, + method: request.method, + ip: request.ip ?? request.socket?.remoteAddress, + userId, + userAgent: request.headers['user-agent'], + body: request.body, + query: request.query, + params: request.params, + headers: safeHeaders, + }; + } + + /** + * Build the error response object + */ + private buildErrorResponse( + mappedError: ReturnType, + correlationId: string, + path: string, + originalError: unknown, + ): ErrorResponse | DevErrorResponse { + const baseResponse: ErrorResponse = { + statusCode: mappedError.statusCode, + errorCode: mappedError.errorCode, + message: mappedError.message, + correlationId, + timestamp: new Date().toISOString(), + path, + }; + + // Include validation error details + if (mappedError.details && mappedError.details.length > 0) { + baseResponse.details = mappedError.details; + } + + // In development, include additional debugging info + if (!this.isProduction && this.config.includeStackTrace) { + const devResponse: DevErrorResponse = { + ...baseResponse, + }; + + if (mappedError.stack) { + devResponse.stack = mappedError.stack; + } + + if (mappedError.context) { + devResponse.context = mappedError.context; + } + + if (originalError instanceof Error && originalError.cause) { + devResponse.cause = String(originalError.cause); + } + + return devResponse; + } + + return baseResponse; + } +} + +/** + * Factory function to create the global exception filter with custom config + */ +export function createGlobalExceptionFilter( + config?: Partial, +): GlobalExceptionFilter { + return new GlobalExceptionFilter(config); +} diff --git a/middleware/src/errors/index.ts b/middleware/src/errors/index.ts new file mode 100644 index 00000000..12777b39 --- /dev/null +++ b/middleware/src/errors/index.ts @@ -0,0 +1,10 @@ +// Error handling module exports +export * from './constants/error-codes'; +export * from './interfaces/error.interface'; +export * from './exceptions/base.exception'; +export * from './exceptions/http.exceptions'; +export * from './exceptions/database.exceptions'; +export * from './filters/global-exception.filter'; +export * from './utils/error-mapper'; +export * from './utils/correlation-id'; +export * from './services/error-logger.service'; diff --git a/middleware/src/errors/interfaces/error.interface.ts b/middleware/src/errors/interfaces/error.interface.ts new file mode 100644 index 00000000..66807620 --- /dev/null +++ b/middleware/src/errors/interfaces/error.interface.ts @@ -0,0 +1,82 @@ +import { ErrorCode } from '../constants/error-codes'; + +/** + * Validation error detail for field-specific errors + */ +export interface ValidationErrorDetail { + field: string; + message: string; + value?: unknown; + constraints?: Record; +} + +/** + * Standard error response format + */ +export interface ErrorResponse { + statusCode: number; + errorCode: string; + message: string; + details?: ValidationErrorDetail[]; + correlationId: string; + timestamp: string; + path: string; +} + +/** + * Extended error response for development environment + */ +export interface DevErrorResponse extends ErrorResponse { + stack?: string; + context?: Record; + cause?: string; +} + +/** + * Error context for logging + */ +export interface ErrorContext { + correlationId: string; + path: string; + method: string; + ip?: string; + userId?: string; + userAgent?: string; + body?: unknown; + query?: unknown; + params?: unknown; + headers?: Record; +} + +/** + * Error log entry structure + */ +export interface ErrorLogEntry { + timestamp: string; + level: 'error' | 'warn'; + errorCode: ErrorCode | string; + message: string; + statusCode: number; + context: ErrorContext; + stack?: string; + cause?: unknown; + metadata?: Record; +} + +/** + * Configuration for error handling + */ +export interface ErrorHandlingConfig { + /** Environment: 'development' | 'production' | 'test' */ + environment: string; + /** Include stack traces in response (only in development) */ + includeStackTrace?: boolean; + /** Log all errors */ + logErrors?: boolean; + /** Sensitive headers to redact from logs */ + sensitiveHeaders?: string[]; + /** Sensitive body fields to redact from logs */ + sensitiveFields?: string[]; + /** Custom error messages for localization */ + customMessages?: Partial>; +} diff --git a/middleware/src/errors/services/error-logger.service.ts b/middleware/src/errors/services/error-logger.service.ts new file mode 100644 index 00000000..5a24d0a8 --- /dev/null +++ b/middleware/src/errors/services/error-logger.service.ts @@ -0,0 +1,192 @@ +import { Logger } from '@nestjs/common'; +import { ErrorLogEntry, ErrorContext } from '../interfaces/error.interface'; +import { ErrorCode } from '../constants/error-codes'; + +/** + * Default sensitive headers to redact + */ +const DEFAULT_SENSITIVE_HEADERS = [ + 'authorization', + 'cookie', + 'x-api-key', + 'x-auth-token', + 'x-access-token', +]; + +/** + * Default sensitive body fields to redact + */ +const DEFAULT_SENSITIVE_FIELDS = [ + 'password', + 'confirmPassword', + 'oldPassword', + 'newPassword', + 'secret', + 'token', + 'apiKey', + 'accessToken', + 'refreshToken', + 'creditCard', + 'cvv', + 'ssn', +]; + +export interface ErrorLoggerOptions { + sensitiveHeaders?: string[]; + sensitiveFields?: string[]; +} + +/** + * Service for logging errors with context + */ +export class ErrorLoggerService { + private readonly logger = new Logger('ErrorHandler'); + private readonly sensitiveHeaders: Set; + private readonly sensitiveFields: Set; + + constructor(options: ErrorLoggerOptions = {}) { + this.sensitiveHeaders = new Set( + (options.sensitiveHeaders ?? DEFAULT_SENSITIVE_HEADERS).map((h) => + h.toLowerCase(), + ), + ); + this.sensitiveFields = new Set( + options.sensitiveFields ?? DEFAULT_SENSITIVE_FIELDS, + ); + } + + /** + * Log an error with full context + */ + logError(entry: ErrorLogEntry): void { + const sanitizedEntry = this.sanitizeLogEntry(entry); + const logMessage = this.formatLogMessage(sanitizedEntry); + + if (entry.level === 'warn') { + this.logger.warn(logMessage); + } else { + this.logger.error(logMessage); + } + + // Log stack trace separately for better readability + if (entry.stack) { + this.logger.debug(`Stack trace for ${entry.context.correlationId}:\n${entry.stack}`); + } + } + + /** + * Create an error log entry + */ + createLogEntry( + error: Error | unknown, + context: ErrorContext, + statusCode: number, + errorCode: ErrorCode | string, + message: string, + ): ErrorLogEntry { + const isClientError = statusCode >= 400 && statusCode < 500; + + return { + timestamp: new Date().toISOString(), + level: isClientError ? 'warn' : 'error', + errorCode, + message, + statusCode, + context: this.sanitizeContext(context), + stack: error instanceof Error ? error.stack : undefined, + cause: error instanceof Error ? error.cause : undefined, + }; + } + + /** + * Sanitize log entry to remove sensitive information + */ + private sanitizeLogEntry(entry: ErrorLogEntry): ErrorLogEntry { + return { + ...entry, + context: this.sanitizeContext(entry.context), + }; + } + + /** + * Sanitize error context + */ + private sanitizeContext(context: ErrorContext): ErrorContext { + return { + ...context, + headers: context.headers + ? this.redactSensitiveHeaders(context.headers) + : undefined, + body: context.body ? this.redactSensitiveFields(context.body) : undefined, + }; + } + + /** + * Redact sensitive headers + */ + private redactSensitiveHeaders( + headers: Record, + ): Record { + const redacted: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + if (this.sensitiveHeaders.has(key.toLowerCase())) { + redacted[key] = '[REDACTED]'; + } else { + redacted[key] = value; + } + } + + return redacted; + } + + /** + * Redact sensitive fields in body + */ + private redactSensitiveFields(body: unknown): unknown { + if (!body || typeof body !== 'object') { + return body; + } + + if (Array.isArray(body)) { + return body.map((item) => this.redactSensitiveFields(item)); + } + + const redacted: Record = {}; + + for (const [key, value] of Object.entries(body as Record)) { + if (this.sensitiveFields.has(key)) { + redacted[key] = '[REDACTED]'; + } else if (typeof value === 'object' && value !== null) { + redacted[key] = this.redactSensitiveFields(value); + } else { + redacted[key] = value; + } + } + + return redacted; + } + + /** + * Format log message for output + */ + private formatLogMessage(entry: ErrorLogEntry): string { + const parts = [ + `[${entry.errorCode}]`, + `${entry.context.method} ${entry.context.path}`, + `Status: ${entry.statusCode}`, + `CorrelationId: ${entry.context.correlationId}`, + `Message: ${entry.message}`, + ]; + + if (entry.context.userId) { + parts.push(`UserId: ${entry.context.userId}`); + } + + if (entry.context.ip) { + parts.push(`IP: ${entry.context.ip}`); + } + + return parts.join(' | '); + } +} diff --git a/middleware/src/errors/utils/correlation-id.ts b/middleware/src/errors/utils/correlation-id.ts new file mode 100644 index 00000000..80f4da54 --- /dev/null +++ b/middleware/src/errors/utils/correlation-id.ts @@ -0,0 +1,32 @@ +import { v4 as uuidv4 } from 'uuid'; +import { Request } from 'express'; + +const CORRELATION_ID_HEADER = 'x-correlation-id'; + +/** + * Generate a new correlation ID + */ +export function generateCorrelationId(): string { + return uuidv4(); +} + +/** + * Extract correlation ID from request headers or generate a new one + */ +export function getCorrelationId(request: Request): string { + const existing = request.headers[CORRELATION_ID_HEADER]; + if (typeof existing === 'string' && existing.length > 0) { + return existing; + } + if (Array.isArray(existing) && existing.length > 0) { + return existing[0]; + } + return generateCorrelationId(); +} + +/** + * Get the correlation ID header name + */ +export function getCorrelationIdHeader(): string { + return CORRELATION_ID_HEADER; +} diff --git a/middleware/src/errors/utils/error-mapper.ts b/middleware/src/errors/utils/error-mapper.ts new file mode 100644 index 00000000..9b00a412 --- /dev/null +++ b/middleware/src/errors/utils/error-mapper.ts @@ -0,0 +1,311 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { ErrorCode, ErrorCodeHttpStatus, ErrorCodeMessages } from '../constants/error-codes'; +import { ValidationErrorDetail } from '../interfaces/error.interface'; +import { BaseException } from '../exceptions/base.exception'; +import { + DatabaseConnectionException, + UniqueConstraintException, + ForeignKeyConstraintException, + DatabaseTimeoutException, + ConstraintViolationException, +} from '../exceptions/database.exceptions'; + +/** + * PostgreSQL error codes + */ +const PG_ERROR_CODES = { + UNIQUE_VIOLATION: '23505', + FOREIGN_KEY_VIOLATION: '23503', + NOT_NULL_VIOLATION: '23502', + CHECK_VIOLATION: '23514', + CONNECTION_ERROR: '08000', + CONNECTION_EXCEPTION: '08003', + CONNECTION_FAILURE: '08006', +}; + +/** + * Mapped error result + */ +export interface MappedError { + statusCode: number; + errorCode: ErrorCode | string; + message: string; + details?: ValidationErrorDetail[]; + stack?: string; + context?: Record; +} + +/** + * Map any error to a standardized format + */ +export function mapError(error: unknown, isProduction: boolean = false): MappedError { + // Handle BaseException (our custom exceptions) + if (error instanceof BaseException) { + return { + statusCode: error.getStatus(), + errorCode: error.errorCode, + message: error.message, + details: error.details, + stack: isProduction ? undefined : error.stack, + context: error.context, + }; + } + + // Handle NestJS HttpException + if (error instanceof HttpException) { + const status = error.getStatus(); + const response = error.getResponse(); + const errorCode = mapHttpStatusToErrorCode(status); + + let message: string; + let details: ValidationErrorDetail[] | undefined; + + if (typeof response === 'string') { + message = response; + } else if (typeof response === 'object' && response !== null) { + const res = response as Record; + message = (res.message as string) ?? error.message; + + // Handle class-validator validation errors + if (Array.isArray(res.message)) { + details = mapValidationMessages(res.message); + message = 'Validation failed'; + } + } else { + message = error.message; + } + + return { + statusCode: status, + errorCode, + message: isProduction ? sanitizeMessage(message, status) : message, + details, + stack: isProduction ? undefined : error.stack, + }; + } + + // Handle database errors + const dbError = mapDatabaseError(error); + if (dbError) { + return { + ...dbError, + stack: isProduction ? undefined : (error as Error)?.stack, + }; + } + + // Handle standard Error + if (error instanceof Error) { + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.INTERNAL_SERVER_ERROR, + message: isProduction + ? ErrorCodeMessages[ErrorCode.INTERNAL_SERVER_ERROR] + : error.message, + stack: isProduction ? undefined : error.stack, + }; + } + + // Handle unknown errors + return { + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.UNKNOWN_ERROR, + message: ErrorCodeMessages[ErrorCode.UNKNOWN_ERROR], + }; +} + +/** + * Map HTTP status code to error code + */ +function mapHttpStatusToErrorCode(status: number): ErrorCode { + const statusMapping: Record = { + 400: ErrorCode.VALIDATION_FAILED, + 401: ErrorCode.AUTH_TOKEN_INVALID, + 403: ErrorCode.INSUFFICIENT_PERMISSIONS, + 404: ErrorCode.RESOURCE_NOT_FOUND, + 409: ErrorCode.DUPLICATE_RESOURCE, + 429: ErrorCode.RATE_LIMIT_EXCEEDED, + 500: ErrorCode.INTERNAL_SERVER_ERROR, + 502: ErrorCode.EXTERNAL_SERVICE_ERROR, + 503: ErrorCode.SERVICE_UNAVAILABLE, + 504: ErrorCode.EXTERNAL_SERVICE_TIMEOUT, + }; + + return statusMapping[status] ?? ErrorCode.UNKNOWN_ERROR; +} + +/** + * Map database errors to application exceptions + */ +function mapDatabaseError(error: unknown): MappedError | null { + if (!error || typeof error !== 'object') { + return null; + } + + const err = error as Record; + + // PostgreSQL errors + if (err.code && typeof err.code === 'string') { + switch (err.code) { + case PG_ERROR_CODES.UNIQUE_VIOLATION: { + const detail = err.detail as string | undefined; + const column = extractColumnFromPgDetail(detail); + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_UNIQUE_VIOLATION], + errorCode: ErrorCode.DB_UNIQUE_VIOLATION, + message: column + ? `A record with this ${column} already exists` + : ErrorCodeMessages[ErrorCode.DB_UNIQUE_VIOLATION], + }; + } + + case PG_ERROR_CODES.FOREIGN_KEY_VIOLATION: { + const detail = err.detail as string | undefined; + const table = extractTableFromPgDetail(detail); + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_FOREIGN_KEY_VIOLATION], + errorCode: ErrorCode.DB_FOREIGN_KEY_VIOLATION, + message: table + ? `Referenced ${table} does not exist` + : ErrorCodeMessages[ErrorCode.DB_FOREIGN_KEY_VIOLATION], + }; + } + + case PG_ERROR_CODES.NOT_NULL_VIOLATION: + case PG_ERROR_CODES.CHECK_VIOLATION: + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_CONSTRAINT_VIOLATION], + errorCode: ErrorCode.DB_CONSTRAINT_VIOLATION, + message: ErrorCodeMessages[ErrorCode.DB_CONSTRAINT_VIOLATION], + }; + + case PG_ERROR_CODES.CONNECTION_ERROR: + case PG_ERROR_CODES.CONNECTION_EXCEPTION: + case PG_ERROR_CODES.CONNECTION_FAILURE: + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_CONNECTION_ERROR], + errorCode: ErrorCode.DB_CONNECTION_ERROR, + message: ErrorCodeMessages[ErrorCode.DB_CONNECTION_ERROR], + }; + } + } + + // TypeORM QueryFailedError + if (err.name === 'QueryFailedError') { + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_QUERY_ERROR], + errorCode: ErrorCode.DB_QUERY_ERROR, + message: ErrorCodeMessages[ErrorCode.DB_QUERY_ERROR], + }; + } + + // Connection errors + if ( + err.name === 'ConnectionError' || + (err.message && typeof err.message === 'string' && err.message.includes('ECONNREFUSED')) + ) { + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_CONNECTION_ERROR], + errorCode: ErrorCode.DB_CONNECTION_ERROR, + message: ErrorCodeMessages[ErrorCode.DB_CONNECTION_ERROR], + }; + } + + // Timeout errors + if ( + err.name === 'TimeoutError' || + (err.message && typeof err.message === 'string' && err.message.includes('timeout')) + ) { + return { + statusCode: ErrorCodeHttpStatus[ErrorCode.DB_TIMEOUT], + errorCode: ErrorCode.DB_TIMEOUT, + message: ErrorCodeMessages[ErrorCode.DB_TIMEOUT], + }; + } + + return null; +} + +/** + * Extract column name from PostgreSQL error detail + */ +function extractColumnFromPgDetail(detail?: string): string | undefined { + if (!detail) return undefined; + // Example: Key (email)=(test@test.com) already exists. + const match = detail.match(/Key \(([^)]+)\)/); + return match ? match[1] : undefined; +} + +/** + * Extract table name from PostgreSQL error detail + */ +function extractTableFromPgDetail(detail?: string): string | undefined { + if (!detail) return undefined; + // Example: Key (user_id)=(123) is not present in table "users". + const match = detail.match(/table "([^"]+)"/); + return match ? match[1] : undefined; +} + +/** + * Map class-validator validation messages to ValidationErrorDetail + */ +function mapValidationMessages(messages: unknown[]): ValidationErrorDetail[] { + return messages + .filter((msg): msg is string | Record => Boolean(msg)) + .map((msg, index) => { + if (typeof msg === 'string') { + return { + field: `field${index}`, + message: msg, + }; + } + + if (typeof msg === 'object' && msg !== null) { + const obj = msg as Record; + return { + field: (obj.property as string) ?? `field${index}`, + message: Array.isArray(obj.constraints) + ? Object.values(obj.constraints).join(', ') + : String(obj.message ?? obj.constraints ?? msg), + constraints: obj.constraints as Record | undefined, + }; + } + + return { + field: `field${index}`, + message: String(msg), + }; + }); +} + +/** + * Sanitize error messages for production + */ +function sanitizeMessage(message: string, statusCode: number): string { + // For 5xx errors, always return a generic message + if (statusCode >= 500) { + return ErrorCodeMessages[ErrorCode.INTERNAL_SERVER_ERROR]; + } + + // Check for potentially sensitive information patterns + const sensitivePatterns = [ + /password/i, + /secret/i, + /token/i, + /api[_-]?key/i, + /credential/i, + /internal/i, + /stack/i, + /sql/i, + /query/i, + /database/i, + /connection/i, + ]; + + for (const pattern of sensitivePatterns) { + if (pattern.test(message)) { + return ErrorCodeMessages[mapHttpStatusToErrorCode(statusCode)]; + } + } + + return message; +} diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 088f941a..c3890791 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -9,6 +9,9 @@ export * from './validation'; export * from './common'; export * from './config'; +// Centralized error handling (#issue) +export * from './errors'; + // Conditional execution helpers (#381) export * from './middleware/utils/conditional.middleware'; diff --git a/middleware/tests/errors/error-handling.spec.ts b/middleware/tests/errors/error-handling.spec.ts new file mode 100644 index 00000000..913f6be5 --- /dev/null +++ b/middleware/tests/errors/error-handling.spec.ts @@ -0,0 +1,432 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { + ErrorCode, + ErrorCodeHttpStatus, + ErrorCodeMessages, + BaseException, + ValidationException, + UnauthorizedException, + ForbiddenException, + NotFoundException, + ConflictException, + RateLimitException, + InternalServerException, + UniqueConstraintException, + ForeignKeyConstraintException, + DatabaseConnectionException, + mapError, + getCorrelationId, + generateCorrelationId, + GlobalExceptionFilter, + createGlobalExceptionFilter, + ErrorLoggerService, +} from '../src/errors'; + +// Mock Request and Response +const createMockRequest = (overrides: Partial = {}) => ({ + url: '/api/test', + method: 'GET', + headers: { + 'content-type': 'application/json', + 'user-agent': 'test-agent', + }, + ip: '127.0.0.1', + body: {}, + query: {}, + params: {}, + socket: { remoteAddress: '127.0.0.1' }, + ...overrides, +}); + +const createMockResponse = () => { + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn().mockReturnThis(), + }; + return res; +}; + +const createMockArgumentsHost = (request: any, response: any) => ({ + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), +}); + +describe('Error Handling Module', () => { + describe('ErrorCode Constants', () => { + it('should have HTTP status codes for all error codes', () => { + const errorCodes = Object.values(ErrorCode); + errorCodes.forEach((code) => { + expect(ErrorCodeHttpStatus[code]).toBeDefined(); + expect(typeof ErrorCodeHttpStatus[code]).toBe('number'); + }); + }); + + it('should have messages for all error codes', () => { + const errorCodes = Object.values(ErrorCode); + errorCodes.forEach((code) => { + expect(ErrorCodeMessages[code]).toBeDefined(); + expect(typeof ErrorCodeMessages[code]).toBe('string'); + }); + }); + + it('should map authentication errors to 401', () => { + expect(ErrorCodeHttpStatus[ErrorCode.AUTH_TOKEN_EXPIRED]).toBe(401); + expect(ErrorCodeHttpStatus[ErrorCode.AUTH_TOKEN_INVALID]).toBe(401); + expect(ErrorCodeHttpStatus[ErrorCode.AUTH_INVALID_CREDENTIALS]).toBe(401); + }); + + it('should map authorization errors to 403', () => { + expect(ErrorCodeHttpStatus[ErrorCode.INSUFFICIENT_PERMISSIONS]).toBe(403); + expect(ErrorCodeHttpStatus[ErrorCode.ROLE_NOT_ALLOWED]).toBe(403); + }); + + it('should map validation errors to 400', () => { + expect(ErrorCodeHttpStatus[ErrorCode.VALIDATION_FAILED]).toBe(400); + expect(ErrorCodeHttpStatus[ErrorCode.INVALID_INPUT]).toBe(400); + }); + + it('should map not found errors to 404', () => { + expect(ErrorCodeHttpStatus[ErrorCode.RESOURCE_NOT_FOUND]).toBe(404); + }); + + it('should map conflict errors to 409', () => { + expect(ErrorCodeHttpStatus[ErrorCode.DUPLICATE_RESOURCE]).toBe(409); + expect(ErrorCodeHttpStatus[ErrorCode.DB_UNIQUE_VIOLATION]).toBe(409); + }); + }); + + describe('BaseException', () => { + it('should create exception with correct properties', () => { + const exception = new BaseException( + ErrorCode.VALIDATION_FAILED, + 'Custom message', + [{ field: 'email', message: 'Invalid email' }], + ); + + expect(exception.getStatus()).toBe(400); + expect(exception.errorCode).toBe(ErrorCode.VALIDATION_FAILED); + expect(exception.message).toBe('Custom message'); + expect(exception.details).toHaveLength(1); + }); + + it('should use default message if not provided', () => { + const exception = new BaseException(ErrorCode.RESOURCE_NOT_FOUND); + expect(exception.message).toBe(ErrorCodeMessages[ErrorCode.RESOURCE_NOT_FOUND]); + }); + }); + + describe('HTTP Exceptions', () => { + describe('ValidationException', () => { + it('should create with field-specific errors', () => { + const details = [ + { field: 'email', message: 'Invalid email format' }, + { field: 'password', message: 'Password too short' }, + ]; + const exception = new ValidationException(details); + + expect(exception.getStatus()).toBe(400); + expect(exception.errorCode).toBe(ErrorCode.VALIDATION_FAILED); + expect(exception.details).toEqual(details); + }); + + it('should create from field errors object', () => { + const exception = ValidationException.fromFieldErrors({ + email: ['Invalid format', 'Already exists'], + name: ['Required'], + }); + + expect(exception.details).toHaveLength(2); + expect(exception.details?.[0].field).toBe('email'); + }); + }); + + describe('UnauthorizedException', () => { + it('should create token expired exception', () => { + const exception = UnauthorizedException.tokenExpired(); + expect(exception.getStatus()).toBe(401); + expect(exception.errorCode).toBe(ErrorCode.AUTH_TOKEN_EXPIRED); + }); + + it('should create invalid credentials exception', () => { + const exception = UnauthorizedException.invalidCredentials(); + expect(exception.errorCode).toBe(ErrorCode.AUTH_INVALID_CREDENTIALS); + }); + }); + + describe('ForbiddenException', () => { + it('should create insufficient permissions exception', () => { + const exception = ForbiddenException.insufficientPermissions(); + expect(exception.getStatus()).toBe(403); + expect(exception.errorCode).toBe(ErrorCode.INSUFFICIENT_PERMISSIONS); + }); + + it('should create role not allowed with custom message', () => { + const exception = ForbiddenException.roleNotAllowed('user'); + expect(exception.message).toContain('user'); + }); + }); + + describe('NotFoundException', () => { + it('should create with resource name', () => { + const exception = new NotFoundException('User', '123'); + expect(exception.getStatus()).toBe(404); + expect(exception.message).toContain('User'); + expect(exception.message).toContain('123'); + }); + }); + + describe('ConflictException', () => { + it('should create duplicate resource exception', () => { + const exception = ConflictException.duplicate('User', 'email'); + expect(exception.getStatus()).toBe(409); + expect(exception.message).toContain('email'); + }); + }); + + describe('RateLimitException', () => { + it('should include retry after', () => { + const exception = new RateLimitException(60); + expect(exception.getStatus()).toBe(429); + expect(exception.retryAfter).toBe(60); + expect(exception.message).toContain('60'); + }); + }); + }); + + describe('Database Exceptions', () => { + it('should create unique constraint exception', () => { + const exception = new UniqueConstraintException('users_email_key', 'email'); + expect(exception.getStatus()).toBe(409); + expect(exception.errorCode).toBe(ErrorCode.DB_UNIQUE_VIOLATION); + expect(exception.columnName).toBe('email'); + }); + + it('should create foreign key constraint exception', () => { + const exception = new ForeignKeyConstraintException('fk_user', 'users'); + expect(exception.getStatus()).toBe(409); + expect(exception.referencedTable).toBe('users'); + }); + + it('should create connection exception', () => { + const exception = new DatabaseConnectionException(); + expect(exception.getStatus()).toBe(503); + }); + }); + + describe('Correlation ID', () => { + it('should generate valid UUID', () => { + const id = generateCorrelationId(); + expect(id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it('should extract correlation ID from headers', () => { + const request = createMockRequest({ + headers: { 'x-correlation-id': 'test-id-123' }, + }); + const id = getCorrelationId(request as any); + expect(id).toBe('test-id-123'); + }); + + it('should generate new ID if not in headers', () => { + const request = createMockRequest(); + const id = getCorrelationId(request as any); + expect(id).toBeDefined(); + expect(id.length).toBeGreaterThan(0); + }); + }); + + describe('Error Mapper', () => { + it('should map BaseException correctly', () => { + const exception = new NotFoundException('User', '123'); + const mapped = mapError(exception, false); + + expect(mapped.statusCode).toBe(404); + expect(mapped.errorCode).toBe(ErrorCode.RESOURCE_NOT_FOUND); + expect(mapped.stack).toBeDefined(); + }); + + it('should map HttpException correctly', () => { + const exception = new HttpException('Not found', HttpStatus.NOT_FOUND); + const mapped = mapError(exception, false); + + expect(mapped.statusCode).toBe(404); + expect(mapped.message).toBe('Not found'); + }); + + it('should hide stack in production', () => { + const exception = new InternalServerException('Oops'); + const mapped = mapError(exception, true); + + expect(mapped.stack).toBeUndefined(); + }); + + it('should map PostgreSQL unique violation', () => { + const pgError = { + code: '23505', + detail: 'Key (email)=(test@test.com) already exists.', + }; + const mapped = mapError(pgError, false); + + expect(mapped.statusCode).toBe(409); + expect(mapped.errorCode).toBe(ErrorCode.DB_UNIQUE_VIOLATION); + expect(mapped.message).toContain('email'); + }); + + it('should map PostgreSQL foreign key violation', () => { + const pgError = { + code: '23503', + detail: 'Key (user_id)=(123) is not present in table "users".', + }; + const mapped = mapError(pgError, false); + + expect(mapped.statusCode).toBe(409); + expect(mapped.errorCode).toBe(ErrorCode.DB_FOREIGN_KEY_VIOLATION); + }); + + it('should map connection errors', () => { + const error = { name: 'ConnectionError', message: 'ECONNREFUSED' }; + const mapped = mapError(error, false); + + expect(mapped.statusCode).toBe(503); + expect(mapped.errorCode).toBe(ErrorCode.DB_CONNECTION_ERROR); + }); + + it('should return generic message for unknown errors in production', () => { + const error = new Error('Some internal detail'); + const mapped = mapError(error, true); + + expect(mapped.message).toBe(ErrorCodeMessages[ErrorCode.INTERNAL_SERVER_ERROR]); + }); + }); + + describe('ErrorLoggerService', () => { + let logger: ErrorLoggerService; + + beforeEach(() => { + logger = new ErrorLoggerService(); + }); + + it('should create log entry with correct level for client errors', () => { + const context = { + correlationId: 'test-123', + path: '/api/test', + method: 'POST', + }; + const entry = logger.createLogEntry( + new Error('Bad request'), + context, + 400, + ErrorCode.VALIDATION_FAILED, + 'Validation failed', + ); + + expect(entry.level).toBe('warn'); + }); + + it('should create log entry with error level for server errors', () => { + const context = { + correlationId: 'test-123', + path: '/api/test', + method: 'POST', + }; + const entry = logger.createLogEntry( + new Error('Server error'), + context, + 500, + ErrorCode.INTERNAL_SERVER_ERROR, + 'Internal error', + ); + + expect(entry.level).toBe('error'); + }); + }); + + describe('GlobalExceptionFilter', () => { + let filter: GlobalExceptionFilter; + + beforeEach(() => { + filter = createGlobalExceptionFilter({ environment: 'test' }); + }); + + it('should format BaseException correctly', () => { + const request = createMockRequest(); + const response = createMockResponse(); + const host = createMockArgumentsHost(request, response); + + const exception = new NotFoundException('User', '123'); + filter.catch(exception, host as any); + + expect(response.status).toHaveBeenCalledWith(404); + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 404, + errorCode: ErrorCode.RESOURCE_NOT_FOUND, + correlationId: expect.any(String), + timestamp: expect.any(String), + path: '/api/test', + }), + ); + }); + + it('should include correlation ID in response header', () => { + const request = createMockRequest({ + headers: { 'x-correlation-id': 'custom-id' }, + }); + const response = createMockResponse(); + const host = createMockArgumentsHost(request, response); + + filter.catch(new Error('test'), host as any); + + expect(response.setHeader).toHaveBeenCalledWith( + 'x-correlation-id', + 'custom-id', + ); + }); + + it('should include validation details', () => { + const request = createMockRequest(); + const response = createMockResponse(); + const host = createMockArgumentsHost(request, response); + + const exception = new ValidationException([ + { field: 'email', message: 'Invalid' }, + ]); + filter.catch(exception, host as any); + + expect(response.json).toHaveBeenCalledWith( + expect.objectContaining({ + details: [{ field: 'email', message: 'Invalid' }], + }), + ); + }); + + it('should hide stack trace in production', () => { + const prodFilter = createGlobalExceptionFilter({ environment: 'production' }); + const request = createMockRequest(); + const response = createMockResponse(); + const host = createMockArgumentsHost(request, response); + + prodFilter.catch(new Error('test'), host as any); + + const jsonCall = response.json.mock.calls[0][0]; + expect(jsonCall.stack).toBeUndefined(); + }); + + it('should include stack trace in development', () => { + const devFilter = createGlobalExceptionFilter({ environment: 'development' }); + const request = createMockRequest(); + const response = createMockResponse(); + const host = createMockArgumentsHost(request, response); + + devFilter.catch(new Error('test'), host as any); + + const jsonCall = response.json.mock.calls[0][0]; + expect(jsonCall.stack).toBeDefined(); + }); + }); +});