Files
TREK/server/tests/unit/middleware/globalMiddleware.correlation.test.ts
T
Maurice 8c6157aa46 test(server): pin the request id through the body parsers
A handler behind express.json() is reached from the request stream's 'end'
event, long after the correlation middleware returned, so it looked as if
every write lost its id. It does not: raw-body and multer bind their
callbacks with AsyncResource at the point they are called, which is inside
the request's correlation. Nothing tested that, though, because every
REQID case was a GET. REQID-007 posts a JSON body and REQID-008 one that
arrives in several chunks, and both expect the handler to see the id the
response carries. The middleware comment now says why this holds.
2026-10-08 18:41:01 +02:00

163 lines
6.2 KiB
TypeScript

/**
* Request correlation in the global middleware: every response says which id
* its request ran under, a well-formed X-Request-Id from the proxy is kept,
* and the access log writes a 5xx with that id and the stack the exception
* filter left behind, as one entry. REQID-001 through REQID-008.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const log = vi.hoisted(() => ({
LOG_LEVEL: 'error',
logInfo: vi.fn(),
logDebug: vi.fn(),
logWarn: vi.fn(),
logError: vi.fn(),
}));
vi.mock('../../../src/nest/audit/audit-log.logger', () => log);
import express, { type Request, type Response } from 'express';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { applyGlobalMiddleware } from '../../../src/middleware/globalMiddleware';
import { httpConfig } from '../../../src/nest/app-config/tokens';
import {
ACCESS_LOG_ATTACHED,
UNHANDLED_ERROR,
currentCorrelation,
} from '../../../src/nest/common/request-correlation';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** The correlation each logError call was made in, captured at call time. */
const loggedUnder: Array<string | undefined> = [];
function app() {
const a = express();
applyGlobalMiddleware(a, { http: httpConfig() });
a.get('/api/echo', (_req: Request, res: Response) => {
res.json({ id: currentCorrelation()?.id ?? null, kind: currentCorrelation()?.kind ?? null });
});
a.get('/api/after-await', async (_req: Request, res: Response) => {
await new Promise((resolve) => setTimeout(resolve, 1));
res.json({ id: currentCorrelation()?.id ?? null });
});
// bootstrap.ts registers the body parsers after the global middleware, so a
// handler with a body is reached from the parser's stream callback.
a.use(express.json());
a.use(express.urlencoded({ extended: true }));
a.post('/api/with-body', (req: Request, res: Response) => {
res.json({ id: currentCorrelation()?.id ?? null, body: req.body as unknown });
});
a.get('/api/boom', (_req: Request, res: Response) => {
// What TrekExceptionFilter does for a 5xx when the access log watches the response.
expect(res.locals[ACCESS_LOG_ATTACHED]).toBe(true);
res.locals[UNHANDLED_ERROR] = new Error('kaput');
res.status(500).json({ error: 'Internal server error' });
});
return a;
}
beforeEach(() => {
vi.clearAllMocks();
loggedUnder.length = 0;
log.logError.mockImplementation(() => {
loggedUnder.push(currentCorrelation()?.id);
});
});
describe('X-Request-Id', () => {
it('REQID-001: a request without one gets a fresh id, on the response and in its context', async () => {
const res = await request(app()).get('/api/echo');
expect(res.headers['x-request-id']).toMatch(UUID);
expect(res.body).toEqual({ id: res.headers['x-request-id'], kind: 'http' });
});
it('REQID-002: a well-formed id from the proxy is kept', async () => {
const res = await request(app()).get('/api/echo').set('X-Request-Id', 'proxy-1234.abc');
expect(res.headers['x-request-id']).toBe('proxy-1234.abc');
expect(res.body.id).toBe('proxy-1234.abc');
});
it('REQID-003: a malformed one is replaced, never echoed', async () => {
const res = await request(app()).get('/api/echo').set('X-Request-Id', 'not an id <b>');
expect(res.headers['x-request-id']).toMatch(UUID);
});
it('REQID-004: the id survives an await in the handler', async () => {
const res = await request(app()).get('/api/after-await');
expect(res.body.id).toBe(res.headers['x-request-id']);
});
it('REQID-005: two requests never share an id', async () => {
const a = app();
const [one, two] = await Promise.all([request(a).get('/api/echo'), request(a).get('/api/echo')]);
expect(one.headers['x-request-id']).not.toBe(two.headers['x-request-id']);
});
});
describe('requests with a body', () => {
it('REQID-007: a JSON body parsed by express.json() keeps the id in the handler', async () => {
const res = await request(app()).post('/api/with-body').send({ a: 1 });
expect(res.body).toEqual({ id: res.headers['x-request-id'], body: { a: 1 } });
});
it('REQID-008: so does a body that arrives in several chunks after the middleware returned', async () => {
const server = app().listen(0);
try {
const { port } = server.address() as AddressInfo;
const answer = await new Promise<{ header: unknown; payload: { id: string | null; body: unknown } }>(
(resolve, reject) => {
const req = http.request(
{
port,
path: '/api/with-body',
method: 'POST',
headers: { 'content-type': 'application/json', 'transfer-encoding': 'chunked' },
},
(res) => {
let raw = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => (raw += chunk));
res.on('end', () =>
resolve({
header: res.headers['x-request-id'],
payload: JSON.parse(raw) as { id: string | null; body: unknown },
}),
);
},
);
req.on('error', reject);
const chunks = ['{"a":', '1,"b"', ':2}'];
const writeNext = () => {
const chunk = chunks.shift();
if (chunk === undefined) {
req.end();
return;
}
req.write(chunk);
setTimeout(writeNext, 10);
};
writeNext();
},
);
expect(answer.header).toMatch(UUID);
expect(answer.payload).toEqual({ id: answer.header, body: { a: 1, b: 2 } });
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
});
describe('the access log', () => {
it('REQID-006: a 5xx is one entry with the request line, the stack and the request id', async () => {
const res = await request(app()).get('/api/boom');
expect(res.status).toBe(500);
await vi.waitFor(() => expect(log.logError).toHaveBeenCalledTimes(1));
const line = String(log.logError.mock.calls[0][0]);
expect(line).toMatch(/^GET \/api\/boom 500 \d+ms ip=/);
expect(line).toContain('\nError: kaput');
expect(loggedUnder[0]).toBe(res.headers['x-request-id']);
});
});