feat(phase-2): workstream 6 — Web Dashboard UI

- 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>
This commit is contained in:
SentryAgent.ai Developer
2026-03-28 23:19:18 +00:00
parent 7328a61c44
commit 7d6e248a14
32 changed files with 4858 additions and 13 deletions

View File

@@ -0,0 +1,150 @@
/**
* 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',
},
});
});
});
});