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('