From 453a5383d2950ae9696df9a4f44dd0d13e210882 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 12 May 2026 10:23:44 +0200 Subject: [PATCH] test: cover hostname title (#82) and tmux size-query (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backfill the two regression gaps flagged on master after the recent hostname-title and tmux-flicker fixes shipped without server-side assertions. * test/server-index-title.test.ts (8 tests) — exercises WebServer's index.html templating path: default os.hostname(), --title-hostname override, HTML-escape against `` + * 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 <title>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('<title>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 `&amp;`. + const html = 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 byte-for-byte identical', () => { + const html = render('laptop'); + 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', () => { + const html = render('laptop'); + // Defense against a future regression where the template gains a + // second `<title>Codeman` (e.g. inside a