Skip to content
24 changes: 4 additions & 20 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
49 changes: 49 additions & 0 deletions backend/src/errors/AppError.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions backend/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { AppError, NotFoundError, ValidationError, AuthError } from './AppError.js';
138 changes: 138 additions & 0 deletions backend/src/middleware/__tests__/errorHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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<Request>;
let res: Partial<Response>;
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');
});
});
104 changes: 104 additions & 0 deletions backend/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -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);
}