Backend endpoints /api/v1/caddycode/{generate,validate,templates} already
shipped at commit 7f83151 (GLM grade B). This commit ships the visual
builder frontend that consumes them.
- status/js/caddy-builder.js — IIFE module that injects a modal with a
form-driven visual builder. State → JSON payload → debounced POST
/generate → preview pane. 5 presets loaded from /templates (simple,
websocket, auth-gated, cors-api, subdirectory). Custom headers list
(add/remove rows), live validation, copy-to-clipboard, reset. Exposes
window.__caddyBuilder for testing.
- status/css/caddy-builder.css — page-specific styles, themed via
existing --bg/--border/--accent/--ok-fg/--warn-fg/--err-fg CSS
variables. Mobile-friendly single-column layout below 880 px.
- status/index.html — adds /css/caddy-builder.css link + the
"🔧 Reverse Proxy Builder" button in the Tools menu.
- status/build.js — registers caddy-builder.js in features.js bundle.
- dashcaddy-api/__tests__/unit/caddy-builder.unit.test.js — 19
pure-function tests covering state defaults, buildPayload, applyTemplate,
generate() against mocked fetch, XSS regression via global escapeHtml.
Verified:
- jest: 19/19 unit + 8/8 caddycode-fleet routes pass
- node build.js: features.js now bundles 27 files (was 26),
new SW cache tag dashcaddy-shell-1ceeb68cff
- Frontend bundle grep finds 6 distinct caddy-builder identifiers
in dist/features.js
- Qwen stand-in judge: A (0 blocking, 0 polish). Substitute for Codex
CLI quota wall. Verdict URN: urn:ump:fco2jwhmcv4tjhmfvutownqbvc6pmvln23ym5jckivvj42ykpc2a
371 lines
13 KiB
JavaScript
371 lines
13 KiB
JavaScript
/**
|
|
* 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, '"').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 = '<script>alert(1)</script>"&<>\'onerror=x';
|
|
const escaped = global.escapeHtml(malicious);
|
|
expect(escaped).not.toContain('<script>');
|
|
expect(escaped).not.toContain('"');
|
|
expect(escaped).toContain('<script>');
|
|
expect(escaped).toContain('"');
|
|
expect(escaped).toContain('&');
|
|
});
|
|
});
|
|
}); |