diff --git a/src/routes/admin.ts b/src/routes/admin.ts index a692bb9..f08c0c7 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -15,6 +15,7 @@ import { } from '../services/quotaService.js'; import { createAdminWebhooksRouter } from './admin/webhooks.js'; import { createAdminApisRouter } from './admin/apis.js'; +import { createAdminHealthProbesRouter } from './admin/health/probes.js'; const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; const usageStore: UsageAdminStore = createUsageStore(); @@ -211,4 +212,11 @@ router.use('/webhooks', createAdminWebhooksRouter()); // --------------------------------------------------------------------------- router.use('/apis', createAdminApisRouter()); +// --------------------------------------------------------------------------- +// Admin health probes (per-component) +// Mounts: GET /api/admin/health/probes +// GET /api/admin/health/probes/:component +// --------------------------------------------------------------------------- +router.use('/health/probes', createAdminHealthProbesRouter()); + export default router; \ No newline at end of file diff --git a/src/routes/admin/health/probes.test.ts b/src/routes/admin/health/probes.test.ts new file mode 100644 index 0000000..a4de380 --- /dev/null +++ b/src/routes/admin/health/probes.test.ts @@ -0,0 +1,267 @@ +/** + * Tests for Admin Health Probes Endpoint. + * + * Covers: + * - GET /api/admin/health/probes (all components) + * - GET /api/admin/health/probes/:component (individual components) + * - Error handling, validation, and HTTP status codes. + */ + +jest.mock("better-sqlite3", () => { + return class MockDatabase { + prepare() { + return { get: () => null }; + } + exec() {} + close() {} + }; +}); + +import express from 'express'; +import request from 'supertest'; +import type { Pool, QueryResult } from 'pg'; +import { errorHandler } from '../../../middleware/errorHandler.js'; +import { createAdminHealthProbesRouter } from './probes.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ADMIN_KEY = 'test-admin-key'; + +function buildApp(deps = {}) { + const app = express(); + app.use(express.json()); + + // Simulate admin authentication + app.use((req, res, next) => { + if (req.headers['x-admin-api-key'] !== ADMIN_KEY) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + res.locals.adminActor = 'admin-api-key'; + next(); + }); + + app.use('/api/admin/health/probes', createAdminHealthProbesRouter(deps)); + app.use(errorHandler); + return app; +} + +function createMockPool(queryResult: QueryResult | Error): Pool { + return { + query: async () => { + if (queryResult instanceof Error) { + throw queryResult; + } + return queryResult; + }, + } as unknown as Pool; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Admin Health Probes Endpoint', () => { + let originalFetch: typeof fetch; + + beforeAll(() => { + originalFetch = global.fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('GET /api/admin/health/probes', () => { + it('returns 200 and all component details when all are healthy', async () => { + const pool = createMockPool({ rows: [{ result: 1 }] } as QueryResult); + const mockFetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ status: 'healthy' }), + })); + global.fetch = mockFetch as unknown as typeof fetch; + + const app = buildApp({ + pool, + config: { + version: '1.0.0', + database: { timeout: 1000 }, + sorobanRpc: { url: 'https://soroban-test.stellar.org', timeout: 1000 }, + horizon: { url: 'https://horizon-testnet.stellar.org', timeout: 1000 }, + }, + }); + + const res = await request(app) + .get('/api/admin/health/probes') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.body.version).toBe('1.0.0'); + expect(res.body.components.api.status).toBe('ok'); + expect(res.body.components.database.status).toBe('ok'); + expect(res.body.components.soroban_rpc.status).toBe('ok'); + expect(res.body.components.horizon.status).toBe('ok'); + }); + + it('returns 503 and down status when database is down', async () => { + const pool = createMockPool(new Error('Connection refused')); + const app = buildApp({ + pool, + config: { + version: '1.0.0', + database: { timeout: 1000 }, + }, + }); + + const res = await request(app) + .get('/api/admin/health/probes') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(503); + expect(res.body.status).toBe('down'); + expect(res.body.components.database.status).toBe('down'); + expect(res.body.components.database.error).toBe('Connection refused'); + }); + + it('returns 200 and degraded status when optional component is down', async () => { + const pool = createMockPool({ rows: [{ result: 1 }] } as QueryResult); + const mockFetch = jest.fn(async () => { + throw new Error('Network error'); + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const app = buildApp({ + pool, + config: { + version: '1.0.0', + database: { timeout: 1000 }, + sorobanRpc: { url: 'https://soroban-test.stellar.org', timeout: 1000 }, + }, + }); + + const res = await request(app) + .get('/api/admin/health/probes') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); // Degraded is 200 for overall probe check, but the components are listed + expect(res.body.status).toBe('degraded'); + expect(res.body.components.database.status).toBe('ok'); + expect(res.body.components.soroban_rpc.status).toBe('down'); + }); + + it('returns 401 when unauthorized', async () => { + const app = buildApp(); + const res = await request(app).get('/api/admin/health/probes'); + expect(res.status).toBe(401); + }); + }); + + describe('GET /api/admin/health/probes/:component', () => { + it('returns 200 for api component', async () => { + const app = buildApp(); + const res = await request(app) + .get('/api/admin/health/probes/api') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('returns 200 for database component when healthy', async () => { + const pool = createMockPool({ rows: [{ result: 1 }] } as QueryResult); + const app = buildApp({ + pool, + config: { database: { timeout: 1000 } }, + }); + + const res = await request(app) + .get('/api/admin/health/probes/database') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('returns 503 for database component when down', async () => { + const pool = createMockPool(new Error('Connection refused')); + const app = buildApp({ + pool, + config: { database: { timeout: 1000 } }, + }); + + const res = await request(app) + .get('/api/admin/health/probes/database') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(503); + expect(res.body.status).toBe('down'); + expect(res.body.error).toBe('Connection refused'); + }); + + it('returns 404 for soroban_rpc when not configured', async () => { + const app = buildApp({ + config: {}, + }); + + const res = await request(app) + .get('/api/admin/health/probes/soroban_rpc') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(404); + }); + + it('returns 200 for soroban_rpc when healthy', async () => { + const mockFetch = jest.fn(async () => ({ + ok: true, + json: async () => ({ status: 'healthy' }), + })); + global.fetch = mockFetch as unknown as typeof fetch; + + const app = buildApp({ + config: { + sorobanRpc: { url: 'https://soroban-test.stellar.org', timeout: 1000 }, + }, + }); + + const res = await request(app) + .get('/api/admin/health/probes/soroban_rpc') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('returns 503 for soroban_rpc when down', async () => { + const mockFetch = jest.fn(async () => { + throw new Error('Network error'); + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const app = buildApp({ + config: { + sorobanRpc: { url: 'https://soroban-test.stellar.org', timeout: 1000 }, + }, + }); + + const res = await request(app) + .get('/api/admin/health/probes/soroban_rpc') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(503); + expect(res.body.status).toBe('down'); + expect(res.body.error).toBe('Network error'); + }); + + it('returns 400 for an invalid component name', async () => { + const app = buildApp(); + const res = await request(app) + .get('/api/admin/health/probes/invalid_component') + .set('x-admin-api-key', ADMIN_KEY); + + expect(res.status).toBe(400); + }); + }); +}); diff --git a/src/routes/admin/health/probes.ts b/src/routes/admin/health/probes.ts new file mode 100644 index 0000000..4bae1b5 --- /dev/null +++ b/src/routes/admin/health/probes.ts @@ -0,0 +1,158 @@ +/** + * Admin Health Probes Router + * + * Provides detailed health monitoring on a per-component basis. + * Accessible only by administrators under /api/admin/health/probes. + */ + +import { Router } from 'express'; +import { z } from 'zod'; +import { pool as defaultPool } from '../../../../db.js'; +import { config as defaultConfig } from '../../../../config/index.js'; +import { + checkDatabase, + checkSorobanRpc, + checkHorizon, + determineOverallStatus, + type ComponentStatus, + type ComponentCheck, +} from '../../../../services/healthCheck.js'; +import { BadRequestError, NotFoundError, InternalServerError } from '../../../../errors/index.js'; +import { logger } from '../../../../logger.js'; +import { getClientIp } from '../../../../lib/clientIp.js'; +import { validate } from '../../../../middleware/validate.js'; + +const TRUST_PROXY = process.env.TRUST_PROXY_HEADERS === 'true'; + +export interface AdminHealthProbesDeps { + pool?: any; + config?: any; +} + +const componentParamSchema = z.object({ + component: z.enum(['api', 'database', 'soroban_rpc', 'horizon']), +}); + +/** + * Factory that returns the admin health probes sub-router. + * Mount it under the existing admin router, e.g.: + * adminRouter.use('/health/probes', createAdminHealthProbesRouter()); + */ +export function createAdminHealthProbesRouter(deps: AdminHealthProbesDeps = {}): Router { + const router = Router(); + const pool = deps.pool ?? defaultPool; + const config = deps.config ?? defaultConfig; + + // Helper to run health check for a single component + const runComponentCheck = async (component: string): Promise => { + switch (component) { + case 'api': + // API is healthy if the request reaches here + return { status: 'ok', responseTime: 0 }; + case 'database': + return await checkDatabase(pool, config.database?.timeout); + case 'soroban_rpc': + if (!config.sorobanRpc) { + throw new NotFoundError('Soroban RPC component is not configured', 'COMPONENT_NOT_CONFIGURED'); + } + return await checkSorobanRpc(config.sorobanRpc.url, config.sorobanRpc.timeout); + case 'horizon': + if (!config.horizon) { + throw new NotFoundError('Horizon component is not configured', 'COMPONENT_NOT_CONFIGURED'); + } + return await checkHorizon(config.horizon.url, config.horizon.timeout); + default: + throw new BadRequestError(`Invalid component: ${component}`); + } + }; + + // ── GET /api/admin/health/probes ───────────────────────────────────────── + /** + * Get detailed health status of all components. + */ + router.get('/', async (req, res, next) => { + try { + const components: Record = {}; + + const dbPromise = checkDatabase(pool, config.database?.timeout); + const sorobanPromise = config.sorobanRpc + ? checkSorobanRpc(config.sorobanRpc.url, config.sorobanRpc.timeout) + : Promise.resolve(undefined); + const horizonPromise = config.horizon + ? checkHorizon(config.horizon.url, config.horizon.timeout) + : Promise.resolve(undefined); + + const [dbCheck, sorobanCheck, horizonCheck] = await Promise.all([ + dbPromise, + sorobanPromise, + horizonPromise, + ]); + + components.api = { status: 'ok', responseTime: 0 }; + components.database = dbCheck; + if (config.sorobanRpc && sorobanCheck) { + components.soroban_rpc = sorobanCheck; + } + if (config.horizon && horizonCheck) { + components.horizon = horizonCheck; + } + + const statuses = { + api: components.api.status, + database: components.database.status, + ...(components.soroban_rpc && { soroban_rpc: components.soroban_rpc.status }), + ...(components.horizon && { horizon: components.horizon.status }), + }; + + const overallStatus = determineOverallStatus(statuses); + const statusCode = overallStatus === 'down' ? 503 : 200; + + logger.audit('READ_HEALTH_PROBES', res.locals.adminActor, { + clientIp: getClientIp(req, TRUST_PROXY), + userAgent: req.get('User-Agent'), + overallStatus, + }); + + res.status(statusCode).json({ + status: overallStatus, + timestamp: new Date().toISOString(), + version: config.version, + components, + }); + } catch (error) { + logger.error('Failed to perform admin health probes:', error); + next(new InternalServerError()); + } + }); + + // ── GET /api/admin/health/probes/:component ────────────────────────────── + /** + * Get detailed health status of a specific component. + */ + router.get( + '/:component', + validate({ params: componentParamSchema }), + async (req, res, next) => { + const { component } = req.params; + try { + const result = await runComponentCheck(component); + const statusCode = result.status === 'down' ? 503 : 200; + + logger.audit('READ_HEALTH_PROBE_COMPONENT', res.locals.adminActor, { + clientIp: getClientIp(req, TRUST_PROXY), + userAgent: req.get('User-Agent'), + component, + status: result.status, + }); + + res.status(statusCode).json(result); + } catch (error) { + next(error); + } + } + ); + + return router; +} + +export default createAdminHealthProbesRouter;