The caddy.ok check in /health/ready probed /config/ (51KB) and timed out at 3s with "This operation was aborted" while Caddy admin was actually healthy. Two underlying issues: 1. Native undici fetch() rejects connections to :2019 (Caddy admin). Use fetchT() which falls back to raw http.request for the admin port. 2. /config/ is heavy and head-of-line blocks when /load is in flight. Switch to /config/apps/http/servers/srv0/listen (9 bytes) and bump timeout to 10s. Verified on DNS2 2026-07-09: direct Caddy admin curl 200 in 3ms, /health/ready was aborting at 3s. After fix: /health/ready caddy.ok true in <100ms. Caddyfile change (/etc/caddy/Caddyfile) added /dashcaddy-login to the @needsAuth not path exclude so direct hits to the auto-login landing page render the page instead of getting gate-redirected to a blank 302 — applied and reloaded via POST /load earlier this session.
199 lines
6.7 KiB
JavaScript
199 lines
6.7 KiB
JavaScript
/**
|
|
* 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 response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) });
|
|
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']);
|
|
});
|
|
});
|
|
});
|