/** * Verifies that WebServer templates the `` tag in the served * index.html with the hostname-aware `codeman:<host>` window title * (feature #82). The title must: * - default to `codeman:<os.hostname()>` when no override is supplied * - honor a custom `titleHostname` passed via the constructor (CLI flag * `--title-hostname <host>` plumbs through to here) * - HTML-escape the hostname so a value like `<script>foo</script>` * can't break out of the title tag * - replace the bare `<title>Codeman` literal exactly once * - leave the rest of the document byte-for-byte identical to the * template on disk * * Strategy: construct WebServer with port 0 / testMode (no network * activity until start()) and call the private `renderIndexHtml()` * method directly. The Fastify `/` and `/index.html` route handlers * are one-liners that call exactly this method (server.ts:539-544), * so testing the render function covers both endpoints without * needing to listen on a port. * * Port: N/A (no server start) */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { readFileSync, mkdtempSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { hostname as osHostname, tmpdir } from 'node:os'; import { WebServer } from '../src/web/server.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const indexHtmlPath = join(__dirname, '..', 'src', 'web', 'public', 'index.html'); const rawTemplate = readFileSync(indexHtmlPath, 'utf-8'); async function render(host?: string): Promise { // 4th arg is the bind host; the title hostname is the 5th arg. const server = new WebServer(0, false, true, '127.0.0.1', host); // renderIndexHtml is async (it reads settings.json for the gesture bundle). return (server as unknown as { renderIndexHtml: () => Promise }).renderIndexHtml(); } describe('WebServer index.html templating (#82)', () => { // renderIndexHtml reads the ambient settings.json (for the gesture bundle and // the header-toggle marker-class strips, e.g. showPlanUsageLimits / // showMultiMonitorButton). Point it at an empty data dir so this test is // deterministic regardless of the developer's real settings — otherwise an // enabled toggle would strip a marker class and break the byte-identical // assertion below. getDataDir() reads CODEMAN_DATA_DIR fresh per call. const _prevDataDir = process.env.CODEMAN_DATA_DIR; beforeAll(() => { process.env.CODEMAN_DATA_DIR = mkdtempSync(join(tmpdir(), 'codeman-title-test-')); }); afterAll(() => { if (_prevDataDir === undefined) delete process.env.CODEMAN_DATA_DIR; else process.env.CODEMAN_DATA_DIR = _prevDataDir; }); it('substitutes the bare <title>Codeman with codeman:', async () => { const html = await render('laptop'); expect(html).toContain('codeman:laptop'); expect(html).not.toContain('Codeman'); }); it('defaults to os.hostname() when no titleHostname is supplied', async () => { const html = await render(); const expected = `codeman:${osHostname()}`; expect(html).toContain(expected); }); it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', async () => { // CLI normally guarantees a non-empty string, but the constructor's // `titleHostname || getHostname()` guard makes empty fall through — // pin that behavior so a future refactor doesn't accidentally ship // a `codeman:` to users. const html = await render(''); expect(html).toMatch(/codeman:.+<\/title>/); expect(html).not.toContain('<title>codeman:'); }); it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', async () => { const html = await render(''); expect(html).toContain('codeman:<script>alert(1)</script>'); // The raw closing from the injected payload must NOT appear // outside the actual title element — escape-then-substitute prevents // an attacker-controlled hostname from terminating the tag early. expect(html).not.toContain(''); }); it('escapes an ampersand without double-encoding existing entities', async () => { // The escaper replaces & first, then < and >. A hostname that already // contains a literal `&` should render as `&` once, not `&amp;`. const html = await render('a&b'); expect(html).toContain('codeman:a&b'); expect(html).not.toContain('&amp;'); }); it('only substitutes the tag — the rest of the template is identical (modulo asset cache-busting)', async () => { // renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin // .js/.css refs, and injects the CLI-availability flags, launch catalog, // custom-model Run-menu picker's CLI list and the transcript-gutter widths // before </head>; strip all so the title remains the only other change. // // The flag strips are what keep this test environment-independent. The // CLI-availability one used to pass here by luck: that script was injected // only where a CLI resolved, so the assertion held on a machine with none // installed and would have failed on a developer's box that had them. The // custom-model list is injected unconditionally (a plain array, possibly // empty), so it needs stripping on every machine, not just where non-empty. const html = (await render('laptop')) .replace(/(\.(?:js|css))\?v=[^"]*/g, '$1') .replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '') .replace(/<script>window\.__codemanCliCatalog=\[.*?\];<\/script>\n/, '') .replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '') // Injected unconditionally as an object keyed by run mode, empty when no // enabled CLI declares a gutter, so it needs stripping on every machine. .replace(/<script>window\.__codemanTranscriptGutter=\{.*?\};<\/script>\n/, ''); const beforeTitle = rawTemplate.split('<title>Codeman')[0]; const afterTitle = rawTemplate.split('Codeman')[1]; expect(html.startsWith(beforeTitle)).toBe(true); expect(html.endsWith(afterTitle)).toBe(true); // Sanity check: length differs only by the title swap. const expectedDelta = `codeman:laptop`.length - `Codeman`.length; expect(html.length - rawTemplate.length).toBe(expectedDelta); }); it('replaces the placeholder exactly once', async () => { const html = await render('laptop'); // Defense against a future regression where the template gains a // second `<title>Codeman` (e.g. inside a