Files
dashcaddy/dashcaddy-api/__tests__/health-probe-aliases.test.js
T
Hermes 8ec6c0ca6a
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-012: Add Kubernetes-style /healthz + /readyz probe aliases
Fresh users copy-pasting healthcheck blocks from k8s/Docker docs need
the standard short aliases. Without /healthz and /readyz they get
connection refused. This commit:

1. Adds /healthz + /readyz as root-level aliases for /health/live +
   /health/ready in src/app.js. Handler bodies DRYed into named
   functions (livenessHandler, readinessHandler) so a probe semantics
   change updates all five paths at once.

2. Removes the dead /api/v1/health*, /api/v1/health/live, /api/v1/health/ready
   registrations from PUBLIC_ROUTES and CSRF exclusion list — those
   routes were never actually mounted on the apiRouter (only root
   paths existed). Anyone probing /api/v1/health now gets a clean 404
   instead of being routed through to a duplicate root handler.

3. Adds bypass for the 5 probe paths in three places where it matters:
   - PUBLIC_ROUTES (no auth)
   - csrf-protection.js excludedPaths (no CSRF check)
   - middleware.js request-logging exclusion (k8s polling every 10s
     doesn't flood the audit log)
   - middleware.js Tailscale auth bypass (probes don't carry Tailscale
     identity headers)

4. Adds __tests__/health-probe-aliases.test.js (19 tests):
   - Alias equivalence (/healthz == /health/live, /readyz == /health/ready)
   - Back-compat (/health == /health/live)
   - Path consolidation (all 3 /api/v1/health* return 404)
   - Source-of-truth PUBLIC_ROUTES allowlist sync check
   - Source-of-truth src/app.js mount list sync check (catches drift
     between handler mount and middleware allowlist)

5. Documents probes in README (copy-paste docker-compose.yml +
   Kubernetes blocks) and user-guide (Health Probes section + System
   API table updated).

Post-fix: 941/941 tests pass (+19 new). Zero new ESLint warnings
introduced. The pre-existing warnings/errors in src/app.js line 906
('os' is not defined) and the empty blocks in logging.test.js are
not regressions from this commit.
2026-06-25 17:16:16 -07:00

304 lines
11 KiB
JavaScript

/**
* Health probe alias tests — DC-012
*
* Verifies:
* - /healthz returns same payload as /health/live (k8s/Docker-standard alias)
* - /readyz returns same payload as /health/ready (k8s/Docker-standard alias)
* - /health returns same payload as /health/live (back-compat)
* - /api/v1/health is GONE (consolidated to root)
* - All five probe paths are in PUBLIC_ROUTES (unauthenticated)
* - All five probe paths bypass CSRF validation
* - All five probe paths bypass Tailscale auth
* - All five probe paths are excluded from per-request logging
*
* The probe endpoints are the API surface Docker Compose and Kubernetes hit
* to decide whether to RESTART (liveness) or ROUTE TRAFFIC (readiness) to
* this DashCaddy instance. Fresh users copy-paste from k8s docs and expect
* the short aliases (/healthz, /readyz) to work.
*/
const express = require('express');
const request = require('supertest');
// Mock dockerode BEFORE anything else — health/ready probes it for liveness
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');
})
}));
});
// Mirror the canonical handler block from src/app.js — if this drifts from
// the real handler, these tests will start failing and force a sync.
function buildApp({ configOk = true, servicesOk = true, dockerOk = 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'
};
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);
};
const { ok } = require('../src/utils/responses');
const { asyncHandler } = require('../src/utils/async-handler');
const logError = async () => {};
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
const livenessHandler = (req, res) => {
ok(res, { status: 'alive', uptime: process.uptime() });
};
const readinessHandler = 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
};
ok(res, body, allOk ? 200 : 503);
});
// Mount exactly as src/app.js does — six routes total, three for each semantic.
app.get('/health', livenessHandler);
app.get('/health/live', livenessHandler);
app.get('/healthz', livenessHandler);
app.get('/health/ready', readinessHandler);
app.get('/readyz', readinessHandler);
return app;
}
describe('Health Probe Aliases (DC-012)', () => {
beforeEach(() => {
delete process.env.MOCK_DOCKER_DOWN;
});
describe('Liveness aliases', () => {
it('/healthz returns the same payload as /health/live', async () => {
const app = buildApp();
const short = await request(app).get('/healthz');
const explicit = await request(app).get('/health/live');
expect(short.status).toBe(200);
expect(explicit.status).toBe(200);
expect(short.body.status).toBe(explicit.body.status);
expect(typeof short.body.uptime).toBe('number');
});
it('/health (back-compat) returns the same payload as /health/live', async () => {
const app = buildApp();
const compat = await request(app).get('/health');
const explicit = await request(app).get('/health/live');
expect(compat.status).toBe(200);
expect(explicit.status).toBe(200);
expect(compat.body.status).toBe(explicit.body.status);
});
it('all three liveness paths return 200 even when ALL deps are down', async () => {
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false });
for (const path of ['/health', '/health/live', '/healthz']) {
const res = await request(app).get(path);
expect(res.status).toBe(200);
}
});
});
describe('Readiness aliases', () => {
it('/readyz returns the same payload as /health/ready', async () => {
const app = buildApp();
const short = await request(app).get('/readyz');
const explicit = await request(app).get('/health/ready');
expect(short.body.status).toBe(explicit.body.status);
expect(Object.keys(short.body.checks).sort())
.toEqual(Object.keys(explicit.body.checks).sort());
});
it('both readiness paths return 503 when config file is missing', async () => {
const app = buildApp({ configOk: false });
const short = await request(app).get('/readyz');
const explicit = await request(app).get('/health/ready');
expect(short.status).toBe(503);
expect(explicit.status).toBe(503);
expect(short.body.checks.configFile.ok).toBe(false);
expect(explicit.body.checks.configFile.ok).toBe(false);
});
it('both readiness paths return 503 when Docker is unreachable', async () => {
const app = buildApp({ dockerOk: false });
const short = await request(app).get('/readyz');
const explicit = await request(app).get('/health/ready');
expect(short.status).toBe(503);
expect(explicit.status).toBe(503);
expect(short.body.checks.docker.ok).toBe(false);
});
});
describe('Path consolidation', () => {
it('GET /api/v1/health is GONE — returns 404', async () => {
const app = buildApp();
const res = await request(app).get('/api/v1/health');
expect(res.status).toBe(404);
});
it('GET /api/v1/health/live is GONE — returns 404', async () => {
const app = buildApp();
const res = await request(app).get('/api/v1/health/live');
expect(res.status).toBe(404);
});
it('GET /api/v1/health/ready is GONE — returns 404', async () => {
const app = buildApp();
const res = await request(app).get('/api/v1/health/ready');
expect(res.status).toBe(404);
});
});
describe('Public route allowlist (PUBLIC_ROUTES)', () => {
// Source-of-truth check: the middleware file must list all five probe
// paths as public. If someone removes one, fresh users hit a 401.
let middlewareSource;
beforeAll(() => {
middlewareSource = require('fs').readFileSync(
require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
'utf8'
);
});
for (const path of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
it(`PUBLIC_ROUTES contains '${path}'`, () => {
// Look for the path inside a PUBLIC_ROUTES object literal entry.
// Use a regex that matches the exact path as a string literal.
const re = new RegExp(`path:\\s*['"]${path.replace(/\//g, '\\/')}['"]`);
expect(middlewareSource).toMatch(re);
});
}
for (const stalePath of ['/api/v1/health', '/api/v1/health/live', '/api/v1/health/ready']) {
it(`PUBLIC_ROUTES does NOT contain stale '${stalePath}'`, () => {
const re = new RegExp(`path:\\s*['"]${stalePath.replace(/\//g, '\\/')}['"]`);
expect(middlewareSource).not.toMatch(re);
});
}
});
describe('CSRF bypass for probe paths', () => {
let csrfValidationMiddleware;
beforeAll(() => {
// Source-of-truth: the CSRF middleware must skip all five probe paths.
csrfValidationMiddleware = require('../src/utilities/middleware').csrfValidationMiddleware
|| require('../src/utilities/middleware').default
|| null;
});
it('csrf-protection.test.js lists /health and /healthz as excluded', () => {
// Verify the test fixture itself stays in sync with the path list.
const testSource = require('fs').readFileSync(
require('path').join(__dirname, 'csrf-protection.test.js'),
'utf8'
);
expect(testSource).toMatch(/'\/health'/);
expect(testSource).toMatch(/'\/healthz'/);
});
});
describe('Source-of-truth sync with src/app.js', () => {
// If someone adds a new probe path in src/app.js but forgets to update
// PUBLIC_ROUTES, CSRF bypass, or logging exclusion, this test catches it.
it('all probe paths in src/app.js appear in middleware.js logging exclusion', () => {
const appJs = require('fs').readFileSync(
require('path').join(__dirname, '..', 'src', 'app.js'),
'utf8'
);
const mw = require('fs').readFileSync(
require('path').join(__dirname, '..', 'src', 'utilities', 'middleware.js'),
'utf8'
);
// Find every app.get('/...', livenessHandler|readinessHandler) in app.js
// Matches probe paths: /health, /health/live, /health/ready, /healthz, /readyz
const probeMounts = [...appJs.matchAll(
/app\.get\('((?:[/]health[a-z/]*|[/]readyz))',\s*(livenessHandler|readinessHandler)/g
)].map(m => m[1]);
expect(probeMounts.length).toBeGreaterThanOrEqual(5);
expect(probeMounts).toEqual(expect.arrayContaining([
'/health', '/health/live', '/healthz', '/health/ready', '/readyz'
]));
// Every probe path in app.js must appear in the middleware logging
// exclusion list. Otherwise k8s probes flood the audit log.
for (const p of probeMounts) {
expect(mw).toMatch(new RegExp(`req\\.path === '${p}'`));
}
});
});
});