const assert = require('node:assert/strict'); const { once } = require('node:events'); const { spawnSync, spawn } = require('node:child_process'); const fs = require('node:fs'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { test } = require('node:test'); const INSTALLER = path.join(__dirname, 'install.js'); const PROXY_DIR = __dirname; function mkTmp(t) { const dirs = { bridge: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-bridge-')), bin: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-bin-')), systemd: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-systemd-')), home: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-home-')), apt: fs.mkdtempSync(path.join(os.tmpdir(), 'pi-apt-')), }; t.after(() => Object.values(dirs).forEach(d => fs.rmSync(d, { recursive: true, force: true }))); return dirs; } function envFor(dirs) { return { ...process.env, PROXY_BRIDGE_DIR: dirs.bridge, PROXY_BRIDGE_BIN_DIR: dirs.bin, PROXY_BRIDGE_SYSTEMD_USER_DIR: dirs.systemd, PROXY_BRIDGE_BASHRC: path.join(dirs.home, '.bashrc'), PROXY_BRIDGE_APT_CONF: path.join(dirs.apt, '80proxy-bridge'), }; } function runInstaller(t, dirs, extraArgs = []) { const r = spawnSync('node', [INSTALLER, '--no-systemd', ...extraArgs], { env: envFor(dirs), encoding: 'utf8', timeout: 60000, input: '', // no stdin: any interactive prompt must not be reached in these non-interactive paths }); r.stdout = String(r.stdout).replace(/\x1b\[[0-9;]*m/g, ''); r.stderr = String(r.stderr || '').replace(/\x1b\[[0-9;]*m/g, ''); return r; } // Async variant: use whenever the test process itself hosts a TCP stub server — // spawnSync would block the event loop and the stub would never answer. function runInstallerAsync(t, dirs, { args = [], stdinLines = [] } = {}) { return new Promise((resolve, reject) => { const child = spawn('node', [INSTALLER, '--no-systemd', ...args], { env: envFor(dirs) }); let stdout = ''; let stderr = ''; child.stdout.on('data', d => { stdout += d; }); child.stderr.on('data', d => { stderr += d; }); const timer = setTimeout(() => { child.kill(); reject(new Error('installer hung')); }, 55000); child.on('exit', code => { clearTimeout(timer); resolve({ status: code, stdout: stdout.replace(/\x1b\[[0-9;]*m/g, ''), stderr: stderr.replace(/\x1b\[[0-9;]*m/g, ''), }); }); // Feed stdin with a small delay so readline has attached before lines arrive. let i = 0; const feed = () => { if (i >= stdinLines.length) { try { child.stdin.end(); } catch {} return; } child.stdin.write(stdinLines[i++] + '\n'); setTimeout(feed, 150); }; setTimeout(feed, 300); }); } test('dry-run changes nothing on disk', { timeout: 30000 }, async t => { const dirs = mkTmp(t); const r = runInstaller(t, dirs, ['--dry-run', '--non-interactive']); // non-interactive with no imported config -> CONFIG section reports FAILED by design; fine for this test assert.equal(fs.readdirSync(dirs.bridge).length, 0, 'bridge dir must stay empty in dry-run'); assert.equal(fs.readdirSync(dirs.bin).length, 0, 'bin dir must stay empty in dry-run'); assert.equal(fs.readdirSync(dirs.systemd).length, 0, 'systemd dir must stay empty in dry-run'); assert.ok(!fs.existsSync(path.join(dirs.home, '.bashrc')), 'bashrc must not be created in dry-run'); assert.match(r.stdout, /Dry run: no changes were made\./); }); test('import-only run is idempotent: second run reports zero CHANGED', { timeout: 60000 }, async t => { const dirs = mkTmp(t); // Seed an 'imported' config the way the live /opt has it fs.writeFileSync(path.join(dirs.bridge, 'config.json'), JSON.stringify({ enabled: true, host: '127.0.0.1', port: '1' })); fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: 'seeded-user' })); // Seed bashrc with the EXACT bridge block the installer would write (idempotency target) fs.writeFileSync(path.join(dirs.home, '.bashrc'), `\n# Local Proxy Bridge Configuration #PROXY_BRIDGE_CONFIG\nexport http_proxy="http://127.0.0.1:8888/"\nexport https_proxy="http://127.0.0.1:8888/"\nexport HTTP_PROXY="http://127.0.0.1:8888/"\nexport HTTPS_PROXY="http://127.0.0.1:8888/"\nexport NO_PROXY="localhost,127.0.0.1,::1"\nexport no_proxy="localhost,127.0.0.1,::1"\n`); // Seed the apt conf the installer would want (no sudo needed: our APT_CONF is a tmp path) fs.writeFileSync(path.join(dirs.apt, '80proxy-bridge'), `Acquire::http::Proxy "http://127.0.0.1:8888/";\nAcquire::https::Proxy "http://127.0.0.1:8888/";\n`); const r1 = runInstaller(t, dirs, ['--non-interactive']); assert.equal(r1.status, 1, JSON.stringify(r1.stdout + r1.stderr)); // creds missing -> expected FAILED in this seed assert.match(r1.stdout, /\[OK\] Runtime config/); assert.match(r1.stdout, /\[FAILED\] Credentials/); // no keyring entry for seeded-user; correct // Second run must show zero CHANGED in the sections that did run const r2 = runInstaller(t, dirs, ['--non-interactive']); const changedLines = r2.stdout.split('\n').filter(l => l.includes('[CHANGED]')); assert.deepEqual(changedLines, [], `second run must be a no-op, got CHANGED: ${changedLines.join(' | ')}`); }); test('credential verification: 200 from upstream stores the password (junk test account)', { timeout: 60000 }, async t => { const dirs = mkTmp(t); const account = `bridge-test-${process.pid}`; // Stub "upstream" that accepts any creds once const upstream = net.createServer(socket => { socket.once('data', () => { socket.end('HTTP/1.1 200 Connection Established\r\n\r\n'); }); }); upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); t.after(() => upstream.close()); const port = upstream.address().port; fs.writeFileSync(path.join(dirs.bridge, 'config.json'), JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(port) })); fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: account })); fs.writeFileSync(path.join(dirs.home, '.bashrc'), ''); const r = await runInstallerAsync(t, dirs, { stdinLines: ['junk-test-password-not-real', 'n'] }); // creds stored via real secret-tool; clean up regardless of outcome spawnSync('secret-tool', ['clear', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' }); assert.match(r.stdout, /\[OK\] Credentials|\[CHANGED\] Credentials/); assert.match(r.stdout, /credentials verified against upstream and stored/); }); test('credential verification: 407 from upstream does NOT store and reports FAILED', { timeout: 60000 }, async t => { const dirs = mkTmp(t); const account = `bridge-test407-${process.pid}`; const upstream = net.createServer(socket => { socket.once('data', () => socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n')); }); upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening'); t.after(() => upstream.close()); const port = upstream.address().port; fs.writeFileSync(path.join(dirs.bridge, 'config.json'), JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(port) })); fs.writeFileSync(path.join(dirs.bridge, 'user.json'), JSON.stringify({ username: account })); fs.writeFileSync(path.join(dirs.home, '.bashrc'), ''); const r = await runInstallerAsync(t, dirs, { stdinLines: ['wrong1', 'wrong2', 'wrong3', 'n'] }); // make sure nothing was stored for this account const check = spawnSync('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' }); assert.notEqual(check.status, 0, 'no keyring entry must exist for rejected creds'); assert.match(r.stdout, /\[FAILED\] Credentials/); assert.match(r.stdout, /nothing stored/i); });