` tag in the served
* index.html with the hostname-aware `codeman:` window title
* (feature #82). The title must:
* - default to `codeman:` when no override is supplied
* - honor a custom `titleHostname` passed via the constructor (CLI flag
* `--title-hostname ` plumbs through to here)
* - HTML-escape the hostname so a value like ``
* can't break out of the title tag
* - replace the bare `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 } from 'vitest';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { hostname as osHostname } 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');
function render(host?: string): string {
const server = new WebServer(0, false, true, host);
return (server as unknown as { renderIndexHtml: () => string }).renderIndexHtml();
}
describe('WebServer index.html templating (#82)', () => {
it('substitutes the bare Codeman with codeman:', () => {
const html = render('laptop');
expect(html).toContain('codeman:laptop');
expect(html).not.toContain('Codeman');
});
it('defaults to os.hostname() when no titleHostname is supplied', () => {
const html = render();
const expected = `codeman:${osHostname()}`;
expect(html).toContain(expected);
});
it('treats an empty-string titleHostname as "not supplied" and falls back to os.hostname()', () => {
// 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 = render('');
expect(html).toMatch(/codeman:.+<\/title>/);
expect(html).not.toContain('codeman:');
});
it('HTML-escapes < > & in the hostname so it cannot break out of the title tag', () => {
const html = 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', () => {
// The escaper replaces & first, then < and >. A hostname that already
// contains a literal `&` should render as `&` once, not `&`.
const html = render('a&b');
expect(html).toContain('codeman:a&b');
expect(html).not.toContain('&');
});
it('only substitutes the tag — the rest of the template is byte-for-byte identical', () => {
const html = render('laptop');
const beforeTitle = rawTemplate.split('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', () => {
const html = render('laptop');
// Defense against a future regression where the template gains a
// second `Codeman` (e.g. inside a