@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.
- package/.versionrc.json +7 -0
- package/README.md +179 -0
- package/__mocks__/uuid.js +8 -0
- package/dist/app/createBaseApp.d.ts +44 -0
- package/dist/app/createBaseApp.d.ts.map +1 -0
- package/dist/app/createBaseApp.js +54 -0
- package/dist/app/createBaseApp.js.map +1 -0
- package/dist/config/env.d.ts +8 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +17 -0
- package/dist/config/env.js.map +1 -0
- package/dist/database/connection.d.ts +3 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +49 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/errors/HttpError.d.ts +32 -0
- package/dist/errors/HttpError.d.ts.map +1 -0
- package/dist/errors/HttpError.js +61 -0
- package/dist/errors/HttpError.js.map +1 -0
- package/dist/health/healthController.d.ts +21 -0
- package/dist/health/healthController.d.ts.map +1 -0
- package/dist/health/healthController.js +56 -0
- package/dist/health/healthController.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +81 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/logger.d.ts +4 -0
- package/dist/logging/logger.d.ts.map +1 -0
- package/dist/logging/logger.js +78 -0
- package/dist/logging/logger.js.map +1 -0
- package/dist/metrics/index.d.ts +17 -0
- package/dist/metrics/index.d.ts.map +1 -0
- package/dist/metrics/index.js +99 -0
- package/dist/metrics/index.js.map +1 -0
- package/dist/middleware/authMiddleware.d.ts +9 -0
- package/dist/middleware/authMiddleware.d.ts.map +1 -0
- package/dist/middleware/authMiddleware.js +32 -0
- package/dist/middleware/authMiddleware.js.map +1 -0
- package/dist/middleware/corsMiddleware.d.ts +20 -0
- package/dist/middleware/corsMiddleware.d.ts.map +1 -0
- package/dist/middleware/corsMiddleware.js +33 -0
- package/dist/middleware/corsMiddleware.js.map +1 -0
- package/dist/middleware/errorHandler.d.ts +14 -0
- package/dist/middleware/errorHandler.d.ts.map +1 -0
- package/dist/middleware/errorHandler.js +88 -0
- package/dist/middleware/errorHandler.js.map +1 -0
- package/dist/middleware/requestLogger.d.ts +16 -0
- package/dist/middleware/requestLogger.d.ts.map +1 -0
- package/dist/middleware/requestLogger.js +49 -0
- package/dist/middleware/requestLogger.js.map +1 -0
- package/dist/middleware/security.d.ts +26 -0
- package/dist/middleware/security.d.ts.map +1 -0
- package/dist/middleware/security.js +47 -0
- package/dist/middleware/security.js.map +1 -0
- package/dist/middleware/validate.d.ts +11 -0
- package/dist/middleware/validate.d.ts.map +1 -0
- package/dist/middleware/validate.js +24 -0
- package/dist/middleware/validate.js.map +1 -0
- package/dist/tracing/index.d.ts +13 -0
- package/dist/tracing/index.d.ts.map +1 -0
- package/dist/tracing/index.js +89 -0
- package/dist/tracing/index.js.map +1 -0
- package/dist/types/auth.d.ts +32 -0
- package/dist/types/auth.d.ts.map +1 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/auth.js.map +1 -0
- package/dist/utils/correlation.d.ts +11 -0
- package/dist/utils/correlation.d.ts.map +1 -0
- package/dist/utils/correlation.js +23 -0
- package/dist/utils/correlation.js.map +1 -0
- package/dist/utils/response.d.ts +24 -0
- package/dist/utils/response.d.ts.map +1 -0
- package/dist/utils/response.js +35 -0
- package/dist/utils/response.js.map +1 -0
- package/jest.config.js +14 -0
- package/package.json +61 -0
- package/src/app/createBaseApp.test.ts +69 -0
- package/src/app/createBaseApp.ts +75 -0
- package/src/config/env.test.ts +28 -0
- package/src/config/env.ts +13 -0
- package/src/database/connection.test.ts +142 -0
- package/src/database/connection.ts +46 -0
- package/src/errors/HttpError.test.ts +38 -0
- package/src/errors/HttpError.ts +57 -0
- package/src/health/healthController.test.ts +91 -0
- package/src/health/healthController.ts +56 -0
- package/src/index.ts +60 -0
- package/src/logging/logger.test.ts +76 -0
- package/src/logging/logger.ts +103 -0
- package/src/metrics/index.test.ts +91 -0
- package/src/metrics/index.ts +110 -0
- package/src/middleware/authMiddleware.test.ts +104 -0
- package/src/middleware/authMiddleware.ts +29 -0
- package/src/middleware/corsMiddleware.test.ts +58 -0
- package/src/middleware/corsMiddleware.ts +36 -0
- package/src/middleware/errorHandler.test.ts +146 -0
- package/src/middleware/errorHandler.ts +98 -0
- package/src/middleware/requestLogger.test.ts +81 -0
- package/src/middleware/requestLogger.ts +47 -0
- package/src/middleware/security.test.ts +45 -0
- package/src/middleware/security.ts +43 -0
- package/src/middleware/validate.test.ts +60 -0
- package/src/middleware/validate.ts +23 -0
- package/src/tracing/index.test.ts +250 -0
- package/src/tracing/index.ts +97 -0
- package/src/types/auth.ts +33 -0
- package/src/utils/correlation.test.ts +47 -0
- package/src/utils/correlation.ts +22 -0
- package/src/utils/response.test.ts +64 -0
- package/src/utils/response.ts +60 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
|
|
2
|
+
import logger from '../logging/logger';
|
|
3
|
+
import { HttpError } from '../errors/HttpError';
|
|
4
|
+
|
|
5
|
+
interface KnownError {
|
|
6
|
+
statusCode: number;
|
|
7
|
+
name: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recognizes error shapes that map to an obvious HTTP status without a
|
|
13
|
+
* controller having to catch and re-throw them: our own typed HttpError
|
|
14
|
+
* hierarchy, plus Mongoose's ValidationError (failed schema validation,
|
|
15
|
+
* e.g. a missing required field) and CastError (e.g. an invalid ObjectId
|
|
16
|
+
* string) — both of which are client mistakes, not server bugs, but throw
|
|
17
|
+
* as plain Mongoose error classes rather than anything of ours.
|
|
18
|
+
*/
|
|
19
|
+
function classifyError(error: Error): KnownError | null {
|
|
20
|
+
if (error instanceof HttpError) {
|
|
21
|
+
return { statusCode: error.statusCode, name: error.name, message: error.message };
|
|
22
|
+
}
|
|
23
|
+
if (error.name === 'ValidationError' || error.name === 'CastError') {
|
|
24
|
+
return { statusCode: 400, name: error.name, message: error.message };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Global error handler middleware.
|
|
31
|
+
*
|
|
32
|
+
* Controllers throw a typed `HttpError` (BadRequestError, NotFoundError,
|
|
33
|
+
* ForbiddenError, ...) instead of hand-rolling try/catch + string-matching
|
|
34
|
+
* on `error.message` to pick a status code — Express 5 forwards rejected
|
|
35
|
+
* promises from async route handlers here automatically. Anything that
|
|
36
|
+
* isn't a recognized error type is treated as an unexpected bug: logged
|
|
37
|
+
* with its full stack and returned as a generic 500 (no message leak
|
|
38
|
+
* outside development), same as before this file knew about HttpError.
|
|
39
|
+
*/
|
|
40
|
+
export const errorHandler: ErrorRequestHandler = (
|
|
41
|
+
error: Error,
|
|
42
|
+
req: Request,
|
|
43
|
+
res: Response,
|
|
44
|
+
_next: NextFunction
|
|
45
|
+
): void => {
|
|
46
|
+
const known = classifyError(error);
|
|
47
|
+
|
|
48
|
+
if (known) {
|
|
49
|
+
// Client errors (4xx) are routine, expected control flow — log without
|
|
50
|
+
// a stack trace at warn level, not as if the service were broken.
|
|
51
|
+
logger.warn('Request failed:', {
|
|
52
|
+
error: known.message,
|
|
53
|
+
statusCode: known.statusCode,
|
|
54
|
+
url: req.url,
|
|
55
|
+
method: req.method,
|
|
56
|
+
correlationId: (req as any).correlationId
|
|
57
|
+
});
|
|
58
|
+
} else {
|
|
59
|
+
logger.error('Unhandled error:', {
|
|
60
|
+
error: error.message,
|
|
61
|
+
stack: error.stack,
|
|
62
|
+
url: req.url,
|
|
63
|
+
method: req.method,
|
|
64
|
+
ip: req.ip,
|
|
65
|
+
userAgent: req.get('User-Agent'),
|
|
66
|
+
correlationId: (req as any).correlationId
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Add CORS headers to error responses
|
|
71
|
+
const origin = req.headers.origin;
|
|
72
|
+
if (origin && (origin.includes('localhost') || origin.includes('tumbaland.eu'))) {
|
|
73
|
+
res.header('Access-Control-Allow-Origin', origin);
|
|
74
|
+
res.header('Access-Control-Allow-Credentials', 'true');
|
|
75
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
76
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, x-correlation-id, x-session-id');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (known) {
|
|
80
|
+
res.status(known.statusCode).json({
|
|
81
|
+
success: false,
|
|
82
|
+
error: known.name,
|
|
83
|
+
message: known.message
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Don't leak error details in production for unexpected errors
|
|
89
|
+
const isDevelopment = process.env.NODE_ENV === 'development';
|
|
90
|
+
const errorMessage = isDevelopment ? error.message : 'Something went wrong';
|
|
91
|
+
|
|
92
|
+
res.status(500).json({
|
|
93
|
+
success: false,
|
|
94
|
+
error: 'Internal server error',
|
|
95
|
+
message: errorMessage,
|
|
96
|
+
...(isDevelopment && { stack: error.stack })
|
|
97
|
+
});
|
|
98
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
jest.mock('../logging/logger', () => ({
|
|
2
|
+
__esModule: true,
|
|
3
|
+
default: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn(), http: jest.fn() }
|
|
4
|
+
}));
|
|
5
|
+
|
|
6
|
+
import logger from '../logging/logger';
|
|
7
|
+
import { requestLogger, requestLoggerWithMetrics, simpleRequestLogger } from './requestLogger';
|
|
8
|
+
|
|
9
|
+
function mockReqRes() {
|
|
10
|
+
const req: any = {
|
|
11
|
+
method: 'GET',
|
|
12
|
+
url: '/albums/123',
|
|
13
|
+
correlationId: 'corr-1',
|
|
14
|
+
ip: '127.0.0.1',
|
|
15
|
+
get: jest.fn().mockReturnValue('test-agent')
|
|
16
|
+
};
|
|
17
|
+
const res: any = { statusCode: 200, on: jest.fn() };
|
|
18
|
+
return { req, res };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('simpleRequestLogger', () => {
|
|
22
|
+
beforeEach(() => jest.clearAllMocks());
|
|
23
|
+
|
|
24
|
+
it('logs an inbound request line with correlation id, ip, and user agent', () => {
|
|
25
|
+
const { req, res } = mockReqRes();
|
|
26
|
+
const next = jest.fn();
|
|
27
|
+
|
|
28
|
+
simpleRequestLogger(req, res, next);
|
|
29
|
+
|
|
30
|
+
expect(logger.info).toHaveBeenCalledWith('→ GET /albums/123', {
|
|
31
|
+
correlationId: 'corr-1',
|
|
32
|
+
ip: '127.0.0.1',
|
|
33
|
+
userAgent: 'test-agent'
|
|
34
|
+
});
|
|
35
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('falls back to "unknown" correlation id when none was set on the request', () => {
|
|
39
|
+
const { req, res } = mockReqRes();
|
|
40
|
+
delete req.correlationId;
|
|
41
|
+
const next = jest.fn();
|
|
42
|
+
|
|
43
|
+
simpleRequestLogger(req, res, next);
|
|
44
|
+
|
|
45
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
46
|
+
expect.any(String),
|
|
47
|
+
expect.objectContaining({ correlationId: 'unknown' })
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('logs the outbound response line with status and duration once the response finishes', () => {
|
|
52
|
+
const { req, res } = mockReqRes();
|
|
53
|
+
const next = jest.fn();
|
|
54
|
+
|
|
55
|
+
simpleRequestLogger(req, res, next);
|
|
56
|
+
|
|
57
|
+
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
|
|
58
|
+
const finishHandler = res.on.mock.calls.find((c: any[]) => c[0] === 'finish')[1];
|
|
59
|
+
res.statusCode = 200;
|
|
60
|
+
finishHandler();
|
|
61
|
+
|
|
62
|
+
expect(logger.info).toHaveBeenCalledWith(
|
|
63
|
+
expect.stringMatching(/^← GET \/albums\/123 200 \d+ms$/),
|
|
64
|
+
expect.objectContaining({ correlationId: 'corr-1', statusCode: 200 })
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('requestLogger', () => {
|
|
70
|
+
it('is a Morgan middleware function', () => {
|
|
71
|
+
expect(typeof requestLogger).toBe('function');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('requestLoggerWithMetrics', () => {
|
|
76
|
+
it('bundles requestLogger and metricsMiddleware as a two-item array', () => {
|
|
77
|
+
expect(requestLoggerWithMetrics).toHaveLength(2);
|
|
78
|
+
expect(requestLoggerWithMetrics[0]).toBe(requestLogger);
|
|
79
|
+
expect(typeof requestLoggerWithMetrics[1]).toBe('function');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import morgan from 'morgan';
|
|
2
|
+
import logger from '../logging/logger';
|
|
3
|
+
import { metricsMiddleware } from '../metrics';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Morgan middleware configured to use Winston logger
|
|
7
|
+
* Logs HTTP requests in structured format
|
|
8
|
+
*/
|
|
9
|
+
export const requestLogger = morgan('combined', {
|
|
10
|
+
stream: {
|
|
11
|
+
write: (message: string) => {
|
|
12
|
+
logger.http(message.trim());
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Combined middleware that includes both logging and metrics
|
|
19
|
+
* Use this instead of separate requestLogger and metricsMiddleware
|
|
20
|
+
*/
|
|
21
|
+
export const requestLoggerWithMetrics = [requestLogger, metricsMiddleware];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Simple request logger for development
|
|
25
|
+
* Logs basic request info with correlation ID
|
|
26
|
+
*/
|
|
27
|
+
export const simpleRequestLogger = (req: any, res: any, next: any) => {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
const correlationId = req.correlationId || 'unknown';
|
|
30
|
+
|
|
31
|
+
logger.info(`→ ${req.method} ${req.url}`, {
|
|
32
|
+
correlationId,
|
|
33
|
+
ip: req.ip,
|
|
34
|
+
userAgent: req.get('User-Agent')
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
res.on('finish', () => {
|
|
38
|
+
const duration = Date.now() - start;
|
|
39
|
+
logger.info(`← ${req.method} ${req.url} ${res.statusCode} ${duration}ms`, {
|
|
40
|
+
correlationId,
|
|
41
|
+
statusCode: res.statusCode,
|
|
42
|
+
duration
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
next();
|
|
47
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { securityHeaders, createRateLimiter } from './security';
|
|
4
|
+
|
|
5
|
+
describe('securityHeaders', () => {
|
|
6
|
+
const app = express();
|
|
7
|
+
app.use(securityHeaders);
|
|
8
|
+
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
9
|
+
|
|
10
|
+
it('sets standard helmet protections', async () => {
|
|
11
|
+
const res = await request(app).get('/ping');
|
|
12
|
+
|
|
13
|
+
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
14
|
+
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('relaxes cross-origin-resource-policy so other-origin frontends can read the response', async () => {
|
|
18
|
+
const res = await request(app).get('/ping');
|
|
19
|
+
|
|
20
|
+
expect(res.headers['cross-origin-resource-policy']).toBe('cross-origin');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('does not set a Content-Security-Policy header (JSON API, not HTML)', async () => {
|
|
24
|
+
const res = await request(app).get('/ping');
|
|
25
|
+
|
|
26
|
+
expect(res.headers['content-security-policy']).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('createRateLimiter', () => {
|
|
31
|
+
it('allows requests under the limit and blocks once the limit is exceeded', async () => {
|
|
32
|
+
const app = express();
|
|
33
|
+
app.use(createRateLimiter({ windowMs: 60_000, max: 2 }));
|
|
34
|
+
app.get('/ping', (_req, res) => res.json({ ok: true }));
|
|
35
|
+
|
|
36
|
+
const first = await request(app).get('/ping');
|
|
37
|
+
const second = await request(app).get('/ping');
|
|
38
|
+
const third = await request(app).get('/ping');
|
|
39
|
+
|
|
40
|
+
expect(first.status).toBe(200);
|
|
41
|
+
expect(second.status).toBe(200);
|
|
42
|
+
expect(third.status).toBe(429);
|
|
43
|
+
expect(third.body).toEqual({ success: false, message: 'Too many requests, please try again later' });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import helmet from 'helmet';
|
|
2
|
+
import rateLimit, { Options as RateLimitOptions } from 'express-rate-limit';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standard security headers (helmet) for all services.
|
|
6
|
+
*
|
|
7
|
+
* - `contentSecurityPolicy` is off: these are JSON APIs, not HTML apps, and a
|
|
8
|
+
* couple of services serve a static Swagger docs page under `/api/docs`
|
|
9
|
+
* whose inline scripts a default CSP would break.
|
|
10
|
+
* - `crossOriginResourcePolicy` is relaxed to `cross-origin`: helmet's default
|
|
11
|
+
* (`same-origin`) makes browsers refuse to read the response from a
|
|
12
|
+
* different origin — which every frontend is, since each front and backend
|
|
13
|
+
* service runs on its own port. CORS (see `createCorsMiddleware`) already
|
|
14
|
+
* does origin enforcement; this header must not fight it.
|
|
15
|
+
*/
|
|
16
|
+
export const securityHeaders = helmet({
|
|
17
|
+
contentSecurityPolicy: false,
|
|
18
|
+
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
|
19
|
+
crossOriginEmbedderPolicy: false
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a rate limiter with sane JSON error output. Counts per client IP.
|
|
24
|
+
*/
|
|
25
|
+
export function createRateLimiter(options: Partial<RateLimitOptions> = {}) {
|
|
26
|
+
return rateLimit({
|
|
27
|
+
windowMs: 15 * 60 * 1000,
|
|
28
|
+
max: 300,
|
|
29
|
+
standardHeaders: true,
|
|
30
|
+
legacyHeaders: false,
|
|
31
|
+
message: { success: false, message: 'Too many requests, please try again later' },
|
|
32
|
+
...options
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Baseline limiter for authenticated/general routes. */
|
|
37
|
+
export const standardRateLimiter = createRateLimiter();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tighter limiter for brute-force-prone unauthenticated routes (token
|
|
41
|
+
* issuance, public endpoints with no auth in front of them).
|
|
42
|
+
*/
|
|
43
|
+
export const strictRateLimiter = createRateLimiter({ max: 20 });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { validate } from './validate';
|
|
5
|
+
import { errorHandler } from './errorHandler';
|
|
6
|
+
|
|
7
|
+
function buildApp(schema: z.ZodType) {
|
|
8
|
+
const app = express();
|
|
9
|
+
app.use(express.json());
|
|
10
|
+
app.post('/things', validate(schema), (req, res) => {
|
|
11
|
+
res.json({ success: true, data: req.body });
|
|
12
|
+
});
|
|
13
|
+
app.use(errorHandler);
|
|
14
|
+
return app;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('validate', () => {
|
|
18
|
+
const schema = z.object({
|
|
19
|
+
name: z.string().min(1),
|
|
20
|
+
age: z.number().int().positive().optional()
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('calls the handler with the parsed body when validation passes', async () => {
|
|
24
|
+
const app = buildApp(schema);
|
|
25
|
+
|
|
26
|
+
const res = await request(app).post('/things').send({ name: 'Alice', age: 30 });
|
|
27
|
+
|
|
28
|
+
expect(res.status).toBe(200);
|
|
29
|
+
expect(res.body).toEqual({ success: true, data: { name: 'Alice', age: 30 } });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('responds 400 via the shared errorHandler when a required field is missing', async () => {
|
|
33
|
+
const app = buildApp(schema);
|
|
34
|
+
|
|
35
|
+
const res = await request(app).post('/things').send({});
|
|
36
|
+
|
|
37
|
+
expect(res.status).toBe(400);
|
|
38
|
+
expect(res.body.success).toBe(false);
|
|
39
|
+
expect(res.body.error).toBe('BadRequestError');
|
|
40
|
+
expect(res.body.message).toContain('name');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('responds 400 when a field has the wrong type', async () => {
|
|
44
|
+
const app = buildApp(schema);
|
|
45
|
+
|
|
46
|
+
const res = await request(app).post('/things').send({ name: 'Bob', age: 'not-a-number' });
|
|
47
|
+
|
|
48
|
+
expect(res.status).toBe(400);
|
|
49
|
+
expect(res.body.error).toBe('BadRequestError');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('strips unknown fields not declared in the schema (zod default behavior)', async () => {
|
|
53
|
+
const app = buildApp(schema);
|
|
54
|
+
|
|
55
|
+
const res = await request(app).post('/things').send({ name: 'Carol', extra: 'nope' });
|
|
56
|
+
|
|
57
|
+
expect(res.status).toBe(200);
|
|
58
|
+
expect(res.body.data).toEqual({ name: 'Carol' });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
|
2
|
+
import { ZodType } from 'zod';
|
|
3
|
+
import { BadRequestError } from '../errors/HttpError';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Validates `req.body` against a zod schema before the route handler runs.
|
|
7
|
+
* On failure, throws a `BadRequestError` (caught by the shared errorHandler)
|
|
8
|
+
* instead of letting controllers hand-roll their own field checks. On
|
|
9
|
+
* success, `req.body` is replaced with the parsed result so handlers get
|
|
10
|
+
* zod's inferred, coerced types rather than raw untyped input.
|
|
11
|
+
*/
|
|
12
|
+
export function validate(schema: ZodType): RequestHandler {
|
|
13
|
+
return (req: Request, _res: Response, next: NextFunction): void => {
|
|
14
|
+
const result = schema.safeParse(req.body);
|
|
15
|
+
if (!result.success) {
|
|
16
|
+
const issue = result.error.issues[0];
|
|
17
|
+
const field = issue.path.length ? `${issue.path.join('.')}: ` : '';
|
|
18
|
+
throw new BadRequestError(`${field}${issue.message}`);
|
|
19
|
+
}
|
|
20
|
+
req.body = result.data;
|
|
21
|
+
next();
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The real OpenTelemetry SDK/exporter/provider are mocked out — they're
|
|
3
|
+
* network- and global-registry-heavy, and createBaseApp.test.ts already
|
|
4
|
+
* exercises the real tracingMiddleware happy path end-to-end via supertest.
|
|
5
|
+
* This file focuses on this module's own branching logic (env-var
|
|
6
|
+
* resolution, parent-span-vs-context handling, the helper wrappers), using
|
|
7
|
+
* the real (side-effect-free) @opentelemetry/api for trace/context/propagation.
|
|
8
|
+
*/
|
|
9
|
+
const mockSpan = () => ({
|
|
10
|
+
setAttribute: jest.fn(),
|
|
11
|
+
addEvent: jest.fn(),
|
|
12
|
+
end: jest.fn(),
|
|
13
|
+
spanContext: jest.fn(() => ({ traceId: 't', spanId: 's', traceFlags: 1 }))
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const mockTracer = { startSpan: jest.fn(() => mockSpan()) };
|
|
17
|
+
const registerMock = jest.fn();
|
|
18
|
+
const getTracerMock = jest.fn(() => mockTracer);
|
|
19
|
+
const providerCtorMock = jest.fn().mockImplementation((opts: unknown) => ({
|
|
20
|
+
__opts: opts,
|
|
21
|
+
register: registerMock,
|
|
22
|
+
getTracer: getTracerMock
|
|
23
|
+
}));
|
|
24
|
+
const exporterCtorMock = jest.fn().mockImplementation((opts: unknown) => ({ __opts: opts }));
|
|
25
|
+
const spanProcessorCtorMock = jest.fn();
|
|
26
|
+
const resourceFromAttributesMock = jest.fn((attrs: unknown) => attrs);
|
|
27
|
+
|
|
28
|
+
jest.mock('@opentelemetry/sdk-trace-node', () => ({
|
|
29
|
+
NodeTracerProvider: providerCtorMock,
|
|
30
|
+
BatchSpanProcessor: spanProcessorCtorMock
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.mock('@opentelemetry/exporter-trace-otlp-http', () => ({
|
|
34
|
+
OTLPTraceExporter: exporterCtorMock
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.mock('@opentelemetry/resources', () => ({
|
|
38
|
+
resourceFromAttributes: resourceFromAttributesMock
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
import { context } from '@opentelemetry/api';
|
|
42
|
+
|
|
43
|
+
async function loadTracing() {
|
|
44
|
+
let tracing!: typeof import('./index');
|
|
45
|
+
await jest.isolateModulesAsync(async () => {
|
|
46
|
+
tracing = await import('./index');
|
|
47
|
+
});
|
|
48
|
+
return tracing;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
jest.clearAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('initTracer', () => {
|
|
56
|
+
const ORIGINAL_ENV = process.env;
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
process.env = { ...ORIGINAL_ENV };
|
|
60
|
+
delete process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT;
|
|
61
|
+
delete process.env.JAEGER_ENDPOINT;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(() => {
|
|
65
|
+
process.env = ORIGINAL_ENV;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('defaults to the in-cluster collector endpoint when no env var is set', async () => {
|
|
69
|
+
const { initTracer } = await loadTracing();
|
|
70
|
+
|
|
71
|
+
initTracer('album-service');
|
|
72
|
+
|
|
73
|
+
expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://jaeger-collector:4318/v1/traces' });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('prefers OTEL_EXPORTER_OTLP_TRACES_ENDPOINT over JAEGER_ENDPOINT', async () => {
|
|
77
|
+
process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://otel-endpoint/v1/traces';
|
|
78
|
+
process.env.JAEGER_ENDPOINT = 'http://jaeger-endpoint/v1/traces';
|
|
79
|
+
const { initTracer } = await loadTracing();
|
|
80
|
+
|
|
81
|
+
initTracer('album-service');
|
|
82
|
+
|
|
83
|
+
expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://otel-endpoint/v1/traces' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('falls back to JAEGER_ENDPOINT when OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is unset', async () => {
|
|
87
|
+
process.env.JAEGER_ENDPOINT = 'http://jaeger-endpoint/v1/traces';
|
|
88
|
+
const { initTracer } = await loadTracing();
|
|
89
|
+
|
|
90
|
+
initTracer('album-service');
|
|
91
|
+
|
|
92
|
+
expect(exporterCtorMock).toHaveBeenCalledWith({ url: 'http://jaeger-endpoint/v1/traces' });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('registers the provider and returns a tracer scoped to the given service name', async () => {
|
|
96
|
+
const { initTracer } = await loadTracing();
|
|
97
|
+
|
|
98
|
+
const tracer = initTracer('album-service');
|
|
99
|
+
|
|
100
|
+
expect(registerMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(getTracerMock).toHaveBeenCalledWith('album-service');
|
|
102
|
+
expect(tracer).toBe(mockTracer);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('getTracer', () => {
|
|
107
|
+
it('lazily initializes on first call using SERVICE_NAME (or "unknown-service")', async () => {
|
|
108
|
+
const { getTracer } = await loadTracing();
|
|
109
|
+
|
|
110
|
+
getTracer();
|
|
111
|
+
|
|
112
|
+
expect(getTracerMock).toHaveBeenCalledWith('unknown-service');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('caches the tracer across calls instead of re-initializing', async () => {
|
|
116
|
+
const { getTracer } = await loadTracing();
|
|
117
|
+
|
|
118
|
+
getTracer();
|
|
119
|
+
getTracer();
|
|
120
|
+
|
|
121
|
+
expect(providerCtorMock).toHaveBeenCalledTimes(1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('startSpan', () => {
|
|
126
|
+
it('starts a root span when no parent is given', async () => {
|
|
127
|
+
const { startSpan } = await loadTracing();
|
|
128
|
+
|
|
129
|
+
startSpan('op');
|
|
130
|
+
|
|
131
|
+
expect(mockTracer.startSpan).toHaveBeenCalledWith('op');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('starts a child span from a real Span parent (has spanContext()), deriving a parent context from it', async () => {
|
|
135
|
+
const { startSpan } = await loadTracing();
|
|
136
|
+
const parentSpan = mockSpan();
|
|
137
|
+
|
|
138
|
+
startSpan('op', parentSpan);
|
|
139
|
+
|
|
140
|
+
const [name, undef, parentContext] = mockTracer.startSpan.mock.calls[0] as unknown as [string, undefined, any];
|
|
141
|
+
expect(name).toBe('op');
|
|
142
|
+
expect(undef).toBeUndefined();
|
|
143
|
+
// trace.setSpan(context.active(), parentSpan) returns a real Context, not the bare parentSpan.
|
|
144
|
+
expect(parentContext).not.toBe(parentSpan);
|
|
145
|
+
expect(typeof parentContext.getValue).toBe('function');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('starts a span from a bare Context parent (no spanContext())', async () => {
|
|
149
|
+
const { startSpan } = await loadTracing();
|
|
150
|
+
const parentContext = context.active();
|
|
151
|
+
|
|
152
|
+
startSpan('op', parentContext);
|
|
153
|
+
|
|
154
|
+
expect(mockTracer.startSpan).toHaveBeenCalledWith('op', undefined, parentContext);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('createChildSpan', () => {
|
|
159
|
+
it('delegates to startSpan with the given parent', async () => {
|
|
160
|
+
const { createChildSpan } = await loadTracing();
|
|
161
|
+
const parentSpan = mockSpan();
|
|
162
|
+
|
|
163
|
+
createChildSpan('child-op', parentSpan);
|
|
164
|
+
|
|
165
|
+
expect(mockTracer.startSpan).toHaveBeenCalledWith('child-op', undefined, expect.anything());
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('logToSpan', () => {
|
|
170
|
+
it('adds an event with data to the given span', async () => {
|
|
171
|
+
const { logToSpan } = await loadTracing();
|
|
172
|
+
const span = mockSpan();
|
|
173
|
+
|
|
174
|
+
logToSpan(span, 'cache-miss', { key: 'abc' });
|
|
175
|
+
|
|
176
|
+
expect(span.addEvent).toHaveBeenCalledWith('cache-miss', { key: 'abc' });
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('setSpanTag', () => {
|
|
181
|
+
it('sets an attribute on the given span', async () => {
|
|
182
|
+
const { setSpanTag } = await loadTracing();
|
|
183
|
+
const span = mockSpan();
|
|
184
|
+
|
|
185
|
+
setSpanTag(span, 'user.id', '42');
|
|
186
|
+
|
|
187
|
+
expect(span.setAttribute).toHaveBeenCalledWith('user.id', '42');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('injectHeaders', () => {
|
|
192
|
+
it('returns a headers object without throwing', async () => {
|
|
193
|
+
const { injectHeaders } = await loadTracing();
|
|
194
|
+
const span = mockSpan();
|
|
195
|
+
|
|
196
|
+
const headers = injectHeaders(span);
|
|
197
|
+
|
|
198
|
+
expect(typeof headers).toBe('object');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('extractSpanContext', () => {
|
|
203
|
+
it('returns a Context derived from the given headers', async () => {
|
|
204
|
+
const { extractSpanContext } = await loadTracing();
|
|
205
|
+
|
|
206
|
+
const extracted = extractSpanContext({ traceparent: '00-abc-def-01' });
|
|
207
|
+
|
|
208
|
+
expect(typeof (extracted as any).getValue).toBe('function');
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe('tracingMiddleware', () => {
|
|
213
|
+
function mockReqRes() {
|
|
214
|
+
const req: any = { method: 'GET', path: '/albums', url: '/albums?x=1', headers: {} };
|
|
215
|
+
const handlers: Record<string, () => void> = {};
|
|
216
|
+
const res: any = {
|
|
217
|
+
statusCode: 200,
|
|
218
|
+
on: jest.fn((event: string, handler: () => void) => {
|
|
219
|
+
handlers[event] = handler;
|
|
220
|
+
})
|
|
221
|
+
};
|
|
222
|
+
return { req, res, handlers };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
it('starts a server span, attaches it to the request, and calls next', async () => {
|
|
226
|
+
const { tracingMiddleware } = await loadTracing();
|
|
227
|
+
const { req, res } = mockReqRes();
|
|
228
|
+
const next = jest.fn();
|
|
229
|
+
|
|
230
|
+
tracingMiddleware(req, res, next);
|
|
231
|
+
|
|
232
|
+
expect(req.span).toBeDefined();
|
|
233
|
+
expect(req.span.setAttribute).toHaveBeenCalledWith('http.method', 'GET');
|
|
234
|
+
expect(req.span.setAttribute).toHaveBeenCalledWith('http.url', '/albums?x=1');
|
|
235
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('records the response status code and ends the span on finish', async () => {
|
|
239
|
+
const { tracingMiddleware } = await loadTracing();
|
|
240
|
+
const { req, res, handlers } = mockReqRes();
|
|
241
|
+
const next = jest.fn();
|
|
242
|
+
|
|
243
|
+
tracingMiddleware(req, res, next);
|
|
244
|
+
res.statusCode = 404;
|
|
245
|
+
handlers.finish();
|
|
246
|
+
|
|
247
|
+
expect(req.span.setAttribute).toHaveBeenCalledWith('http.status_code', 404);
|
|
248
|
+
expect(req.span.end).toHaveBeenCalledTimes(1);
|
|
249
|
+
});
|
|
250
|
+
});
|