- dashboard/: Vite 5 + React 18 + TypeScript strict SPA
- Auth: sessionStorage credentials, TokenManager validation, AuthProvider context
- Pages: Login, Agents (search + filter), AgentDetail (suspend/reactivate),
Credentials (generate/rotate/revoke, new secret shown once),
AuditLog (filters + pagination), Health (PG + Redis status, 30s refresh)
- Components: Button, Badge, ConfirmDialog, AppShell, RequireAuth
- All destructive actions gated by ConfirmDialog
- Zero dangerouslySetInnerHTML; sessionStorage only (OWASP compliant)
- src/routes/health.ts: unauthenticated GET /health — PG + Redis connectivity
- src/app.ts: health route + dashboard/dist/ served at /dashboard with SPA fallback
- 6 new health route tests; 308/308 unit tests passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
/**
|
|
* Unit tests for src/routes/health.ts
|
|
*
|
|
* Tests the GET /health endpoint via the createHealthRouter factory.
|
|
* PostgreSQL and Redis dependencies are fully mocked — no live services required.
|
|
*/
|
|
|
|
import express, { Application } from 'express';
|
|
import request from 'supertest';
|
|
import { Pool, PoolClient } from 'pg';
|
|
import { RedisClientType } from 'redis';
|
|
import { createHealthRouter } from '../../../src/routes/health';
|
|
|
|
// ── Mock helpers ──────────────────────────────────────────────────────────────
|
|
|
|
/** Builds a mock pg PoolClient with controllable query/release. */
|
|
function makePoolClient(queryError?: Error): jest.Mocked<Pick<PoolClient, 'query' | 'release'>> {
|
|
return {
|
|
query: queryError
|
|
? jest.fn().mockRejectedValue(queryError)
|
|
: jest.fn().mockResolvedValue({ rows: [{ '?column?': 1 }], rowCount: 1 }),
|
|
release: jest.fn(),
|
|
} as unknown as jest.Mocked<Pick<PoolClient, 'query' | 'release'>>;
|
|
}
|
|
|
|
/** Builds a mock pg Pool whose connect() resolves or rejects on demand. */
|
|
function makePool(connectError?: Error, queryError?: Error): jest.Mocked<Pool> {
|
|
return {
|
|
connect: connectError
|
|
? jest.fn().mockRejectedValue(connectError)
|
|
: jest.fn().mockResolvedValue(makePoolClient(queryError)),
|
|
} as unknown as jest.Mocked<Pool>;
|
|
}
|
|
|
|
/** Builds a mock Redis client whose ping() resolves or rejects on demand. */
|
|
function makeRedis(pingError?: Error): jest.Mocked<RedisClientType> {
|
|
return {
|
|
ping: pingError
|
|
? jest.fn().mockRejectedValue(pingError)
|
|
: jest.fn().mockResolvedValue('PONG'),
|
|
} as unknown as jest.Mocked<RedisClientType>;
|
|
}
|
|
|
|
/** Creates a minimal Express app with the health router mounted at /health. */
|
|
function buildApp(pool: jest.Mocked<Pool>, redis: jest.Mocked<RedisClientType>): Application {
|
|
const app = express();
|
|
app.use('/health', createHealthRouter(pool as unknown as Pool, redis as unknown as RedisClientType));
|
|
return app;
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('GET /health', () => {
|
|
describe('when both PostgreSQL and Redis are healthy', () => {
|
|
it('returns 200 with status ok and both services connected', async () => {
|
|
const app = buildApp(makePool(), makeRedis());
|
|
|
|
const response = await request(app).get('/health');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toMatchObject({
|
|
status: 'ok',
|
|
services: {
|
|
postgres: 'connected',
|
|
redis: 'connected',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('includes version and uptime fields in the response', async () => {
|
|
const app = buildApp(makePool(), makeRedis());
|
|
|
|
const response = await request(app).get('/health');
|
|
|
|
expect(typeof response.body.version).toBe('string');
|
|
expect(typeof response.body.uptime).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('when PostgreSQL connect() throws', () => {
|
|
it('returns 503 with status degraded and postgres disconnected, redis connected', async () => {
|
|
const pool = makePool(new Error('PG connection refused'));
|
|
const app = buildApp(pool, makeRedis());
|
|
|
|
const response = await request(app).get('/health');
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body).toMatchObject({
|
|
status: 'degraded',
|
|
services: {
|
|
postgres: 'disconnected',
|
|
redis: 'connected',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when Redis ping() throws', () => {
|
|
it('returns 503 with status degraded and postgres connected, redis disconnected', async () => {
|
|
const app = buildApp(makePool(), makeRedis(new Error('Redis ECONNREFUSED')));
|
|
|
|
const response = await request(app).get('/health');
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body).toMatchObject({
|
|
status: 'degraded',
|
|
services: {
|
|
postgres: 'connected',
|
|
redis: 'disconnected',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when both PostgreSQL and Redis fail', () => {
|
|
it('returns 503 with status degraded and both services disconnected', async () => {
|
|
const pool = makePool(new Error('PG down'));
|
|
const app = buildApp(pool, makeRedis(new Error('Redis down')));
|
|
|
|
const response = await request(app).get('/health');
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body).toMatchObject({
|
|
status: 'degraded',
|
|
services: {
|
|
postgres: 'disconnected',
|
|
redis: 'disconnected',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('when PostgreSQL query() throws (connect succeeds but query fails)', () => {
|
|
it('returns 503 with postgres disconnected', async () => {
|
|
const pool = makePool(undefined, new Error('PG query error'));
|
|
const app = buildApp(pool, makeRedis());
|
|
|
|
const response = await request(app).get('/health');
|
|
|
|
expect(response.status).toBe(503);
|
|
expect(response.body).toMatchObject({
|
|
status: 'degraded',
|
|
services: {
|
|
postgres: 'disconnected',
|
|
redis: 'connected',
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|