const assert = require('node:assert/strict'); const { once } = require('node:events'); const { 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 PROXYCTL = path.join(__dirname, 'proxyctl.js'); function mkState(t) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'proxyctl-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return { bridgeDir: dir, config: path.join(dir, 'config.json'), user: path.join(dir, 'user.json'), stamp: path.join(dir, 'reauth-stamp.json'), }; } function runProxyctl(st, { args, stdinLines = [], upstreamPort = null }) { return new Promise((resolve, reject) => { const env = { ...process.env, PROXY_BRIDGE_CONFIG_FILE: st.config, PROXY_BRIDGE_USER_FILE: st.user, PROXY_BRIDGE_REAUTH_FILE: st.stamp, }; const child = spawn('node', [PROXYCTL, ...args], { env }); let stdout = '', stderr = ''; child.stdout.on('data', d => { stdout += d; }); child.stderr.on('data', d => { stderr += d; }); const timer = setTimeout(() => { child.kill(); reject(new Error('proxyctl hung')); }, 20000); child.on('exit', code => { clearTimeout(timer); resolve({ status: code, stdout: stdout.replace(/\x1b\[[0-9;]*m/g, ''), stderr }); }); 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, 250); }); } test('reauth: verified new password is stored and stamp file dropped', { timeout: 30000 }, async t => { const st = mkState(t); const account = `reauth-test-${process.pid}`; // Upstream accepts exactly the password 'correct-new-pw' const upstream = net.createServer(socket => { socket.once('data', d => { const auth = /^Proxy-Authorization: (.+)$/mi.exec(d.toString()); const decoded = auth ? Buffer.from(auth[1].replace(/^Basic\s+/i, ''), 'base64').toString() : ''; const ok = decoded === `${account}:correct-new-pw`; socket.end(ok ? 'HTTP/1.1 200 Connection Established\r\n\r\n' : '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(st.config, JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(port) })); fs.writeFileSync(st.user, JSON.stringify({ username: account })); const r = await runProxyctl(st, { args: ['reauth'], stdinLines: ['correct-new-pw'] }); assert.equal(r.status, 0, JSON.stringify(r.stderr + r.stdout)); assert.match(r.stdout, /verified|accepted/i); // keyring got the password (real secret-tool with a junk account; clean up after) const lookup = await runProxyctl(st, { args: ['config'] }).catch(() => null); // not the check; real lookup below const { spawnSync } = require('node:child_process'); const kr = spawnSync('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' }); assert.equal(kr.stdout.trim(), 'correct-new-pw'); spawnSync('secret-tool', ['clear', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' }); // stamp file written so the running bridge picks it up within ~1s const stamp = JSON.parse(fs.readFileSync(st.stamp, 'utf8')); assert.ok(stamp.at > 0); }); test('reauth: rejected password is NOT stored and no stamp is dropped', { timeout: 30000 }, async t => { const st = mkState(t); const account = `reauth-407-${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()); fs.writeFileSync(st.config, JSON.stringify({ enabled: true, host: '127.0.0.1', port: String(upstream.address().port) })); fs.writeFileSync(st.user, JSON.stringify({ username: account })); const r = await runProxyctl(st, { args: ['reauth'], stdinLines: ['wrong'] }); assert.notEqual(r.status, 0, 'reauth of a rejected password must fail'); assert.match(r.stdout + r.stderr, /407|rejected/i); const { spawnSync } = require('node:child_process'); const kr = spawnSync('secret-tool', ['lookup', 'service', 'proxy-bridge', 'account', account], { encoding: 'utf8' }); assert.notEqual(kr.status, 0, 'rejected password must not reach the keyring'); assert.equal(fs.existsSync(st.stamp), false, 'no stamp on failed reauth'); });