diff --git a/backend/src/common/errors/app.exception.ts b/backend/src/common/errors/app.exception.ts index 592f0f56..6c3f03a8 100644 --- a/backend/src/common/errors/app.exception.ts +++ b/backend/src/common/errors/app.exception.ts @@ -54,7 +54,164 @@ export class AppException extends HttpException { } } -// ─── Convenience subclasses ────────────────────────────────────────────────── +// ─── Convenience subclasses for new standardized error codes ─────────────────── + +export class ValidationError extends AppException { + constructor(details: ErrorDetail[], message = 'Validation failed') { + super({ + errorCode: AppErrorCode.VALIDATION_ERROR, + status: HttpStatus.BAD_REQUEST, + message, + details, + i18nKey: 'errors.validation_failed', + }); + } +} + +export class UnauthorizedException extends AppException { + constructor(message = 'Authentication required') { + super({ + errorCode: AppErrorCode.UNAUTHORIZED, + status: HttpStatus.UNAUTHORIZED, + message, + i18nKey: 'errors.unauthorized', + }); + } +} + +export class ForbiddenException extends AppException { + constructor(message = 'Insufficient permissions') { + super({ + errorCode: AppErrorCode.FORBIDDEN, + status: HttpStatus.FORBIDDEN, + message, + i18nKey: 'errors.forbidden', + }); + } +} + +export class NotFoundError extends AppException { + constructor(resource = 'Resource', message?: string) { + super({ + errorCode: AppErrorCode.NOT_FOUND, + status: HttpStatus.NOT_FOUND, + message: message || `${resource} not found`, + i18nKey: 'errors.not_found', + }); + } +} + +export class ConflictException extends AppException { + constructor(message = 'Resource conflict occurred') { + super({ + errorCode: AppErrorCode.CONFLICT, + status: HttpStatus.CONFLICT, + message, + i18nKey: 'errors.conflict', + }); + } +} + +export class RateLimitedException extends AppException { + constructor(message = 'Too many requests, please try again later') { + super({ + errorCode: AppErrorCode.RATE_LIMITED, + status: HttpStatus.TOO_MANY_REQUESTS, + message, + i18nKey: 'errors.rate_limited', + }); + } +} + +export class SessionExpiredException extends AppException { + constructor(message = 'Your session has expired') { + super({ + errorCode: AppErrorCode.SESSION_EXPIRED, + status: HttpStatus.UNAUTHORIZED, + message, + i18nKey: 'errors.session_expired', + }); + } +} + +export class SessionInvalidException extends AppException { + constructor(message = 'Invalid session') { + super({ + errorCode: AppErrorCode.SESSION_INVALID, + status: HttpStatus.BAD_REQUEST, + message, + i18nKey: 'errors.session_invalid', + }); + } +} + +export class ChallengeUnavailableException extends AppException { + constructor(message = 'This challenge is currently unavailable') { + super({ + errorCode: AppErrorCode.CHALLENGE_UNAVAILABLE, + status: HttpStatus.BAD_REQUEST, + message, + i18nKey: 'errors.challenge_unavailable', + }); + } +} + +export class InvalidAnswerException extends AppException { + constructor(message = 'The provided answer is invalid') { + super({ + errorCode: AppErrorCode.INVALID_ANSWER, + status: HttpStatus.BAD_REQUEST, + message, + i18nKey: 'errors.invalid_answer', + }); + } +} + +export class DuplicateSubmissionException extends AppException { + constructor(message = 'You have already submitted an answer for this challenge') { + super({ + errorCode: AppErrorCode.DUPLICATE_SUBMISSION, + status: HttpStatus.CONFLICT, + message, + i18nKey: 'errors.duplicate_submission', + }); + } +} + +export class RewardNotEligibleException extends AppException { + constructor(message = 'You are not eligible to receive this reward') { + super({ + errorCode: AppErrorCode.REWARD_NOT_ELIGIBLE, + status: HttpStatus.BAD_REQUEST, + message, + i18nKey: 'errors.reward_not_eligible', + }); + } +} + +export class BlockchainError extends AppException { + constructor(message = 'Blockchain operation failed') { + super({ + errorCode: AppErrorCode.BLOCKCHAIN_ERROR, + status: HttpStatus.INTERNAL_SERVER_ERROR, + message, + i18nKey: 'errors.blockchain_error', + }); + } +} + +export class InternalServerError extends AppException { + constructor(message = 'An unexpected internal error occurred') { + super({ + errorCode: AppErrorCode.INTERNAL_SERVER_ERROR, + status: HttpStatus.INTERNAL_SERVER_ERROR, + message, + i18nKey: 'errors.internal_server_error', + }); + } +} + +// ─── Legacy convenience classes (still supported for backward compatibility) ──── export class ValidationException extends AppException { constructor(details: ErrorDetail[], message = 'Validation failed') { @@ -104,7 +261,7 @@ export class NotFoundException extends AppException { } } -export class ConflictException extends AppException { +export class LegacyConflictException extends AppException { constructor(message = 'Resource already exists') { super({ errorCode: AppErrorCode.DUPLICATE_RESOURCE, @@ -140,4 +297,4 @@ export class DatabaseException extends AppException { context, }); } -} +} \ No newline at end of file diff --git a/backend/src/common/errors/error-codes.enum.ts b/backend/src/common/errors/error-codes.enum.ts index 7d34551a..f1bf187d 100644 --- a/backend/src/common/errors/error-codes.enum.ts +++ b/backend/src/common/errors/error-codes.enum.ts @@ -4,37 +4,58 @@ * can react without string-matching on human-readable messages. */ export enum AppErrorCode { + // ── Validation ─────────────────────────────────────────────────────────────── + VALIDATION_ERROR = 'VALIDATION_ERROR', + // ── Authentication ────────────────────────────────────────────────────────── + UNAUTHORIZED = 'UNAUTHORIZED', + + // ── Authorization ──────────────────────────────────────────────────────────── + FORBIDDEN = 'FORBIDDEN', + + // ── Resource ───────────────────────────────────────────────────────────────── + NOT_FOUND = 'NOT_FOUND', + CONFLICT = 'CONFLICT', + + // ── Rate Limiting ───────────────────────────────────────────────────────────── + RATE_LIMITED = 'RATE_LIMITED', + + // ── Session Errors ───────────────────────────────────────────────────────────── + SESSION_EXPIRED = 'SESSION_EXPIRED', + SESSION_INVALID = 'SESSION_INVALID', + + // ── Challenge Errors ─────────────────────────────────────────────────────────── + CHALLENGE_UNAVAILABLE = 'CHALLENGE_UNAVAILABLE', + INVALID_ANSWER = 'INVALID_ANSWER', + DUPLICATE_SUBMISSION = 'DUPLICATE_SUBMISSION', + + // ── Reward Errors ───────────────────────────────────────────────────────────── + REWARD_NOT_ELIGIBLE = 'REWARD_NOT_ELIGIBLE', + + // ── Blockchain Errors ───────────────────────────────────────────────────────── + BLOCKCHAIN_ERROR = 'BLOCKCHAIN_ERROR', + + // ── Internal ─────────────────────────────────────────────────────────────────── + INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', + + // ── Legacy (still supported for backward compatibility) ─────────────────────── AUTH_TOKEN_EXPIRED = 'AUTH_TOKEN_EXPIRED', AUTH_TOKEN_INVALID = 'AUTH_TOKEN_INVALID', AUTH_TOKEN_MISSING = 'AUTH_TOKEN_MISSING', AUTH_TOKEN_BLACKLISTED = 'AUTH_TOKEN_BLACKLISTED', AUTH_INVALID_CREDENTIALS = 'AUTH_INVALID_CREDENTIALS', AUTH_USER_NOT_FOUND = 'AUTH_USER_NOT_FOUND', - - // ── Authorization ──────────────────────────────────────────────────────────── INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', ACCESS_DENIED = 'ACCESS_DENIED', - - // ── Validation ─────────────────────────────────────────────────────────────── VALIDATION_FAILED = 'VALIDATION_FAILED', INVALID_INPUT = 'INVALID_INPUT', - - // ── Resource ───────────────────────────────────────────────────────────────── RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', DUPLICATE_RESOURCE = 'DUPLICATE_RESOURCE', RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', - - // ── Rate Limiting ───────────────────────────────────────────────────────────── RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', - - // ── Database ────────────────────────────────────────────────────────────────── DB_CONNECTION_ERROR = 'DB_CONNECTION_ERROR', DB_CONSTRAINT_VIOLATION = 'DB_CONSTRAINT_VIOLATION', DB_QUERY_FAILED = 'DB_QUERY_FAILED', - - // ── Generic ─────────────────────────────────────────────────────────────────── - INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR', SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', NOT_IMPLEMENTED = 'NOT_IMPLEMENTED', -} +} \ No newline at end of file diff --git a/backend/src/common/errors/index.ts b/backend/src/common/errors/index.ts index fa2b3ce8..64b47426 100644 --- a/backend/src/common/errors/index.ts +++ b/backend/src/common/errors/index.ts @@ -1,2 +1,20 @@ export * from './error-codes.enum'; export * from './app.exception'; + +// Re-export the new standardized exceptions for easy importing +export { + ValidationError, + UnauthorizedException, + ForbiddenException, + NotFoundError, + ConflictException, + RateLimitedException, + SessionExpiredException, + SessionInvalidException, + ChallengeUnavailableException, + InvalidAnswerException, + DuplicateSubmissionException, + RewardNotEligibleException, + BlockchainError, + InternalServerError +} from './app.exception'; \ No newline at end of file diff --git a/backend/src/common/filters/http-exception.filter.spec.ts b/backend/src/common/filters/http-exception.filter.spec.ts new file mode 100644 index 00000000..8afd456e --- /dev/null +++ b/backend/src/common/filters/http-exception.filter.spec.ts @@ -0,0 +1,138 @@ +import { AllExceptionsFilter } from './http-exception.filter'; +import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { QueryFailedError, EntityNotFoundError } from 'typeorm'; +import { AppException, ValidationError } from '../errors/app.exception'; +import { AppErrorCode } from '../errors/error-codes.enum'; + +describe('AllExceptionsFilter', () => { + let filter: AllExceptionsFilter; + let mockResponse: Partial; + let mockRequest: Partial; + let mockHost: Partial; + let jsonSpy: jest.SpyInstance; + let statusSpy: jest.SpyInstance; + + beforeEach(() => { + filter = new AllExceptionsFilter(); + jsonSpy = jest.fn(); + statusSpy = jest.fn().mockReturnValue({ json: jsonSpy }); + mockResponse = { + status: statusSpy, + } as unknown as Partial; + mockRequest = { + url: '/api/test', + method: 'GET', + } as Partial; + // Add correlationId as a custom property + (mockRequest as any).correlationId = 'test-correlation-id'; + mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + }; + process.env.NODE_ENV = 'development'; + }); + + it('should be defined', () => { + expect(filter).toBeDefined(); + }); + + it('should format AppException correctly', () => { + const exception = new ValidationError( + [{ field: 'email', message: 'Email is invalid' }], + 'Validation failed' + ); + + filter.catch(exception, mockHost as ArgumentsHost); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(jsonSpy).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + statusCode: HttpStatus.BAD_REQUEST, + error: 'Bad Request', + message: 'Validation failed', + code: AppErrorCode.VALIDATION_ERROR, + path: '/api/test', + requestId: 'test-correlation-id', + details: [{ field: 'email', message: 'Email is invalid' }], + })); + }); + + it('should format NestJS HttpException correctly', () => { + const exception = new HttpException('Not found', HttpStatus.NOT_FOUND); + + filter.catch(exception, mockHost as ArgumentsHost); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(jsonSpy).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + statusCode: HttpStatus.NOT_FOUND, + error: 'Not Found', + message: 'Not found', + code: AppErrorCode.NOT_FOUND, + })); + }); + + it('should format TypeORM EntityNotFoundError correctly', () => { + const exception = new EntityNotFoundError('User', '123'); + + filter.catch(exception, mockHost as ArgumentsHost); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(jsonSpy).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + statusCode: HttpStatus.NOT_FOUND, + error: 'Not Found', + code: AppErrorCode.NOT_FOUND, + })); + }); + + it('should format Postgres unique violation error correctly', () => { + const exception = new QueryFailedError('SELECT * FROM users', [], { code: '23505' } as any); + + filter.catch(exception, mockHost as ArgumentsHost); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(jsonSpy).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + statusCode: HttpStatus.CONFLICT, + error: 'Conflict', + code: AppErrorCode.CONFLICT, + })); + }); + + it('should format unknown errors as internal server error', () => { + const exception = new Error('Something went wrong'); + + filter.catch(exception, mockHost as ArgumentsHost); + + expect(statusSpy).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR); + expect(jsonSpy).toHaveBeenCalledWith(expect.objectContaining({ + success: false, + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + error: 'Internal Server Error', + code: AppErrorCode.INTERNAL_SERVER_ERROR, + })); + }); + + it('should hide stack trace in production', () => { + process.env.NODE_ENV = 'production'; + const exception = new Error('Something went wrong'); + + filter.catch(exception, mockHost as ArgumentsHost); + + const response = jsonSpy.mock.calls[0][0]; + expect(response.stack).toBeUndefined(); + }); + + it('should include stack trace in development', () => { + const exception = new Error('Something went wrong'); + + filter.catch(exception, mockHost as ArgumentsHost); + + const response = jsonSpy.mock.calls[0][0]; + expect(response.stack).toBeDefined(); + }); +}); \ No newline at end of file diff --git a/backend/src/common/filters/http-exception.filter.ts b/backend/src/common/filters/http-exception.filter.ts index d57fb2af..c2e39929 100644 --- a/backend/src/common/filters/http-exception.filter.ts +++ b/backend/src/common/filters/http-exception.filter.ts @@ -12,7 +12,7 @@ import { AppException } from '../errors/app.exception'; import { AppErrorCode } from '../errors/error-codes.enum'; /** - * Standard API error response structure. + * Standard API error response structure as required. * Every error returned by the API must conform to this shape. */ export interface ErrorResponse { @@ -21,11 +21,12 @@ export interface ErrorResponse { code: string; errorCode: string; // Maintain backwards compatibility message: string; - /** Field-level validation details — only present on 400 responses. */ - details?: Array<{ field?: string; message: string; value?: unknown }>; - correlationId: string; timestamp: string; path: string; + /** Field-level validation details — only present on validation errors. */ + details?: Array<{ field?: string; message: string; value?: unknown }>; + /** Unique request ID for tracing */ + requestId?: string; /** Full stack trace — development only, never sent to production clients. */ stack?: string; } @@ -38,27 +39,27 @@ const PG_ERROR_MAP: Record< { code: AppErrorCode; status: HttpStatus; message: string } > = { '23505': { - code: AppErrorCode.DUPLICATE_RESOURCE, + code: AppErrorCode.CONFLICT, status: HttpStatus.CONFLICT, message: 'A record with the same unique value already exists.', }, '23503': { - code: AppErrorCode.DB_CONSTRAINT_VIOLATION, + code: AppErrorCode.CONFLICT, status: HttpStatus.UNPROCESSABLE_ENTITY, message: 'Referenced resource does not exist.', }, '23502': { - code: AppErrorCode.VALIDATION_FAILED, + code: AppErrorCode.VALIDATION_ERROR, status: HttpStatus.BAD_REQUEST, message: 'A required field is missing.', }, '23514': { - code: AppErrorCode.DB_CONSTRAINT_VIOLATION, + code: AppErrorCode.VALIDATION_ERROR, status: HttpStatus.BAD_REQUEST, message: 'A check constraint was violated.', }, ECONNREFUSED: { - code: AppErrorCode.DB_CONNECTION_ERROR, + code: AppErrorCode.INTERNAL_SERVER_ERROR, status: HttpStatus.SERVICE_UNAVAILABLE, message: 'Database is temporarily unavailable. Please try again later.', }, @@ -103,6 +104,9 @@ export class AllExceptionsFilter implements ExceptionFilter { // ── 2. Log full details (always — even in production) ──────────────────── this.logError(exception, resolved, correlationId, path, request.method); + // Get the standard error name based on status code + const errorName = this.getHttpStatusName(resolved.status); + // ── 3. Build the response body ──────────────────────────────────────────── // Map VALIDATION_FAILED to VALIDATION_ERROR for the new 'code' field to match requirements const responseCode = resolved.errorCode === AppErrorCode.VALIDATION_FAILED @@ -114,9 +118,9 @@ export class AllExceptionsFilter implements ExceptionFilter { code: responseCode, errorCode: resolved.errorCode, // Maintain backwards compatibility message: resolved.message, - correlationId, timestamp, path, + requestId: correlationId, }; if (resolved.details?.length) { @@ -196,11 +200,19 @@ export class AllExceptionsFilter implements ExceptionFilter { ) { const messages = (raw as any).message; if (Array.isArray(messages)) { + // Parse class-validator error messages to extract field information when possible + const details = messages.map((m: string) => { + // Try to extract field name from common validation error formats + const fieldMatch = m.match(/^(\w+)\s/); + const field = fieldMatch ? fieldMatch[1] : undefined; + return { message: m, field }; + }); + return { status, - errorCode: AppErrorCode.VALIDATION_FAILED, + errorCode: AppErrorCode.VALIDATION_ERROR, message: 'Validation failed', - details: messages.map((m: string) => ({ message: m })), + details, }; } } @@ -224,15 +236,30 @@ export class AllExceptionsFilter implements ExceptionFilter { }; } + private getHttpStatusName(status: HttpStatus): string { + const statusNames: Record = { + [HttpStatus.BAD_REQUEST]: 'Bad Request', + [HttpStatus.UNAUTHORIZED]: 'Unauthorized', + [HttpStatus.FORBIDDEN]: 'Forbidden', + [HttpStatus.NOT_FOUND]: 'Not Found', + [HttpStatus.CONFLICT]: 'Conflict', + [HttpStatus.TOO_MANY_REQUESTS]: 'Too Many Requests', + [HttpStatus.SERVICE_UNAVAILABLE]: 'Service Unavailable', + [HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error', + [HttpStatus.UNPROCESSABLE_ENTITY]: 'Unprocessable Entity', + }; + return statusNames[status] ?? 'Unknown Error'; + } + private mapHttpStatusToErrorCode(status: HttpStatus): string { const map: Partial> = { - [HttpStatus.BAD_REQUEST]: AppErrorCode.VALIDATION_FAILED, - [HttpStatus.UNAUTHORIZED]: AppErrorCode.AUTH_TOKEN_INVALID, - [HttpStatus.FORBIDDEN]: AppErrorCode.INSUFFICIENT_PERMISSIONS, - [HttpStatus.NOT_FOUND]: AppErrorCode.RESOURCE_NOT_FOUND, - [HttpStatus.CONFLICT]: AppErrorCode.DUPLICATE_RESOURCE, - [HttpStatus.TOO_MANY_REQUESTS]: AppErrorCode.RATE_LIMIT_EXCEEDED, - [HttpStatus.SERVICE_UNAVAILABLE]: AppErrorCode.SERVICE_UNAVAILABLE, + [HttpStatus.BAD_REQUEST]: AppErrorCode.VALIDATION_ERROR, + [HttpStatus.UNAUTHORIZED]: AppErrorCode.UNAUTHORIZED, + [HttpStatus.FORBIDDEN]: AppErrorCode.FORBIDDEN, + [HttpStatus.NOT_FOUND]: AppErrorCode.NOT_FOUND, + [HttpStatus.CONFLICT]: AppErrorCode.CONFLICT, + [HttpStatus.TOO_MANY_REQUESTS]: AppErrorCode.RATE_LIMITED, + [HttpStatus.SERVICE_UNAVAILABLE]: AppErrorCode.INTERNAL_SERVER_ERROR, [HttpStatus.INTERNAL_SERVER_ERROR]: AppErrorCode.INTERNAL_SERVER_ERROR, }; return map[status] ?? AppErrorCode.INTERNAL_SERVER_ERROR;