From caaf26b5805ff9c4fbb152fffcde81b933f8cfa0 Mon Sep 17 00:00:00 2001 From: Justin Ling Date: Thu, 30 Jul 2026 23:53:32 +0800 Subject: [PATCH] Protect loopback review sessions --- README.md | 3 +- app/page.tsx | 22 +- benchmarks/live-update-speed.mjs | 16 +- package-lock.json | 64 ++++ package.json | 6 +- scripts/access-token.mjs | 21 ++ scripts/cli-args.mjs | 3 +- scripts/present.mjs | 23 +- scripts/serve-built.mjs | 393 +++++++++++++++----- tests/access-token.test.mjs | 32 ++ tests/browser/serve-built-security.test.mjs | 92 +++++ tests/live-update-speed.test.mjs | 30 ++ tests/present-instances.test.mjs | 27 +- tests/rendered-html.test.mjs | 11 + tests/serve-built.test.mjs | 387 ++++++++++--------- 15 files changed, 850 insertions(+), 280 deletions(-) create mode 100644 scripts/access-token.mjs create mode 100644 tests/access-token.test.mjs create mode 100644 tests/browser/serve-built-security.test.mjs create mode 100644 tests/live-update-speed.test.mjs diff --git a/README.md b/README.md index 8d5aa46..5aa9c35 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,10 @@ npm run diffsplain -- doctor npm run doctor ``` -Run the checks: +Install Chromium once, then run the checks: ```sh +npm run test:browser:install npm run lint npm test ``` diff --git a/app/page.tsx b/app/page.tsx index 64084f1..a537f59 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -236,11 +236,17 @@ export default function Home() { const latestVersion = useRef(null); const touchStart = useRef(null); const searchRef = useRef(null); + const session = useMemo( + () => new URLSearchParams(window.location.hash.slice(1)), + [], + ); + const [access, setAccess] = useState(() => session.get("access")); const refresh = useCallback(async () => { try { const liveUrl = new URL("diff-data.json", document.baseURI); liveUrl.searchParams.set("t", String(Date.now())); + if (access) liveUrl.searchParams.set("access", access); const liveResponse = await fetch(liveUrl, { cache: "no-store", }); @@ -268,7 +274,7 @@ export default function Home() { error instanceof Error ? error.message : "Could not read the snapshot", ); } - }, []); + }, [access]); useEffect(() => { const initial = window.setTimeout(() => void refresh(), 0); @@ -287,10 +293,9 @@ export default function Home() { }; if ("EventSource" in window) { const eventsUrl = new URL("events", document.baseURI); - const project = new URLSearchParams(window.location.hash.slice(1)).get( - "project", - ); + const project = session.get("project"); if (project) eventsUrl.searchParams.set("project", project); + if (access) eventsUrl.searchParams.set("access", access); events = new EventSource(eventsUrl); events.addEventListener("ready", () => { stopPolling(); @@ -299,6 +304,13 @@ export default function Home() { void refresh(); }); events.addEventListener("update", () => void refresh()); + events.addEventListener("access", (event) => { + const nextAccess = (event as MessageEvent).data; + if (!/^[A-Za-z0-9_-]{32,}$/.test(nextAccess)) return; + session.set("access", nextAccess); + window.history.replaceState(null, "", `#${session}`); + setAccess(nextAccess); + }); events.addEventListener("error", startPolling); } else { startPolling(); @@ -310,7 +322,7 @@ export default function Home() { events?.close(); window.clearInterval(ticker); }; - }, [refresh]); + }, [access, refresh, session]); const files = useMemo(() => snapshot?.files ?? [], [snapshot]); const currentIndex = Math.max( diff --git a/benchmarks/live-update-speed.mjs b/benchmarks/live-update-speed.mjs index 6894c69..647e044 100644 --- a/benchmarks/live-update-speed.mjs +++ b/benchmarks/live-update-speed.mjs @@ -61,6 +61,15 @@ function stop(child) { }); } +function protectedUrl(reviewUrl, path) { + const route = new URL(path, reviewUrl); + const access = new URLSearchParams(new URL(reviewUrl).hash.slice(1)).get( + "access", + ); + if (access) route.searchParams.set("access", access); + return route; +} + const temporary = mkdtempSync(join(tmpdir(), "diffsplain-updates-")); const output = join(temporary, "diff-data.json"); writeFileSync(output, JSON.stringify({ version: "0" })); @@ -82,7 +91,10 @@ try { const url = await waitForUrl(child); const samples = []; if (mode === "events") { - const response = await fetch(`${url}/events`); + const response = await fetch(protectedUrl(url, "events")); + if (!response.ok) { + throw new Error(`Event stream returned ${response.status}`); + } reader = response.body.getReader(); await reader.read(); for (let version = 1; version <= 9; version += 1) { @@ -96,7 +108,7 @@ try { let seenVersion = "0"; const waiters = new Map(); poll = setInterval(async () => { - const response = await fetch(`${url}/diff-data.json`, { + const response = await fetch(protectedUrl(url, "diff-data.json"), { cache: "no-store", }); const value = await response.json(); diff --git a/package-lock.json b/package-lock.json index 134dd24..3e7fbf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "diffsplain": "scripts/present.mjs" }, "devDependencies": { + "@playwright/test": "1.62.0", "@tailwindcss/postcss": "4.2.1", "@types/node": "22.19.19", "@types/react": "19.2.14", @@ -2900,6 +2901,22 @@ } } }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -14723,6 +14740,53 @@ "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/package.json b/package.json index 1eff28f..60d59de 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ }, "files": [ "dist", + "scripts/access-token.mjs", "scripts/build-diff-data.mjs", "scripts/cli-args.mjs", "scripts/coding-agents.mjs", @@ -56,10 +57,13 @@ "build": "vite build && node scripts/clean-built-live-data.mjs", "start": "node scripts/serve-built.mjs", "prepack": "npm run build", - "test": "npm run build && node --test tests/*.test.mjs", + "test": "npm run build && node --test tests/*.test.mjs && npm run test:browser", + "test:browser": "node --test tests/browser/*.test.mjs", + "test:browser:install": "playwright install chromium", "lint": "tsc --noEmit && eslint ." }, "devDependencies": { + "@playwright/test": "1.62.0", "@tailwindcss/postcss": "4.2.1", "@types/node": "22.19.19", "@types/react": "19.2.14", diff --git a/scripts/access-token.mjs b/scripts/access-token.mjs new file mode 100644 index 0000000..6c1138e --- /dev/null +++ b/scripts/access-token.mjs @@ -0,0 +1,21 @@ +import { createHash } from 'node:crypto'; +import { tmpdir, userInfo } from 'node:os'; +import { join } from 'node:path'; + +export function accessTokenDirectory({ + temporaryDirectory = tmpdir(), + identity = userInfo(), +} = {}) { + const userKey = createHash('sha256') + .update( + JSON.stringify([ + identity.uid, + identity.gid, + identity.username, + identity.homedir, + ]), + ) + .digest('hex') + .slice(0, 16); + return join(temporaryDirectory, `diffsplain-access-${userKey}`); +} diff --git a/scripts/cli-args.mjs b/scripts/cli-args.mjs index 4c9420d..89a724d 100644 --- a/scripts/cli-args.mjs +++ b/scripts/cli-args.mjs @@ -315,6 +315,7 @@ export function parseCliArgs( if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) { fail('--port must be a number from 0 to 65535'); } + const host = (options.get('--host') || 'localhost').replace(/^\[|\]$/g, ''); return { help: false, @@ -326,7 +327,7 @@ export function parseCliArgs( agentArgs, port: Number(portValue), portWasPassed: options.has('--port'), - host: options.get('--host') || 'localhost', + host, browserEnabled: !options.has('--no-browser'), forceSummaryRegeneration: options.has('--force'), }; diff --git a/scripts/present.mjs b/scripts/present.mjs index e7562c0..2dc54ad 100755 --- a/scripts/present.mjs +++ b/scripts/present.mjs @@ -1,12 +1,15 @@ #!/usr/bin/env node import { spawn, spawnSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { + chmodSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, + writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -19,6 +22,7 @@ import { selectCodingAgent, } from './coding-agents.mjs'; import { doctorReport } from './doctor.mjs'; +import { accessTokenDirectory } from './access-token.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const callerDirectory = process.cwd(); @@ -95,6 +99,20 @@ const projectKey = createHash('sha256') ) .digest('hex') .slice(0, 12); +const accessDirectory = accessTokenDirectory(); +const accessPath = join(accessDirectory, `${projectKey}.token`); +mkdirSync(accessDirectory, { recursive: true, mode: 0o700 }); +chmodSync(accessDirectory, 0o700); +let previousAccess; +try { + const savedAccess = readFileSync(accessPath, 'utf8').trim(); + if (/^[A-Za-z0-9_-]{43}$/.test(savedAccess)) previousAccess = savedAccess; +} catch { + // The first run for a project has no prior tab access value. +} +const access = randomBytes(32).toString('base64url'); +writeFileSync(accessPath, access, { mode: 0o600 }); +chmodSync(accessPath, 0o600); if (agentEnabled) { feedArgs.push('--ignore-summary-watch'); agentArgs.push('--snapshot', outputPath); @@ -172,6 +190,9 @@ function startSite() { host, '--project', projectKey, + '--access', + access, + ...(previousAccess ? ['--previous-access', previousAccess] : []), ...(!cli.portWasPassed ? ['--increment-port'] : []), ], { cwd: root, stdio: ['ignore', 'pipe', 'inherit'] }, diff --git a/scripts/serve-built.mjs b/scripts/serve-built.mjs index beaae49..22376de 100644 --- a/scripts/serve-built.mjs +++ b/scripts/serve-built.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node -import { createServer } from 'node:http'; +import { randomBytes, timingSafeEqual } from 'node:crypto'; import { watchFile, unwatchFile } from 'node:fs'; import { readFile, stat } from 'node:fs/promises'; -import { Readable } from 'node:stream'; +import { createServer } from 'node:http'; import { dirname, extname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -24,10 +24,18 @@ function option(name, fallback) { const output = resolve(option('--output', resolve(root, '.cache/diff-data.json'))); const project = option('--project', ''); const portValue = option('--port', '2299'); -const host = option('--host', 'localhost'); +const host = option('--host', 'localhost').replace(/^\[|\]$/g, ''); +const access = option('--access', randomBytes(32).toString('base64url')); +const previousAccess = option('--previous-access', ''); if (!/^\d+$/.test(portValue) || Number(portValue) > 65_535) { throw new Error('--port must be a number from 0 to 65535'); } +if (!/^[A-Za-z0-9_-]{32,}$/.test(access)) { + throw new Error('--access must be an unguessable URL-safe value'); +} +if (previousAccess && !/^[A-Za-z0-9_-]{32,}$/.test(previousAccess)) { + throw new Error('--previous-access must be an unguessable URL-safe value'); +} const incrementPort = rawArgs.includes('--increment-port') || !rawArgs.includes('--port'); @@ -42,22 +50,65 @@ const contentTypes = { '.woff2': 'font/woff2', }; +const restrictiveHeaders = { + 'content-security-policy': + "base-uri 'none'; connect-src 'self'; default-src 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; style-src 'self' 'unsafe-inline'", + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-resource-policy': 'same-origin', + 'permissions-policy': 'geolocation=(), microphone=(), camera=()', + 'referrer-policy': 'no-referrer', + 'x-content-type-options': 'nosniff', + 'x-frame-options': 'DENY', +}; + +function webResponse(body, { status = 200, headers = {} } = {}) { + return new Response(body, { + status, + headers: { ...restrictiveHeaders, ...headers }, + }); +} + +function clientError() { + return webResponse('Bad request', { status: 400 }); +} + function notFound() { - return new Response('Not found', { status: 404 }); + return webResponse('Not found', { status: 404 }); +} + +function isLoopback(address) { + const normalized = address.replace(/^\[|\]$/g, '').toLowerCase(); + return ( + normalized === 'localhost' || + normalized === '::1' || + normalized === '::ffff:127.0.0.1' || + /^127(?:\.\d{1,3}){3}$/.test(normalized) + ); +} + +function normalizedAddress(address) { + return address.replace(/^\[|\]$/g, '').toLowerCase(); +} + +function cacheControl(file) { + const extension = extname(file).toLowerCase(); + if (extension === '.html') return 'no-cache'; + if (/[-_][A-Za-z0-9_-]{8,}\.(?:css|js|woff2?)$/.test(file)) { + return 'public, max-age=31536000, immutable'; + } + return 'no-cache'; } async function fileResponse(file, { live = false } = {}) { try { const info = await stat(file); if (!info.isFile()) return notFound(); - return new Response(await readFile(file), { + return webResponse(await readFile(file), { headers: { + 'cache-control': live ? 'no-store' : cacheControl(file), 'content-type': contentTypes[extname(file).toLowerCase()] || 'application/octet-stream', - 'cache-control': live - ? 'no-store' - : 'public, max-age=31536000, immutable', }, }); } catch { @@ -65,30 +116,153 @@ async function fileResponse(file, { live = false } = {}) { } } -async function fetchAsset(request) { - const url = new URL(request.url); - if (url.pathname === '/diff-data.json') { - return fileResponse(output, { live: true }); +function decodeRequestPath(rawPath) { + try { + return decodeURIComponent(rawPath); + } catch { + return undefined; } +} + +function unsafeRequestPath(rawPath, decodedPath) { + if (!rawPath.startsWith('/')) return true; + if (authorityPath(rawPath, decodedPath)) return true; + if (decodedPath.includes('\\')) return true; + return decodedPath.split('/').includes('..'); +} - let pathname; +function authorityPath(rawPath, decodedPath) { + return rawPath.startsWith('//') || decodedPath.startsWith('//'); +} + +function parseUrl(raw) { try { - pathname = decodeURIComponent(url.pathname); + return new URL(raw, 'http://localhost'); } catch { - return notFound(); + return undefined; + } +} + +function parseRequestUrl(request) { + const raw = request.url || '/'; + const rawPath = raw.split(/[?#]/, 1)[0]; + const decodedPath = decodeRequestPath(rawPath); + if (decodedPath === undefined) return undefined; + if (unsafeRequestPath(rawPath, decodedPath)) return undefined; + return parseUrl(raw); +} + +function parseHttpHost(value) { + if (!value) return undefined; + try { + return new URL(`http://${value}`); + } catch { + return undefined; + } +} + +function serverPort(url) { + if (url.port) return url.port; + return readyState?.port === 80 ? '80' : ''; +} + +function plainHost(url) { + return ![ + url.username, + url.password, + url.pathname !== '/', + url.search, + url.hash, + ].some(Boolean); +} + +function matchesServerAddress(candidate, localAddress) { + const local = normalizedAddress(localAddress); + const accepted = new Set([normalizedAddress(host), local]); + if (accepted.has(candidate)) return true; + return isLoopback(candidate) && isLoopback(local); +} + +function validHost(value, localAddress) { + const url = parseHttpHost(value); + if (!url) return false; + if (!plainHost(url)) return false; + if (serverPort(url) !== String(readyState.port)) return false; + return matchesServerAddress(normalizedAddress(url.hostname), localAddress); +} + +function parseOriginPair(value, requestHost) { + if (value === 'null') return undefined; + try { + return { + origin: new URL(value), + requestUrl: new URL(`http://${requestHost}`), + }; + } catch { + return undefined; + } +} + +function matchingOrigin({ origin, requestUrl }) { + const plainOrigin = ![ + origin.protocol !== 'http:', + origin.username, + origin.password, + origin.pathname !== '/', + origin.search, + origin.hash, + ].some(Boolean); + if (!plainOrigin) return false; + if ( + normalizedAddress(origin.hostname) !== + normalizedAddress(requestUrl.hostname) + ) { + return false; } + return serverPort(origin) === serverPort(requestUrl); +} + +function validOrigin(value, requestHost, localAddress) { + if (!value) return true; + const pair = parseOriginPair(value, requestHost); + if (!pair) return false; + if (!matchingOrigin(pair)) return false; + return validHost(pair.origin.host, localAddress); +} + +function validAccess(value, { allowPrevious = false } = {}) { + if (matchingAccess(value, access)) return true; + return ( + allowPrevious && + Boolean(previousAccess) && + matchingAccess(value, previousAccess) + ); +} + +function matchingAccess(value, expectedValue) { + const received = Buffer.from(value || ''); + const expected = Buffer.from(expectedValue); + return received.length === expected.length && timingSafeEqual(received, expected); +} + +async function fetchAsset(url) { + if (url.pathname === '/diff-data.json') { + return fileResponse(output, { live: true }); + } + const pathname = decodeURIComponent(url.pathname); const file = resolve( clientRoot, pathname === '/' ? 'index.html' : `.${pathname}`, ); if (file !== clientRoot && !file.startsWith(`${clientRoot}${sep}`)) { - return notFound(); + return clientError(); } return fileResponse(file); } -function jsonResponse(value) { - return new Response(JSON.stringify(value), { +function jsonResponse(value, status = 200) { + return webResponse(JSON.stringify(value), { + status, headers: { 'cache-control': 'no-store', 'content-type': 'application/json; charset=utf-8', @@ -96,90 +270,136 @@ function jsonResponse(value) { }); } -function nodeRequest(request) { - const host = request.headers.host || 'localhost'; - const init = { - method: request.method, - headers: request.headers, - }; - if (request.method !== 'GET' && request.method !== 'HEAD') { - init.body = Readable.toWeb(request); - init.duplex = 'half'; +async function send(nodeResponse, webResponse) { + nodeResponse.writeHead( + webResponse.status, + Object.fromEntries(webResponse.headers), + ); + if (!webResponse.body) { + nodeResponse.end(); + return; } - return new Request(`http://${host}${request.url}`, init); + nodeResponse.end(Buffer.from(await webResponse.arrayBuffer())); } -async function send(nodeResponse, response) { - nodeResponse.writeHead(response.status, Object.fromEntries(response.headers)); - if (!response.body) { - nodeResponse.end(); +const eventClients = new Set(); +let selectedPort = Number(portValue); +let readyState; +let closing = false; + +function requestAuthorityError(request) { + const localAddress = request.socket.localAddress || ''; + if (!validHost(request.headers.host, localAddress)) { + return clientError(); + } + if (!validOrigin(request.headers.origin, request.headers.host, localAddress)) { + return webResponse('Forbidden', { status: 403 }); + } + return undefined; +} + +function requestContext(request) { + const url = parseRequestUrl(request); + if (!url) return { error: clientError() }; + const authorityError = requestAuthorityError(request); + if (authorityError) return { error: authorityError }; + if (!['GET', 'HEAD'].includes(request.method)) { + return { error: webResponse('Method not allowed', { status: 405 }) }; + } + return { url }; +} + +function forcedHandlerFailure(url) { + return ( + process.env.DIFFSPLAIN_TEST_HANDLER_FAILURE && + url.pathname === '/__test/fail' + ); +} + +async function sendEvents(response, url) { + if ( + !validAccess(url.searchParams.get('access'), { allowPrevious: true }) + ) { + await send(response, webResponse('Forbidden', { status: 403 })); return; } - Readable.fromWeb(response.body).pipe(nodeResponse); + response.writeHead(200, { + ...restrictiveHeaders, + 'content-type': 'text/event-stream', + 'cache-control': 'no-store', + connection: 'keep-alive', + }); + response.write('retry: 250\nevent: ready\ndata: {}\n\n'); + response.write(`event: access\ndata: ${access}\n\n`); + eventClients.add(response); + const requestProject = url.searchParams.get('project'); + if (project && requestProject === project) { + console.log('Diffsplain tab: connected'); + } + response.once('close', () => eventClients.delete(response)); } -const server = createServer(async (request, response) => { - try { - const webRequest = nodeRequest(request); - const url = new URL(webRequest.url); - if (url.pathname === '/health') { - await send(response, jsonResponse(readyState)); - return; +async function routeResponse(url) { + if (url.pathname === '/health') return jsonResponse(readyState); + if (url.pathname === '/diff-data.json') { + if (!validAccess(url.searchParams.get('access'))) { + return webResponse('Forbidden', { status: 403 }); } - if (url.pathname === '/events') { - response.writeHead(200, { - 'content-type': 'text/event-stream', - 'cache-control': 'no-store', - connection: 'keep-alive', - }); - response.write('retry: 250\nevent: ready\ndata: {}\n\n'); - eventClients.add(response); - const requestProject = url.searchParams.get('project'); - if (project && requestProject === project) { - console.log('Diffsplain tab: connected'); - } - request.once('close', () => eventClients.delete(response)); + } + return fetchAsset(url); +} + +async function handleRequest(request, response) { + const context = requestContext(request); + if (context.error) { + await send(response, context.error); + return; + } + if (forcedHandlerFailure(context.url)) { + throw new Error('forced request handler failure'); + } + if (context.url.pathname === '/events') { + await sendEvents(response, context.url); + return; + } + await send(response, await routeResponse(context.url)); +} + +async function containHandlerFailure(response) { + try { + if (response.headersSent) { + response.end(); return; } - await send(response, await fetchAsset(webRequest)); - } catch (error) { - console.error(error); - response.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' }); - response.end('Diffsplain could not load this page.'); + await send( + response, + webResponse('Internal server error', { status: 500 }), + ); + } catch { + response.destroy(); } +} + +const server = createServer((request, response) => { + void handleRequest(request, response).catch(() => + containHandlerFailure(response), + ); }); -const eventClients = new Set(); watchFile(output, { interval: 100 }, (current, previous) => { - if ( - current.mtimeMs === previous.mtimeMs && - current.size === previous.size - ) { + if (current.mtimeMs === previous.mtimeMs && current.size === previous.size) { return; } for (const client of eventClients) { - client.write('event: update\ndata: {}\n\n'); + if (!client.destroyed) client.write('event: update\ndata: {}\n\n'); } }); -let selectedPort = Number(portValue); -let readyState; -let closing = false; - function urlFor(address, port) { const formattedAddress = address.includes(':') ? `[${address}]` : address; return `http://${formattedAddress}:${port}`; } -function isLoopback(address) { - return ( - address === 'localhost' || - address === '::1' || - address === '::ffff:127.0.0.1' || - /^127(?:\.\d{1,3}){3}$/.test(address) - ); -} - function listen() { server.listen(selectedPort, host); } @@ -195,9 +415,10 @@ server.on('listening', () => { const readyPort = address && typeof address === 'object' ? address.port : selectedPort; const url = urlFor(host, readyPort); - const projectHash = project - ? `#project=${encodeURIComponent(project)}` - : ''; + const fragment = new URLSearchParams({ + ...(project ? { project } : {}), + access, + }); readyState = { status: 'ok', address: readyAddress, @@ -205,11 +426,11 @@ server.on('listening', () => { }; if (!isLoopback(readyAddress)) { console.warn( - `Warning: Diffsplain is listening on ${readyAddress}. Anyone who can reach this address can view this review.`, + `Warning: Diffsplain is listening on ${readyAddress}. Review data still requires the per-run access value.`, ); } - console.log(`Diffsplain: ${url}${projectHash}`); - console.log(JSON.stringify({ event: 'ready', ...readyState, url })); + console.log(`Diffsplain: ${url}#${fragment}`); + console.log(JSON.stringify({ event: 'ready', ...readyState, url, access })); }); server.on('error', (error) => { @@ -223,7 +444,11 @@ server.on('error', (error) => { listen(); return; } - console.error(`Could not start Diffsplain: ${error.message}`); + const message = + error.code === 'EADDRINUSE' + ? `port ${selectedPort} is already in use` + : error.message; + console.error(`Could not start Diffsplain: ${message}`); close(1); }); diff --git a/tests/access-token.test.mjs b/tests/access-token.test.mjs new file mode 100644 index 0000000..e73e127 --- /dev/null +++ b/tests/access-token.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import test from 'node:test'; +import { accessTokenDirectory } from '../scripts/access-token.mjs'; + +const firstUser = { + uid: 1000, + gid: 1000, + username: 'first', + homedir: '/home/first', +}; +const secondUser = { + uid: 1001, + gid: 1001, + username: 'second', + homedir: '/home/second', +}; + +test('scopes saved access values to the current user', () => { + const firstDirectory = accessTokenDirectory({ + temporaryDirectory: '/shared', + identity: firstUser, + }); + const secondDirectory = accessTokenDirectory({ + temporaryDirectory: '/shared', + identity: secondUser, + }); + + assert.match(firstDirectory, /^\/shared\/diffsplain-access-[a-f0-9]{16}$/); + assert.notEqual(firstDirectory, secondDirectory); + assert.match(accessTokenDirectory(), new RegExp(`^${tmpdir()}/`)); +}); diff --git a/tests/browser/serve-built-security.test.mjs b/tests/browser/serve-built-security.test.mjs new file mode 100644 index 0000000..1998631 --- /dev/null +++ b/tests/browser/serve-built-security.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import test from 'node:test'; +import { chromium } from '@playwright/test'; + +const root = new URL('../..', import.meta.url).pathname; +const script = new URL('../../scripts/serve-built.mjs', import.meta.url).pathname; +const snapshot = new URL('../../public/demo-diff-data.json', import.meta.url) + .pathname; + +function startServer() { + const child = spawn(process.execPath, [ + script, + '--output', + snapshot, + '--port', + '0', + ], { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const ready = new Promise((resolve, reject) => { + let output = ''; + const timer = setTimeout( + () => reject(new Error(`Built server did not start: ${output}`)), + 10_000, + ); + child.stdout.on('data', (chunk) => { + output += chunk; + for (const line of output.split('\n')) { + try { + const event = JSON.parse(line); + if (event.event !== 'ready') continue; + clearTimeout(timer); + resolve(event); + return; + } catch { + // The server also writes a browser URL. + } + } + }); + child.stderr.on('data', (chunk) => { + output += chunk; + }); + child.once('error', reject); + child.once('exit', (code) => { + clearTimeout(timer); + reject(new Error(`Built server exited with ${code}: ${output}`)); + }); + }); + return { child, ready }; +} + +test('renders a compiled diff with the restrictive CSP', async () => { + const { child, ready } = startServer(); + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + const browserErrors = []; + page.on('console', (message) => { + if (message.type() === 'error') browserErrors.push(message.text()); + }); + page.on('pageerror', (error) => browserErrors.push(error.message)); + + try { + const session = await ready; + await page.goto(`${session.url}#access=${session.access}`); + const renderer = page.getByLabel('Unified code diff'); + await renderer.waitFor(); + await page.waitForFunction(() => + [...document.querySelectorAll('diffs-container')].some( + (element) => element.shadowRoot?.querySelector('style'), + ), + ); + const size = await renderer.boundingBox(); + + assert.ok(size && size.height > 0 && size.width > 0); + assert.deepEqual( + browserErrors.filter((message) => + /content security policy|refused to apply.*style/i.test(message), + ), + [], + ); + } finally { + await page.close(); + await browser.close(); + if (child.exitCode === null) { + child.kill('SIGTERM'); + await once(child, 'exit'); + } + } +}); diff --git a/tests/live-update-speed.test.mjs b/tests/live-update-speed.test.mjs new file mode 100644 index 0000000..f22aa27 --- /dev/null +++ b/tests/live-update-speed.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import test from 'node:test'; + +const run = promisify(execFile); +const benchmark = new URL( + '../benchmarks/live-update-speed.mjs', + import.meta.url, +).pathname; + +for (const [mode, sampleCount] of [ + ['events', 9], + ['poll', 7], +]) { + test(`runs the protected ${mode} live-update benchmark`, async () => { + const { stdout } = await run(process.execPath, [ + benchmark, + '--mode', + mode, + ], { + timeout: 30_000, + }); + const result = JSON.parse(stdout); + + assert.equal(result.mode, mode); + assert.equal(result.samplesMs.length, sampleCount); + assert.ok(result.samplesMs.every((sample) => Number.isFinite(sample))); + }); +} diff --git a/tests/present-instances.test.mjs b/tests/present-instances.test.mjs index 027a085..ba570e3 100644 --- a/tests/present-instances.test.mjs +++ b/tests/present-instances.test.mjs @@ -99,6 +99,13 @@ function stop(child) { }); } +function reviewUrl(base, path) { + const url = new URL(path, base); + const access = new URLSearchParams(new URL(base).hash.slice(1)).get('access'); + if (access) url.searchParams.set('access', access); + return url; +} + test('keeps simultaneous presenters on separate ports and data files', async () => { const root = await mkdtemp(join(tmpdir(), 'diffsplain-instances-')); const browser = join(root, 'browser'); @@ -139,11 +146,11 @@ test('keeps simultaneous presenters on separate ports and data files', async () const [firstData, secondData] = await Promise.all([ waitFor(async () => { - const response = await fetch(new URL('diff-data.json', firstUrl)); + const response = await fetch(reviewUrl(firstUrl, 'diff-data.json')); return response.ok ? response.json() : undefined; }), waitFor(async () => { - const response = await fetch(new URL('diff-data.json', secondUrl)); + const response = await fetch(reviewUrl(secondUrl, 'diff-data.json')); return response.ok ? response.json() : undefined; }), ]); @@ -225,7 +232,7 @@ test('does not serve an old snapshot while the first refresh runs', async () => ); const url = await waitForUrl(presenter); - const response = await fetch(new URL('diff-data.json', url)); + const response = await fetch(reviewUrl(url, 'diff-data.json')); assert.equal(response.status, 200); const snapshot = await response.json(); assert.equal(snapshot.repo.name, 'repo'); @@ -291,7 +298,10 @@ test('reuses a matching project tab when it reconnects', async () => { { cwd: root, env: environment, stdio: ['ignore', 'pipe', 'pipe'] }, ); const secondUrl = new URL(await waitForUrl(second)); - assert.equal(secondUrl.hash, firstUrl.hash); + const firstSession = new URLSearchParams(firstUrl.hash.slice(1)); + const secondSession = new URLSearchParams(secondUrl.hash.slice(1)); + assert.equal(secondSession.get('project'), firstSession.get('project')); + assert.notEqual(secondSession.get('access'), firstSession.get('access')); const reused = new Promise((resolve, reject) => { const timer = setTimeout( @@ -305,14 +315,17 @@ test('reuses a matching project tab when it reconnects', async () => { } }); }); - const eventsUrl = new URL('events', secondUrl); + const eventsUrl = reviewUrl(secondUrl, 'events'); + eventsUrl.searchParams.set('access', firstSession.get('access')); eventsUrl.searchParams.set( 'project', - new URLSearchParams(secondUrl.hash.slice(1)).get('project'), + secondSession.get('project'), ); const response = await fetch(eventsUrl); reader = response.body.getReader(); - await reader.read(); + const initialEvents = new TextDecoder().decode((await reader.read()).value); + assert.match(initialEvents, /event: access/); + assert.match(initialEvents, new RegExp(secondSession.get('access'))); await reused; const opened = (await readFile(browserLog, 'utf8')).trim().split('\n'); diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs index f125627..7678998 100644 --- a/tests/rendered-html.test.mjs +++ b/tests/rendered-html.test.mjs @@ -24,6 +24,17 @@ test("builds the static Diffsplain entry page", async () => { assert.doesNotMatch(html, /codex-preview|react-loading-skeleton/i); }); +test("documents browser setup before the full checks", async () => { + const readme = await readFile( + new URL("../README.md", import.meta.url), + "utf8", + ); + assert.match( + readme, + /npm run test:browser:install\nnpm run lint\nnpm test/, + ); +}); + test("ships the ten-file todo-list demo", async () => { const [{ todoDemoFiles }, payloadText] = await Promise.all([ import("../site/todo-demo.js"), diff --git a/tests/serve-built.test.mjs b/tests/serve-built.test.mjs index 689e62e..7d474d6 100644 --- a/tests/serve-built.test.mjs +++ b/tests/serve-built.test.mjs @@ -1,32 +1,18 @@ import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { request } from 'node:http'; import { createServer } from 'node:net'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; const script = new URL('../scripts/serve-built.mjs', import.meta.url).pathname; -function waitForUrl(child) { - return new Promise((resolve, reject) => { - let output = ''; - const timer = setTimeout(() => { - reject(new Error(`Built server did not start: ${output}`)); - }, 10_000); - child.stdout.on('data', (chunk) => { - output += chunk; - const match = output.match(/Diffsplain: (http:\/\/[^\s]+)/); - if (match) { - clearTimeout(timer); - resolve(match[1]); - } - }); - child.once('error', reject); - child.once('exit', (code) => { - clearTimeout(timer); - reject(new Error(`Built server exited with ${code}: ${output}`)); - }); +function start(args, env = process.env) { + return spawn(process.execPath, [script, ...args], { + env, + stdio: ['ignore', 'pipe', 'pipe'], }); } @@ -47,7 +33,7 @@ function waitForReady(child) { return; } } catch { - // The server also writes human-readable status lines. + // The server also writes a browser URL. } } }); @@ -59,6 +45,18 @@ function waitForReady(child) { }); } +function stop(child) { + return new Promise((resolve, reject) => { + child.once('exit', resolve); + child.once('error', reject); + child.kill('SIGTERM'); + }); +} + +async function stopIfRunning(child) { + if (child?.exitCode === null) await stop(child); +} + function waitForText(stream, pattern) { return new Promise((resolve, reject) => { let output = ''; @@ -75,102 +73,126 @@ function waitForText(stream, pattern) { }); } -function stop(child) { - return new Promise((resolve, reject) => { - child.once('exit', resolve); - child.once('error', reject); - child.kill('SIGTERM'); - }); +function reviewUrl(ready, path) { + const url = new URL(path, ready.url); + url.searchParams.set('access', ready.access); + return url; } -function waitForExit(child) { +function rawRequest( + ready, + { + host = 'localhost', + method = 'GET', + path = '/', + headers = {}, + } = {}, +) { return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - child.kill('SIGKILL'); - reject(new Error('Built server did not exit after its bind error')); - }, 10_000); - child.once('error', reject); - child.once('exit', (code, signal) => { - clearTimeout(timer); - resolve({ code, signal }); + const requestOptions = { + host, + port: ready.port, + method, + path, + headers: { Host: `${host}:${ready.port}`, ...headers }, + }; + const client = request(requestOptions, (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + resolve({ + status: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks).toString('utf8'), + }); + }); }); + client.once('error', reject); + client.end(); }); } -test('serves the built review page with live diff data', async () => { +test('requires a per-run access value for data and event routes', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffsplain-server-')); const output = join(directory, 'diff-data.json'); let child; try { await writeFile(output, JSON.stringify({ version: 'test-version' })); - child = spawn( - process.execPath, - [script, '--output', output, '--port', '0'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const url = await waitForUrl(child); - assert.equal(new URL(url).hostname, 'localhost'); + child = start(['--output', output, '--port', '0']); + const ready = await waitForReady(child); + assert.match(ready.access, /^[A-Za-z0-9_-]{43}$/); - const [page, data, missing] = await Promise.all([ - fetch(url), - fetch(`${url}/diff-data.json`), - fetch(`${url}/assets/missing.js`), + const [page, data, deniedData, deniedEvents] = await Promise.all([ + fetch(ready.url), + fetch(reviewUrl(ready, 'diff-data.json')), + fetch(new URL('diff-data.json', ready.url)), + fetch(new URL('events', ready.url)), ]); + const health = await fetch(new URL('health', ready.url)); assert.equal(page.status, 200); - assert.match(await page.text(), /Diffsplain<\/title>/i); + assert.equal(data.status, 200); assert.deepEqual(await data.json(), { version: 'test-version' }); - assert.equal(data.headers.get('cache-control'), 'no-store'); - assert.equal(missing.status, 404); + assert.equal(deniedData.status, 403); + assert.equal(deniedEvents.status, 403); + assert.deepEqual(await health.json(), { + status: 'ok', + address: ready.address, + port: ready.port, + }); + assert.equal(page.headers.get('cache-control'), 'no-cache'); + assert.match( + page.headers.get('content-security-policy'), + /style-src 'self' 'unsafe-inline'/, + ); + assert.equal(page.headers.get('x-frame-options'), 'DENY'); + + const html = await page.text(); + const asset = html.match(/\.\/assets\/[^"']+\.js/)?.[0]; + assert.ok(asset); + const assetResponse = await fetch(new URL(asset, ready.url)); + assert.match(assetResponse.headers.get('cache-control'), /immutable/); } finally { if (child && child.exitCode === null) await stop(child); await rm(directory, { recursive: true, force: true }); } }); -test('reports a matching project tab connection', async () => { - const directory = await mkdtemp(join(tmpdir(), 'diffsplain-tab-')); +test('hands a prior protected tab the current access value', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffsplain-handoff-')); const output = join(directory, 'diff-data.json'); + const access = 'a'.repeat(43); + const previousAccess = 'b'.repeat(43); let child; let reader; try { await writeFile(output, '{}'); - child = spawn( - process.execPath, - [ - script, - '--output', - output, - '--port', - '0', - '--project', - 'project-key', - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const url = new URL(await waitForUrl(child)); - assert.equal(url.hostname, 'localhost'); - assert.equal(url.hash, '#project=project-key'); - - const connected = new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error('Server did not report the tab connection')), - 2_000, - ); - child.stdout.on('data', (chunk) => { - if (chunk.toString().includes('Diffsplain tab: connected')) { - clearTimeout(timer); - resolve(); - } - }); - }); - const eventsUrl = new URL('events', url); - eventsUrl.searchParams.set('project', 'project-key'); - const response = await fetch(eventsUrl); + child = start([ + '--output', + output, + '--port', + '0', + '--access', + access, + '--previous-access', + previousAccess, + ]); + const ready = await waitForReady(child); + const events = new URL('events', ready.url); + events.searchParams.set('access', previousAccess); + const data = new URL('diff-data.json', ready.url); + data.searchParams.set('access', previousAccess); + const [response, deniedData] = await Promise.all([ + fetch(events), + fetch(data), + ]); + assert.equal(response.status, 200); + assert.equal(deniedData.status, 403); reader = response.body.getReader(); - await reader.read(); - await connected; + const initialEvents = new TextDecoder().decode((await reader.read()).value); + assert.match(initialEvents, /event: access/); + assert.match(initialEvents, new RegExp(access)); } finally { await reader?.cancel(); if (child && child.exitCode === null) await stop(child); @@ -178,104 +200,110 @@ test('reports a matching project tab connection', async () => { } }); -test('reports machine-readable readiness and closes its health endpoint', async () => { - const directory = await mkdtemp(join(tmpdir(), 'diffsplain-health-')); +test('rejects hostile hosts, origins, methods, and malformed paths', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffsplain-hostile-')); const output = join(directory, 'diff-data.json'); let child; try { await writeFile(output, '{}'); - child = spawn( - process.execPath, - [script, '--output', output, '--port', '0'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); + child = start(['--output', output, '--port', '0']); const ready = await waitForReady(child); - assert.ok(['127.0.0.1', '::1'].includes(ready.address)); - assert.ok(ready.port > 0); - assert.equal(ready.url, `http://localhost:${ready.port}`); - - const health = await fetch(`${ready.url}/health`); - assert.equal(health.status, 200); - assert.deepEqual(await health.json(), { - status: 'ok', - address: ready.address, - port: ready.port, - }); - - assert.equal(await stop(child), 0); - await assert.rejects(fetch(`${ready.url}/health`)); + const [host, origin, method, traversal, authority, malformed] = + await Promise.all([ + rawRequest(ready, { headers: { Host: 'attacker.test' } }), + rawRequest(ready, { headers: { Origin: 'http://attacker.test' } }), + rawRequest(ready, { method: 'POST' }), + rawRequest(ready, { path: '/%2e%2e/package.json' }), + rawRequest(ready, { path: '//attacker.test/' }), + rawRequest(ready, { path: '/%' }), + ]); + assert.equal(host.status, 400); + assert.equal(origin.status, 403); + assert.equal(method.status, 405); + assert.equal(traversal.status, 400); + assert.equal(authority.status, 400); + assert.equal(malformed.status, 400); } finally { if (child && child.exitCode === null) await stop(child); await rm(directory, { recursive: true, force: true }); } }); -test('warns before binding the review to a remote address', async () => { +test('guards an explicit remote bind with host, origin, and access checks', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffsplain-remote-')); const output = join(directory, 'diff-data.json'); let child; try { - await writeFile(output, '{}'); - child = spawn( - process.execPath, - [script, '--output', output, '--port', '0', '--host', '0.0.0.0'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const warning = waitForText(child.stderr, /anyone who can reach/i); + await writeFile(output, JSON.stringify({ version: 'remote' })); + child = start([ + '--output', + output, + '--port', + '0', + '--host', + '0.0.0.0', + ]); + const warning = waitForText(child.stderr, /access value/i); const ready = await waitForReady(child); await warning; assert.equal(ready.address, '0.0.0.0'); - assert.ok(ready.port > 0); - const health = await fetch(`http://127.0.0.1:${ready.port}/health`); - assert.equal(health.status, 200); + const path = `/diff-data.json?access=${ready.access}`; + const origin = `http://127.0.0.1:${ready.port}`; + const [allowed, missingAccess, hostileOrigin] = await Promise.all([ + rawRequest(ready, { + host: '127.0.0.1', + path, + headers: { Origin: origin }, + }), + rawRequest(ready, { + host: '127.0.0.1', + path: '/diff-data.json', + headers: { Origin: origin }, + }), + rawRequest(ready, { + host: '127.0.0.1', + path, + headers: { Origin: 'http://attacker.test' }, + }), + ]); + assert.equal(allowed.status, 200); + assert.deepEqual(JSON.parse(allowed.body), { version: 'remote' }); + assert.equal(missingAccess.status, 403); + assert.equal(hostileOrigin.status, 403); } finally { if (child && child.exitCode === null) await stop(child); await rm(directory, { recursive: true, force: true }); } }); -test('exits with an error when the requested host cannot bind', async () => { - const directory = await mkdtemp(join(tmpdir(), 'diffsplain-bind-error-')); +test('contains handler failures and keeps the review server available', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffsplain-failure-')); const output = join(directory, 'diff-data.json'); let child; - let stdout = ''; - let stderr = ''; try { await writeFile(output, '{}'); - child = spawn( - process.execPath, - [ - script, - '--output', - output, - '--port', - '0', - '--host', - '192.0.2.1', - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - child.stdout.on('data', (chunk) => { - stdout += chunk; + child = start(['--output', output, '--port', '0'], { + ...process.env, + DIFFSPLAIN_TEST_HANDLER_FAILURE: '1', }); - child.stderr.on('data', (chunk) => { - stderr += chunk; - }); - - assert.deepEqual(await waitForExit(child), { code: 1, signal: null }); - assert.match(stderr, /Could not start Diffsplain/); - assert.doesNotMatch(stdout, /"event":"ready"/); + const ready = await waitForReady(child); + const failed = await rawRequest(ready, { path: '/__test/fail' }); + assert.equal(failed.status, 500); + assert.equal(failed.body, 'Internal server error'); + assert.doesNotMatch(failed.body, /forced|handler/i); + assert.equal((await fetch(ready.url)).status, 200); + assert.equal(child.exitCode, null); } finally { - if (child && child.exitCode === null) child.kill('SIGKILL'); + if (child && child.exitCode === null) await stop(child); await rm(directory, { recursive: true, force: true }); } }); -test('pushes an event soon after live diff data changes', async () => { +test('preserves live updates and closes event streams on shutdown', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffsplain-events-')); const output = join(directory, 'diff-data.json'); let child; @@ -283,31 +311,28 @@ test('pushes an event soon after live diff data changes', async () => { try { await writeFile(output, JSON.stringify({ version: 'before' })); - child = spawn( - process.execPath, - [script, '--output', output, '--port', '0'], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const url = await waitForUrl(child); - const response = await fetch(`${url}/events`); + child = start(['--output', output, '--port', '0', '--project', 'project-key']); + const ready = await waitForReady(child); + const events = reviewUrl(ready, 'events'); + events.searchParams.set('project', 'project-key'); + const response = await fetch(events); assert.equal(response.status, 200); - assert.match(response.headers.get('content-type'), /text\/event-stream/); reader = response.body.getReader(); const decoder = new TextDecoder(); let buffered = decoder.decode((await reader.read()).value); assert.match(buffered, /event: ready/); - const started = performance.now(); await writeFile(output, JSON.stringify({ version: 'after' })); while (!buffered.includes('event: update')) { const next = await reader.read(); assert.equal(next.done, false); buffered += decoder.decode(next.value); } - assert.ok( - performance.now() - started < 500, - 'expected an update event within 500 ms', - ); + + assert.equal(await stop(child), 0); + const closed = await reader.read(); + assert.equal(closed.done, true); + await assert.rejects(fetch(ready.url)); } finally { await reader?.cancel(); if (child && child.exitCode === null) await stop(child); @@ -315,11 +340,12 @@ test('pushes an event soon after live diff data changes', async () => { } }); -test('increments the requested port when automatic selection is enabled', async () => { +test('fails on an occupied fixed port and increments only when asked', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffsplain-port-')); const output = join(directory, 'diff-data.json'); const blocker = createServer(); - let child; + let fixed; + let incremented; try { await writeFile(output, '{}'); @@ -330,24 +356,29 @@ test('increments the requested port when automatic selection is enabled', async const address = blocker.address(); assert.ok(address && typeof address === 'object'); - child = spawn( - process.execPath, - [ - script, - '--output', - output, - '--port', - String(address.port), - '--increment-port', - ], - { stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const url = new URL(await waitForUrl(child)); + fixed = start(['--output', output, '--port', String(address.port)]); + const fixedOutput = await new Promise((resolve) => { + let outputText = ''; + fixed.stderr.on('data', (chunk) => { + outputText += chunk; + }); + fixed.once('exit', (code) => resolve({ code, outputText })); + }); + assert.equal(fixedOutput.code, 1); + assert.match(fixedOutput.outputText, /port .*already in use/i); - assert.ok(Number(url.port) > address.port); - assert.equal((await fetch(url)).status, 200); + incremented = start([ + '--output', + output, + '--port', + String(address.port), + '--increment-port', + ]); + const ready = await waitForReady(incremented); + assert.ok(ready.port > address.port); } finally { - if (child && child.exitCode === null) await stop(child); + await stopIfRunning(fixed); + await stopIfRunning(incremented); await new Promise((resolve) => blocker.close(resolve)); await rm(directory, { recursive: true, force: true }); }