|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * Unit tests for verify.js's output sniffers — run: node test/verify.test.js |
| 3 | + * |
| 4 | + * These read a background command's STDOUT, which is whatever a repo's dev script chose to print — |
| 5 | + * i.e. untrusted text. One direction is load-bearing: |
| 6 | + * |
| 7 | + * sniffPreviewUrl decides what the editor's built-in browser is pointed at, automatically. If it |
| 8 | + * ever returns a REMOTE url, a hostile repo can navigate the user's editor anywhere just by logging |
| 9 | + * a line. Every "remote" case below must return null (or the local url found elsewhere in the text). |
| 10 | + * Erring toward opening nothing is always safe; erring toward opening is not. |
| 11 | + *--------------------------------------------------------------------------------------------*/ |
| 12 | +// @ts-check |
| 13 | +'use strict'; |
| 14 | + |
| 15 | +const assert = require('assert'); |
| 16 | +const { sniffPreviewUrl, sniffPort, looksReady, createPreviewGate } = require('../verify'); |
| 17 | + |
| 18 | +let n = 0; |
| 19 | +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } |
| 20 | + |
| 21 | +// ---- 1. the security bound: only LOCAL addresses may ever be opened --------------------------- |
| 22 | + |
| 23 | +// Real-looking lines a hostile or merely misconfigured repo could print. None may be opened. |
| 24 | +const REMOTE = [ |
| 25 | + 'Local: http://evil.example.com:3000/', |
| 26 | + ' ➜ Network: https://attacker.test:8080/pwn', |
| 27 | + 'Server started at http://169.254.169.254:80/latest/meta-data', // cloud metadata endpoint |
| 28 | + 'listening: http://10.0.0.5:3000', |
| 29 | + 'open http://sub.domain.co.uk:4200 to view', |
| 30 | + 'http://localhost.evil.com:3000', // suffix trick — the host is NOT localhost |
| 31 | + 'http://127.0.0.1.evil.com:3000' // same trick with the loopback literal |
| 32 | +]; |
| 33 | + |
| 34 | +test('SECURITY: remote-only output opens NOTHING', () => { |
| 35 | + // Strictly null, not "null or some localhost url". An earlier version of this assertion allowed a |
| 36 | + // localhost fallback, which quietly permitted the thing it was meant to forbid: the port would be |
| 37 | + // re-extracted from the refused remote address and we'd open localhost:<their port> — a preview |
| 38 | + // conjured entirely out of a line we ignored. If the fixture is remote-only, the answer is nothing. |
| 39 | + for (const line of REMOTE) { |
| 40 | + assert.strictEqual(sniffPreviewUrl(line), null, |
| 41 | + 'remote-only output must open nothing, got ' + JSON.stringify(sniffPreviewUrl(line)) + |
| 42 | + ' from ' + JSON.stringify(line)); |
| 43 | + } |
| 44 | +}); |
| 45 | + |
| 46 | +test('SECURITY: a local url still wins when a remote one is printed alongside it', () => { |
| 47 | + // Vite prints both; we must take the Local line and ignore Network, whatever its host. |
| 48 | + const out = sniffPreviewUrl(' ➜ Local: http://localhost:5173/\n ➜ Network: http://192.168.1.14:5173/'); |
| 49 | + assert.strictEqual(out, 'http://localhost:5173/'); |
| 50 | +}); |
| 51 | + |
| 52 | +// ---- 2. finding the address the user actually wants ------------------------------------------- |
| 53 | + |
| 54 | +test('PREVIEW: prefers the full printed url, keeping scheme, port and base path', () => { |
| 55 | + assert.strictEqual(sniffPreviewUrl(' ➜ Local: http://localhost:5173/'), 'http://localhost:5173/'); |
| 56 | + assert.strictEqual(sniffPreviewUrl('ready - started server on http://localhost:3000/app'), 'http://localhost:3000/app'); |
| 57 | + assert.strictEqual(sniffPreviewUrl('https://localhost:8443/'), 'https://localhost:8443/'); |
| 58 | + assert.strictEqual(sniffPreviewUrl('running at http://127.0.0.1:4200'), 'http://127.0.0.1:4200'); |
| 59 | +}); |
| 60 | + |
| 61 | +test('PREVIEW: bind addresses are rewritten to something a browser can actually resolve', () => { |
| 62 | + // 0.0.0.0 / [::] mean "every interface", not a destination — opening them literally often fails. |
| 63 | + assert.strictEqual(sniffPreviewUrl('Listening on http://0.0.0.0:8000'), 'http://localhost:8000'); |
| 64 | + assert.strictEqual(sniffPreviewUrl('serving on http://[::]:9000/'), 'http://localhost:9000/'); |
| 65 | +}); |
| 66 | + |
| 67 | +test('PREVIEW: falls back to localhost when only a port is announced', () => { |
| 68 | + // Plenty of servers never print a url — the Express boilerplate is exactly this line. |
| 69 | + assert.strictEqual(sniffPreviewUrl('Server running on port 3000'), 'http://localhost:3000'); |
| 70 | + assert.strictEqual(sniffPreviewUrl('listening on :8080'), 'http://localhost:8080'); |
| 71 | +}); |
| 72 | + |
| 73 | +test('PREVIEW: silence when nothing resembles a server', () => { |
| 74 | + for (const quiet of ['', ' ', 'building...', 'Compiled 42 modules', 'error TS2304: cannot find name', null, undefined]) { |
| 75 | + assert.strictEqual(sniffPreviewUrl(quiet), null, JSON.stringify(quiet) + ' should not open anything'); |
| 76 | + } |
| 77 | +}); |
| 78 | + |
| 79 | +test('PREVIEW: garbage input returns null — not merely "does not throw"', () => { |
| 80 | + // This runs inside a stdout handler, so not throwing is necessary but nowhere near sufficient: |
| 81 | + // returning a non-null url would still pop a browser tab. Assert the value, not just the absence |
| 82 | + // of an exception. |
| 83 | + for (const junk of [{}, [], 42, true, Symbol.iterator.toString(), NaN, () => {}]) { |
| 84 | + let got; |
| 85 | + assert.doesNotThrow(() => { got = sniffPreviewUrl(/** @type {any} */(junk)); }, 'threw on ' + String(junk)); |
| 86 | + assert.strictEqual(got, null, 'opened something from junk input: ' + String(junk)); |
| 87 | + } |
| 88 | +}); |
| 89 | + |
| 90 | +test('SPLIT: a url straddling two stdout chunks matches only once reassembled', () => { |
| 91 | + // Why agent.js sniffs the accumulated ring TAIL rather than the raw chunk. runCommand streams |
| 92 | + // arbitrary slices, so a dev server's address routinely arrives in two pieces — and each piece on |
| 93 | + // its own is invisible to the sniffer, which would mean the preview silently never opened. |
| 94 | + const first = ' ➜ Local: http://local'; |
| 95 | + const second = 'host:5173/\n'; |
| 96 | + assert.strictEqual(sniffPreviewUrl(first), null, 'the leading half must not match on its own'); |
| 97 | + assert.strictEqual(sniffPreviewUrl(second), null, 'the trailing half must not match on its own'); |
| 98 | + assert.strictEqual(sniffPreviewUrl(first + second), 'http://localhost:5173/', 'reassembled, it must'); |
| 99 | +}); |
| 100 | + |
| 101 | +// ---- 3. the sniffers the preview builds on (previously untested) ------------------------------ |
| 102 | + |
| 103 | +test('PORT: the most specific pattern wins, so later logs cannot masquerade as the server', () => { |
| 104 | + assert.strictEqual(sniffPort('http://localhost:5173/'), '5173'); |
| 105 | + assert.strictEqual(sniffPort('Server running on port 3000'), '3000'); |
| 106 | + assert.strictEqual(sniffPort('no port here'), null); |
| 107 | + // A url earlier in the text takes precedence over a bare "port N" mentioned later. |
| 108 | + assert.strictEqual(sniffPort('http://localhost:5173/ ... connected to db on port 5432'), '5173'); |
| 109 | +}); |
| 110 | + |
| 111 | +test('READY: recognises the common "it is up" lines, and nothing else', () => { |
| 112 | + for (const up of ['compiled successfully', 'Listening on :3000', 'server is running', 'ready in 412 ms', ' Local: http://x']) { |
| 113 | + assert.strictEqual(looksReady(up), true, JSON.stringify(up)); |
| 114 | + } |
| 115 | + for (const notUp of ['', 'building...', 'error: failed to compile']) { |
| 116 | + assert.strictEqual(looksReady(notUp), false, JSON.stringify(notUp)); |
| 117 | + } |
| 118 | +}); |
| 119 | + |
| 120 | +// ---- 3b. the preview gate: shown-once, but a FAILED open stays retryable ---------------------- |
| 121 | + |
| 122 | +test('GATE: a successful open is final — closing the tab is never undone', () => { |
| 123 | + const g = createPreviewGate(); |
| 124 | + const url = 'http://localhost:3000'; |
| 125 | + assert.strictEqual(g.shouldOpen(url), true); |
| 126 | + g.begin(url); |
| 127 | + g.succeeded(url); |
| 128 | + // The user may now close that tab. Nothing — no later run, no chatty log line — may reopen it. |
| 129 | + assert.strictEqual(g.shouldOpen(url), false); |
| 130 | +}); |
| 131 | + |
| 132 | +test('GATE: a FAILED open is retryable — the bug this gate exists for', () => { |
| 133 | + // PR #35 review: the first version marked the URL as previewed BEFORE attempting to open it, so a |
| 134 | + // single transient failure (Simple Browser disabled for a moment) blacklisted that address for the |
| 135 | + // rest of the session — the preview then silently never appeared, with nothing to point at. |
| 136 | + const g = createPreviewGate(); |
| 137 | + const url = 'http://localhost:5173'; |
| 138 | + g.begin(url); |
| 139 | + g.failed(url); |
| 140 | + assert.strictEqual(g.shouldOpen(url), true, 'a failed open must not suppress later attempts'); |
| 141 | + // …and a later attempt that works still closes the door exactly once. |
| 142 | + g.begin(url); g.succeeded(url); |
| 143 | + assert.strictEqual(g.shouldOpen(url), false); |
| 144 | +}); |
| 145 | + |
| 146 | +test('GATE: an in-flight open blocks a concurrent duplicate', () => { |
| 147 | + // Opening is async, so two runs advertising the same address could both pass the check and stack |
| 148 | + // two tabs. This is why "have we shown it" alone is not enough state. |
| 149 | + const g = createPreviewGate(); |
| 150 | + const url = 'http://localhost:8080'; |
| 151 | + g.begin(url); |
| 152 | + assert.strictEqual(g.shouldOpen(url), false, 'must not open the same URL twice concurrently'); |
| 153 | +}); |
| 154 | + |
| 155 | +test('GATE: distinct URLs are independent, and clear() resets everything', () => { |
| 156 | + const g = createPreviewGate(); |
| 157 | + g.begin('http://localhost:3000'); g.succeeded('http://localhost:3000'); |
| 158 | + assert.strictEqual(g.shouldOpen('http://localhost:4000'), true, 'a different port is a different site'); |
| 159 | + g.clear(); |
| 160 | + assert.strictEqual(g.shouldOpen('http://localhost:3000'), true, 'New Chat may legitimately preview again'); |
| 161 | +}); |
| 162 | + |
| 163 | +test('GATE: junk urls are never openable', () => { |
| 164 | + const g = createPreviewGate(); |
| 165 | + for (const bad of [null, undefined, '']) { assert.strictEqual(g.shouldOpen(/** @type {any} */(bad)), false); } |
| 166 | +}); |
| 167 | + |
| 168 | +// ---- 4. source hygiene ------------------------------------------------------------------------ |
| 169 | + |
| 170 | +test('SOURCE: verify.js contains no raw control bytes', () => { |
| 171 | + // It shipped with a raw NUL in the diagKey separator, which made `file` report "data" and made grep |
| 172 | + // and diff skip the whole module in silence — you could not search your own source, and a reviewer |
| 173 | + // saw only "Binary file matches". The runtime value of an escape is identical, so nothing else |
| 174 | + // catches this. (Same defect was caught by review in the MCP modules.) |
| 175 | + const buf = require('fs').readFileSync(require('path').join(__dirname, '..', 'verify.js')); |
| 176 | + const bad = []; |
| 177 | + for (let i = 0; i < buf.length; i++) { |
| 178 | + const b = buf[i]; |
| 179 | + if (b < 9 || (b > 13 && b < 32)) { bad.push(i); } |
| 180 | + } |
| 181 | + assert.deepStrictEqual(bad, [], 'raw control bytes at ' + bad.slice(0, 5).join(', ') + ' — use an escape'); |
| 182 | +}); |
| 183 | + |
| 184 | +console.log('\nverify.js: ' + n + ' tests passed.'); |
0 commit comments