diff --git a/dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js b/dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js new file mode 100644 index 0000000..19b80f1 --- /dev/null +++ b/dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js @@ -0,0 +1,371 @@ +/** + * 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('