/** * DC-106: Reverse Proxy Visual Builder — pure-function unit tests. * * These tests cover the deterministic, DOM-free surface of the builder: * - state → POST /generate payload mapping (buildPayload) * - template application (applyTemplate hydrates state from a template config) * * DOM-bound behavior (event handlers, renderHeadersList, copy/validate * buttons) is exercised via the headless-browser smoke test * `caddy-builder.browser.smoke.test.js` which uses the running dev server. * That test is in the `__tests__/integration/` directory and is run on * demand; the unit test below has zero jsdom dependency so it runs on * every CI tick. * * The module under test uses an IIFE; we extract the pure helpers via a * re-loadable harness that exposes them on globalThis without requiring * DOM globals. */ // --- DOM stub: minimal window/document/injectModal/escapeHtml shims --- // The caddy-builder.js IIFE needs window.injectModal, window.escapeHtml, // window.fetch, document.body.insertAdjacentHTML, and document.getElementById // at module-load time. We stub all of them with no-ops so the IIFE runs // without exploding — but no actual DOM rendering happens. That's fine for // testing the pure helpers, which only need `state`. global.window = global.window || {}; global.window.injectModal = () => {}; global.window.escapeHtml = (text) => String(text ?? '') .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); // Also expose as bare globals — the module's IIFE references `injectModal` // and `escapeHtml` as bare identifiers, so they need to be resolvable in // the eval scope. global.injectModal = global.window.injectModal; global.escapeHtml = global.window.escapeHtml; // Tiny DOM shim — only what caddy-builder.js touches at load time. // Built in two passes to avoid the "Cannot access 'stubEl' before // initialization" TDZ trap (parentNode self-reference). function buildStubEl() { const el = { style: {}, classList: { add() {}, remove() {} }, dataset: {}, addEventListener() {}, removeEventListener() {}, insertAdjacentHTML() {}, setAttribute() {}, getAttribute() { return null; }, dispatchEvent() {}, focus() {}, blur() {}, set innerHTML(_) {}, get innerHTML() { return ''; }, set textContent(_) {}, get textContent() { return ''; }, set value(_) {}, get value() { return ''; }, set checked(_) {}, get checked() { return false; }, set disabled(_) {}, get disabled() { return false; }, children: [], parentNode: null, firstChild: null, }; el.parentNode = el; el.firstChild = el; el.appendChild = function(child) { el.children.push(child); child.parentNode = el; return child; }; el.querySelector = () => el; el.querySelectorAll = () => []; return el; } const stubEl = buildStubEl(); const elementById = new Map(); function makeEl(id, tag) { const el = Object.create(stubEl); el.id = id; el.tagName = (tag || 'div').toUpperCase(); el.children = []; el.parentNode = stubEl; el._children = []; el.appendChild = function(child) { el.children.push(child); child.parentNode = el; return child; }; el.querySelector = function(sel) { // Very dumb: return first descendant whose tag matches the selector's tagname const m = sel.match(/^[a-z]+/); const tag = m ? m[0].toUpperCase() : null; function find(node) { if (tag && node.tagName === tag) return node; for (const c of (node.children || [])) { const r = find(c); if (r) return r; } return null; } return find(el) || stubEl; }; el.querySelectorAll = function() { return []; }; elementById.set(id, el); return el; } global.document = { body: stubEl, getElementById: (id) => elementById.get(id) || makeEl(id), createElement: (tag) => makeEl('dyn-' + Math.random().toString(36).slice(2), tag), createRange: () => ({ selectNodeContents() {}, setStart() {}, setEnd() {}, collapse() {} }), }; global.Event = class Event { constructor(type) { this.type = type; } }; global.navigator = { clipboard: { writeText: async () => {} } }; global.fetch = jest.fn(); global.setTimeout = setTimeout; global.clearTimeout = clearTimeout; // --- Load the module under test --- function loadModule() { const fs = require('fs'); const path = require('path'); const code = fs.readFileSync( path.join(__dirname, '..', '..', '..', 'status', 'js', 'caddy-builder.js'), 'utf8' ); // eslint-disable-next-line no-eval (0, eval)(code); return global.window.__caddyBuilder; } // --- Tests --------------------------------------------------------------- describe('DC-106: Caddy Visual Builder (pure)', () => { let builder; beforeEach(() => { fetch.mockReset(); // loadTemplates() runs at module-load time and hits /caddycode/templates. // Mock it to resolve with the 5 template presets so applyTemplate works. fetch.mockResolvedValue({ ok: true, status: 200, json: async () => ({ success: true, templates: { 'simple-proxy': { label: 'Simple reverse proxy', config: { domain: 'app.example.com', upstream: 'localhost:8080' } }, 'websocket-app': { label: 'WebSocket application', config: { domain: 'app.example.com', upstream: 'localhost:3000', websocket: true, compress: true } }, 'auth-gated': { label: 'Auth-gated (DashCaddy SSO)', config: { domain: 'app.example.com', upstream: 'localhost:8096', auth: true, authService: 'app' } }, 'cors-api': { label: 'API with CORS', config: { domain: 'api.example.com', upstream: 'localhost:3001', cors: true, compress: true } }, 'subdirectory': { label: 'Subdirectory proxy', config: { domain: 'example.com', upstream: 'localhost:8080', stripPrefix: '/app' } }, }, }), }); elementById.clear(); builder = loadModule(); }); // Filter helper: count only POST /generate or POST /validate calls. const postCalls = () => fetch.mock.calls.filter(([url, opts]) => String(url).includes('/caddycode/') && opts && opts.method === 'POST' ); describe('state defaults', () => { it('initializes with sensible defaults', () => { expect(builder.state.domain).toBe('blog.example.com'); expect(builder.state.upstream).toBe('localhost:8080'); expect(builder.state.upstreamProtocol).toBe('http'); expect(builder.state.tls).toBe('auto'); expect(builder.state.auth).toBe(false); expect(builder.state.compress).toBe(true); expect(builder.state.headers).toEqual([]); }); it('exposes the generated state via getCaddyfile()', () => { expect(builder.getCaddyfile()).toBe(''); }); }); describe('buildPayload', () => { it('maps state → POST /generate payload (happy path)', () => { builder.state.domain = 'app.example.com'; builder.state.upstream = 'localhost:3000'; builder.state.websocket = true; builder.state.cors = true; builder.state.headers = [{ key: 'X-Forwarded-For', value: '{remote_host}' }]; const payload = builder.buildPayload(); expect(payload).toEqual({ domain: 'app.example.com', upstream: 'localhost:3000', upstreamProtocol: 'http', tls: 'auto', auth: false, authService: null, websocket: true, cors: true, compress: true, stripPrefix: null, redirectToHttps: true, headers: { 'X-Forwarded-For': '{remote_host}' }, }); }); it('trims whitespace on domain / upstream / stripPrefix', () => { builder.state.domain = ' app.example.com '; builder.state.upstream = '\tlocalhost:8080\n'; builder.state.stripPrefix = ' /api '; const p = builder.buildPayload(); expect(p.domain).toBe('app.example.com'); expect(p.upstream).toBe('localhost:8080'); expect(p.stripPrefix).toBe('/api'); }); it('drops blank header keys (only headers with non-blank keys are sent)', () => { builder.state.headers = [ { key: 'X-Real-IP', value: '{remote_host}' }, { key: '', value: 'ignored' }, { key: ' ', value: 'also ignored' }, ]; const p = builder.buildPayload(); expect(p.headers).toEqual({ 'X-Real-IP': '{remote_host}' }); }); it('nullifies authService when auth is off (security: never leaks auth_id without auth=true)', () => { builder.state.auth = false; builder.state.authService = 'leftover'; const p = builder.buildPayload(); expect(p.authService).toBe(null); }); it('passes authService when auth is on', () => { builder.state.auth = true; builder.state.authService = 'blog'; const p = builder.buildPayload(); expect(p.authService).toBe('blog'); }); it('nullifies stripPrefix when blank', () => { builder.state.stripPrefix = ''; const p = builder.buildPayload(); expect(p.stripPrefix).toBe(null); }); it('sends all boolean fields with explicit values (no undefined)', () => { const p = builder.buildPayload(); expect(typeof p.websocket).toBe('boolean'); expect(typeof p.cors).toBe('boolean'); expect(typeof p.compress).toBe('boolean'); expect(typeof p.redirectToHttps).toBe('boolean'); expect(typeof p.auth).toBe('boolean'); }); }); describe('applyTemplate', () => { it('hydrates state from an auth-gated template', () => { builder.applyTemplate('auth-gated'); expect(builder.state.auth).toBe(true); expect(builder.state.authService).toBe('app'); expect(builder.state.upstream).toBe('localhost:8096'); }); it('hydrates state from a cors-api template', () => { builder.applyTemplate('cors-api'); expect(builder.state.cors).toBe(true); expect(builder.state.compress).toBe(true); expect(builder.state.upstream).toBe('localhost:3001'); }); it('does nothing for unknown template id', () => { const before = JSON.stringify(builder.state); builder.applyTemplate('does-not-exist'); const after = JSON.stringify(builder.state); expect(after).toBe(before); }); }); describe('fetch integration (mocked)', () => { it('generate() POSTs to /caddycode/generate with correct headers', async () => { fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ success: true, caddyfile: 'app.example.com {\n reverse_proxy localhost:8080\n}', }), }); builder.state.domain = 'app.example.com'; builder.state.upstream = 'localhost:8080'; await builder.generate(); expect(fetch).toHaveBeenCalledWith('/api/v1/caddycode/generate', expect.objectContaining({ method: 'POST', credentials: 'same-origin', headers: expect.objectContaining({ 'Content-Type': 'application/json' }), })); }); it('generate() stores the returned caddyfile on success', async () => { fetch.mockResolvedValueOnce({ ok: true, status: 200, json: async () => ({ success: true, caddyfile: 'app.example.com {\n reverse_proxy localhost:8080\n}', }), }); builder.state.domain = 'app.example.com'; builder.state.upstream = 'localhost:8080'; await builder.generate(); expect(builder.getCaddyfile()).toContain('reverse_proxy localhost:8080'); }); it('generate() captures 400 errors without throwing', async () => { fetch.mockResolvedValueOnce({ ok: false, status: 400, json: async () => ({ success: false, error: 'Invalid configuration', errors: ['upstream must be host:port'], }), }); builder.state.domain = 'app.example.com'; builder.state.upstream = 'bad'; await expect(builder.generate()).resolves.toBeUndefined(); expect(builder.getCaddyfile()).toBe(''); }); it('generate() handles network failures gracefully', async () => { fetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); builder.state.domain = 'app.example.com'; builder.state.upstream = 'localhost:8080'; await expect(builder.generate()).resolves.toBeUndefined(); }); it('generate() short-circuits when domain missing', async () => { builder.state.domain = ''; builder.state.upstream = 'localhost:8080'; await builder.generate(); // loadTemplates() (run at module load) makes a GET to /templates; we // only care that generate() didn't POST /generate. Filter to POST. expect(postCalls()).toHaveLength(0); }); it('generate() short-circuits when upstream missing', async () => { builder.state.domain = 'app.example.com'; builder.state.upstream = ''; await builder.generate(); expect(postCalls()).toHaveLength(0); }); }); describe('XSS protection (escapeHtml integration)', () => { it('escapes user-typed values when headers would be rendered', () => { // The caddy-builder.js module calls escapeHtml() in renderHeadersList // to attribute-escape header keys/values before innerHTML injection. // Verify the global escapeHtml contract the module depends on. const malicious = '"&<>\'onerror=x'; const escaped = global.escapeHtml(malicious); expect(escaped).not.toContain('