feat(health): add versioned liveness and database readiness API
This commit is contained in:
parent
39290c4944
commit
340485914d
|
|
@ -0,0 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EnvironmentModule } from './config/environment.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({ imports: [EnvironmentModule, HealthModule] })
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import type { INestApplication } from '@nestjs/common';
|
||||
import helmet from 'helmet';
|
||||
import type { Environment } from './config/environment';
|
||||
|
||||
export function configureApp(
|
||||
app: INestApplication,
|
||||
environment: Environment,
|
||||
): void {
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.use(helmet());
|
||||
app.enableCors({ origin: environment.CORS_ORIGINS, credentials: true });
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly health: HealthService) {}
|
||||
|
||||
@Get('live')
|
||||
live() {
|
||||
return this.health.live();
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return this.health.ready();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from '../database/database.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [HealthController],
|
||||
providers: [HealthService],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class HealthService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
live() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
async ready() {
|
||||
try {
|
||||
await this.database.ping();
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Service is not ready');
|
||||
}
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'reflect-metadata';
|
||||
import 'dotenv/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { ENVIRONMENT } from './config/environment.module';
|
||||
import type { Environment } from './config/environment';
|
||||
import { configureApp } from './configure-app';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const environment = app.get<Environment>(ENVIRONMENT);
|
||||
configureApp(app, environment);
|
||||
await app.listen(environment.PORT);
|
||||
}
|
||||
|
||||
void bootstrap().catch(() => {
|
||||
console.error(
|
||||
'Backend startup failed. Check configuration and database availability.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import 'reflect-metadata';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { DatabaseService } from '../src/database/database.service';
|
||||
import { ENVIRONMENT } from '../src/config/environment.module';
|
||||
import { configureApp } from '../src/configure-app';
|
||||
import { parseEnvironment } from '../src/config/environment';
|
||||
|
||||
describe('health API', () => {
|
||||
let app: INestApplication;
|
||||
const database = { ping: jest.fn() };
|
||||
|
||||
beforeAll(async () => {
|
||||
const environment = parseEnvironment({
|
||||
DATABASE_URL: 'postgresql://localhost/mani',
|
||||
CORS_ORIGINS: 'https://shop.example.com',
|
||||
});
|
||||
const module = await Test.createTestingModule({ imports: [AppModule] })
|
||||
.overrideProvider(DatabaseService)
|
||||
.useValue(database)
|
||||
.overrideProvider(ENVIRONMENT)
|
||||
.useValue(environment)
|
||||
.compile();
|
||||
app = module.createNestApplication();
|
||||
configureApp(app, environment);
|
||||
await app.init();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
beforeEach(() => {
|
||||
database.ping.mockReset();
|
||||
});
|
||||
|
||||
it('reports liveness without requiring the database', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/health/live')
|
||||
.expect(200, { status: 'ok' });
|
||||
expect(database.ping).not.toHaveBeenCalled();
|
||||
});
|
||||
it('checks the database before reporting readiness', async () => {
|
||||
database.ping.mockResolvedValue(undefined);
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/health/ready')
|
||||
.expect(200, { status: 'ok' });
|
||||
expect(database.ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('returns 503 without leaking database errors', async () => {
|
||||
database.ping.mockRejectedValue(new Error('postgresql://secret'));
|
||||
const response = await request(app.getHttpServer())
|
||||
.get('/api/v1/health/ready')
|
||||
.expect(503);
|
||||
expect(response.text).not.toContain('secret');
|
||||
});
|
||||
it('sets security headers and permits only configured browser origins', async () => {
|
||||
const allowed = await request(app.getHttpServer())
|
||||
.get('/api/v1/health/live')
|
||||
.set('Origin', 'https://shop.example.com');
|
||||
expect(allowed.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(allowed.headers['access-control-allow-origin']).toBe(
|
||||
'https://shop.example.com',
|
||||
);
|
||||
const denied = await request(app.getHttpServer())
|
||||
.get('/api/v1/health/live')
|
||||
.set('Origin', 'https://untrusted.example.com');
|
||||
expect(denied.headers['access-control-allow-origin']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue