From a003df9a8a2649ce5688361817bea7dfbc36f1ee Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 10 Apr 2026 20:55:03 +0700 Subject: [PATCH] =?UTF-8?q?fix(health):=20resolve=20404=20on=20/health=20e?= =?UTF-8?q?ndpoints=20=E2=80=94=20restructure=20routes=20under=20/health?= =?UTF-8?q?=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: HealthController used @Controller() (empty prefix) with @Get('health') and @Get('ready') flat routes. The global prefix exclusion for 'health' and 'ready' was unreliable for module-scoped controllers. Changes: - Set @Controller('health') prefix so routes are /health, /health/ready, /health/db, /health/redis - Update global prefix exclusion to use 'health/(.*)' wildcard pattern - Exclude health endpoints from CSRF middleware (K8s probes don't send cookies) - Add dedicated /health/db and /health/redis endpoints per acceptance criteria - Expand unit tests to cover all 4 health endpoints (15 tests passing) Co-Authored-By: Paperclip --- apps/api/src/app.module.ts | 7 +- apps/api/src/main.ts | 2 +- .../__tests__/health.controller.spec.ts | 76 ++++++++++++++++++- .../src/modules/health/health.controller.ts | 22 +++++- 4 files changed, 101 insertions(+), 6 deletions(-) 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'), + ]); + } }