Bug class: silent state corruption via path-style endpoint inconsistency. Pre-fix, only POST /caddy/upstreams/mute (bare body-style) rejected unknown hosts with a 400. The path-style POST /caddy/upstreams/:host/mute and POST /caddy/upstreams/:host/unmute endpoints skipped that check entirely. An authenticated operator could POST /caddy/upstreams/phantom.test:12345/mute and caddyUpstreamWatcher.setMuted() would silently add the phantom host to its muted Set and _saveState() would persist it to disk. The phantom entry survives container restarts and pollutes the snapshot view. Fix: consolidate validation in a single validateAndMuteHost() helper used by all three mute endpoints. The helper enforces (1) host format charset, (2) length cap, (3) membership in caddyUpstreamWatcher.upstreams (the live registry populated by scanSites()). No phantom host can reach setMuted. Tests: 15 new regression tests in __tests__/routes/caddy-upstreams-dc073.routes.test.js — exercises the helper directly (unit) and via each endpoint (integration), asserts rejection happens BEFORE setMuted is called (no state corruption), and the existing 3 caddy-upstreams.routes.test.js cases still pass. Router introspection test asserts no duplicate route registrations. Full suite: 2342/2342 tests / 101 suites.
273 lines
12 KiB
JavaScript
273 lines
12 KiB
JavaScript
/**
|
|
* DC-073: regression tests for the caddy-upstreams mute endpoints.
|
|
*
|
|
* Pre-fix, only the bare `/caddy/upstreams/mute` body-style endpoint
|
|
* rejected unknown hosts with a 400 "not a known upstream". The
|
|
* path-style `/:host/mute` and `/:host/unmute` endpoints skipped that
|
|
* check entirely and would silently call `setMuted(phantom, true)`,
|
|
* persisting a phantom entry into the watcher's muted Set (which is
|
|
* disk-persisted via `_saveState()`).
|
|
*
|
|
* These tests prove:
|
|
* (1) every endpoint now rejects an unknown host with 400
|
|
* (2) the rejection happens BEFORE setMuted is invoked (no state
|
|
* corruption — `fakeWatcher.setMuted` is asserted to be
|
|
* untouched on the rejection path)
|
|
* (3) the rejection message is the canonical "not a known upstream"
|
|
* so callers can branch on it
|
|
* (4) known hosts still mute / unmute correctly (no regression)
|
|
* (5) the bare handler still accepts the body { host, muted: 'false' }
|
|
* string-coercion quirk it had before (so the original
|
|
* caddy-upstreams.routes.test.js suite keeps passing)
|
|
*
|
|
* @module __tests__/routes/caddy-upstreams-dc073
|
|
*/
|
|
|
|
const express = require('express');
|
|
const { validateAndMuteHost } = require('../../routes/caddy-upstreams').__test;
|
|
|
|
function buildRouter(deps) {
|
|
const mod = require('../../routes/caddy-upstreams');
|
|
return mod(deps);
|
|
}
|
|
|
|
function buildApp(mod_deps) {
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use((req, res, next) => {
|
|
res.success = (data) => res.json({ success: true, ...data });
|
|
res.errorResponse = (msg, code) => res.status(code || 500).json({ success: false, error: msg });
|
|
next();
|
|
});
|
|
app.use(buildRouter({
|
|
asyncHandler: (fn, _ctx) => async (req, res, next) => {
|
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
},
|
|
...mod_deps,
|
|
}));
|
|
// Error middleware MUST be registered AFTER routes so it actually catches.
|
|
app.use((err, req, res, next) => {
|
|
if (err && err.statusCode === 400) {
|
|
return res.status(400).json({ success: false, error: err.message });
|
|
}
|
|
return res.status(err?.statusCode || 500).json({ success: false, error: err?.message || 'unknown' });
|
|
});
|
|
return app;
|
|
}
|
|
|
|
function makeKnownWatcher(known = ['known.svc.example:80', '1.1.1.1:80']) {
|
|
const upstreams = new Map(known.map(h => [h, { host: h }]));
|
|
return {
|
|
upstreams,
|
|
setMuted: jest.fn((host, muted) => ({ host, muted: !!muted })),
|
|
snapshot: jest.fn(() => ({ upstreams: [], config: {} })),
|
|
};
|
|
}
|
|
|
|
describe('routes/caddy-upstreams — DC-073 phantom-mute regression', () => {
|
|
describe('validateAndMuteHost helper (unit)', () => {
|
|
test('rejects empty / non-string host', () => {
|
|
const w = makeKnownWatcher();
|
|
expect(() => validateAndMuteHost(w, '', true)).toThrow(/non-empty string/);
|
|
expect(() => validateAndMuteHost(w, null, true)).toThrow(/non-empty string/);
|
|
expect(() => validateAndMuteHost(w, undefined, true)).toThrow(/non-empty string/);
|
|
expect(() => validateAndMuteHost(w, 12345, true)).toThrow(/non-empty string/);
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects host longer than 253 chars', () => {
|
|
const w = makeKnownWatcher();
|
|
const long = 'a'.repeat(254);
|
|
expect(() => validateAndMuteHost(w, long, true)).toThrow(/non-empty string/);
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects host with charset-violating chars', () => {
|
|
const w = makeKnownWatcher();
|
|
for (const bad of ['host name', 'host?', 'host/abc', 'host;rm', 'host${x}', 'host<>']) {
|
|
expect(() => validateAndMuteHost(w, bad, true)).toThrow(/valid host/);
|
|
}
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('rejects host not in watcher.upstreams (phantom-mute vector)', () => {
|
|
const w = makeKnownWatcher(['known:80']);
|
|
// This is the regression: pre-fix, this call would have
|
|
// silently added 'phantom.test:12345' to watcher.muted.
|
|
expect(() => validateAndMuteHost(w, 'phantom.test:12345', true))
|
|
.toThrow(/not a known upstream/);
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('accepts a known host and forwards setMuted(host, wantMuted)', () => {
|
|
const w = makeKnownWatcher(['known:80']);
|
|
const result = validateAndMuteHost(w, 'known:80', true);
|
|
expect(w.setMuted).toHaveBeenCalledWith('known:80', true);
|
|
expect(result).toEqual({ host: 'known:80', muted: true });
|
|
|
|
w.setMuted.mockClear();
|
|
const result2 = validateAndMuteHost(w, 'known:80', false);
|
|
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
|
expect(result2).toEqual({ host: 'known:80', muted: false });
|
|
});
|
|
|
|
test('handles missing watcher / upstreams map (defensive)', () => {
|
|
expect(() => validateAndMuteHost(null, 'x:80', true)).toThrow(/not a known upstream/);
|
|
expect(() => validateAndMuteHost({}, 'x:80', true)).toThrow(/not a known upstream/);
|
|
expect(() => validateAndMuteHost({ upstreams: null }, 'x:80', true)).toThrow(/not a known upstream/);
|
|
});
|
|
});
|
|
|
|
describe('POST /caddy/upstreams/mute (bare body-style)', () => {
|
|
test('rejects unknown host with 400 (was already correct, regression-proof)', async () => {
|
|
const w = makeKnownWatcher(['known:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ host: 'phantom:12345' }),
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(res.status).toBe(400);
|
|
expect(body.error).toMatch(/not a known upstream/);
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('muted: "false" string still coerces to unmute (regression from caddy-upstreams.routes.test.js)', async () => {
|
|
const w = makeKnownWatcher(['known:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/mute`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ host: 'known:80', muted: 'false' }),
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(body.success).toBe(true);
|
|
expect(w.setMuted).toHaveBeenCalledWith('known:80', false);
|
|
});
|
|
});
|
|
|
|
describe('POST /caddy/upstreams/:host/mute (path-style) — DC-073 main fix', () => {
|
|
test('rejects unknown host with 400 instead of silent phantom-mute', async () => {
|
|
const w = makeKnownWatcher(['known:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
// Pre-fix this would have silently added 'phantom.test:12345' to
|
|
// the watcher's muted Set and called _saveState(). Post-fix it
|
|
// returns 400 and never touches the watcher.
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/mute`, {
|
|
method: 'POST',
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(res.status).toBe(400);
|
|
expect(body.error).toMatch(/not a known upstream/);
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('mutes a known host via bare POST (no body)', async () => {
|
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
|
method: 'POST',
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(res.status).toBe(200);
|
|
expect(body.success).toBe(true);
|
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
|
});
|
|
|
|
test('mutes via ?muted=true query', async () => {
|
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute?muted=true`, {
|
|
method: 'POST',
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', true);
|
|
expect(body.success).toBe(true);
|
|
});
|
|
|
|
test('unmutes via body { muted: false }', async () => {
|
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/mute`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ muted: false }),
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
|
expect(body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST /caddy/upstreams/:host/unmute (path-style) — DC-073 main fix', () => {
|
|
test('rejects unknown host with 400 instead of silent phantom-unmute', async () => {
|
|
const w = makeKnownWatcher(['known:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/phantom.test:12345/unmute`, {
|
|
method: 'POST',
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(res.status).toBe(400);
|
|
expect(body.error).toMatch(/not a known upstream/);
|
|
expect(w.setMuted).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('unmutes a known host', async () => {
|
|
const w = makeKnownWatcher(['known.svc.example:80']);
|
|
const app = buildApp({ caddyUpstreamWatcher: w, healthChecker: { incidents: [] } });
|
|
const server = app.listen(0);
|
|
const { port } = server.address();
|
|
const res = await fetch(`http://127.0.0.1:${port}/caddy/upstreams/known.svc.example:80/unmute`, {
|
|
method: 'POST',
|
|
});
|
|
const body = await res.json();
|
|
server.close();
|
|
expect(res.status).toBe(200);
|
|
expect(w.setMuted).toHaveBeenCalledWith('known.svc.example:80', false);
|
|
expect(body.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('router introspection (DC-057-style mount-count assertion)', () => {
|
|
test('exactly one POST handler per (method,path) — no duplicate registration', () => {
|
|
const w = makeKnownWatcher();
|
|
const router = buildRouter({
|
|
asyncHandler: (fn) => fn,
|
|
caddyUpstreamWatcher: w,
|
|
healthChecker: { incidents: [] },
|
|
});
|
|
const sigs = router.stack
|
|
.filter((l) => l.route)
|
|
.map((l) => Object.keys(l.route.methods).map((m) => `${m.toUpperCase()} ${l.route.path}`))
|
|
.flat();
|
|
// Each (method,path) should appear exactly once
|
|
const counts = sigs.reduce((m, s) => (m[s] = (m[s] || 0) + 1, m), {});
|
|
for (const [sig, n] of Object.entries(counts)) {
|
|
expect({ sig, n }).toEqual({ sig, n: 1 });
|
|
}
|
|
});
|
|
});
|
|
});
|