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:
@@ -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 { APP_FILTER, APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
import { CqrsModule } from '@nestjs/cqrs';
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
@@ -94,8 +94,13 @@ export class AppModule implements NestModule {
|
|||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
|
|
||||||
// CSRF double-submit cookie (sets on GET, validates on state-changing methods)
|
// CSRF double-submit cookie (sets on GET, validates on state-changing methods)
|
||||||
|
// Exclude health endpoints — they must remain accessible without cookies
|
||||||
consumer
|
consumer
|
||||||
.apply(CsrfMiddleware)
|
.apply(CsrfMiddleware)
|
||||||
|
.exclude(
|
||||||
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
|
{ path: 'health/(.*)', method: RequestMethod.GET },
|
||||||
|
)
|
||||||
.forRoutes('*');
|
.forRoutes('*');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async function bootstrap() {
|
|||||||
app.setGlobalPrefix('api/v1', {
|
app.setGlobalPrefix('api/v1', {
|
||||||
exclude: [
|
exclude: [
|
||||||
{ path: 'health', method: RequestMethod.GET },
|
{ path: 'health', method: RequestMethod.GET },
|
||||||
{ path: 'ready', method: RequestMethod.GET },
|
{ path: 'health/(.*)', method: RequestMethod.GET },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('HealthController', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('liveness', () => {
|
describe('liveness (GET /health)', () => {
|
||||||
it('calls health.check with an empty array', () => {
|
it('calls health.check with an empty array', () => {
|
||||||
const expected = { status: 'ok', details: {} };
|
const expected = { status: 'ok', details: {} };
|
||||||
mockHealthCheckService.check.mockReturnValue(expected);
|
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 () => {
|
it('calls health.check with prisma and redis health indicators', async () => {
|
||||||
const expected = {
|
const expected = {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -74,4 +74,76 @@ describe('HealthController', () => {
|
|||||||
expect(typeof callbacks[1]).toBe('function');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { PrismaHealthIndicator } from './infrastructure/prisma.health';
|
|||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
import { RedisHealthIndicator } from './infrastructure/redis.health';
|
import { RedisHealthIndicator } from './infrastructure/redis.health';
|
||||||
|
|
||||||
@Controller()
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly health: HealthCheckService,
|
private readonly health: HealthCheckService,
|
||||||
@@ -15,7 +15,7 @@ export class HealthController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Liveness probe — always returns 200 if the process is running */
|
/** Liveness probe — always returns 200 if the process is running */
|
||||||
@Get('health')
|
@Get()
|
||||||
@HealthCheck()
|
@HealthCheck()
|
||||||
liveness() {
|
liveness() {
|
||||||
return this.health.check([]);
|
return this.health.check([]);
|
||||||
@@ -30,4 +30,22 @@ export class HealthController {
|
|||||||
() => this.redisHealth.isHealthy('redis'),
|
() => 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'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user