/** * 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}'`)); } }); }); });