feat(F-003): HTTP foundation with request context and error envelope
- request_id generated or sanitized-propagated on every request (x-request-id)
- structured JSON logging (pino), one correlated line per request, injectable logger
- error envelope v2 { error: { statusCode, code, message, details? }, requestId }
- 5xx messages always generic; stack traces stay in server logs only
- explicit parseJson (zod) input validation hook at the API layer
- README HTTP contract section; deps justified in spec/tech.md
- all gates approved; verify.sh green
This commit is contained in:
205
project/src/app/tests/http-foundation.test.ts
Normal file
205
project/src/app/tests/http-foundation.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import { z } from 'zod';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import { parseJson } from '../../shared/http-input.js';
|
||||
|
||||
type LogLine = Record<string, unknown>;
|
||||
|
||||
function memoryLogger(): { logger: ReturnType<typeof createLogger>; lines: LogLine[] } {
|
||||
const lines: LogLine[] = [];
|
||||
const destination: DestinationStream = {
|
||||
write(msg: string): void {
|
||||
lines.push(JSON.parse(msg) as LogLine);
|
||||
},
|
||||
};
|
||||
return { logger: createLogger({ level: 'info', destination }), lines };
|
||||
}
|
||||
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
interface ErrorBody {
|
||||
error: { statusCode: number; code: string; message: string; details?: unknown[] };
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
describe('http foundation', () => {
|
||||
it('every response carries an x-request-id header', async () => {
|
||||
const { logger } = memoryLogger();
|
||||
const app = await buildApp({ logger });
|
||||
|
||||
const ok = await app.inject({ method: 'GET', url: '/health' });
|
||||
expect(ok.headers['x-request-id']).toMatch(UUID_V4);
|
||||
|
||||
const missing = await app.inject({ method: 'GET', url: '/nope' });
|
||||
expect(missing.headers['x-request-id']).toMatch(UUID_V4);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('a valid incoming x-request-id is propagated to response and logs', async () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
const app = await buildApp({ logger });
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
headers: { 'x-request-id': 'client-trace-42' },
|
||||
});
|
||||
|
||||
expect(response.headers['x-request-id']).toBe('client-trace-42');
|
||||
const completed = lines.filter((line) => line.msg === 'request completed');
|
||||
expect(completed).toHaveLength(1);
|
||||
expect(completed[0]?.requestId).toBe('client-trace-42');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('a malicious x-request-id is replaced by a generated UUID', async () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
const app = await buildApp({ logger });
|
||||
|
||||
const badCharset = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
headers: { 'x-request-id': 'evil\nx-injected: 1' },
|
||||
});
|
||||
expect(badCharset.headers['x-request-id']).toMatch(UUID_V4);
|
||||
expect(badCharset.headers['x-request-id']).not.toContain('\n');
|
||||
|
||||
const tooLong = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/health',
|
||||
headers: { 'x-request-id': 'a'.repeat(129) },
|
||||
});
|
||||
expect(tooLong.headers['x-request-id']).toMatch(UUID_V4);
|
||||
|
||||
const completed = lines.filter((line) => line.msg === 'request completed');
|
||||
expect(completed).toHaveLength(2);
|
||||
expect(completed[0]?.requestId).toBe(badCharset.headers['x-request-id']);
|
||||
expect(completed[1]?.requestId).toBe(tooLong.headers['x-request-id']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('unknown routes return the stable envelope with code NOT_FOUND and requestId', async () => {
|
||||
const { logger } = memoryLogger();
|
||||
const app = await buildApp({ logger });
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/does-not-exist' });
|
||||
expect(response.statusCode).toBe(404);
|
||||
const body = response.json() as ErrorBody;
|
||||
expect(body.error.statusCode).toBe(404);
|
||||
expect(body.error.code).toBe('NOT_FOUND');
|
||||
expect(body.error.message).toBe('Not Found');
|
||||
expect(body.requestId).toBe(response.headers['x-request-id']);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('validation hook on the API layer', () => {
|
||||
async function buildValidatingApp() {
|
||||
const memory = memoryLogger();
|
||||
const app = await buildApp({ logger: memory.logger });
|
||||
const schema = z.object({ name: z.string().min(1) });
|
||||
app.post('/__test/echo', async (request) => {
|
||||
return parseJson(schema, request.body);
|
||||
});
|
||||
return { app, lines: memory.lines };
|
||||
}
|
||||
|
||||
it('malformed JSON yields the stable 400 envelope', async () => {
|
||||
const { app, lines } = await buildValidatingApp();
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/__test/echo',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{"name": broken',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const body = response.json() as ErrorBody;
|
||||
expect(body.error.statusCode).toBe(400);
|
||||
expect(typeof body.error.code).toBe('string');
|
||||
expect(typeof body.error.message).toBe('string');
|
||||
expect(body.requestId).toBe(response.headers['x-request-id']);
|
||||
expect(response.body).not.toContain('stack');
|
||||
|
||||
const rejected = lines.filter((line) => line.msg === 'request rejected');
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect(rejected[0]?.requestId).toBe(body.requestId);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('schema-invalid payloads yield code VALIDATION_ERROR with field paths only', async () => {
|
||||
const { app } = await buildValidatingApp();
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/__test/echo',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: { name: '' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
const body = response.json() as ErrorBody;
|
||||
expect(body.error.code).toBe('VALIDATION_ERROR');
|
||||
expect(body.error.details).toEqual([{ path: 'name', message: expect.any(String) }]);
|
||||
expect(JSON.stringify(body)).not.toContain('stack');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('valid payloads pass through the hook', async () => {
|
||||
const { app } = await buildValidatingApp();
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/__test/echo',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: { name: 'ana' },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ name: 'ana' });
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
|
||||
it('internal errors never leak message or stack to the client', async () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
const app = await buildApp({ logger });
|
||||
app.get('/__test/boom', () => {
|
||||
throw new Error('secret internal detail');
|
||||
});
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/__test/boom' });
|
||||
expect(response.statusCode).toBe(500);
|
||||
const body = response.json() as ErrorBody;
|
||||
expect(body.error.code).toBe('INTERNAL_ERROR');
|
||||
expect(body.error.message).toBe('Internal Server Error');
|
||||
expect(response.body).not.toContain('secret internal detail');
|
||||
expect(response.body).not.toContain('stack');
|
||||
|
||||
const failed = lines.filter((line) => line.msg === 'request failed');
|
||||
expect(failed).toHaveLength(1);
|
||||
expect(failed[0]?.requestId).toBe(response.headers['x-request-id']);
|
||||
const err = failed[0]?.err as { message?: string; stack?: string };
|
||||
expect(err.message).toBe('secret internal detail');
|
||||
expect(err.stack).toContain('Error');
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('every request produces exactly one request completed log line', async () => {
|
||||
const { logger, lines } = memoryLogger();
|
||||
const app = await buildApp({ logger });
|
||||
|
||||
await app.inject({ method: 'GET', url: '/health' });
|
||||
await app.inject({ method: 'GET', url: '/missing' });
|
||||
|
||||
const completed = lines.filter((line) => line.msg === 'request completed');
|
||||
expect(completed).toHaveLength(2);
|
||||
for (const line of completed) {
|
||||
expect(line.requestId).toMatch(UUID_V4);
|
||||
expect(line).toHaveProperty('method');
|
||||
expect(line).toHaveProperty('statusCode');
|
||||
expect(line).toHaveProperty('durationMs');
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user