feat(health): add versioned liveness and database readiness API

This commit is contained in:
mihir 2026-09-08 20:33:58 +05:30
parent 39290c4944
commit 340485914d
7 changed files with 158 additions and 0 deletions

6
src/app.module.ts Normal file
View File

@ -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 {}

13
src/configure-app.ts Normal file
View File

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

View File

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

View File

@ -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 {}

View File

@ -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' };
}
}

21
src/main.ts Normal file
View File

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

70
test/health.spec.ts Normal file
View File

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