233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { Writable } from 'node:stream';
|
|
import {
|
|
API_DEFAULT_HOST,
|
|
API_DEFAULT_PORT,
|
|
buildApp,
|
|
createListenOptions,
|
|
REDACT_PATHS,
|
|
} from './app.js';
|
|
|
|
const registerProbe = async (app: FastifyInstance): Promise<void> => {
|
|
app.post(
|
|
'/api/v1/test-only/schema-probe',
|
|
{
|
|
schema: {
|
|
body: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['name'],
|
|
properties: { name: { type: 'string', minLength: 1 } },
|
|
},
|
|
},
|
|
},
|
|
async (request) => ({ name: (request.body as { name: string }).name }),
|
|
);
|
|
app.get('/api/v1/test-only/failure', async () => {
|
|
throw new Error('internal secret detail');
|
|
});
|
|
};
|
|
|
|
describe('Fastify API skeleton', () => {
|
|
it('is injectable without listening and exposes health/readiness with server request IDs', async () => {
|
|
const app = buildApp({ readiness: () => ({ ready: true, checks: { bootstrap: 'ready' } }) });
|
|
const health = await app.inject({
|
|
method: 'GET',
|
|
url: '/healthz',
|
|
headers: { 'x-request-id': 'attacker-controlled' },
|
|
});
|
|
const ready = await app.inject({ method: 'GET', url: '/readyz' });
|
|
|
|
expect(health.statusCode).toBe(200);
|
|
expect(health.json()).toEqual({ status: 'ok' });
|
|
expect(ready.statusCode).toBe(200);
|
|
expect(ready.json()).toEqual({ ready: true, checks: { bootstrap: 'ready' } });
|
|
expect(health.headers['x-request-id']).toMatch(/^[0-9a-f-]{36}$/);
|
|
expect(health.headers['x-request-id']).not.toBe('attacker-controlled');
|
|
await app.close();
|
|
}, 15_000);
|
|
|
|
it('returns RFC Problem Details for schema failures and unexpected errors without leaking detail', async () => {
|
|
let logs = '';
|
|
const logStream = new Writable({
|
|
write(chunk, _encoding, callback) {
|
|
logs += String(chunk);
|
|
callback();
|
|
},
|
|
});
|
|
const app = buildApp({ logger: { stream: logStream }, registerRoutes: registerProbe });
|
|
const invalid = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/test-only/schema-probe',
|
|
headers: { 'content-type': 'application/json' },
|
|
payload: {},
|
|
});
|
|
expect(invalid.statusCode).toBe(400);
|
|
expect(invalid.headers['content-type']).toContain('application/problem+json');
|
|
expect(invalid.json()).toMatchObject({
|
|
type: 'about:blank',
|
|
title: 'Bad Request',
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
requestId: invalid.headers['x-request-id'],
|
|
});
|
|
expect(invalid.json().validation).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
field: expect.any(String),
|
|
code: expect.any(String),
|
|
message: expect.any(String),
|
|
}),
|
|
]),
|
|
);
|
|
|
|
const failure = await app.inject({ method: 'GET', url: '/api/v1/test-only/failure' });
|
|
expect(failure.statusCode).toBe(500);
|
|
expect(failure.headers['content-type']).toContain('application/problem+json');
|
|
expect(JSON.stringify(failure.json())).not.toContain('internal secret detail');
|
|
expect(logs).toContain('request failed');
|
|
expect(logs).not.toContain('internal secret detail');
|
|
await app.close();
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
name: 'malformed JSON',
|
|
request: {
|
|
method: 'POST' as const,
|
|
url: '/api/v1/test-only/schema-probe',
|
|
headers: { 'content-type': 'application/json' },
|
|
payload: '{',
|
|
},
|
|
status: 400,
|
|
},
|
|
{
|
|
name: 'unsupported media type',
|
|
request: {
|
|
method: 'POST' as const,
|
|
url: '/api/v1/test-only/schema-probe',
|
|
headers: { 'content-type': 'application/x-unsupported' },
|
|
payload: 'x',
|
|
},
|
|
status: 415,
|
|
},
|
|
{
|
|
name: 'oversized body',
|
|
request: {
|
|
method: 'POST' as const,
|
|
url: '/api/v1/test-only/schema-probe',
|
|
headers: { 'content-type': 'application/json' },
|
|
payload: JSON.stringify({ name: 'x'.repeat(1_048_577) }),
|
|
},
|
|
status: 413,
|
|
},
|
|
])('preserves Fastify client status for $name', async ({ request, status }) => {
|
|
const app = buildApp({ registerRoutes: registerProbe });
|
|
const response = await app.inject(request);
|
|
expect(response.statusCode).toBe(status);
|
|
expect(response.headers['content-type']).toContain('application/problem+json');
|
|
expect(response.json()).toMatchObject({ status, requestId: response.headers['x-request-id'] });
|
|
await app.close();
|
|
});
|
|
|
|
it('rejects non-loopback Host and cross-origin browser requests before route execution', async () => {
|
|
const app = buildApp({ registerRoutes: registerProbe });
|
|
const foreignHost = await app.inject({
|
|
method: 'GET',
|
|
url: '/healthz',
|
|
headers: { host: 'example.com' },
|
|
});
|
|
expect(foreignHost.statusCode).toBe(403);
|
|
expect(foreignHost.headers['content-type']).toContain('application/problem+json');
|
|
|
|
const crossOrigin = await app.inject({
|
|
method: 'GET',
|
|
url: '/healthz',
|
|
headers: { host: '127.0.0.1:8790', origin: 'http://localhost:8790' },
|
|
});
|
|
expect(crossOrigin.statusCode).toBe(403);
|
|
|
|
const sameOrigin = await app.inject({
|
|
method: 'GET',
|
|
url: '/healthz',
|
|
headers: { host: '127.0.0.1:8790', origin: 'http://127.0.0.1:8790' },
|
|
});
|
|
expect(sameOrigin.statusCode).toBe(200);
|
|
|
|
for (const host of [
|
|
'127.0.0.1@example.com',
|
|
'127.0.0.1/path',
|
|
'[::ffff:127.0.0.1]:8790',
|
|
'2130706433',
|
|
'0177.0.0.1',
|
|
'0x7f.0.0.1',
|
|
'127.1',
|
|
'127.0.1',
|
|
'[0:0:0:0:0:0:0:1]',
|
|
]) {
|
|
const response = await app.inject({ method: 'GET', url: '/healthz', headers: { host } });
|
|
expect(response.statusCode, host).toBe(403);
|
|
}
|
|
for (const host of ['localhost:8790', '[::1]:8790']) {
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/healthz',
|
|
headers: { host, origin: `http://${host}` },
|
|
});
|
|
expect(response.statusCode, host).toBe(200);
|
|
}
|
|
for (const origin of [
|
|
'https://127.0.0.1:8790',
|
|
'http://evil@127.0.0.1:8790',
|
|
'http://127.0.0.1:8790/path',
|
|
'http://2130706433:8790',
|
|
]) {
|
|
const response = await app.inject({
|
|
method: 'GET',
|
|
url: '/healthz',
|
|
headers: { host: '127.0.0.1:8790', origin },
|
|
});
|
|
expect(response.statusCode, origin).toBe(403);
|
|
}
|
|
await app.close();
|
|
});
|
|
|
|
it('returns 503 with structured readiness checks when dependencies are not ready', async () => {
|
|
const app = buildApp({
|
|
readiness: () => ({ ready: false, checks: { database: 'unavailable' } }),
|
|
});
|
|
const response = await app.inject({ method: 'GET', url: '/readyz' });
|
|
expect(response.statusCode).toBe(503);
|
|
expect(response.json()).toEqual({ ready: false, checks: { database: 'unavailable' } });
|
|
await app.close();
|
|
});
|
|
|
|
it('defaults to a separate loopback listener and rejects unsafe listen overrides', () => {
|
|
expect(API_DEFAULT_HOST).toBe('127.0.0.1');
|
|
expect(API_DEFAULT_PORT).not.toBe(8788);
|
|
expect(createListenOptions({})).toEqual({ host: API_DEFAULT_HOST, port: API_DEFAULT_PORT });
|
|
expect(createListenOptions({ API_HOST: '::1' })).toEqual({
|
|
host: '::1',
|
|
port: API_DEFAULT_PORT,
|
|
});
|
|
expect(() => createListenOptions({ API_HOST: '0.0.0.0' })).toThrow(/loopback/i);
|
|
expect(() => createListenOptions({ API_PORT: '8788' })).toThrow(/reserved|legacy/i);
|
|
expect(() => createListenOptions({ API_PORT: 'not-a-port' })).toThrow(/port/i);
|
|
});
|
|
|
|
it('defines immutable logger redaction for credentials, confirmation headers and cookies', () => {
|
|
expect(REDACT_PATHS).toEqual(
|
|
expect.arrayContaining([
|
|
'req.headers.authorization',
|
|
'req.headers.cookie',
|
|
'req.headers.x-confirmation-token',
|
|
'req.body.password',
|
|
'req.body.newPassword',
|
|
'res.headers.set-cookie',
|
|
]),
|
|
);
|
|
expect(new Set(REDACT_PATHS).size).toBe(REDACT_PATHS.length);
|
|
});
|
|
});
|