@tumbaland/backend-core 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.versionrc.json +7 -0
  2. package/README.md +179 -0
  3. package/__mocks__/uuid.js +8 -0
  4. package/dist/app/createBaseApp.d.ts +44 -0
  5. package/dist/app/createBaseApp.d.ts.map +1 -0
  6. package/dist/app/createBaseApp.js +54 -0
  7. package/dist/app/createBaseApp.js.map +1 -0
  8. package/dist/config/env.d.ts +8 -0
  9. package/dist/config/env.d.ts.map +1 -0
  10. package/dist/config/env.js +17 -0
  11. package/dist/config/env.js.map +1 -0
  12. package/dist/database/connection.d.ts +3 -0
  13. package/dist/database/connection.d.ts.map +1 -0
  14. package/dist/database/connection.js +49 -0
  15. package/dist/database/connection.js.map +1 -0
  16. package/dist/errors/HttpError.d.ts +32 -0
  17. package/dist/errors/HttpError.d.ts.map +1 -0
  18. package/dist/errors/HttpError.js +61 -0
  19. package/dist/errors/HttpError.js.map +1 -0
  20. package/dist/health/healthController.d.ts +21 -0
  21. package/dist/health/healthController.d.ts.map +1 -0
  22. package/dist/health/healthController.js +56 -0
  23. package/dist/health/healthController.js.map +1 -0
  24. package/dist/index.d.ts +20 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +81 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/logging/logger.d.ts +4 -0
  29. package/dist/logging/logger.d.ts.map +1 -0
  30. package/dist/logging/logger.js +78 -0
  31. package/dist/logging/logger.js.map +1 -0
  32. package/dist/metrics/index.d.ts +17 -0
  33. package/dist/metrics/index.d.ts.map +1 -0
  34. package/dist/metrics/index.js +99 -0
  35. package/dist/metrics/index.js.map +1 -0
  36. package/dist/middleware/authMiddleware.d.ts +9 -0
  37. package/dist/middleware/authMiddleware.d.ts.map +1 -0
  38. package/dist/middleware/authMiddleware.js +32 -0
  39. package/dist/middleware/authMiddleware.js.map +1 -0
  40. package/dist/middleware/corsMiddleware.d.ts +20 -0
  41. package/dist/middleware/corsMiddleware.d.ts.map +1 -0
  42. package/dist/middleware/corsMiddleware.js +33 -0
  43. package/dist/middleware/corsMiddleware.js.map +1 -0
  44. package/dist/middleware/errorHandler.d.ts +14 -0
  45. package/dist/middleware/errorHandler.d.ts.map +1 -0
  46. package/dist/middleware/errorHandler.js +88 -0
  47. package/dist/middleware/errorHandler.js.map +1 -0
  48. package/dist/middleware/requestLogger.d.ts +16 -0
  49. package/dist/middleware/requestLogger.d.ts.map +1 -0
  50. package/dist/middleware/requestLogger.js +49 -0
  51. package/dist/middleware/requestLogger.js.map +1 -0
  52. package/dist/middleware/security.d.ts +26 -0
  53. package/dist/middleware/security.d.ts.map +1 -0
  54. package/dist/middleware/security.js +47 -0
  55. package/dist/middleware/security.js.map +1 -0
  56. package/dist/middleware/validate.d.ts +11 -0
  57. package/dist/middleware/validate.d.ts.map +1 -0
  58. package/dist/middleware/validate.js +24 -0
  59. package/dist/middleware/validate.js.map +1 -0
  60. package/dist/tracing/index.d.ts +13 -0
  61. package/dist/tracing/index.d.ts.map +1 -0
  62. package/dist/tracing/index.js +89 -0
  63. package/dist/tracing/index.js.map +1 -0
  64. package/dist/types/auth.d.ts +32 -0
  65. package/dist/types/auth.d.ts.map +1 -0
  66. package/dist/types/auth.js +3 -0
  67. package/dist/types/auth.js.map +1 -0
  68. package/dist/utils/correlation.d.ts +11 -0
  69. package/dist/utils/correlation.d.ts.map +1 -0
  70. package/dist/utils/correlation.js +23 -0
  71. package/dist/utils/correlation.js.map +1 -0
  72. package/dist/utils/response.d.ts +24 -0
  73. package/dist/utils/response.d.ts.map +1 -0
  74. package/dist/utils/response.js +35 -0
  75. package/dist/utils/response.js.map +1 -0
  76. package/jest.config.js +14 -0
  77. package/package.json +61 -0
  78. package/src/app/createBaseApp.test.ts +69 -0
  79. package/src/app/createBaseApp.ts +75 -0
  80. package/src/config/env.test.ts +28 -0
  81. package/src/config/env.ts +13 -0
  82. package/src/database/connection.test.ts +142 -0
  83. package/src/database/connection.ts +46 -0
  84. package/src/errors/HttpError.test.ts +38 -0
  85. package/src/errors/HttpError.ts +57 -0
  86. package/src/health/healthController.test.ts +91 -0
  87. package/src/health/healthController.ts +56 -0
  88. package/src/index.ts +60 -0
  89. package/src/logging/logger.test.ts +76 -0
  90. package/src/logging/logger.ts +103 -0
  91. package/src/metrics/index.test.ts +91 -0
  92. package/src/metrics/index.ts +110 -0
  93. package/src/middleware/authMiddleware.test.ts +104 -0
  94. package/src/middleware/authMiddleware.ts +29 -0
  95. package/src/middleware/corsMiddleware.test.ts +58 -0
  96. package/src/middleware/corsMiddleware.ts +36 -0
  97. package/src/middleware/errorHandler.test.ts +146 -0
  98. package/src/middleware/errorHandler.ts +98 -0
  99. package/src/middleware/requestLogger.test.ts +81 -0
  100. package/src/middleware/requestLogger.ts +47 -0
  101. package/src/middleware/security.test.ts +45 -0
  102. package/src/middleware/security.ts +43 -0
  103. package/src/middleware/validate.test.ts +60 -0
  104. package/src/middleware/validate.ts +23 -0
  105. package/src/tracing/index.test.ts +250 -0
  106. package/src/tracing/index.ts +97 -0
  107. package/src/types/auth.ts +33 -0
  108. package/src/utils/correlation.test.ts +47 -0
  109. package/src/utils/correlation.ts +22 -0
  110. package/src/utils/response.test.ts +64 -0
  111. package/src/utils/response.ts +60 -0
  112. package/tsconfig.build.json +4 -0
  113. package/tsconfig.json +20 -0
@@ -0,0 +1,103 @@
1
+ import winston from 'winston';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ // Define log levels
6
+ const levels = {
7
+ error: 0,
8
+ warn: 1,
9
+ info: 2,
10
+ http: 3,
11
+ debug: 4,
12
+ };
13
+
14
+ const level = () => {
15
+ const env = process.env.NODE_ENV || 'development';
16
+ const isDevelopment = env === 'development';
17
+ return isDevelopment ? 'debug' : 'info';
18
+ };
19
+
20
+ // Define colors for each level
21
+ const colors = {
22
+ error: 'red',
23
+ warn: 'yellow',
24
+ info: 'green',
25
+ http: 'magenta',
26
+ debug: 'white',
27
+ };
28
+
29
+ winston.addColors(colors);
30
+
31
+ // Define format for console (human-readable)
32
+ const consoleFormat = winston.format.combine(
33
+ winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
34
+ winston.format.colorize({ all: true }),
35
+ winston.format.printf(
36
+ (info: any) => `${info.timestamp} ${info.level}: ${info.message}`,
37
+ ),
38
+ );
39
+
40
+ // Define JSON format for production (structured logging)
41
+ const jsonFormat = winston.format.combine(
42
+ winston.format.timestamp(),
43
+ winston.format.errors({ stack: true }),
44
+ winston.format.json()
45
+ );
46
+
47
+ // Choose format based on environment
48
+ const isProduction = process.env.NODE_ENV === 'production';
49
+ const logFormat = isProduction ? jsonFormat : consoleFormat;
50
+
51
+ // Define which transports the logger must use
52
+ const transports: winston.transport[] = [
53
+ // Console transport (goes to Docker stdout/stderr)
54
+ new winston.transports.Console({
55
+ format: logFormat,
56
+ level: level()
57
+ }),
58
+ ];
59
+
60
+ // Add file transport only in development (optional)
61
+ if (!isProduction && process.env.LOG_TO_FILE === 'true') {
62
+
63
+ // Create logs directory if it doesn't exist
64
+ const logsDir = path.join(process.cwd(), 'logs');
65
+ if (!fs.existsSync(logsDir)) {
66
+ fs.mkdirSync(logsDir, { recursive: true });
67
+ }
68
+
69
+ transports.push(
70
+ new winston.transports.File({
71
+ filename: path.join(logsDir, 'app.log'),
72
+ format: jsonFormat,
73
+ level: 'debug'
74
+ })
75
+ );
76
+ }
77
+
78
+ // Create the logger instance
79
+ const logger = winston.createLogger({
80
+ level: level(),
81
+ levels,
82
+ format: logFormat,
83
+ transports,
84
+ // Handle exceptions and rejections
85
+ exceptionHandlers: [
86
+ new winston.transports.Console({
87
+ format: winston.format.combine(
88
+ winston.format.colorize(),
89
+ winston.format.simple()
90
+ )
91
+ })
92
+ ],
93
+ rejectionHandlers: [
94
+ new winston.transports.Console({
95
+ format: winston.format.combine(
96
+ winston.format.colorize(),
97
+ winston.format.simple()
98
+ )
99
+ })
100
+ ]
101
+ });
102
+
103
+ export default logger;
@@ -0,0 +1,91 @@
1
+ import { Request, Response } from 'express';
2
+ import {
3
+ activeConnections,
4
+ businessMetrics,
5
+ httpRequestDuration,
6
+ httpRequestsTotal,
7
+ metricsHandler,
8
+ metricsMiddleware,
9
+ register
10
+ } from './index';
11
+
12
+ function mockRes(): Response {
13
+ const res: Partial<Response> & { _finishHandler?: () => void } = {};
14
+ res.set = jest.fn().mockReturnValue(res);
15
+ res.end = jest.fn().mockReturnValue(res);
16
+ res.status = jest.fn().mockReturnValue(res);
17
+ res.statusCode = 200;
18
+ res.on = jest.fn((event: string, handler: () => void) => {
19
+ if (event === 'finish') res._finishHandler = handler;
20
+ return res as Response;
21
+ });
22
+ return res as Response;
23
+ }
24
+
25
+ describe('metricsHandler', () => {
26
+ it('writes the registry content-type header and the serialized metrics body', async () => {
27
+ const res = mockRes();
28
+
29
+ await metricsHandler({} as Request, res);
30
+
31
+ expect(res.set).toHaveBeenCalledWith('Content-Type', register.contentType);
32
+ expect(res.end).toHaveBeenCalledWith(expect.any(String));
33
+ });
34
+
35
+ it('responds 500 when the registry fails to serialize metrics', async () => {
36
+ const res = mockRes();
37
+ jest.spyOn(register, 'metrics').mockRejectedValueOnce(new Error('boom'));
38
+
39
+ await metricsHandler({} as Request, res);
40
+
41
+ expect(res.status).toHaveBeenCalledWith(500);
42
+ expect(res.end).toHaveBeenCalledWith('Error generating metrics');
43
+ });
44
+ });
45
+
46
+ describe('metricsMiddleware', () => {
47
+ it('increments active connections immediately and decrements once the response finishes', async () => {
48
+ const req = { method: 'GET', url: '/albums' } as Request;
49
+ const res = mockRes() as Response & { _finishHandler?: () => void };
50
+ const next = jest.fn();
51
+ const before = (await activeConnections.get()).values[0]?.value ?? 0;
52
+
53
+ metricsMiddleware(req, res, next);
54
+
55
+ expect(next).toHaveBeenCalledTimes(1);
56
+ expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
57
+ expect((await activeConnections.get()).values[0]?.value).toBe(before + 1);
58
+
59
+ (res as any)._finishHandler();
60
+
61
+ expect((await activeConnections.get()).values[0]?.value).toBe(before);
62
+ });
63
+
64
+ it('records request duration and total-count metrics labeled by method/url/status on finish', () => {
65
+ const req = { method: 'POST', url: '/albums' } as Request;
66
+ const res = mockRes();
67
+ res.statusCode = 201;
68
+ const next = jest.fn();
69
+
70
+ const durationSpy = jest.spyOn(httpRequestDuration, 'labels');
71
+ const totalSpy = jest.spyOn(httpRequestsTotal, 'labels');
72
+
73
+ metricsMiddleware(req, res, next);
74
+ (res as any)._finishHandler();
75
+
76
+ expect(durationSpy).toHaveBeenCalledWith('POST', '/albums', '201');
77
+ expect(totalSpy).toHaveBeenCalledWith('POST', '/albums', '201');
78
+
79
+ durationSpy.mockRestore();
80
+ totalSpy.mockRestore();
81
+ });
82
+ });
83
+
84
+ describe('businessMetrics', () => {
85
+ it('exposes counters that can be incremented without throwing', () => {
86
+ expect(() => businessMetrics.albumsCreated.inc()).not.toThrow();
87
+ expect(() => businessMetrics.filesUploaded.inc({ file_type: 'image' })).not.toThrow();
88
+ expect(() => businessMetrics.usersRegistered.inc()).not.toThrow();
89
+ expect(() => businessMetrics.paymentsProcessed.inc({ status: 'success', method: 'card' })).not.toThrow();
90
+ });
91
+ });
@@ -0,0 +1,110 @@
1
+ import promClient from 'prom-client';
2
+
3
+ // Create a Registry which registers the metrics
4
+ const register = new promClient.Registry();
5
+
6
+ // Add a default label which is added to all metrics
7
+ register.setDefaultLabels({
8
+ app: process.env.SERVICE_NAME || 'unknown-service',
9
+ version: process.env.npm_package_version || '1.0.0'
10
+ });
11
+
12
+ // Enable the collection of default metrics
13
+ promClient.collectDefaultMetrics({ register });
14
+
15
+ // Custom metrics
16
+ export const httpRequestDuration = new promClient.Histogram({
17
+ name: 'http_request_duration_seconds',
18
+ help: 'Duration of HTTP requests in seconds',
19
+ labelNames: ['method', 'route', 'status_code'],
20
+ buckets: [0.1, 0.5, 1, 2, 5, 10]
21
+ });
22
+
23
+ export const httpRequestsTotal = new promClient.Counter({
24
+ name: 'http_requests_total',
25
+ help: 'Total number of HTTP requests',
26
+ labelNames: ['method', 'route', 'status_code']
27
+ });
28
+
29
+ export const activeConnections = new promClient.Gauge({
30
+ name: 'active_connections',
31
+ help: 'Number of active connections'
32
+ });
33
+
34
+ export const databaseQueryDuration = new promClient.Histogram({
35
+ name: 'database_query_duration_seconds',
36
+ help: 'Duration of database queries in seconds',
37
+ labelNames: ['operation', 'collection'],
38
+ buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5]
39
+ });
40
+
41
+ export const databaseQueriesTotal = new promClient.Counter({
42
+ name: 'database_queries_total',
43
+ help: 'Total number of database queries',
44
+ labelNames: ['operation', 'collection', 'status']
45
+ });
46
+
47
+ export const businessMetrics = {
48
+ albumsCreated: new promClient.Counter({
49
+ name: 'albums_created_total',
50
+ help: 'Total number of albums created'
51
+ }),
52
+
53
+ filesUploaded: new promClient.Counter({
54
+ name: 'files_uploaded_total',
55
+ help: 'Total number of files uploaded',
56
+ labelNames: ['file_type']
57
+ }),
58
+
59
+ usersRegistered: new promClient.Counter({
60
+ name: 'users_registered_total',
61
+ help: 'Total number of users registered'
62
+ }),
63
+
64
+ paymentsProcessed: new promClient.Counter({
65
+ name: 'payments_processed_total',
66
+ help: 'Total number of payments processed',
67
+ labelNames: ['status', 'method']
68
+ })
69
+ };
70
+
71
+ // Metrics endpoint handler
72
+ export const metricsHandler = async (req: any, res: any) => {
73
+ try {
74
+ res.set('Content-Type', register.contentType);
75
+ const metrics = await register.metrics();
76
+ res.end(metrics);
77
+ } catch (error) {
78
+ res.status(500).end('Error generating metrics');
79
+ }
80
+ };
81
+
82
+ // Middleware to collect HTTP metrics
83
+ export const metricsMiddleware = (req: any, res: any, next: any) => {
84
+ const start = Date.now();
85
+ const { method, url } = req;
86
+
87
+ // Increment active connections
88
+ activeConnections.inc();
89
+
90
+ res.on('finish', () => {
91
+ const duration = (Date.now() - start) / 1000; // Convert to seconds
92
+ const { statusCode } = res;
93
+
94
+ // Record metrics
95
+ httpRequestDuration
96
+ .labels(method, url, statusCode.toString())
97
+ .observe(duration);
98
+
99
+ httpRequestsTotal
100
+ .labels(method, url, statusCode.toString())
101
+ .inc();
102
+
103
+ // Decrement active connections
104
+ activeConnections.dec();
105
+ });
106
+
107
+ next();
108
+ };
109
+
110
+ export { register };
@@ -0,0 +1,104 @@
1
+ import { Request, Response } from 'express';
2
+ import jwt from 'jsonwebtoken';
3
+ import { authenticateToken } from './authMiddleware';
4
+ import { UserPayload } from '../types/auth';
5
+
6
+ function mockReq(overrides: Partial<Request> = {}): Request {
7
+ return { cookies: {}, headers: {}, ...overrides } as unknown as Request;
8
+ }
9
+
10
+ function mockRes(): Response {
11
+ const res: Partial<Response> = {};
12
+ res.status = jest.fn().mockReturnValue(res);
13
+ res.json = jest.fn().mockReturnValue(res);
14
+ return res as Response;
15
+ }
16
+
17
+ describe('authenticateToken', () => {
18
+ const ORIGINAL_ENV = process.env;
19
+ const next = jest.fn();
20
+
21
+ beforeEach(() => {
22
+ process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-secret' };
23
+ next.mockClear();
24
+ });
25
+
26
+ afterAll(() => {
27
+ process.env = ORIGINAL_ENV;
28
+ });
29
+
30
+ const payload: UserPayload = { id: '1', email: 'user@example.com', name: 'Test User' };
31
+
32
+ it('attaches the decoded user and calls next for a valid cookie token', () => {
33
+ const token = jwt.sign(payload, 'test-secret');
34
+ const req = mockReq({ cookies: { access_token: token } });
35
+ const res = mockRes();
36
+
37
+ authenticateToken(req, res, next);
38
+
39
+ expect(next).toHaveBeenCalledTimes(1);
40
+ expect(req.user).toMatchObject({ id: '1', email: 'user@example.com' });
41
+ });
42
+
43
+ it('attaches an empty userGroups array when the token has no groups claim', () => {
44
+ const token = jwt.sign(payload, 'test-secret');
45
+ const req = mockReq({ cookies: { access_token: token } });
46
+ const res = mockRes();
47
+
48
+ authenticateToken(req, res, next);
49
+
50
+ expect(req.userGroups).toEqual([]);
51
+ });
52
+
53
+ it('attaches userGroups from the token when present', () => {
54
+ const token = jwt.sign({ ...payload, groups: ['group-1', 'group-2'] }, 'test-secret');
55
+ const req = mockReq({ cookies: { access_token: token } });
56
+ const res = mockRes();
57
+
58
+ authenticateToken(req, res, next);
59
+
60
+ expect(req.userGroups).toEqual(['group-1', 'group-2']);
61
+ });
62
+
63
+ it('accepts a Bearer token from the Authorization header', () => {
64
+ const token = jwt.sign(payload, 'test-secret');
65
+ const req = mockReq({ headers: { authorization: `Bearer ${token}` } });
66
+ const res = mockRes();
67
+
68
+ authenticateToken(req, res, next);
69
+
70
+ expect(next).toHaveBeenCalledTimes(1);
71
+ });
72
+
73
+ it('returns 401 when no token is present', () => {
74
+ const req = mockReq();
75
+ const res = mockRes();
76
+
77
+ authenticateToken(req, res, next);
78
+
79
+ expect(next).not.toHaveBeenCalled();
80
+ expect(res.status).toHaveBeenCalledWith(401);
81
+ expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Access token required' });
82
+ });
83
+
84
+ it('returns 401 for an invalid token', () => {
85
+ const req = mockReq({ cookies: { access_token: 'not-a-real-token' } });
86
+ const res = mockRes();
87
+
88
+ authenticateToken(req, res, next);
89
+
90
+ expect(next).not.toHaveBeenCalled();
91
+ expect(res.status).toHaveBeenCalledWith(401);
92
+ expect(res.json).toHaveBeenCalledWith({ success: false, message: 'Invalid or expired token' });
93
+ });
94
+
95
+ it('returns 401 for a token signed with a different secret', () => {
96
+ const token = jwt.sign(payload, 'wrong-secret');
97
+ const req = mockReq({ cookies: { access_token: token } });
98
+ const res = mockRes();
99
+
100
+ authenticateToken(req, res, next);
101
+
102
+ expect(res.status).toHaveBeenCalledWith(401);
103
+ });
104
+ });
@@ -0,0 +1,29 @@
1
+ import { Request, Response, NextFunction } from 'express';
2
+ import jwt from 'jsonwebtoken';
3
+ import { requireEnv } from '../config/env';
4
+ import { UserPayload } from '../types/auth';
5
+
6
+ /**
7
+ * JWT authentication middleware
8
+ * Verifies JWT token and attaches user info (and its embedded groups) to
9
+ * the request — typed via the global `Express.Request` augmentation in
10
+ * `../types/auth`, no `(req as any)` cast needed.
11
+ */
12
+ export function authenticateToken(req: Request, res: Response, next: NextFunction): void {
13
+ try {
14
+ const JWT_SECRET = requireEnv('JWT_SECRET');
15
+ const token = req.cookies?.access_token || req.headers.authorization?.replace('Bearer ', '');
16
+
17
+ if (!token) {
18
+ res.status(401).json({ success: false, message: 'Access token required' });
19
+ return;
20
+ }
21
+
22
+ const user = jwt.verify(token, JWT_SECRET) as UserPayload;
23
+ req.user = user;
24
+ req.userGroups = user.groups ?? [];
25
+ next();
26
+ } catch (error) {
27
+ res.status(401).json({ success: false, message: 'Invalid or expired token' });
28
+ }
29
+ }
@@ -0,0 +1,58 @@
1
+ import express from 'express';
2
+ import request from 'supertest';
3
+ import { createCorsMiddleware } from './corsMiddleware';
4
+ import { errorHandler } from './errorHandler';
5
+
6
+ jest.mock('../logging/logger', () => ({
7
+ __esModule: true,
8
+ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
9
+ }));
10
+
11
+ function buildApp(origins: string) {
12
+ const app = express();
13
+ app.use(createCorsMiddleware({ origins }));
14
+ app.get('/ping', (_req, res) => res.json({ ok: true }));
15
+ app.use(errorHandler);
16
+ return app;
17
+ }
18
+
19
+ describe('createCorsMiddleware', () => {
20
+ const app = buildApp('https://allowed.example,https://also-allowed.example');
21
+
22
+ it('allows a request with no Origin header (server-to-server / health checks)', async () => {
23
+ const res = await request(app).get('/ping');
24
+ expect(res.status).toBe(200);
25
+ expect(res.body).toEqual({ ok: true });
26
+ });
27
+
28
+ it('allows a whitelisted origin and echoes it back with credentials', async () => {
29
+ const res = await request(app).get('/ping').set('Origin', 'https://allowed.example');
30
+
31
+ expect(res.status).toBe(200);
32
+ expect(res.headers['access-control-allow-origin']).toBe('https://allowed.example');
33
+ expect(res.headers['access-control-allow-credentials']).toBe('true');
34
+ });
35
+
36
+ it('rejects an origin not on the whitelist', async () => {
37
+ const res = await request(app).get('/ping').set('Origin', 'https://evil.example');
38
+
39
+ expect(res.status).toBe(500);
40
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
41
+ });
42
+
43
+ it('falls back to process.env.CORS_ORIGIN when no origins option is given', async () => {
44
+ const ORIGINAL_ENV = process.env;
45
+ process.env = { ...ORIGINAL_ENV, CORS_ORIGIN: 'https://from-env.example' };
46
+
47
+ const envApp = express();
48
+ envApp.use(createCorsMiddleware());
49
+ envApp.get('/ping', (_req, res) => res.json({ ok: true }));
50
+ envApp.use(errorHandler);
51
+
52
+ const res = await request(envApp).get('/ping').set('Origin', 'https://from-env.example');
53
+ expect(res.status).toBe(200);
54
+ expect(res.headers['access-control-allow-origin']).toBe('https://from-env.example');
55
+
56
+ process.env = ORIGINAL_ENV;
57
+ });
58
+ });
@@ -0,0 +1,36 @@
1
+ import cors from 'cors';
2
+ import logger from '../logging/logger';
3
+
4
+ export interface CorsMiddlewareOptions {
5
+ /**
6
+ * Comma-separated origin whitelist. Defaults to process.env.CORS_ORIGIN,
7
+ * read per-request so it works regardless of dotenv.config() timing.
8
+ */
9
+ origins?: string;
10
+ exposedHeaders?: string[];
11
+ }
12
+
13
+ /**
14
+ * Shared CORS middleware with an explicit origin whitelist.
15
+ * Requests without an Origin header (server-to-server, curl, health checks)
16
+ * are always allowed; browser requests must match the whitelist.
17
+ */
18
+ export function createCorsMiddleware(options: CorsMiddlewareOptions = {}) {
19
+ return cors({
20
+ origin: (origin, callback) => {
21
+ const allowedOrigins = (options.origins ?? process.env.CORS_ORIGIN ?? '')
22
+ .split(',')
23
+ .map(o => o.trim())
24
+ .filter(Boolean);
25
+ if (!origin || allowedOrigins.includes(origin)) {
26
+ return callback(null, true);
27
+ }
28
+ logger.warn('Blocked CORS origin', { origin });
29
+ return callback(new Error('Not allowed by CORS: ' + origin));
30
+ },
31
+ credentials: true,
32
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
33
+ allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'x-correlation-id', 'x-session-id'],
34
+ exposedHeaders: options.exposedHeaders
35
+ });
36
+ }
@@ -0,0 +1,146 @@
1
+ import express from 'express';
2
+ import request from 'supertest';
3
+ import { errorHandler } from './errorHandler';
4
+ import { NotFoundError, ForbiddenError } from '../errors/HttpError';
5
+
6
+ jest.mock('../logging/logger', () => ({
7
+ __esModule: true,
8
+ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn(), http: jest.fn() }
9
+ }));
10
+
11
+ import logger from '../logging/logger';
12
+
13
+ function namedError(name: string, message: string): Error {
14
+ const error = new Error(message);
15
+ error.name = name;
16
+ return error;
17
+ }
18
+
19
+ function buildApp() {
20
+ const app = express();
21
+ app.get('/boom', (_req, _res, next) => next(new Error('kaboom')));
22
+ app.get('/missing', (_req, _res, next) => next(new NotFoundError('Album not found')));
23
+ app.get('/forbidden', (_req, _res, next) => next(new ForbiddenError()));
24
+ app.get('/validation', (_req, _res, next) =>
25
+ next(namedError('ValidationError', 'CollageModel validation failed: title: Path `title` is required.'))
26
+ );
27
+ app.get('/cast', (_req, _res, next) => next(namedError('CastError', 'Cast to ObjectId failed for value "abc"')));
28
+ app.use(errorHandler);
29
+ return app;
30
+ }
31
+
32
+ describe('errorHandler', () => {
33
+ const ORIGINAL_ENV = process.env;
34
+
35
+ beforeEach(() => {
36
+ process.env = { ...ORIGINAL_ENV };
37
+ (logger.error as jest.Mock).mockClear();
38
+ (logger.warn as jest.Mock).mockClear();
39
+ });
40
+
41
+ afterAll(() => {
42
+ process.env = ORIGINAL_ENV;
43
+ });
44
+
45
+ it('logs the error and responds 500 with a generic message in production', async () => {
46
+ process.env.NODE_ENV = 'production';
47
+ const app = buildApp();
48
+
49
+ const res = await request(app).get('/boom');
50
+
51
+ expect(res.status).toBe(500);
52
+ expect(res.body).toEqual({
53
+ success: false,
54
+ error: 'Internal server error',
55
+ message: 'Something went wrong'
56
+ });
57
+ expect(logger.error).toHaveBeenCalledWith('Unhandled error:', expect.objectContaining({ error: 'kaboom' }));
58
+ });
59
+
60
+ it('includes the real error message and stack in development', async () => {
61
+ process.env.NODE_ENV = 'development';
62
+ const app = buildApp();
63
+
64
+ const res = await request(app).get('/boom');
65
+
66
+ expect(res.status).toBe(500);
67
+ expect(res.body.message).toBe('kaboom');
68
+ expect(res.body.stack).toBeDefined();
69
+ });
70
+
71
+ it('adds CORS headers to the error response for an allowed origin', async () => {
72
+ process.env.NODE_ENV = 'production';
73
+ const app = buildApp();
74
+
75
+ const res = await request(app).get('/boom').set('Origin', 'http://localhost:3000');
76
+
77
+ expect(res.headers['access-control-allow-origin']).toBe('http://localhost:3000');
78
+ expect(res.headers['access-control-allow-credentials']).toBe('true');
79
+ });
80
+
81
+ it('does not add CORS headers for a disallowed origin', async () => {
82
+ process.env.NODE_ENV = 'production';
83
+ const app = buildApp();
84
+
85
+ const res = await request(app).get('/boom').set('Origin', 'https://evil.example');
86
+
87
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
88
+ });
89
+
90
+ describe('typed HttpError instances', () => {
91
+ it('uses the error’s own status code and message, in production too', async () => {
92
+ process.env.NODE_ENV = 'production';
93
+ const app = buildApp();
94
+
95
+ const res = await request(app).get('/missing');
96
+
97
+ expect(res.status).toBe(404);
98
+ expect(res.body).toEqual({ success: false, error: 'NotFoundError', message: 'Album not found' });
99
+ });
100
+
101
+ it('logs at warn level, not error, and without a stack trace', async () => {
102
+ const app = buildApp();
103
+
104
+ await request(app).get('/forbidden');
105
+
106
+ expect(logger.warn).toHaveBeenCalledWith(
107
+ 'Request failed:',
108
+ expect.objectContaining({ error: 'Access denied', statusCode: 403 })
109
+ );
110
+ expect(logger.error).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('does not include a stack trace in the response body, even in development', async () => {
114
+ process.env.NODE_ENV = 'development';
115
+ const app = buildApp();
116
+
117
+ const res = await request(app).get('/missing');
118
+
119
+ expect(res.body.stack).toBeUndefined();
120
+ });
121
+ });
122
+
123
+ describe('Mongoose ValidationError / CastError', () => {
124
+ it('maps a ValidationError to 400, using its own message', async () => {
125
+ const app = buildApp();
126
+
127
+ const res = await request(app).get('/validation');
128
+
129
+ expect(res.status).toBe(400);
130
+ expect(res.body).toEqual({
131
+ success: false,
132
+ error: 'ValidationError',
133
+ message: 'CollageModel validation failed: title: Path `title` is required.'
134
+ });
135
+ });
136
+
137
+ it('maps a CastError (e.g. an invalid ObjectId) to 400', async () => {
138
+ const app = buildApp();
139
+
140
+ const res = await request(app).get('/cast');
141
+
142
+ expect(res.status).toBe(400);
143
+ expect(res.body.error).toBe('CastError');
144
+ });
145
+ });
146
+ });