const express = require('express'); const request = require('supertest'); // Minimal asyncHandler that catches errors function asyncHandler(fn) { return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); } function createApp(depsOverride = {}) { const defaultDeps = { fetchT: jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }), SERVICES_FILE: '/tmp/services.json', servicesStateManager: { read: jest.fn().mockResolvedValue([]), write: jest.fn().mockResolvedValue(), update: jest.fn().mockResolvedValue([]), }, siteConfig: { tld: 'sami' }, buildServiceUrl: jest.fn(id => `https://${id}.sami`), asyncHandler, logError: jest.fn(), healthChecker: { getCurrentStatus: jest.fn().mockReturnValue({}), getServiceStats: jest.fn().mockReturnValue(null), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([]), getIncidentHistory: jest.fn().mockReturnValue([]), }, }; const deps = { ...defaultDeps, ...depsOverride }; const healthRoutes = require('../../routes/health'); const app = express(); app.use(express.json()); app.use('/api', healthRoutes(deps)); // Simple error handler app.use((err, req, res, next) => { const status = err.statusCode || 500; res.status(status).json({ success: false, error: err.message }); }); return { app, deps }; } jest.mock('child_process', () => ({ execSync: jest.fn(), })); jest.mock('../../platform-paths', () => ({ caCertDir: '/mock/ca', pkiRootCert: '/mock/pki/root.crt', })); // Mock fs-helpers.exists jest.mock('../../fs-helpers', () => ({ exists: jest.fn().mockResolvedValue(true), })); jest.mock('../../url-resolver', () => ({ resolveServiceUrl: jest.fn((id) => `https://${id}.test`), })); jest.mock('../../pagination', () => ({ paginate: jest.fn((data, params) => ({ data, pagination: null })), parsePaginationParams: jest.fn(() => null), })); const { exists } = require('../../fs-helpers'); const { resolveServiceUrl } = require('../../url-resolver'); const { execSync } = require('child_process'); describe('Health Routes', () => { beforeEach(() => { jest.clearAllMocks(); exists.mockResolvedValue(true); }); describe('GET /api/health/cached', () => { it('returns cached health data with 200', async () => { const { app } = createApp(); const res = await request(app).get('/api/health/cached'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body).toHaveProperty('health'); expect(res.body).toHaveProperty('lastCheck'); }); }); describe('GET /api/health/services', () => { it('returns empty health when no services file', async () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.health).toEqual({}); }); it('returns health for each service', async () => { const stateManager = { read: jest.fn().mockResolvedValue([ { id: 'plex', name: 'Plex' }, { id: 'radarr', name: 'Radarr' }, ]), }; const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body).toHaveProperty('checkedAt'); }); }); describe('GET /api/health/service/:id', () => { it('returns 404 when services file missing', async () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).get('/api/health/service/plex'); expect(res.status).toBe(404); }); it('returns 404 when service not found', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'radarr', name: 'Radarr' }]), }; const { app } = createApp({ servicesStateManager: stateManager }); const res = await request(app).get('/api/health/service/nonexistent'); expect(res.status).toBe(404); }); it('returns health for existing service', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), }; const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, }); const res = await request(app).get('/api/health/service/plex'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.serviceId).toBe('plex'); }); }); describe('GET /api/health/pylon', () => { it('returns configured:false when no pylon', async () => { const { app } = createApp({ siteConfig: {} }); const res = await request(app).get('/api/health/pylon'); expect(res.status).toBe(200); expect(res.body.configured).toBe(false); }); it('returns reachable:true when pylon responds', async () => { const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({ status: 'ok' }) }); const { app } = createApp({ siteConfig: { pylon: { url: 'http://pylon.test' } }, fetchT, }); const res = await request(app).get('/api/health/pylon'); expect(res.status).toBe(200); expect(res.body.configured).toBe(true); expect(res.body.reachable).toBe(true); }); it('returns reachable:false when pylon errors', async () => { const fetchT = jest.fn().mockRejectedValue(new Error('Connection refused')); const { app } = createApp({ siteConfig: { pylon: { url: 'http://pylon.test' } }, fetchT, }); const res = await request(app).get('/api/health/pylon'); expect(res.status).toBe(200); expect(res.body.configured).toBe(true); expect(res.body.reachable).toBe(false); }); }); describe('GET /api/health-checks/status', () => { it('returns current health checker status', async () => { const healthChecker = { getCurrentStatus: jest.fn().mockReturnValue({ svc1: { status: 'up', responseTime: 100 } }), getServiceStats: jest.fn(), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([]), getIncidentHistory: jest.fn().mockReturnValue([]), }; const { app } = createApp({ healthChecker }); const res = await request(app).get('/api/health-checks/status'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.status.svc1.status).toBe('up'); }); }); describe('GET /api/health-checks/:serviceId/stats', () => { it('returns 404 when service not found', async () => { const { app } = createApp(); const res = await request(app).get('/api/health-checks/unknown/stats'); expect(res.status).toBe(404); }); it('returns stats when service exists', async () => { const healthChecker = { getCurrentStatus: jest.fn().mockReturnValue({}), getServiceStats: jest.fn().mockReturnValue({ totalChecks: 100, uptime: 99.5 }), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([]), getIncidentHistory: jest.fn().mockReturnValue([]), }; const { app } = createApp({ healthChecker }); const res = await request(app).get('/api/health-checks/svc1/stats'); expect(res.status).toBe(200); expect(res.body.stats.uptime).toBe(99.5); }); }); describe('POST /api/health-checks/:serviceId/configure', () => { it('configures health check for service', async () => { const healthChecker = { getCurrentStatus: jest.fn().mockReturnValue({}), getServiceStats: jest.fn(), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([]), getIncidentHistory: jest.fn().mockReturnValue([]), }; const { app } = createApp({ healthChecker }); const res = await request(app) .post('/api/health-checks/svc1/configure') .send({ url: 'http://test.local', timeout: 5000 }); expect(res.status).toBe(200); expect(healthChecker.configureService).toHaveBeenCalledWith('svc1', expect.objectContaining({ url: 'http://test.local' })); }); }); describe('DELETE /api/health-checks/:serviceId/configure', () => { it('removes health check configuration', async () => { const healthChecker = { getCurrentStatus: jest.fn().mockReturnValue({}), getServiceStats: jest.fn(), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([]), getIncidentHistory: jest.fn().mockReturnValue([]), }; const { app } = createApp({ healthChecker }); const res = await request(app).delete('/api/health-checks/svc1/configure'); expect(res.status).toBe(200); expect(healthChecker.removeService).toHaveBeenCalledWith('svc1'); }); }); describe('GET /api/health-checks/incidents', () => { it('returns open incidents', async () => { const healthChecker = { getCurrentStatus: jest.fn().mockReturnValue({}), getServiceStats: jest.fn(), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([ { id: 'inc-1', serviceId: 'svc1', type: 'outage', status: 'open' } ]), getIncidentHistory: jest.fn().mockReturnValue([]), }; const { app } = createApp({ healthChecker }); const res = await request(app).get('/api/health-checks/incidents'); expect(res.status).toBe(200); expect(res.body.incidents).toHaveLength(1); expect(res.body.incidents[0].type).toBe('outage'); }); }); // ===== NEW TESTS FOR DEEPER COVERAGE ===== describe('GET /api/health/services (deeper scenarios)', () => { it('falls back to pylon when direct check fails', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'myapp', name: 'MyApp' }]), }; // HEAD fails, GET fails, pylon succeeds const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) // HEAD in checkDirect .mockRejectedValueOnce(new Error('GET failed')) // GET fallback in checkDirect .mockResolvedValueOnce({ // pylon probe call ok: true, status: 200, json: () => ({ status: 'healthy', statusCode: 200, responseTime: 42 }), }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, siteConfig: { pylon: { url: 'http://pylon.test' } }, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.health.myapp).toBeDefined(); expect(res.body.health.myapp.via).toBe('pylon'); }); it('returns unhealthy when both direct and pylon fail', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'deadapp', name: 'DeadApp' }]), }; const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) .mockRejectedValueOnce(new Error('GET failed')) .mockRejectedValueOnce(new Error('pylon failed')); const { app } = createApp({ servicesStateManager: stateManager, fetchT, siteConfig: { pylon: { url: 'http://pylon.test' } }, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.health.deadapp.status).toBe('unhealthy'); expect(res.body.health.deadapp.reason).toMatch(/direct \+ pylon/); }); it('returns unhealthy with "fetch failed" when direct fails and no pylon configured', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'deadapp', name: 'DeadApp' }]), }; const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) .mockRejectedValueOnce(new Error('GET failed')); const { app } = createApp({ servicesStateManager: stateManager, fetchT, siteConfig: {}, // no pylon }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.health.deadapp.status).toBe('unhealthy'); expect(res.body.health.deadapp.reason).toBe('fetch failed'); }); it('skips services without id or name', async () => { const stateManager = { read: jest.fn().mockResolvedValue([ { id: 'valid', name: 'Valid' }, { url: 'http://no-id-or-name.test' }, // no id, no name ]), }; const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); // Only the valid service should appear expect(Object.keys(res.body.health)).toEqual(['valid']); }); it('returns unknown status when no URL configured for service', async () => { resolveServiceUrl.mockReturnValueOnce(null); const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'nourl', name: 'NoUrl' }]), }; const { app } = createApp({ servicesStateManager: stateManager, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.health.nourl.status).toBe('unknown'); expect(res.body.health.nourl.reason).toBe('No URL configured'); }); it('returns error status when exception occurs during check', async () => { // resolveServiceUrl throws an error resolveServiceUrl.mockImplementationOnce(() => { throw new Error('resolve boom'); }); const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'boom', name: 'Boom' }]), }; const { app } = createApp({ servicesStateManager: stateManager, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.health.boom.status).toBe('error'); expect(res.body.health.boom.reason).toBe('resolve boom'); }); it('handles servicesData as object with .services property', async () => { const stateManager = { read: jest.fn().mockResolvedValue({ services: [{ id: 'wrapped', name: 'Wrapped' }], }), }; const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({}) }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.health.wrapped).toBeDefined(); expect(res.body.health.wrapped.status).toBe('healthy'); }); it('reports unhealthy when server returns 500+', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'err500', name: 'Err500' }]), }; const fetchT = jest.fn().mockResolvedValue({ ok: false, status: 502, json: () => ({}) }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, }); const res = await request(app).get('/api/health/services'); expect(res.status).toBe(200); expect(res.body.health.err500.status).toBe('unhealthy'); expect(res.body.health.err500.statusCode).toBe(502); }); }); describe('GET /api/health/service/:id (pylon fallback)', () => { it('falls back to pylon when direct fails', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), }; const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) .mockRejectedValueOnce(new Error('GET failed')) .mockResolvedValueOnce({ ok: true, status: 200, json: () => ({ status: 'healthy', statusCode: 200, responseTime: 55 }), }); const { app } = createApp({ servicesStateManager: stateManager, fetchT, siteConfig: { pylon: { url: 'http://pylon.test', key: 'secret123' } }, }); const res = await request(app).get('/api/health/service/plex'); expect(res.status).toBe(200); expect(res.body.health.via).toBe('pylon'); expect(res.body.health.status).toBe('healthy'); // Verify pylon key header was sent const pylonCall = fetchT.mock.calls[2]; expect(pylonCall[1].headers['x-pylon-key']).toBe('secret123'); }); it('returns unhealthy when both direct and pylon fail', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), }; const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) .mockRejectedValueOnce(new Error('GET failed')) .mockRejectedValueOnce(new Error('pylon failed')); const { app } = createApp({ servicesStateManager: stateManager, fetchT, siteConfig: { pylon: { url: 'http://pylon.test' } }, }); const res = await request(app).get('/api/health/service/plex'); expect(res.status).toBe(200); expect(res.body.health.status).toBe('unhealthy'); expect(res.body.health.reason).toMatch(/direct \+ pylon/); }); it('returns unhealthy with "fetch failed" when direct fails and no pylon', async () => { const stateManager = { read: jest.fn().mockResolvedValue([{ id: 'plex', name: 'Plex' }]), }; const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) .mockRejectedValueOnce(new Error('GET failed')); const { app } = createApp({ servicesStateManager: stateManager, fetchT, siteConfig: {}, // no pylon }); const res = await request(app).get('/api/health/service/plex'); expect(res.status).toBe(200); expect(res.body.health.status).toBe('unhealthy'); expect(res.body.health.reason).toBe('fetch failed'); }); }); describe('GET /api/health/probe', () => { it('returns health result when url provided and direct check succeeds', async () => { const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200 }); const { app } = createApp({ fetchT }); const res = await request(app).get('/api/health/probe?url=http://example.com'); expect(res.status).toBe(200); expect(res.body.status).toBe('healthy'); expect(res.body.statusCode).toBe(200); expect(res.body.url).toBe('http://example.com'); }); it('returns unhealthy when direct check completely fails', async () => { const fetchT = jest.fn() .mockRejectedValueOnce(new Error('HEAD failed')) .mockRejectedValueOnce(new Error('GET failed')); const { app } = createApp({ fetchT }); const res = await request(app).get('/api/health/probe?url=http://dead.test'); expect(res.status).toBe(200); expect(res.body.status).toBe('unhealthy'); expect(res.body.reason).toBe('fetch failed'); expect(res.body.url).toBe('http://dead.test'); }); it('returns error when no url parameter provided', async () => { const { app } = createApp(); const res = await request(app).get('/api/health/probe'); // ValidationError is not imported at module scope, so this throws a ReferenceError // which the error handler catches as a 500 expect(res.status).toBe(500); }); }); describe('GET /api/health/ca', () => { it('returns healthy when cert has >90 days remaining', async () => { exists.mockResolvedValue(true); const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 365); const dateStr = futureDate.toUTCString(); execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('healthy'); expect(res.body.daysUntilExpiration).toBeGreaterThan(90); }); it('returns warning when cert has 30-90 days remaining', async () => { exists.mockResolvedValue(true); const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 60); const dateStr = futureDate.toUTCString(); execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('warning'); expect(res.body.daysUntilExpiration).toBeLessThan(90); expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30); }); it('returns critical when cert has <30 days remaining', async () => { exists.mockResolvedValue(true); const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 15); const dateStr = futureDate.toUTCString(); execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('critical'); expect(res.body.daysUntilExpiration).toBeLessThan(30); expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0); }); it('returns critical when cert has <7 days remaining', async () => { exists.mockResolvedValue(true); const futureDate = new Date(); futureDate.setDate(futureDate.getDate() + 3); const dateStr = futureDate.toUTCString(); execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('critical'); expect(res.body.daysUntilExpiration).toBeLessThan(7); }); it('returns critical when cert is expired', async () => { exists.mockResolvedValue(true); const pastDate = new Date(); pastDate.setDate(pastDate.getDate() - 10); const dateStr = pastDate.toUTCString(); execSync.mockReturnValue(`notBefore=Jan 1 00:00:00 2024 GMT\nnotAfter=${dateStr}`); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('critical'); expect(res.body.daysUntilExpiration).toBeLessThan(0); expect(res.body.message).toMatch(/EXPIRED/); }); it('returns error when cert file not found', async () => { exists.mockResolvedValue(false); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('error'); expect(res.body.message).toMatch(/not found/); expect(res.body.daysUntilExpiration).toBeNull(); }); it('returns error when execSync throws', async () => { exists.mockResolvedValue(true); execSync.mockImplementation(() => { throw new Error('openssl not found'); }); const { app } = createApp(); const res = await request(app).get('/api/health/ca'); expect(res.status).toBe(200); expect(res.body.status).toBe('error'); expect(res.body.message).toBe('openssl not found'); expect(res.body.daysUntilExpiration).toBeNull(); }); }); describe('GET /api/health-checks/incidents/history', () => { it('returns incident history', async () => { const healthChecker = { getCurrentStatus: jest.fn().mockReturnValue({}), getServiceStats: jest.fn(), configureService: jest.fn(), removeService: jest.fn(), getOpenIncidents: jest.fn().mockReturnValue([]), getIncidentHistory: jest.fn().mockReturnValue([ { id: 'inc-1', serviceId: 'svc1', type: 'outage', resolvedAt: '2025-01-01T00:00:00Z' }, { id: 'inc-2', serviceId: 'svc2', type: 'degraded', resolvedAt: '2025-01-02T00:00:00Z' }, ]), }; const { app } = createApp({ healthChecker }); const res = await request(app).get('/api/health-checks/incidents/history'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body.history).toHaveLength(2); expect(res.body.history[0].id).toBe('inc-1'); expect(res.body.history[1].type).toBe('degraded'); }); }); describe('GET /api/health/pylon (with key)', () => { it('sends x-pylon-key header when key is configured', async () => { const fetchT = jest.fn().mockResolvedValue({ ok: true, status: 200, json: () => ({ status: 'ok' }), }); const { app } = createApp({ siteConfig: { pylon: { url: 'http://pylon.test', key: 'my-secret-key' } }, fetchT, }); const res = await request(app).get('/api/health/pylon'); expect(res.status).toBe(200); expect(res.body.configured).toBe(true); expect(res.body.reachable).toBe(true); // Verify the x-pylon-key header was sent const fetchCall = fetchT.mock.calls[0]; expect(fetchCall[1].headers['x-pylon-key']).toBe('my-secret-key'); }); }); });