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