Two mirrored health-handler test suites (health-endpoints, health-probe-aliases) probed the Caddy admin API with raw Origin-less native fetch. On the prod host the adversarial cron runs the full jest suite every 30 min against a live Caddy admin with enforce_origin: 12 journal 403 lines per run (~700/day of 'client is not allowed to access from origin' spam) while tests stayed green. - Mirrors now call fetchT (byte-identical to src/app.js:930 probe) with fetchT jest.spyOn-mocked at buildApp scope; caddyOk-configurable in both suites - New guard test in utils-http-caddy-admin-origin.test.js: any __tests__ file pairing a raw await-fetch with a Caddy-admin token (:2019|adminUrl| CADDY_ADMIN) fails the suite — file-level pairing catches the historical cross-line drift shape a call-window regex missed - DC-087-ALLOW-RAW-FETCH comment escape hatch (raw-text marker, guard file never self-exempts, skips logged to jest output) Judge: GLM-5.3 cold read via delegate_task deleg_4d384dea (round 1 C -> round 2 B, zero blockers, polish folded). Verdict URN: urn:ump:azrv2xp72koiwi5r4yb6ureu4aqqloqq64sgmftsajh6ci2mzj2q Mutation probes: historical drift reintroduction -> guard red; hatch marker -> skipped+logged; restore -> 33/33. Full suite 2603/2603.
312 lines
12 KiB
JavaScript
312 lines
12 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, caddyOk = true } = {}) {
|
|
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
|
|
|
// DC-087 — mirror src/app.js: caddy check via fetchT (Origin-injecting),
|
|
// mocked here so the suite is hermetic. The old raw-fetch mirror probed the
|
|
// LIVE Caddy admin on :2019 whenever the full suite ran on the prod host
|
|
// (adversarial cron): Origin-less → 403 → 12 journal error lines per run.
|
|
const fetchT = jest.spyOn(require('../src/utils/http'), 'fetchT')
|
|
.mockImplementation(async () => (caddyOk
|
|
? { ok: true, status: 200 }
|
|
: { ok: false, status: 403 }));
|
|
|
|
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 {
|
|
// DC-087 — mirror src/app.js exactly: fetchT (mocked above), not raw
|
|
// fetch. Hermetic: no live request to a real Caddy admin.
|
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
|
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 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
|
|
};
|
|
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}'`));
|
|
}
|
|
});
|
|
});
|
|
});
|