diff --git a/backend/src/app.ts b/backend/src/app.ts index e2a3bab4..ff86168c 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,8 +4,6 @@ import morgan from 'morgan'; import helmet from 'helmet'; import path from 'path'; import { fileURLToPath } from 'url'; -import config from './config/index.js'; -import logger from './utils/logger.js'; import passport from './config/passport.js'; import { apiVersionMiddleware } from './middlewares/apiVersionMiddleware.js'; import { requestIdMiddleware } from './middleware/requestId.js'; @@ -33,6 +31,7 @@ import contractEventRoutes from './routes/contractEventRoutes.js'; import certificateRoutes from './routes/certificateRoutes.js'; import cashFlowForecastRoutes from './routes/cashFlowForecastRoutes.js'; import { HealthController } from './controllers/healthController.js'; +import { errorHandler, notFoundHandler } from './middleware/errorHandler.js'; // Part 49 — admin, audit integrity, per-tenant rate limits, quotas import adminRoutes from './routes/adminRoutes.js'; @@ -176,23 +175,8 @@ app.use('/api/audit-analytics', auditAnalyticsRoutes); app.use('/api/smart-rate-limit', smartRateLimitRoutes); app.use('/api/tenant-security', tenantSecurityRoutes); -// 404 handler -app.use((req, res) => { - res.status(404).json({ - error: 'Not Found', - path: req.path, - requestId: (req as any).requestId, - }); -}); - -// Error handler -app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => { - logger.error('Unhandled error', { err, requestId: (req as any).requestId }); - res.status(500).json({ - error: 'Internal Server Error', - message: config.nodeEnv === 'development' ? err.message : 'An error occurred', - requestId: (req as any).requestId, - }); -}); +// 404 + global error handler (typed AppError responses) +app.use(notFoundHandler); +app.use(errorHandler); export default app; diff --git a/backend/src/errors/AppError.ts b/backend/src/errors/AppError.ts new file mode 100644 index 00000000..d29144db --- /dev/null +++ b/backend/src/errors/AppError.ts @@ -0,0 +1,49 @@ +/** + * Base application error with HTTP status and machine-readable code. + * Controllers and services throw subclasses; the global error middleware + * maps them to a consistent JSON response shape. + */ +export class AppError extends Error { + public readonly statusCode: number; + public readonly code: string; + public readonly isOperational: boolean; + + constructor( + message: string, + statusCode: number, + code: string, + isOperational = true + ) { + super(message); + this.name = this.constructor.name; + this.statusCode = statusCode; + this.code = code; + this.isOperational = isOperational; + Object.setPrototypeOf(this, new.target.prototype); + } +} + +/** Resource was not found (HTTP 404). */ +export class NotFoundError extends AppError { + constructor(message = 'Resource not found', code = 'NOT_FOUND') { + super(message, 404, code); + } +} + +/** Request failed validation (HTTP 400). */ +export class ValidationError extends AppError { + constructor(message = 'Validation failed', code = 'VALIDATION_ERROR') { + super(message, 400, code); + } +} + +/** Authentication or authorization failure (HTTP 401 / 403). */ +export class AuthError extends AppError { + constructor( + message = 'Authentication required', + statusCode: 401 | 403 = 401, + code = 'AUTH_ERROR' + ) { + super(message, statusCode, code); + } +} diff --git a/backend/src/errors/index.ts b/backend/src/errors/index.ts new file mode 100644 index 00000000..57b9ed14 --- /dev/null +++ b/backend/src/errors/index.ts @@ -0,0 +1 @@ +export { AppError, NotFoundError, ValidationError, AuthError } from './AppError.js'; diff --git a/backend/src/middleware/__tests__/errorHandler.test.ts b/backend/src/middleware/__tests__/errorHandler.test.ts new file mode 100644 index 00000000..e6f5e91a --- /dev/null +++ b/backend/src/middleware/__tests__/errorHandler.test.ts @@ -0,0 +1,138 @@ +import { Request, Response, NextFunction } from 'express'; +import { errorHandler, notFoundHandler } from '../errorHandler.js'; +import { NotFoundError, ValidationError, AuthError, AppError } from '../../errors/index.js'; +import config from '../../config/index.js'; +import logger from '../../utils/logger.js'; + +jest.mock('../../config/index.js', () => ({ + __esModule: true, + default: { nodeEnv: 'test' }, +})); + +jest.mock('../../utils/logger.js', () => ({ + __esModule: true, + default: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }, +})); + +describe('errorHandler middleware', () => { + let req: Partial; + let res: Partial; + let next: NextFunction; + let statusMock: jest.Mock; + let jsonMock: jest.Mock; + + beforeEach(() => { + jsonMock = jest.fn().mockReturnThis(); + statusMock = jest.fn().mockReturnValue({ json: jsonMock }); + req = { + method: 'GET', + path: '/api/missing', + originalUrl: '/api/missing', + requestId: 'req-abc-123', + }; + res = { status: statusMock, json: jsonMock } as any; + next = jest.fn(); + (config as any).nodeEnv = 'test'; + jest.clearAllMocks(); + }); + + it('maps NotFoundError to consistent 404 payload', () => { + errorHandler(new NotFoundError('Employee not found'), req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(404); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'NotFoundError', + message: 'Employee not found', + code: 'NOT_FOUND', + requestId: 'req-abc-123', + }); + expect(jsonMock.mock.calls[0][0].stack).toBeUndefined(); + }); + + it('maps ValidationError to 400 with VALIDATION_ERROR code', () => { + errorHandler( + new ValidationError('email is required'), + req as Request, + res as Response, + next + ); + + expect(statusMock).toHaveBeenCalledWith(400); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'ValidationError', + message: 'email is required', + code: 'VALIDATION_ERROR', + requestId: 'req-abc-123', + }) + ); + }); + + it('maps AuthError to 401 by default', () => { + errorHandler(new AuthError(), req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(401); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + error: 'AuthError', + code: 'AUTH_ERROR', + requestId: 'req-abc-123', + }) + ); + }); + + it('maps AuthError with 403 when forbidden', () => { + errorHandler( + new AuthError('Forbidden', 403, 'FORBIDDEN'), + req as Request, + res as Response, + next + ); + + expect(statusMock).toHaveBeenCalledWith(403); + expect(jsonMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'FORBIDDEN', + message: 'Forbidden', + }) + ); + }); + + it('hides internal details for unknown errors outside development', () => { + errorHandler(new Error('SELECT * FROM secrets'), req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(500); + expect(jsonMock).toHaveBeenCalledWith({ + error: 'InternalServerError', + message: 'An error occurred', + code: 'INTERNAL_ERROR', + requestId: 'req-abc-123', + }); + expect(logger.error).toHaveBeenCalled(); + }); + + it('includes stack traces only in development', () => { + (config as any).nodeEnv = 'development'; + const err = new AppError('boom', 500, 'BOOM'); + + errorHandler(err, req as Request, res as Response, next); + + expect(statusMock).toHaveBeenCalledWith(500); + const body = jsonMock.mock.calls[0][0]; + expect(body.stack).toEqual(expect.stringContaining('AppError')); + expect(body.message).toBe('boom'); + }); + + it('notFoundHandler forwards a NotFoundError to next', () => { + notFoundHandler(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + const forwarded = (next as jest.Mock).mock.calls[0][0]; + expect(forwarded).toBeInstanceOf(NotFoundError); + expect(forwarded.message).toContain('GET /api/missing'); + }); +}); diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 00000000..310f6a7f --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,104 @@ +import { Request, Response, NextFunction } from 'express'; +import config from '../config/index.js'; +import logger from '../utils/logger.js'; +import { AppError, NotFoundError } from '../errors/index.js'; + +/** Consistent error payload returned to clients. */ +export interface ErrorResponseBody { + error: string; + message: string; + code: string; + requestId?: string; + stack?: string; +} + +function requestIdOf(req: Request): string | undefined { + return typeof req.requestId === 'string' ? req.requestId : undefined; +} + +/** + * Express 404 fallback that uses the same response shape as the error handler. + */ +export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void { + next(new NotFoundError(`Cannot ${req.method} ${req.path}`)); +} + +/** + * Global error middleware. Maps AppError subclasses (and unknown errors) to + * `{ error, message, code, requestId }` and only includes stack traces in + * development. + */ +export function errorHandler( + err: unknown, + req: Request, + res: Response, + next: NextFunction +): void { + if (res.headersSent) { + next(err); + return; + } + + const requestId = requestIdOf(req); + const isDev = config.nodeEnv === 'development'; + + if (err instanceof AppError) { + if (!err.isOperational || err.statusCode >= 500) { + logger.error('Operational/server error', { + err, + code: err.code, + statusCode: err.statusCode, + requestId, + path: req.originalUrl, + method: req.method, + }); + } else { + logger.warn('Client error', { + message: err.message, + code: err.code, + statusCode: err.statusCode, + requestId, + path: req.originalUrl, + method: req.method, + }); + } + + const body: ErrorResponseBody = { + error: err.name, + message: err.message, + code: err.code, + requestId, + }; + + if (isDev && err.stack) { + body.stack = err.stack; + } + + res.status(err.statusCode).json(body); + return; + } + + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error ? err.stack : undefined; + + logger.error('Unhandled error', { + message, + stack, + requestId, + path: req.originalUrl, + method: req.method, + }); + + const body: ErrorResponseBody = { + error: 'InternalServerError', + message: isDev ? message || 'An error occurred' : 'An error occurred', + code: 'INTERNAL_ERROR', + requestId, + }; + + if (isDev && stack) { + body.stack = stack; + } + + res.status(500).json(body); +}