From f2285a255073bd3af5248e3bc972aca8225e4fed Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 22 Aug 2026 15:14:25 -0700 Subject: [PATCH] test(api): DC-087 hermetic caddy-admin health mirrors + file-level raw-fetch guard [glm-grade=B] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/health-endpoints.test.js | 19 +++++- .../__tests__/health-probe-aliases.test.js | 15 ++++- .../utils-http-caddy-admin-origin.test.js | 67 ++++++++++++++++++- 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/dashcaddy-api/__tests__/health-endpoints.test.js b/dashcaddy-api/__tests__/health-endpoints.test.js index e8c2c48..03cdccb 100644 --- a/dashcaddy-api/__tests__/health-endpoints.test.js +++ b/dashcaddy-api/__tests__/health-endpoints.test.js @@ -26,6 +26,19 @@ jest.mock('dockerode', () => { function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) { process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1'; + // DC-087 — mirror src/app.js faithfully: the caddy check goes through + // fetchT (which injects the Origin header Caddy's enforce_origin allowlist + // requires), and is MOCKED so the suite is hermetic — no live request to a + // real Caddy admin on :2019. The previous raw-`fetch` mirror sent an + // Origin-less probe to the LIVE admin whenever the full suite ran on the + // prod host (adversarial cron every 30 min): 12 journal 403 lines per run, + // ~700/day of `client is not allowed to access from origin ''` noise, + // plus a false checks.caddy.ok=false in the mirrored readiness payload. + 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', @@ -103,9 +116,13 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk allOk = false; } + // DC-087 — mirror src/app.js exactly (fetchT, not raw fetch). fetchT is + // mocked at buildApp() scope, so this stays hermetic: no live probe to a + // real Caddy admin (the old raw-fetch mirror 403-spammed the prod journal + // every time the adversarial cron ran the full suite on this host). try { const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; - const response = await fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) }); + 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) { diff --git a/dashcaddy-api/__tests__/health-probe-aliases.test.js b/dashcaddy-api/__tests__/health-probe-aliases.test.js index bc0d263..d185000 100644 --- a/dashcaddy-api/__tests__/health-probe-aliases.test.js +++ b/dashcaddy-api/__tests__/health-probe-aliases.test.js @@ -33,9 +33,18 @@ jest.mock('dockerode', () => { // 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 } = {}) { +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', @@ -108,8 +117,10 @@ function buildApp({ configOk = true, servicesOk = true, dockerOk = true } = {}) 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 fetch(`${caddyUrl}/config/apps/http/servers/srv0/listen`, { signal: AbortSignal.timeout(10000) }); + 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) { diff --git a/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js b/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js index a7f2748..01bcd7e 100644 --- a/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js +++ b/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js @@ -118,6 +118,67 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () expect(offenders).toEqual([]); }); + test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => { + // DC-087 — the same rule, extended into __tests__. The api-code walk above + // skips __tests__, which let two mirrored health-handler test files keep a + // raw await-fetch caddy probe long after src/app.js moved to fetchT. On a + // host where the suite runs alongside a live Caddy admin (the prod box + // runs the full jest suite every 30 min via a cron adversarial check), + // that Origin-less raw fetch 403-spammed the Caddy journal (~700 + // client-not-allowed error lines per day) while the tests still passed — + // checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors + // MUST call fetchT (mocked at buildApp scope for hermeticity). A raw + // await-fetch at a Caddy-admin-URL call site in a test is an offender. + // NOTE: keep this comment free of backticks — stripComments pairs + // backtick spans across lines, and a stray pair shields real code from + // the comment stripper (this test self-flagged its first draft). + // + // Detection is deliberately FILE-LEVEL, not call-window: the historical + // drift kept the fetch call itself token-free (the URL came from a + // caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL), + // so a call-window regex never fired. Any raw await-fetch in a file + // that also references the Caddy admin anywhere is an offender. + // Escape hatch for future tests that intentionally assert Origin-less + // 403 behavior against their own local listener: put the marker + // DC-087-ALLOW-RAW-FETCH in the file and it is skipped. + const testsRoot = path.join(__dirname); + const offenders = []; + const skipped = []; + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules') continue; + const p = path.join(dir, entry.name); + if (entry.isDirectory()) walk(p); + else if (entry.name.endsWith('.js')) { + const rawText = fs.readFileSync(p, 'utf8'); + // Escape hatch (checked on RAW text so a comment marker works — + // comments are stripped below): a file carrying the + // DC-087-ALLOW-RAW-FETCH marker declares it intentionally + // raw-fetches the Caddy admin (e.g. asserting Origin-less 403 + // against its own local listener). The guard file itself is + // always scanned (never skipped) so the hatch can't be used to + // blind this very test. + if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) { + skipped.push(p); + continue; + } + const text = stripComments(rawText); + const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text); + const hasRawAwaitFetch = /await\s+fetch\(/.test(text); + if (hasAdminToken && hasRawAwaitFetch) { + offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`); + } + } + } + } + walk(testsRoot); + if (skipped.length) { + // Visibility for hatch use — shows up in jest output for reviewers. + console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', ')); + } + expect(offenders).toEqual([]); + }); + test('readiness handler in src/app.js probes the exact URL the watcher needs', () => { const raw = fs.readFileSync( path.join(__dirname, '../src/app.js'), @@ -127,9 +188,9 @@ describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/); // Goes through fetchT, NOT bare fetch — that's how the Origin injection // takes effect. Look at the 800 chars BEFORE the probe URL on the same - // line / call site — the call must be `fetchT(...)`, not `await fetch(...)`. - // (We look backward because the URL sits inside the call's argument list, - // so the call site comes before the URL token.) + // line / call site — the call must be fetchT(...), never a raw await of + // the global fetch. (We look backward because the URL sits inside the + // call's argument list, so the call site comes before the URL token.) const idx = raw.indexOf('srv0/listen'); const around = raw.substr(Math.max(0, idx - 400), 800); expect(around).toMatch(/fetchT\(/);