unbound-cli 1.6.3 → 1.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +2 -1
- package/scripts/verify-nuke-ubuntu.sh +92 -0
- package/src/api.js +9 -3
- package/src/auth.js +28 -8
- package/src/commands/discover.js +56 -36
- package/src/commands/onboard.js +18 -4
- package/src/commands/setup.js +204 -117
- package/src/index.js +32 -1
- package/src/run-id.js +27 -0
- package/src/setup-report.js +119 -0
- package/src/telemetry.js +201 -0
- package/src/utils.js +86 -10
- package/test/auth-server-leak.test.js +201 -0
- package/test/cli-flush.test.js +101 -0
- package/test/download-pipe-hole.test.js +221 -0
- package/test/onboard-failure-exit.test.js +125 -0
- package/test/setup-args.test.js +178 -1
- package/test/setup-report.test.js +102 -0
- package/test/telemetry.test.js +114 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
// reportSetupFailure must be BEST-EFFORT: it posts {serial_number, step,
|
|
5
|
+
// exit_code} to /api/v1/setup/failed/, but a POST failure (offline/429/4xx)
|
|
6
|
+
// must never reject — so a caller can `await` it inline in an error path without
|
|
7
|
+
// masking the real error or changing the exit code.
|
|
8
|
+
|
|
9
|
+
function freshReport() {
|
|
10
|
+
for (const m of ['../src/setup-report', '../src/api', '../src/config']) {
|
|
11
|
+
delete require.cache[require.resolve(m)];
|
|
12
|
+
}
|
|
13
|
+
const api = require('../src/api');
|
|
14
|
+
const config = require('../src/config');
|
|
15
|
+
const report = require('../src/setup-report');
|
|
16
|
+
return { api, config, report };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
test('reportSetupFailure: posts the contract payload to /api/v1/setup/failed/', async () => {
|
|
20
|
+
const { api, config, report } = freshReport();
|
|
21
|
+
let captured = null;
|
|
22
|
+
api.post = async (path, opts) => { captured = { path, opts }; return { ok: true }; };
|
|
23
|
+
config.getApiKey = () => 'stored-key';
|
|
24
|
+
|
|
25
|
+
const ok = await report.reportSetupFailure({ step: 'cursor', exitCode: 2, backendUrl: 'https://b.acme' });
|
|
26
|
+
|
|
27
|
+
assert.equal(ok, true);
|
|
28
|
+
assert.equal(captured.path, '/api/v1/setup/failed/');
|
|
29
|
+
assert.equal(captured.opts.apiKey, 'stored-key');
|
|
30
|
+
assert.equal(captured.opts.baseUrl, 'https://b.acme');
|
|
31
|
+
const body = captured.opts.body;
|
|
32
|
+
assert.equal(typeof body.serial_number, 'string');
|
|
33
|
+
assert.ok(body.serial_number.length > 0 && body.serial_number.length <= 255);
|
|
34
|
+
assert.equal(body.step, 'cursor');
|
|
35
|
+
assert.equal(body.exit_code, 2);
|
|
36
|
+
// install_mode/managed are forced server-side — the CLI must NOT send them.
|
|
37
|
+
assert.equal('install_mode' in body, false);
|
|
38
|
+
assert.equal('managed' in body, false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reportSetupFailure: bounds the POST with a 3s timeout (never blocks on default 30s)', async () => {
|
|
42
|
+
const { api, config, report } = freshReport();
|
|
43
|
+
let captured = null;
|
|
44
|
+
api.post = async (path, opts) => { captured = opts; return { ok: true }; };
|
|
45
|
+
config.getApiKey = () => 'k';
|
|
46
|
+
|
|
47
|
+
await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
48
|
+
assert.equal(captured.timeoutMs, 3000, 'failure report must pass a tight 3s timeout');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('reportSetupFailure: swallows a POST rejection and returns false (best-effort)', async () => {
|
|
52
|
+
const { api, config, report } = freshReport();
|
|
53
|
+
api.post = async () => { throw new Error('ECONNREFUSED / offline'); };
|
|
54
|
+
config.getApiKey = () => 'k';
|
|
55
|
+
|
|
56
|
+
let result;
|
|
57
|
+
await assert.doesNotReject(async () => { result = await report.reportSetupFailure({ step: 'codex', exitCode: 1 }); });
|
|
58
|
+
assert.equal(result, false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('reportSetupFailure: a 429 (rate-limited) is swallowed too', async () => {
|
|
62
|
+
const { api, config, report } = freshReport();
|
|
63
|
+
api.post = async () => { const e = new Error('Too many requests'); e.statusCode = 429; throw e; };
|
|
64
|
+
config.getApiKey = () => 'k';
|
|
65
|
+
|
|
66
|
+
const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
67
|
+
assert.equal(result, false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('reportSetupFailure: no API key → skips quietly (false), no POST attempted', async () => {
|
|
71
|
+
const { api, config, report } = freshReport();
|
|
72
|
+
let called = false;
|
|
73
|
+
api.post = async () => { called = true; return {}; };
|
|
74
|
+
config.getApiKey = () => null;
|
|
75
|
+
|
|
76
|
+
const result = await report.reportSetupFailure({ step: 'cursor', exitCode: 1 });
|
|
77
|
+
assert.equal(result, false);
|
|
78
|
+
assert.equal(called, false, 'no POST without a key');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('normalizeStep: clamps to 64 chars and defaults empties to "unknown"', () => {
|
|
82
|
+
const { report } = freshReport();
|
|
83
|
+
assert.equal(report._normalizeStep(''), 'unknown');
|
|
84
|
+
assert.equal(report._normalizeStep(null), 'unknown');
|
|
85
|
+
assert.equal(report._normalizeStep('cursor'), 'cursor');
|
|
86
|
+
assert.equal(report._normalizeStep('x'.repeat(100)).length, 64);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('normalizeExitCode: integers pass through, non-integers fall back to 1', () => {
|
|
90
|
+
const { report } = freshReport();
|
|
91
|
+
assert.equal(report._normalizeExitCode(2), 2);
|
|
92
|
+
assert.equal(report._normalizeExitCode(-1), -1);
|
|
93
|
+
assert.equal(report._normalizeExitCode('nope'), 1);
|
|
94
|
+
assert.equal(report._normalizeExitCode(undefined), 1);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('deviceIdentifier: returns a non-empty string within the 255-char limit', () => {
|
|
98
|
+
const { report } = freshReport();
|
|
99
|
+
const id = report.deviceIdentifier();
|
|
100
|
+
assert.equal(typeof id, 'string');
|
|
101
|
+
assert.ok(id.length > 0 && id.length <= 255);
|
|
102
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
|
|
4
|
+
// Sentry observability is opt-out and DSN-gated. These tests prove it stays
|
|
5
|
+
// fully disabled (no init, no network, no throw) when there's no DSN or when
|
|
6
|
+
// the user opts out, and that beforeSend scrubs secrets before anything leaves
|
|
7
|
+
// the machine. All without any real network or a real Sentry client.
|
|
8
|
+
|
|
9
|
+
function freshTelemetry() {
|
|
10
|
+
delete require.cache[require.resolve('../src/telemetry')];
|
|
11
|
+
return require('../src/telemetry');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function withEnv(vars, fn) {
|
|
15
|
+
const saved = {};
|
|
16
|
+
for (const k of Object.keys(vars)) {
|
|
17
|
+
saved[k] = process.env[k];
|
|
18
|
+
if (vars[k] === undefined) delete process.env[k];
|
|
19
|
+
else process.env[k] = vars[k];
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return fn();
|
|
23
|
+
} finally {
|
|
24
|
+
for (const k of Object.keys(vars)) {
|
|
25
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
26
|
+
else process.env[k] = saved[k];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
test('telemetry: disabled (no init, no throw) when UNBOUND_CLI_SENTRY_DSN is unset', () => {
|
|
32
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined, UNBOUND_TELEMETRY: undefined }, () => {
|
|
33
|
+
const t = freshTelemetry();
|
|
34
|
+
assert.equal(t.isEnabled(), false);
|
|
35
|
+
// init/captureError/flush must be no-ops that never throw or hit the network.
|
|
36
|
+
assert.doesNotThrow(() => t.init({ flow: 'onboard', runId: 'r1' }));
|
|
37
|
+
assert.doesNotThrow(() => t.captureError(new Error('boom')));
|
|
38
|
+
return t.flush(); // resolves, no-op
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('telemetry: opt-out via UNBOUND_TELEMETRY disables even with a DSN present', () => {
|
|
43
|
+
for (const optOut of ['0', 'false', 'no', 'FALSE', 'No']) {
|
|
44
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: 'https://abc@example.invalid/1', UNBOUND_TELEMETRY: optOut }, () => {
|
|
45
|
+
const t = freshTelemetry();
|
|
46
|
+
assert.equal(t.isEnabled(), false, `opt-out value "${optOut}" should disable`);
|
|
47
|
+
assert.doesNotThrow(() => t.init());
|
|
48
|
+
assert.doesNotThrow(() => t.captureError(new Error('x')));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('telemetry: enabled only when DSN set and not opted out', () => {
|
|
54
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: 'https://abc@example.invalid/1', UNBOUND_TELEMETRY: undefined }, () => {
|
|
55
|
+
const t = freshTelemetry();
|
|
56
|
+
assert.equal(t.isEnabled(), true);
|
|
57
|
+
});
|
|
58
|
+
withEnv({ UNBOUND_CLI_SENTRY_DSN: 'https://abc@example.invalid/1', UNBOUND_TELEMETRY: '1' }, () => {
|
|
59
|
+
const t = freshTelemetry();
|
|
60
|
+
assert.equal(t.isEnabled(), true, 'UNBOUND_TELEMETRY=1 is not an opt-out');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('telemetry: wrapAction returns the action result and never inits when disabled', async () => {
|
|
65
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
66
|
+
const t = freshTelemetry();
|
|
67
|
+
const wrapped = t.wrapAction('setup', async (a, b) => a + b);
|
|
68
|
+
const result = await wrapped(2, 3);
|
|
69
|
+
assert.equal(result, 5);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('telemetry: wrapAction captures then rethrows the original error', async () => {
|
|
74
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
75
|
+
const t = freshTelemetry();
|
|
76
|
+
const original = new Error('real failure');
|
|
77
|
+
const wrapped = t.wrapAction('onboard', async () => { throw original; });
|
|
78
|
+
await assert.rejects(() => wrapped(), (err) => err === original);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('beforeSend: scrubs known secret values, home paths, and username', () => {
|
|
83
|
+
const t = freshTelemetry();
|
|
84
|
+
t.rememberSecret('sk-supersecretkey123');
|
|
85
|
+
const home = require('os').homedir();
|
|
86
|
+
const username = require('os').userInfo().username;
|
|
87
|
+
|
|
88
|
+
const event = {
|
|
89
|
+
server_name: 'my-laptop',
|
|
90
|
+
user: { username, id: '1' },
|
|
91
|
+
message: `failed with key sk-supersecretkey123 at ${home}/.unbound/config.json`,
|
|
92
|
+
exception: { values: [{ value: `token ub_abcdefgh12345 leaked for user ${username}` }] },
|
|
93
|
+
};
|
|
94
|
+
const out = t._beforeSend(event);
|
|
95
|
+
|
|
96
|
+
assert.ok(out, 'event is not dropped');
|
|
97
|
+
assert.equal(out.server_name, undefined, 'server_name removed');
|
|
98
|
+
assert.equal(out.user.username, undefined, 'username field removed');
|
|
99
|
+
assert.ok(!out.message.includes('sk-supersecretkey123'), out.message);
|
|
100
|
+
assert.ok(!out.message.includes(home), out.message);
|
|
101
|
+
assert.ok(out.message.includes('~/.unbound'), out.message);
|
|
102
|
+
assert.ok(!out.exception.values[0].value.includes('ub_abcdefgh12345'), out.exception.values[0].value);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('beforeSend: never throws — returns null instead of crashing the flow', () => {
|
|
106
|
+
const t = freshTelemetry();
|
|
107
|
+
// A frozen object can't be mutated; beforeSend must catch and drop, not throw.
|
|
108
|
+
const frozen = Object.freeze({ message: 'x', user: Object.freeze({ username: 'a' }) });
|
|
109
|
+
assert.doesNotThrow(() => {
|
|
110
|
+
const r = t._beforeSend(frozen);
|
|
111
|
+
// Either scrubbed or dropped (null) — both acceptable; the point is no throw.
|
|
112
|
+
assert.ok(r === null || typeof r === 'object');
|
|
113
|
+
});
|
|
114
|
+
});
|