mirror of https://github.com/garrytan/gstack.git
test(browse): integration coverage for daemon mismatch + proxy fail-fast
Adds two integration tests that exercise the full process boundary, not just the module-level wiring. daemon-mismatch-refuse.test.ts (D2): - Stubs a healthy state file with a fake configHash and a fake /health HTTP server, runs the actual cli.ts binary with a mismatching --proxy, asserts exit 1 + 'different config' / 'browse disconnect' hint in stderr. - Same shape with the plain-daemon-meets---headed case. - Positive case: matching configHash → CLI does NOT emit the mismatch hint (regardless of whether the actual command succeeds). server-proxy-fail-fast.test.ts: - Starts the rejecting SOCKS5 upstream, spawns server.ts with BROWSE_PROXY_URL pointing at it, BROWSE_HEADLESS_SKIP=1 to skip Chromium launch. - Asserts exit 1, 'FAIL upstream' in stderr (testUpstream pre-flight ran), no raw credential leakage in any output (redaction works on the failure path), and exit within 30s upper bound. Both tests use the existing spawn-bun-cli pattern from commands.test.ts so they run on the same CI infrastructure as the rest of the bun test suite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0947f0f935
commit
95268abb87
|
|
@ -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<void> }> {
|
||||||
|
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<void>((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<string, string>, 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<string, string> = {};
|
||||||
|
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<string, string> = {};
|
||||||
|
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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
@ -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<void> }> {
|
||||||
|
// 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<void>((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<string, string> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue