diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 35e1940..20f393f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,4 +1,4 @@ -import { type MiddlewareConsumer, Module, type NestModule } from '@nestjs/common'; +import { type MiddlewareConsumer, Module, type NestModule, RequestMethod } from '@nestjs/common'; import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { CqrsModule } from '@nestjs/cqrs'; import { ScheduleModule } from '@nestjs/schedule'; @@ -94,8 +94,13 @@ export class AppModule implements NestModule { .forRoutes('*'); // CSRF double-submit cookie (sets on GET, validates on state-changing methods) + // Exclude health endpoints — they must remain accessible without cookies consumer .apply(CsrfMiddleware) + .exclude( + { path: 'health', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.GET }, + ) .forRoutes('*'); } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 071e2a1..b1c15a1 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -22,7 +22,7 @@ async function bootstrap() { app.setGlobalPrefix('api/v1', { exclude: [ { path: 'health', method: RequestMethod.GET }, - { path: 'ready', method: RequestMethod.GET }, + { path: 'health/(.*)', method: RequestMethod.GET }, ], }); diff --git a/apps/api/src/modules/health/__tests__/health.controller.spec.ts b/apps/api/src/modules/health/__tests__/health.controller.spec.ts index 90479e7..b86cf04 100644 --- a/apps/api/src/modules/health/__tests__/health.controller.spec.ts +++ b/apps/api/src/modules/health/__tests__/health.controller.spec.ts @@ -21,7 +21,7 @@ describe('HealthController', () => { ); }); - describe('liveness', () => { + describe('liveness (GET /health)', () => { it('calls health.check with an empty array', () => { const expected = { status: 'ok', details: {} }; mockHealthCheckService.check.mockReturnValue(expected); @@ -34,7 +34,7 @@ describe('HealthController', () => { }); }); - describe('readiness', () => { + describe('readiness (GET /health/ready)', () => { it('calls health.check with prisma and redis health indicators', async () => { const expected = { status: 'ok', @@ -74,4 +74,76 @@ describe('HealthController', () => { expect(typeof callbacks[1]).toBe('function'); }); }); + + describe('database (GET /health/db)', () => { + it('calls health.check with only the prisma health indicator', async () => { + const expected = { + status: 'ok', + details: { database: { status: 'up' } }, + }; + mockHealthCheckService.check.mockImplementation( + async (indicators: Array<() => Promise>) => { + for (const indicator of indicators) { + await indicator(); + } + return expected; + }, + ); + (mockPrismaHealth.isHealthy as ReturnType).mockResolvedValue({ + database: { status: 'up' }, + }); + + const result = await controller.database(); + + expect(result).toBe(expected); + expect(mockPrismaHealth.isHealthy).toHaveBeenCalledWith('database'); + expect(mockRedisHealth.isHealthy).not.toHaveBeenCalled(); + }); + + it('passes one health indicator callback to health.check', () => { + mockHealthCheckService.check.mockReturnValue({ status: 'ok' }); + + controller.database(); + + const callbacks = mockHealthCheckService.check.mock.calls[0][0]; + expect(callbacks).toHaveLength(1); + expect(typeof callbacks[0]).toBe('function'); + }); + }); + + describe('redis (GET /health/redis)', () => { + it('calls health.check with only the redis health indicator', async () => { + const expected = { + status: 'ok', + details: { redis: { status: 'up' } }, + }; + mockHealthCheckService.check.mockImplementation( + async (indicators: Array<() => Promise>) => { + for (const indicator of indicators) { + await indicator(); + } + return expected; + }, + ); + (mockRedisHealth.isHealthy as ReturnType).mockResolvedValue({ + redis: { status: 'up' }, + }); + + const result = await controller.redis(); + + expect(result).toBe(expected); + expect(mockRedisHealth.isHealthy).toHaveBeenCalledWith('redis'); + expect(mockPrismaHealth.isHealthy).not.toHaveBeenCalled(); + }); + + it('passes one health indicator callback to health.check', () => { + mockHealthCheckService.check.mockReturnValue({ status: 'ok' }); + + controller.redis(); + + const callbacks = mockHealthCheckService.check.mock.calls[0][0]; + expect(callbacks).toHaveLength(1); + expect(typeof callbacks[0]).toBe('function'); + }); + }); }); diff --git a/apps/api/src/modules/health/health.controller.ts b/apps/api/src/modules/health/health.controller.ts index 6421112..124682f 100644 --- a/apps/api/src/modules/health/health.controller.ts +++ b/apps/api/src/modules/health/health.controller.ts @@ -6,7 +6,7 @@ import { PrismaHealthIndicator } from './infrastructure/prisma.health'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports import { RedisHealthIndicator } from './infrastructure/redis.health'; -@Controller() +@Controller('health') export class HealthController { constructor( private readonly health: HealthCheckService, @@ -15,7 +15,7 @@ export class HealthController { ) {} /** Liveness probe — always returns 200 if the process is running */ - @Get('health') + @Get() @HealthCheck() liveness() { return this.health.check([]); @@ -30,4 +30,22 @@ export class HealthController { () => this.redisHealth.isHealthy('redis'), ]); } + + /** Database readiness — checks PostgreSQL connectivity */ + @Get('db') + @HealthCheck() + database() { + return this.health.check([ + () => this.prismaHealth.isHealthy('database'), + ]); + } + + /** Redis readiness — checks Redis connectivity */ + @Get('redis') + @HealthCheck() + redis() { + return this.health.check([ + () => this.redisHealth.isHealthy('redis'), + ]); + } }