[grade=A] feat(caddy-builder): DC-106 visual reverse proxy builder (frontend)
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
This commit is contained in:
@@ -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, '"').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('&');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user