diff --git a/app/backend/src/app.service.ts b/app/backend/src/app.service.ts index 8816eca9..5c60f37d 100644 --- a/app/backend/src/app.service.ts +++ b/app/backend/src/app.service.ts @@ -9,6 +9,7 @@ export class AppService { docs: '/api/docs', endpoints: { health: '/api/v1/health', + healthDependencies: '/api/v1/health/dependencies', aid: '/api/v1/aid', verification: '/api/v1/verification', }, diff --git a/app/backend/src/health/health.controller.spec.ts b/app/backend/src/health/health.controller.spec.ts index af646137..d0997e5b 100644 --- a/app/backend/src/health/health.controller.spec.ts +++ b/app/backend/src/health/health.controller.spec.ts @@ -139,4 +139,37 @@ describe('HealthController', () => { }), ); }); + + it('GET /health/dependencies returns ready when all dependency probes pass', async () => { + const healthService = app.get(HealthService); + jest + .spyOn(healthService as any, 'getDependencyProbe') + .mockResolvedValueOnce({ + status: 'ready', + ready: true, + service: 'backend', + timestamp: new Date().toISOString(), + checks: { + redis: { status: 'up' }, + providerConfiguration: { status: 'up' }, + filesystem: { status: 'up' }, + }, + }); + + const res = await request(app.getHttpServer()) + .get('/health/dependencies') + .expect(200); + + expect(res.body).toEqual( + expect.objectContaining({ + status: 'ready', + ready: true, + checks: { + redis: expect.objectContaining({ status: 'up' }), + providerConfiguration: expect.objectContaining({ status: 'up' }), + filesystem: expect.objectContaining({ status: 'up' }), + }, + }), + ); + }); }); diff --git a/app/backend/src/health/health.controller.ts b/app/backend/src/health/health.controller.ts index ce638039..897bc72c 100644 --- a/app/backend/src/health/health.controller.ts +++ b/app/backend/src/health/health.controller.ts @@ -9,7 +9,11 @@ import { import { Response } from 'express'; import { RequestWithRequestId } from '../middleware/request-correlation.middleware'; import { HealthService } from './health.service'; -import { LivenessResponse, ReadinessResponse } from './health.service'; +import { + LivenessResponse, + ReadinessResponse, + DependencyProbeResponse, +} from './health.service'; import { API_VERSIONS } from '../common/constants/api-version.constants'; import { Public } from '../common/decorators/public.decorator'; import { Throttle } from '@nestjs/throttler'; @@ -110,6 +114,56 @@ export class HealthController { return readiness; } + @Public() + @Get('dependencies') + @Version(API_VERSIONS.V1) + @ApiOperation({ + summary: 'Dependency probe', + description: + 'Checks Redis connectivity, provider configuration readiness, and filesystem/temp access.', + }) + @ApiOkResponse({ + description: 'All dependency checks passed.', + schema: { + example: { + status: 'ready', + ready: true, + service: 'backend', + checks: { + redis: { status: 'up' }, + providerConfiguration: { status: 'up' }, + filesystem: { status: 'up' }, + }, + }, + }, + }) + @ApiServiceUnavailableResponse({ + description: 'One or more dependency checks failed.', + schema: { + example: { + status: 'not_ready', + ready: false, + service: 'backend', + checks: { + redis: { status: 'down' }, + providerConfiguration: { status: 'up' }, + filesystem: { status: 'up' }, + }, + }, + }, + }) + async dependencies( + @Res({ passthrough: true }) res: Response, + ): Promise { + const probe = await this.healthService.getDependencyProbe(); + + if (!probe.ready) { + res.status(HttpStatus.SERVICE_UNAVAILABLE); + } + + return probe; + } + @Get('error') @Version(API_VERSIONS.V1) @ApiOperation({ summary: 'Trigger an error for testing' }) diff --git a/app/backend/src/health/health.service.spec.ts b/app/backend/src/health/health.service.spec.ts new file mode 100644 index 00000000..458b8b38 --- /dev/null +++ b/app/backend/src/health/health.service.spec.ts @@ -0,0 +1,91 @@ +import { ConfigService } from '@nestjs/config'; +import { HealthService } from './health.service'; +import { LoggerService } from '../logger/logger.service'; +import { PrismaService } from '../prisma/prisma.service'; + +describe('HealthService dependency probe', () => { + let service: HealthService; + const configValues: Record = { + VERIFICATION_MODE: 'mock', + AI_SERVICE_URL: 'http://localhost:8000', + OPENAI_API_KEY: undefined, + }; + + const configMock = { + get: jest.fn((key: string) => configValues[key]), + }; + + const loggerMock = { + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const prismaMock = {} as PrismaService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new HealthService( + configMock as unknown as ConfigService, + loggerMock as unknown as LoggerService, + prismaMock, + ); + }); + + it('marks provider configuration ready for mock verification mode', async () => { + configValues.VERIFICATION_MODE = 'mock'; + configValues.AI_SERVICE_URL = undefined; + configValues.OPENAI_API_KEY = undefined; + + const result = await (service as any).checkProviderConfiguration(); + + expect(result.status).toBe('up'); + expect(result.details).toEqual( + expect.objectContaining({ + verificationMode: 'mock', + aiServiceUrlConfigured: false, + openAIKeyConfigured: false, + required: false, + }), + ); + }); + + it('marks provider configuration not ready when AI mode is enabled and required config is missing', async () => { + configValues.VERIFICATION_MODE = 'ai'; + configValues.AI_SERVICE_URL = 'http://localhost:8000'; + configValues.OPENAI_API_KEY = undefined; + + const result = await (service as any).checkProviderConfiguration(); + + expect(result.status).toBe('down'); + expect(result.details).toEqual( + expect.objectContaining({ + verificationMode: 'ai', + aiServiceUrlConfigured: true, + openAIKeyConfigured: false, + required: true, + }), + ); + }); + + it('returns a ready dependency probe result when all checks pass', async () => { + configValues.VERIFICATION_MODE = 'mock'; + configValues.AI_SERVICE_URL = undefined; + configValues.OPENAI_API_KEY = undefined; + + jest + .spyOn(service as any, 'checkRedisConnectivity') + .mockResolvedValue({ status: 'up' }); + jest + .spyOn(service as any, 'checkFilesystemAccess') + .mockResolvedValue({ status: 'up' }); + + const probe = await service.getDependencyProbe(); + + expect(probe.ready).toBe(true); + expect(probe.status).toBe('ready'); + expect(probe.checks.redis.status).toBe('up'); + expect(probe.checks.filesystem.status).toBe('up'); + expect(probe.checks.providerConfiguration.status).toBe('up'); + }); +}); diff --git a/app/backend/src/health/health.service.ts b/app/backend/src/health/health.service.ts index 1b71d823..28a585fb 100644 --- a/app/backend/src/health/health.service.ts +++ b/app/backend/src/health/health.service.ts @@ -2,6 +2,10 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../prisma/prisma.service'; import { LoggerService } from '../logger/logger.service'; +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import Redis from 'ioredis'; type CheckStatus = 'up' | 'down' | 'skipped'; @@ -10,6 +14,23 @@ interface HealthCheckResult { details?: Record; } +interface DependencyCheckResult { + status: 'up' | 'down'; + details?: Record; +} + +export interface DependencyProbeResponse { + status: 'ready' | 'not_ready'; + ready: boolean; + service: 'backend'; + timestamp: string; + checks: { + redis: DependencyCheckResult; + providerConfiguration: DependencyCheckResult; + filesystem: DependencyCheckResult; + }; +} + export interface LivenessResponse { status: 'ok'; service: 'backend'; @@ -103,6 +124,31 @@ export class HealthService { }; } + async getDependencyProbe(): Promise { + const [redis, providerConfiguration, filesystem] = await Promise.all([ + this.checkRedisConnectivity(), + this.checkProviderConfiguration(), + this.checkFilesystemAccess(), + ]); + + const ready = + redis.status === 'up' && + providerConfiguration.status === 'up' && + filesystem.status === 'up'; + + return { + status: ready ? 'ready' : 'not_ready', + ready, + service: 'backend', + timestamp: new Date().toISOString(), + checks: { + redis, + providerConfiguration, + filesystem, + }, + }; + } + logHealthCheck(requestId?: string) { this.logger.log('Health check endpoint accessed', 'HealthService', { requestId, @@ -149,6 +195,125 @@ export class HealthService { } } + private async checkRedisConnectivity(): Promise { + let client: Redis | null = null; + + try { + const redisUrl = this.configService.get('REDIS_URL'); + const redisHost = this.configService.get('REDIS_HOST') ?? 'localhost'; + const redisPort = parseInt( + this.configService.get('REDIS_PORT') ?? '6379', + 10, + ); + + client = redisUrl + ? new Redis(redisUrl) + : new Redis({ host: redisHost, port: redisPort }); + + await client.ping(); + + return { + status: 'up', + details: { + connected: true, + }, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown Redis error'; + + this.logger.warn( + 'Redis dependency check failed', + undefined, + 'HealthService', + { + error: message, + }, + ); + + return { + status: 'down', + details: { + connected: false, + }, + }; + } finally { + if (client) { + try { + await client.quit(); + } catch { + client.disconnect(); + } + } + } + } + + private checkProviderConfiguration(): DependencyCheckResult { + const verificationMode = + this.configService.get('VERIFICATION_MODE')?.trim().toLowerCase() || + 'mock'; + const aiServiceUrl = this.configService.get('AI_SERVICE_URL'); + const openAIKey = this.configService.get('OPENAI_API_KEY'); + const aiRequired = verificationMode === 'ai'; + + const ready = !aiRequired || (!!aiServiceUrl && !!openAIKey); + + return { + status: ready ? 'up' : 'down', + details: { + verificationMode, + aiServiceUrlConfigured: Boolean(aiServiceUrl), + openAIKeyConfigured: Boolean(openAIKey), + required: aiRequired, + }, + }; + } + + private async checkFilesystemAccess(): Promise { + const tempDirectory = tmpdir(); + const tempFile = join( + tempDirectory, + `soter-health-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`, + ); + + try { + await fs.writeFile(tempFile, 'ok', { encoding: 'utf8' }); + await fs.readFile(tempFile, 'utf8'); + + return { + status: 'up', + details: { + tempDirectoryAccessible: true, + }, + }; + } catch (error) { + const message = + error instanceof Error ? error.message : 'Unknown filesystem error'; + + this.logger.warn( + 'Filesystem dependency check failed', + undefined, + 'HealthService', + { + error: message, + }, + ); + + return { + status: 'down', + details: { + tempDirectoryAccessible: false, + }, + }; + } finally { + try { + await fs.unlink(tempFile); + } catch { + // Ignore cleanup errors; the probe is best-effort for transient temp access + } + } + } + private async checkStellarRpc(): Promise { const rpcUrl = this.configService.get('STELLAR_RPC_URL');