diff --git a/docs/API.md b/docs/API.md index 1bff7ce6..414d7d58 100644 --- a/docs/API.md +++ b/docs/API.md @@ -6,6 +6,28 @@ The Orivex API provides endpoints for user management, learning modules, rewards **Base URL:** `https://api.orivex.io/v1` (production) or `http://localhost:3001/v1` (development) +## Request correlation (`X-Request-Id`) + +Every response includes an `X-Request-Id` header so clients and operators can +correlate a single HTTP call with server logs. + +```txt +X-Request-Id: 550e8400-e29b-41d4-a716-446655440000 +``` + +Behavior: + +- If the client sends a valid `X-Request-Id` (1–128 characters of + `[A-Za-z0-9_.:-]`), the server **honors** it and echoes it back. +- Missing, oversized, or malformed values are **replaced** with a newly + generated UUID v4. Invalid headers never cause the request to fail. +- Error responses also include the same value as `error.requestId` in the JSON + envelope (see [Error Handling](#error-handling)). + +Browser clients can read the header: CORS exposes `X-Request-Id` via +`Access-Control-Expose-Headers`. Clients that ignore unknown headers or fields +remain compatible. + ## Authentication Most endpoints require authentication using a JWT token. @@ -453,10 +475,11 @@ Error response format: ```json { - "status": "error", + "success": false, "error": { - "code": "RESOURCE_NOT_FOUND", + "code": 404, "message": "The requested module was not found", + "requestId": "550e8400-e29b-41d4-a716-446655440000", "details": { "moduleId": "mod_invalid" } @@ -464,6 +487,8 @@ Error response format: } ``` +The `requestId` matches the `X-Request-Id` response header for the same call. + ## Rate Limiting - Public endpoints: 60 requests per minute diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 67fdb374..289df8b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,7 +47,8 @@ versioned JSON API under `/api/v1` and is responsible for: | Validation | Zod schemas in `src/schemas`, applied by `validation.middleware.ts` | | Rate limiting | `src/middleware/rate-limit.middleware.ts` backed by Redis (production) or in-memory Map (development / test) | | Error response shape | `src/utils/errors.ts`, formatted by `error.middleware.ts` | -| Logging | `src/config/logger.ts` (Winston) + `morgan` request logs | +| Logging | `src/config/logger.ts` (Winston) + `morgan` request logs; correlated via `X-Request-Id` / AsyncLocalStorage | +| Request correlation | `src/middleware/request-id.middleware.ts` — UUID v4 (or honored inbound ID), echoed as `X-Request-Id` | | Webhook delivery | `src/services/webhook.service.ts` with HMAC `X-Orivex-Signature` | | Push notifications | `src/services/notification.service.ts` via Firebase Admin | | Crypto / Stellar | `src/services/stellar.service.ts`, `src/services/soroban.service.ts` | diff --git a/docs/ERROR_HANDLING.md b/docs/ERROR_HANDLING.md index 9926c582..97dc522c 100644 --- a/docs/ERROR_HANDLING.md +++ b/docs/ERROR_HANDLING.md @@ -155,7 +155,8 @@ app.use(errorHandler) "success": false, "error": { "message": "User not found", - "code": 404 + "code": 404, + "requestId": "550e8400-e29b-41d4-a716-446655440000" } } ``` @@ -168,6 +169,7 @@ app.use(errorHandler) "error": { "message": "User not found", "code": 404, + "requestId": "550e8400-e29b-41d4-a716-446655440000", "stack": [ "NotFoundError: User not found", "at Array.getUserById [as handler] (/path/to/controller.ts:25:11)", diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 2dec760e..edd779b0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -36,8 +36,32 @@ This document is for operators of the **Orivex-Backend** service. ## Logs - Production logs are emitted via `winston` (`src/config/logger.ts`). -- Request logs go through `morgan('dev')` in development — disable in - production by setting `NODE_ENV=production`. +- Request access logs go through `morgan` in `src/app.ts` and include the + request ID as the first token on each line. +- Every request is assigned a correlation ID (UUID v4 by default). The ID is + stored in AsyncLocalStorage and attached to Winston log lines as + `requestId=…` structured metadata. + +### Querying logs by request ID + +1. Capture the `X-Request-Id` header from the HTTP response (or from the + `error.requestId` field on error envelopes). Browser JavaScript can read + the header because CORS exposes it via `Access-Control-Expose-Headers`. +2. Filter application logs for that value, for example: + +```bash +# Example: stream container logs and filter by ID +grep 'requestId=550e8400-e29b-41d4-a716-446655440000' /var/log/orivex/*.log + +# Example: kubectl / cloud log query (adjust for your provider) +kubectl logs -l app=orivex-backend --since=1h | grep '550e8400-e29b-41d4-a716-446655440000' +``` + +Morgan access lines also start with the same ID, so a single grep covers +access logs, Winston service logs, and error-handler output for that request. + +Valid client-supplied `X-Request-Id` values (≤ 128 chars, `[A-Za-z0-9_.:-]`) +are honored; oversized or malformed values are overwritten with a new UUID. ## Secrets management diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d6fe9db4..3549e3c2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -15,7 +15,7 @@ service. It is intentionally short — execution happens in feature branches. - ✅ Replace in-memory stores in `src/services/reward.service.ts` with Prisma calls, removing the implicit in-test singletons (completed in #15). -- Add structured request IDs and propagate them across logs and HTTP +- ✅ Add structured request IDs and propagate them across logs and HTTP responses. - Add OpenTelemetry traces for outbound Stellar RPC and webhook delivery. - Background job queue (BullMQ or Inngest) for module reward payouts. diff --git a/src/app.ts b/src/app.ts index 09098da5..339ad9a8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,15 +10,27 @@ import swaggerUi from 'swagger-ui-express' import { specs } from './config/swagger' import routes from './routes' import { errorHandler, notFoundHandler } from './middleware/error.middleware' +import { requestIdMiddleware } from './middleware/request-id.middleware' const app: express.Application = express() +// Request ID must run before access logs and routes so every downstream +// log line and response can correlate on the same identifier. +app.use(requestIdMiddleware) + app.use(express.json()) -app.use(cors()) +app.use( + cors({ + // Browser clients cannot read X-Request-Id unless it is explicitly exposed. + exposedHeaders: ['X-Request-Id'], + }), +) app.use(helmet({ contentSecurityPolicy: false, // Disable CSP for Swagger UI to work correctly })) -app.use(morgan('dev')) + +morgan.token('id', (req) => (req as express.Request).requestId ?? '-') +app.use(morgan(':id :method :url :status :response-time ms - :res[content-length]')) // API routes app.use('/api', routes) @@ -37,4 +49,4 @@ app.use(notFoundHandler) // Global error handler - must be last app.use(errorHandler) -export default app \ No newline at end of file +export default app diff --git a/src/config/logger.ts b/src/config/logger.ts index 2f947dd1..70d0b1d1 100644 --- a/src/config/logger.ts +++ b/src/config/logger.ts @@ -1,12 +1,39 @@ import winston from 'winston' +import { getRequestId } from './request-context' + +/** + * Inject the active request ID (from AsyncLocalStorage) into every log record + * as structured metadata so operators can filter by correlation ID. + */ +const requestIdFormat = winston.format((info) => { + const requestId = getRequestId() + if (requestId) { + info.requestId = requestId + } + + return info +}) + const logger = winston.createLogger({ level: 'info', format: winston.format.combine( winston.format.timestamp(), - winston.format.simple() + requestIdFormat(), + winston.format.printf((info) => { + const { timestamp, level, message, requestId, ...meta } = info + const idPart = requestId ? ` requestId=${requestId}` : '' + // Drop Symbol keys Winston attaches (e.g. Symbol(level)) from meta dump + const printable = Object.fromEntries( + Object.entries(meta).filter(([key]) => typeof key === 'string'), + ) + const metaPart = + Object.keys(printable).length > 0 ? ` ${JSON.stringify(printable)}` : '' + + return `${timestamp} ${level}:${idPart} ${message}${metaPart}` + }), ), - transports: [new winston.transports.Console()] + transports: [new winston.transports.Console()], }) -export default logger \ No newline at end of file +export default logger diff --git a/src/config/request-context.ts b/src/config/request-context.ts new file mode 100644 index 00000000..c9cb968f --- /dev/null +++ b/src/config/request-context.ts @@ -0,0 +1,16 @@ +import { AsyncLocalStorage } from 'node:async_hooks' + +/** + * Per-request context propagated via AsyncLocalStorage so Winston (and any + * other code) can read the active request ID without threading it through + * every call site. + */ +export interface RequestContext { + requestId: string +} + +export const requestContext = new AsyncLocalStorage() + +export function getRequestId(): string | undefined { + return requestContext.getStore()?.requestId +} diff --git a/src/middleware/error.middleware.ts b/src/middleware/error.middleware.ts index facbe12e..3b0ccb3a 100644 --- a/src/middleware/error.middleware.ts +++ b/src/middleware/error.middleware.ts @@ -3,6 +3,11 @@ import { NextFunction, Request, Response } from 'express' import { env } from '../config/env' import logger from '../config/logger' +import { getRequestId } from '../config/request-context' + +function requestIdFrom(req: Request): string | undefined { + return req.requestId ?? getRequestId() +} /** * Global error handler middleware @@ -13,14 +18,21 @@ export const errorHandler = ( err: Error | AppError, req: Request, res: Response, + _next: NextFunction, ): void => { let error = err + const requestId = requestIdFrom(req) + + if (requestId) { + res.setHeader('X-Request-Id', requestId) + } logger.error({ message: err.message, stack: err.stack, path: req.path, method: req.method, + requestId, timestamp: new Date().toISOString(), }) @@ -33,7 +45,21 @@ export const errorHandler = ( const statusCode = (error as AppError).statusCode || 500 const isDevelopment = env.NODE_ENV === 'development' - const errorResponse: any = { + const errorResponse: { + success: false + error: { + message: string + code: number | string + requestId?: string + stack?: string[] + details?: unknown + request?: { + method: string + path: string + headers: Request['headers'] + } + } + } = { success: false, error: { message: (error as AppError).message, @@ -41,12 +67,16 @@ export const errorHandler = ( }, } + if (requestId) { + errorResponse.error.requestId = requestId + } + if (isDevelopment && err.stack) { errorResponse.error.stack = err.stack.split('\n') } - if ('errors' in error && (error as any).errors) { - errorResponse.error.details = (error as any).errors + if ('errors' in error && (error as AppError & { errors?: unknown }).errors) { + errorResponse.error.details = (error as AppError & { errors?: unknown }).errors } if (isDevelopment) { @@ -70,11 +100,13 @@ export const notFoundHandler = ( next: NextFunction ): void => { const notFound = new NotFoundError(`Cannot ${req.method} ${req.path}`) + const requestId = requestIdFrom(req) logger.warn({ message: 'Not Found', path: req.path, method: req.method, + requestId, timestamp: new Date().toISOString(), }) @@ -96,10 +128,11 @@ export const asyncHandler = ( stack: error.stack, path: req.path, method: req.method, + requestId: requestIdFrom(req), timestamp: new Date().toISOString(), }) next(error) }) } -} \ No newline at end of file +} diff --git a/src/middleware/request-id.middleware.ts b/src/middleware/request-id.middleware.ts new file mode 100644 index 00000000..8f5441e2 --- /dev/null +++ b/src/middleware/request-id.middleware.ts @@ -0,0 +1,74 @@ +import { randomUUID } from 'node:crypto' +import { NextFunction, Request, Response } from 'express' + +import { requestContext } from '../config/request-context' + +/** Maximum accepted length for a client-supplied X-Request-Id. */ +export const REQUEST_ID_MAX_LENGTH = 128 + +/** + * Allowed characters for an inbound request ID. + * Covers UUID v4, ULID, and common gateway/correlation formats without + * permitting whitespace or control characters (log-injection risk). + */ +const REQUEST_ID_PATTERN = /^[\w.:-]+$/ + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + requestId?: string + } + } +} + +/** + * Returns true when `value` is a safe, bounded request identifier. + */ +export function isValidRequestId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= REQUEST_ID_MAX_LENGTH && + REQUEST_ID_PATTERN.test(value) + ) +} + +/** + * Resolve the request ID from an inbound header, or generate a UUID v4. + * + * Decision: a valid client-supplied `X-Request-Id` is honored so upstream + * proxies and clients can correlate across services. Invalid, oversized, or + * missing values are overwritten with a newly generated UUID — never rejected + * with an error response, so malformed headers cannot crash or block the request. + */ +export function resolveRequestId( + inbound: string | string[] | undefined, +): string { + const candidate = Array.isArray(inbound) ? inbound[0] : inbound + if (isValidRequestId(candidate)) { + return candidate + } + + return randomUUID() +} + +/** + * Assigns a unique request ID early in the pipeline, stores it on the + * request and in AsyncLocalStorage, and echoes it on every response via + * the `X-Request-Id` header. + */ +export function requestIdMiddleware( + req: Request, + res: Response, + next: NextFunction, +): void { + const requestId = resolveRequestId(req.headers['x-request-id']) + + req.requestId = requestId + res.setHeader('X-Request-Id', requestId) + + requestContext.run({ requestId }, () => { + next() + }) +} diff --git a/src/types/api.types.ts b/src/types/api.types.ts index 929f1eca..2537e0b6 100644 --- a/src/types/api.types.ts +++ b/src/types/api.types.ts @@ -43,6 +43,7 @@ export interface ApiError { error: { code: string; message: string; + requestId?: string; details?: Record; }; timestamp: string; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 43a605ed..07ff6f3b 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,5 @@ import configLogger from '../config/logger' +import { getRequestId } from '../config/request-context' export type LogLevel = | 'error' @@ -7,8 +8,14 @@ export type LogLevel = | 'http' | 'verbose' | 'debug' - | 'silly'; + | 'silly' +/** + * Thin wrapper around the Winston logger in `src/config/logger.ts`. + * Request IDs are injected automatically via AsyncLocalStorage — callers do + * not need to pass them. Prefer `getRequestId()` when the ID is needed for + * responses or external systems. + */ const logger = { error: (message: string, meta?: any) => configLogger.error(message, meta), warn: (message: string, meta?: any) => configLogger.warn(message, meta), @@ -20,6 +27,7 @@ const logger = { setLevel: (level: LogLevel) => { configLogger.level = level }, + getRequestId, } export default logger diff --git a/tests/error.middleware.test.ts b/tests/error.middleware.test.ts index 129f113d..18a4ec7b 100644 --- a/tests/error.middleware.test.ts +++ b/tests/error.middleware.test.ts @@ -53,11 +53,13 @@ describe('Error Handling Middleware', () => { path: '/api/test', method: 'GET', headers: { 'content-type': 'application/json' }, + requestId: 'test-request-id-001', } mockResponse = { status: statusMock as unknown as Response['status'], json: jsonMock as unknown as Response['json'], + setHeader: vi.fn() as unknown as Response['setHeader'], } mockNext = vi.fn() @@ -160,6 +162,24 @@ describe('Error Handling Middleware', () => { expect(response).toHaveProperty('error') expect(response.error).toHaveProperty('message') expect(response.error).toHaveProperty('code') + expect(response.error).toHaveProperty('requestId', 'test-request-id-001') + }) + + it('should include requestId in the error envelope and response header', () => { + const error = new BadRequestError('Invalid input') + errorHandler( + error, + mockRequest as Request, + mockResponse as Response, + mockNext as unknown as NextFunction, + ) + + const response = jsonMock.mock.calls[0][0] + expect(response.error.requestId).toBe('test-request-id-001') + expect(mockResponse.setHeader).toHaveBeenCalledWith( + 'X-Request-Id', + 'test-request-id-001', + ) }) it('should handle regular Error by converting to InternalServerError', () => { @@ -234,6 +254,7 @@ describe('Error Handling Middleware', () => { message: 'Test error', path: '/api/test', method: 'GET', + requestId: 'test-request-id-001', }) ) }) @@ -287,6 +308,7 @@ describe('Error Handling Middleware', () => { message: 'Not Found', path: '/api/test', method: 'GET', + requestId: 'test-request-id-001', }) ) }) @@ -358,6 +380,7 @@ describe('Error Handling Middleware', () => { expect.objectContaining({ message: 'Async error caught', error: 'Database error', + requestId: 'test-request-id-001', }) ) }) diff --git a/tests/request-id.app.test.ts b/tests/request-id.app.test.ts new file mode 100644 index 00000000..2fedd788 --- /dev/null +++ b/tests/request-id.app.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from 'vitest' +import request from 'supertest' + +// Avoid pulling JWT_SECRET / DB requirements via route imports during app boot. +vi.mock('../src/config/env', () => ({ + env: { + NODE_ENV: 'test', + PORT: 3000, + }, +})) + +vi.mock('../src/config/swagger', () => ({ + specs: {}, +})) + +vi.mock('../src/routes', () => ({ + default: (_req: unknown, res: { status: (n: number) => { json: (b: unknown) => void } }, next: (err?: unknown) => void) => { + // Minimal router stub: /api/boom throws, everything else 404s via notFoundHandler + const expressReq = _req as { path?: string; url?: string; originalUrl?: string } + const path = expressReq.originalUrl ?? expressReq.url ?? '' + if (path === '/api/boom' || path.endsWith('/boom')) { + next(new Error('boom')) + + return + } + next() + }, +})) + +import app from '../src/app' + +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +describe('Request ID end-to-end', () => { + it('returns a generated X-Request-Id on successful responses', async () => { + const res = await request(app).get('/health') + + expect(res.status).toBe(200) + expect(res.headers['x-request-id']).toMatch(UUID_V4_RE) + }) + + it('echoes a valid client-supplied X-Request-Id on success', async () => { + const inbound = 'client-corr-id-42' + const res = await request(app) + .get('/health') + .set('X-Request-Id', inbound) + + expect(res.status).toBe(200) + expect(res.headers['x-request-id']).toBe(inbound) + }) + + it('includes requestId on error responses (header + envelope)', async () => { + const inbound = 'err-corr-id-99' + const res = await request(app) + .get('/api/boom') + .set('X-Request-Id', inbound) + + expect(res.status).toBe(500) + expect(res.headers['x-request-id']).toBe(inbound) + expect(res.body.success).toBe(false) + expect(res.body.error.requestId).toBe(inbound) + expect(res.body.error).toHaveProperty('message') + expect(res.body.error).toHaveProperty('code') + }) + + it('overwrites an oversized inbound X-Request-Id', async () => { + const oversized = 'x'.repeat(200) + const res = await request(app) + .get('/health') + .set('X-Request-Id', oversized) + + expect(res.status).toBe(200) + expect(res.headers['x-request-id']).toMatch(UUID_V4_RE) + expect(res.headers['x-request-id']).not.toBe(oversized) + }) + + it('exposes X-Request-Id to browser clients via CORS', async () => { + const res = await request(app) + .get('/health') + .set('Origin', 'http://localhost:3000') + + expect(res.status).toBe(200) + expect(res.headers['access-control-expose-headers']).toMatch(/X-Request-Id/i) + }) + + it('includes requestId on 404 responses (header + envelope)', async () => { + const inbound = 'not-found-corr-id-3' + const res = await request(app) + .get('/api/does-not-exist') + .set('X-Request-Id', inbound) + + expect(res.status).toBe(404) + expect(res.headers['x-request-id']).toBe(inbound) + expect(res.body.success).toBe(false) + expect(res.body.error.requestId).toBe(inbound) + expect(res.body.error).toHaveProperty('message') + expect(res.body.error).toHaveProperty('code') + }) + + it('emits the same request ID in morgan and winston log output', async () => { + const inbound = 'log-corr-id-7' + const chunks: string[] = [] + const spies: Array> = [] + + const intercept = (stream: { write: NodeJS.WriteStream['write'] }) => { + const original = stream.write.bind(stream) + spies.push( + vi.spyOn(stream, 'write').mockImplementation((( + chunk: unknown, + ...args: unknown[] + ) => { + chunks.push(String(chunk)) + + return original(chunk as string, ...(args as [])) + }) as typeof stream.write), + ) + } + + intercept(process.stdout) + intercept(process.stderr) + const consoleStdout = (console as { _stdout?: NodeJS.WriteStream })._stdout + const consoleStderr = (console as { _stderr?: NodeJS.WriteStream })._stderr + if (consoleStdout && consoleStdout !== process.stdout) intercept(consoleStdout) + if (consoleStderr && consoleStderr !== process.stderr) intercept(consoleStderr) + + try { + const res = await request(app) + .get('/api/boom') + .set('X-Request-Id', inbound) + + expect(res.status).toBe(500) + expect(res.body.error.requestId).toBe(inbound) + + const combined = chunks.join('') + expect(combined).toContain(`${inbound} GET /api/boom`) + expect(combined).toContain(`requestId=${inbound}`) + } finally { + spies.forEach((spy) => spy.mockRestore()) + } + }) +}) diff --git a/tests/unit/request-id.middleware.test.ts b/tests/unit/request-id.middleware.test.ts new file mode 100644 index 00000000..9ed98224 --- /dev/null +++ b/tests/unit/request-id.middleware.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { Request, Response, NextFunction } from 'express' +import { + requestIdMiddleware, + resolveRequestId, + isValidRequestId, + REQUEST_ID_MAX_LENGTH, +} from '../../src/middleware/request-id.middleware' +import { getRequestId } from '../../src/config/request-context' + +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function makeMocks(headers: Record = {}) { + const req = { + headers, + } as Partial + + const res = { + setHeader: vi.fn(), + } as Partial + + const next: NextFunction = vi.fn() + + return { req, res, next } +} + +describe('isValidRequestId', () => { + it('accepts a UUID v4', () => { + expect(isValidRequestId('550e8400-e29b-41d4-a716-446655440000')).toBe(true) + }) + + it('accepts alphanumeric and common separator characters', () => { + expect(isValidRequestId('req_abc-123.xyz:01')).toBe(true) + }) + + it('rejects empty strings', () => { + expect(isValidRequestId('')).toBe(false) + }) + + it('rejects values longer than the bound', () => { + expect(isValidRequestId('a'.repeat(REQUEST_ID_MAX_LENGTH + 1))).toBe(false) + }) + + it('rejects values with whitespace or control characters', () => { + expect(isValidRequestId('bad id')).toBe(false) + expect(isValidRequestId('bad\nid')).toBe(false) + }) + + it('rejects non-strings', () => { + expect(isValidRequestId(undefined)).toBe(false) + expect(isValidRequestId(42)).toBe(false) + }) +}) + +describe('resolveRequestId', () => { + it('generates a UUID v4 when no inbound header is present', () => { + const id = resolveRequestId(undefined) + expect(id).toMatch(UUID_V4_RE) + }) + + it('honors a valid inbound X-Request-Id', () => { + const inbound = 'client-supplied-id-001' + expect(resolveRequestId(inbound)).toBe(inbound) + }) + + it('overwrites an oversized inbound value with a generated UUID', () => { + const oversized = 'x'.repeat(REQUEST_ID_MAX_LENGTH + 1) + const id = resolveRequestId(oversized) + expect(id).not.toBe(oversized) + expect(id).toMatch(UUID_V4_RE) + }) + + it('overwrites a malformed inbound value with a generated UUID', () => { + const id = resolveRequestId('has spaces and\nnewlines') + expect(id).toMatch(UUID_V4_RE) + }) + + it('uses the first value when the header is an array', () => { + expect(resolveRequestId(['first-id', 'second-id'])).toBe('first-id') + }) +}) + +describe('requestIdMiddleware', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('generates a valid UUID, stores it on the request, and sets the response header', () => { + const { req, res, next } = makeMocks() + + requestIdMiddleware(req as Request, res as Response, next) + + expect(req.requestId).toMatch(UUID_V4_RE) + expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.requestId) + expect(next).toHaveBeenCalledOnce() + }) + + it('honors a valid client-supplied X-Request-Id', () => { + const inbound = 'upstream-gateway-abc' + const { req, res, next } = makeMocks({ 'x-request-id': inbound }) + + requestIdMiddleware(req as Request, res as Response, next) + + expect(req.requestId).toBe(inbound) + expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', inbound) + expect(next).toHaveBeenCalledOnce() + }) + + it('overwrites an oversized inbound header without throwing', () => { + const oversized = 'z'.repeat(REQUEST_ID_MAX_LENGTH + 50) + const { req, res, next } = makeMocks({ 'x-request-id': oversized }) + + expect(() => + requestIdMiddleware(req as Request, res as Response, next), + ).not.toThrow() + + expect(req.requestId).toMatch(UUID_V4_RE) + expect(req.requestId).not.toBe(oversized) + expect(res.setHeader).toHaveBeenCalledWith('X-Request-Id', req.requestId) + expect(next).toHaveBeenCalledOnce() + }) + + it('propagates the request ID through AsyncLocalStorage for the request scope', () => { + const { req, res } = makeMocks() + let seenInside: string | undefined + + const next: NextFunction = () => { + seenInside = getRequestId() + } + + requestIdMiddleware(req as Request, res as Response, next) + + expect(seenInside).toBe(req.requestId) + expect(getRequestId()).toBeUndefined() + }) +})