Files
gists/scripts/proxy/proxyctl.test.js
T
tke be0e2d84cc feat(proxy): desktop lockout notification + 'proxy-bridge reauth' command
Notification path (bridge.js): entering auth lockout fires ONE desktop
notification per episode (notify-send, critical urgency), only when a session
is detectable (DBUS_SESSION_BUS_ADDRESS or DISPLAY) — headless stays silent.
Message explains the 407 cause and points at 'proxy-bridge reauth'.

Recovery path (proxyctl.js): 'proxy-bridge reauth' prompts masked, verifies the
new password with a SINGLE upstream CONNECT (407 -> NOT stored), stores via
secret-tool on success, then writes a reauth-stamp.json the bridge watches
(1s poll): clears in-memory authState and reloads keyring creds — no restart.

Shared one-shot verifier extracted to verify-creds.js (install.js + proxyctl.js
use the same code; also fixes proxyctl 'setup' shelling to the retired
/opt/setup.js — 'reauth' is its replacement for the password-expiry flow).

Tests: +3 bridge (notify once-per-episode incl. remediation hint; headless
silent; stamp clears lockout without restart) +2 proxyctl (verified stored +
stamped; rejected never stored/stamped). Suite: 28/28.
2026-09-24 13:10:42 +02:00

107 lines
4.9 KiB
JavaScript

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');
});