fix(health): resolve 404 on /health endpoints — restructure routes under /health prefix

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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 20:55:03 +07:00
parent d36a13d536
commit a003df9a8a
4 changed files with 101 additions and 6 deletions

View File

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

View File

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

View File

@@ -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<unknown>>) => {
for (const indicator of indicators) {
await indicator();
}
return expected;
},
);
(mockPrismaHealth.isHealthy as ReturnType<typeof vi.fn>).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<unknown>>) => {
for (const indicator of indicators) {
await indicator();
}
return expected;
},
);
(mockRedisHealth.isHealthy as ReturnType<typeof vi.fn>).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');
});
});
});

View File

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