diff --git a/browse/test/daemon-mismatch-refuse.test.ts b/browse/test/daemon-mismatch-refuse.test.ts new file mode 100644 index 00000000..b36ab64e --- /dev/null +++ b/browse/test/daemon-mismatch-refuse.test.ts @@ -0,0 +1,178 @@ +/** + * D2: integration test for daemon-mismatch refuse. + * + * Stubs a healthy-looking state file with a known configHash, spins up a + * tiny HTTP listener that answers /health (so the CLI's health check + * passes), then runs the actual cli.ts binary with a different --proxy + * value (different configHash). Asserts exit 1 and the disconnect hint + * in stderr. + * + * This catches integration regressions that the unit tests on + * extractGlobalFlags can't see — specifically the wiring between + * extractGlobalFlags → ensureServer → state-file diff comparison. + */ + +import { describe, test, expect } from 'bun:test'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as http from 'http'; + +async function startFakeHealthServer(token: string): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer((req, res) => { + if (req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'healthy', token })); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('fake server: bad address'); + return { + port: addr.port, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +async function runCli(args: string[], env: Record, timeoutMs = 10000): Promise<{ code: number; stdout: string; stderr: string }> { + const cliPath = path.resolve(__dirname, '../src/cli.ts'); + return new Promise((resolve) => { + const proc = spawn('bun', ['run', cliPath, ...args], { + timeout: timeoutMs, + env, + }); + let stdout = ''; let stderr = ''; + proc.stdout.on('data', (d) => stdout += d.toString()); + proc.stderr.on('data', (d) => stderr += d.toString()); + proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr })); + }); +} + +describe('D2 daemon-mismatch refuse (CLI integration)', () => { + test('refuses when existing daemon has different configHash', async () => { + // Set up a fake healthy daemon with a config-hash that won't match. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-mismatch-')); + const stateFile = path.join(tmpDir, 'browse.json'); + const fakeServer = await startFakeHealthServer('fake-token'); + + fs.writeFileSync(stateFile, JSON.stringify({ + pid: process.pid, // alive (current bun process); health check is what really gates this + port: fakeServer.port, + token: 'fake-token', + startedAt: new Date().toISOString(), + serverPath: '', + mode: 'launched', + configHash: 'aaaaaaaaaaaaaaaa', // 16-char hex; won't match new --proxy hash + }, null, 2)); + + const cliEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) cliEnv[k] = v; + } + cliEnv.BROWSE_STATE_FILE = stateFile; + + try { + const result = await runCli( + ['--proxy', 'socks5://example.com:1080', 'status'], + cliEnv, + ); + expect(result.code).toBe(1); + expect(result.stderr.toLowerCase()).toMatch(/different config|mismatch|browse disconnect/); + } finally { + await fakeServer.close(); + try { fs.unlinkSync(stateFile); } catch { /* ignore */ } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 15000); + + test('refuses when existing plain daemon meets a --proxy invocation', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-mismatch-plain-')); + const stateFile = path.join(tmpDir, 'browse.json'); + const fakeServer = await startFakeHealthServer('fake-token'); + + // Plain daemon (no configHash) — represents the existing-default case. + fs.writeFileSync(stateFile, JSON.stringify({ + pid: process.pid, + port: fakeServer.port, + token: 'fake-token', + startedAt: new Date().toISOString(), + serverPath: '', + mode: 'launched', + }, null, 2)); + + const cliEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) cliEnv[k] = v; + } + cliEnv.BROWSE_STATE_FILE = stateFile; + + try { + const result = await runCli( + ['--headed', 'status'], + cliEnv, + ); + expect(result.code).toBe(1); + expect(result.stderr.toLowerCase()).toMatch(/without --proxy|browse disconnect/); + } finally { + await fakeServer.close(); + try { fs.unlinkSync(stateFile); } catch { /* ignore */ } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 15000); + + test('reuses existing daemon when configHash matches', async () => { + // A successful match: build a fake daemon with the SAME configHash the + // CLI would compute for `--proxy socks5://reuse.example:1080`. + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-match-')); + const stateFile = path.join(tmpDir, 'browse.json'); + const fakeServer = await startFakeHealthServer('fake-token'); + + const { computeConfigHash } = await import('../src/proxy-config'); + const matchingHash = computeConfigHash({ + proxyUrl: 'socks5://reuse.example:1080', + headed: false, + }); + + fs.writeFileSync(stateFile, JSON.stringify({ + pid: process.pid, + port: fakeServer.port, + token: 'fake-token', + startedAt: new Date().toISOString(), + serverPath: '', + mode: 'launched', + configHash: matchingHash, + }, null, 2)); + + const cliEnv: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) cliEnv[k] = v; + } + cliEnv.BROWSE_STATE_FILE = stateFile; + + try { + const result = await runCli( + ['--proxy', 'socks5://reuse.example:1080', 'status'], + cliEnv, + ); + // Status command would fail to actually return useful data because our + // fake server doesn't implement /command, but the CLI must NOT exit + // with the mismatch error code path (which is exit 1 + 'different + // config' in stderr). Acceptable outcomes: + // - exit 0 (status returned ok somehow) + // - exit !=0 from a different reason (bad token, command-handler missing) + // The thing we assert is: stderr does NOT contain the mismatch hint. + expect(result.stderr).not.toMatch(/different config|run 'browse disconnect' first/i); + } finally { + await fakeServer.close(); + try { fs.unlinkSync(stateFile); } catch { /* ignore */ } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 15000); +}); diff --git a/browse/test/server-proxy-fail-fast.test.ts b/browse/test/server-proxy-fail-fast.test.ts new file mode 100644 index 00000000..289fe9d1 --- /dev/null +++ b/browse/test/server-proxy-fail-fast.test.ts @@ -0,0 +1,98 @@ +/** + * Integration test: server.ts startup fail-fast on bad SOCKS5 upstream. + * + * Spawns the actual server.ts with BROWSE_PROXY_URL pointing at a port + * that listens but rejects every CONNECT. Asserts: + * - exit code 1 + * - stderr contains "FAIL upstream" (proof the testUpstream pre-flight ran) + * - stderr does NOT contain raw credentials (proof redaction works on + * the failure path) + * - exits within the 5s budget + retry overhead + */ + +import { describe, test, expect } from 'bun:test'; +import { spawn } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as net from 'net'; + +async function startRejectingUpstream(): Promise<{ port: number; close: () => Promise }> { + // Accepts TCP connections, completes the SOCKS5 username/password auth + // handshake by REJECTING (status 0x01), then closes. Our testUpstream() + // should retry 3x and exhaust within ~5s. + const server = net.createServer((sock) => { + sock.once('data', (greeting) => { + if (greeting[0] !== 0x05) { sock.destroy(); return; } + const methods = greeting.subarray(2, 2 + greeting[1]); + if (!methods.includes(0x02)) { sock.write(Buffer.from([0x05, 0xFF])); sock.destroy(); return; } + sock.write(Buffer.from([0x05, 0x02])); + sock.once('data', () => { + // Reject auth (0x01) + try { sock.write(Buffer.from([0x01, 0x01])); } catch { /* peer gone */ } + sock.destroy(); + }); + }); + sock.on('error', () => sock.destroy()); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address(); + if (!addr || typeof addr === 'string') throw new Error('rejecting upstream: bad address'); + return { + port: addr.port, + close: () => new Promise((r) => server.close(() => r())), + }; +} + +describe('server fail-fast on bad SOCKS5 upstream', () => { + test('exits 1 with redacted error within budget', async () => { + const upstream = await startRejectingUpstream(); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-fail-fast-')); + const stateFile = path.join(tmpDir, 'browse.json'); + + const serverPath = path.resolve(__dirname, '../src/server.ts'); + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined) env[k] = v; + } + env.BROWSE_STATE_FILE = stateFile; + env.BROWSE_PARENT_PID = '0'; // disable watchdog so we can isolate the proxy failure + env.BROWSE_HEADLESS_SKIP = '1'; // skip the chromium launch (we only test the proxy gate) + env.BROWSE_PROXY_URL = `socks5://baduser:badpass@127.0.0.1:${upstream.port}`; + + const start = Date.now(); + const result = await new Promise<{ code: number; stdout: string; stderr: string; ms: number }>((resolve) => { + const proc = spawn('bun', ['run', serverPath], { + timeout: 30000, + env, + }); + let stdout = ''; let stderr = ''; + proc.stdout.on('data', (d) => stdout += d.toString()); + proc.stderr.on('data', (d) => stderr += d.toString()); + proc.on('close', (code) => resolve({ code: code ?? 1, stdout, stderr, ms: Date.now() - start })); + }); + + try { + // Expectation 1: exit 1 + expect(result.code).toBe(1); + // Expectation 2: stderr names the failure mode and references the upstream + const combined = result.stdout + result.stderr; + expect(combined).toMatch(/FAIL upstream/); + // Expectation 3: redaction. Raw 'baduser' and 'badpass' must NEVER + // appear in any output, even on the failure path. + expect(combined).not.toContain('baduser'); + expect(combined).not.toContain('badpass'); + // Expectation 4: budget. testUpstream caps at 5s plus a small amount + // of script startup overhead (~3-5s for `bun run`). Cap at 30s as a + // generous upper bound so the assertion is meaningful but not flaky. + expect(result.ms).toBeLessThan(30000); + } finally { + await upstream.close(); + try { fs.unlinkSync(stateFile); } catch { /* ignore */ } + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }, 60000); +});