Files
multi-simadmin/apps/api/src/app.test.ts
T

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);
});
});