unbound-cli 1.6.4 → 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.
@@ -0,0 +1,221 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ // The old `curl -fsSL ... | bash -s --` / `| python3 -` pipe reported the
6
+ // SHELL's exit code, not curl's, and dash has no pipefail — so a failed download
7
+ // (GitHub down/404/empty) silently ran an empty script and exited 0 = fake
8
+ // success. The fix is download-then-run with a non-200/empty-body check.
9
+ //
10
+ // These tests prove the hole is closed: a failed download now causes a NON-zero
11
+ // exit / a rejection, never a silent success. No real network is used — the
12
+ // download is stubbed to fail.
13
+
14
+ // Stub utils.downloadToFile BEFORE requiring discover so the destructured
15
+ // binding inside discover.js captures the stub, not the real https downloader.
16
+ function freshDiscover(downloadStub) {
17
+ for (const m of ['../src/commands/discover', '../src/utils', '../src/config', '../src/output', '../src/telemetry']) {
18
+ delete require.cache[require.resolve(m)];
19
+ }
20
+ const utils = require('../src/utils');
21
+ if (downloadStub) utils.downloadToFile = downloadStub;
22
+ const config = require('../src/config');
23
+ const output = require('../src/output');
24
+ const discover = require('../src/commands/discover');
25
+ return { utils, config, output, discover };
26
+ }
27
+
28
+ test('discover: a failed script download makes runDiscoveryScan REJECT (no silent success)', async () => {
29
+ // Simulate GitHub returning a 404 / partial — downloadToFile rejects before
30
+ // bash ever runs, so the scan can't fake-succeed.
31
+ const { discover } = freshDiscover(async () => { throw new Error('Failed to download install.sh: HTTP 404'); });
32
+
33
+ await assert.rejects(
34
+ () => discover.runDiscoveryScan({ apiKey: 'disc-key', domain: 'https://b.acme' }),
35
+ /Failed to download/,
36
+ );
37
+ });
38
+
39
+ test('discover: an empty-body 200 download also rejects (empty script is not success)', async () => {
40
+ const { discover } = freshDiscover(async () => { throw new Error('Failed to download install.sh: empty response body'); });
41
+
42
+ await assert.rejects(
43
+ () => discover.runDiscoveryScan({ apiKey: 'disc-key' }),
44
+ /empty response body/,
45
+ );
46
+ });
47
+
48
+ test('discover command: a failed download sets process.exitCode = 1 (not a silent 0)', async () => {
49
+ const { output, discover } = freshDiscover(async () => { throw new Error('Failed to download install.sh: HTTP 500'); });
50
+ for (const k of ['info', 'success', 'error', 'warn']) output[k] = () => {};
51
+
52
+ const savedExit = process.exitCode;
53
+ process.exitCode = 0;
54
+ try {
55
+ const program = new Command();
56
+ program.exitOverride();
57
+ discover.register(program);
58
+ // Non-root user scope; the scan download fails → exitCode must become 1.
59
+ await program.parseAsync(['node', 'unbound', 'discover', '--api-key', 'disc-key']);
60
+ assert.equal(process.exitCode, 1, 'failed download must surface a non-zero exit code');
61
+ } finally {
62
+ process.exitCode = savedExit;
63
+ }
64
+ });
65
+
66
+ // The tests below exercise the SHIPPED utils.downloadToFile (no inline re-impl).
67
+ // The production function defaults to https.get but accepts an injectable
68
+ // `{ transport }` so we can drive it against a local http server without TLS —
69
+ // this guarantees the real byte-count guard AND the security hardening are what's
70
+ // under test.
71
+ const http = require('http');
72
+ const os = require('os');
73
+ const path = require('path');
74
+ const fs = require('fs');
75
+
76
+ // One shared local server. Routes:
77
+ // /empty 200 with a zero-byte body (pipe-hole: must reject)
78
+ // /ok 200 with a normal body (must succeed, file mode 0600)
79
+ // /404 404 (non-200: must reject)
80
+ function startServer() {
81
+ const server = http.createServer((req, res) => {
82
+ if (req.url === '/empty') { res.writeHead(200); res.end(); }
83
+ else if (req.url === '/404') { res.writeHead(404); res.end('nope'); }
84
+ else { res.writeHead(200); res.end('echo hi\n'); }
85
+ });
86
+ return new Promise((resolve) => {
87
+ server.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
88
+ });
89
+ }
90
+
91
+ // Fresh utils each time so a prior test's mutation can't leak in.
92
+ function freshUtils() {
93
+ delete require.cache[require.resolve('../src/utils')];
94
+ return require('../src/utils');
95
+ }
96
+
97
+ // A unique path inside a private dir so each test starts from a clean slate.
98
+ function freshTmp(suffix) {
99
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dl-test-'));
100
+ return { dir, file: path.join(dir, `script${suffix}`) };
101
+ }
102
+
103
+ test('downloadToFile (shipped): rejects a 200 with an empty body (pipe hole stays closed)', async () => {
104
+ const utils = freshUtils();
105
+ const { server, port } = await startServer();
106
+ const { dir, file } = freshTmp('.sh');
107
+ try {
108
+ await assert.rejects(
109
+ () => utils.downloadToFile(`http://127.0.0.1:${port}/empty`, file, { transport: http }),
110
+ /empty response body/,
111
+ );
112
+ // The partial/empty file must not be left behind.
113
+ assert.equal(fs.existsSync(file), false, 'empty download must not leave a file');
114
+ } finally {
115
+ fs.rmSync(dir, { recursive: true, force: true });
116
+ server.close();
117
+ }
118
+ });
119
+
120
+ test('downloadToFile (shipped): rejects a non-200 (404)', async () => {
121
+ const utils = freshUtils();
122
+ const { server, port } = await startServer();
123
+ const { dir, file } = freshTmp('.sh');
124
+ try {
125
+ await assert.rejects(
126
+ () => utils.downloadToFile(`http://127.0.0.1:${port}/404`, file, { transport: http }),
127
+ /HTTP 404/,
128
+ );
129
+ } finally {
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ server.close();
132
+ }
133
+ });
134
+
135
+ test('downloadToFile (shipped): writes the file with owner-only mode 0600', async () => {
136
+ const utils = freshUtils();
137
+ const { server, port } = await startServer();
138
+ const { dir, file } = freshTmp('.sh');
139
+ try {
140
+ await utils.downloadToFile(`http://127.0.0.1:${port}/ok`, file, { transport: http });
141
+ assert.equal(fs.readFileSync(file, 'utf8'), 'echo hi\n');
142
+ const mode = fs.statSync(file).mode & 0o777;
143
+ assert.equal(mode, 0o600, `expected 0600, got 0${mode.toString(8)}`);
144
+ } finally {
145
+ fs.rmSync(dir, { recursive: true, force: true });
146
+ server.close();
147
+ }
148
+ });
149
+
150
+ test('downloadToFile (shipped): a pre-existing file at the target FAILS (EEXIST, no overwrite)', async () => {
151
+ const utils = freshUtils();
152
+ const { server, port } = await startServer();
153
+ const { dir, file } = freshTmp('.sh');
154
+ try {
155
+ // Attacker pre-creates the predictable path with their own payload.
156
+ fs.writeFileSync(file, 'malicious-preexisting\n');
157
+ await assert.rejects(
158
+ () => utils.downloadToFile(`http://127.0.0.1:${port}/ok`, file, { transport: http }),
159
+ (err) => err.code === 'EEXIST',
160
+ 'an existing target must yield EEXIST, never be overwritten',
161
+ );
162
+ // The attacker's content must be untouched (we did not follow/overwrite it).
163
+ assert.equal(fs.readFileSync(file, 'utf8'), 'malicious-preexisting\n');
164
+ } finally {
165
+ fs.rmSync(dir, { recursive: true, force: true });
166
+ server.close();
167
+ }
168
+ });
169
+
170
+ test('downloadToFile (shipped): a symlink at the target FAILS (ELOOP/EEXIST, not followed)', async () => {
171
+ const utils = freshUtils();
172
+ const { server, port } = await startServer();
173
+ const { dir, file } = freshTmp('.sh');
174
+ // The symlink points at a victim file the attacker wants us to clobber.
175
+ const victim = path.join(dir, 'victim.txt');
176
+ fs.writeFileSync(victim, 'do-not-touch\n');
177
+ fs.symlinkSync(victim, file);
178
+ try {
179
+ await assert.rejects(
180
+ () => utils.downloadToFile(`http://127.0.0.1:${port}/ok`, file, { transport: http }),
181
+ (err) => err.code === 'ELOOP' || err.code === 'EEXIST',
182
+ 'a symlink at the target must not be followed',
183
+ );
184
+ // The victim behind the symlink must be untouched.
185
+ assert.equal(fs.readFileSync(victim, 'utf8'), 'do-not-touch\n');
186
+ } finally {
187
+ fs.rmSync(dir, { recursive: true, force: true });
188
+ server.close();
189
+ }
190
+ });
191
+
192
+ test('downloadToFile (shipped): a normal download succeeds and stores the body', async () => {
193
+ const utils = freshUtils();
194
+ const { server, port } = await startServer();
195
+ const { dir, file } = freshTmp('.sh');
196
+ try {
197
+ await assert.doesNotReject(
198
+ () => utils.downloadToFile(`http://127.0.0.1:${port}/ok`, file, { transport: http }),
199
+ );
200
+ assert.equal(fs.readFileSync(file, 'utf8'), 'echo hi\n');
201
+ } finally {
202
+ fs.rmSync(dir, { recursive: true, force: true });
203
+ server.close();
204
+ }
205
+ });
206
+
207
+ test('withSecureTempFile (shipped): creates a 0700 private dir and removes it after', async () => {
208
+ const utils = freshUtils();
209
+ let seenPath = null;
210
+ let seenDirMode = null;
211
+ await utils.withSecureTempFile('.py', async (p) => {
212
+ seenPath = p;
213
+ seenDirMode = fs.statSync(path.dirname(p)).mode & 0o777;
214
+ fs.writeFileSync(p, 'x'); // simulate a download into it
215
+ });
216
+ assert.equal(seenDirMode, 0o700, `expected dir mode 0700, got 0${(seenDirMode || 0).toString(8)}`);
217
+ assert.ok(seenPath.endsWith('.py'));
218
+ // The whole private dir (and the file inside it) must be gone afterward.
219
+ assert.equal(fs.existsSync(seenPath), false, 'temp file must be cleaned up');
220
+ assert.equal(fs.existsSync(path.dirname(seenPath)), false, 'temp dir must be cleaned up');
221
+ });
@@ -0,0 +1,125 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ // Onboarding-failure observability, client side:
6
+ // - a failed setup must set process.exitCode = 1 (no fake success), and
7
+ // - the best-effort failure report must never change that exit code or mask the
8
+ // real error, even when the report POST itself fails (offline/429).
9
+ //
10
+ // All downstream deps are stubbed; no network, no setup scripts, no Sentry.
11
+
12
+ function loadOnboard({ isRoot, setupOk, reportThrows }) {
13
+ for (const m of [
14
+ '../src/commands/onboard', '../src/commands/setup', '../src/commands/discover',
15
+ '../src/auth', '../src/config', '../src/output', '../src/scheduled',
16
+ '../src/setup-report', '../src/telemetry',
17
+ ]) {
18
+ delete require.cache[require.resolve(m)];
19
+ }
20
+
21
+ const calls = { setup: 0, discovery: 0, report: 0 };
22
+ const setup = require('../src/commands/setup');
23
+ const discover = require('../src/commands/discover');
24
+ const auth = require('../src/auth');
25
+ const config = require('../src/config');
26
+ const output = require('../src/output');
27
+ const setupReport = require('../src/setup-report');
28
+
29
+ setup.hasRootPrivileges = () => isRoot;
30
+ // Mirror runBatch's real behavior: on failure it sets exitCode and (best-effort)
31
+ // reports, then returns { ok: false }. We model both the report call and the
32
+ // exitCode side effect so the test exercises onboard's own handling.
33
+ const bundle = async () => {
34
+ calls.setup++;
35
+ if (!setupOk) {
36
+ process.exitCode = 1;
37
+ calls.report++;
38
+ await setupReport.reportSetupFailure({ step: 'cursor', exitCode: 2 });
39
+ return { ok: false, skipped: [] };
40
+ }
41
+ return { ok: true, skipped: [] };
42
+ };
43
+ setup.runSetupAllBundle = bundle;
44
+ setup.runMdmSetupAllBundle = bundle;
45
+ discover.runDiscoveryScan = async () => { calls.discovery++; };
46
+ auth.ensureLoggedIn = async () => {};
47
+ config.setUrls = () => ({});
48
+ config.getApiKey = () => 'resolved-key';
49
+ config.getBaseUrl = () => 'https://b.acme';
50
+ config.getFrontendUrl = () => 'https://f.acme';
51
+ config.getGatewayUrl = () => 'https://g.acme';
52
+ config.isLoggedIn = () => true;
53
+ for (const k of ['info', 'success', 'error', 'warn']) output[k] = () => {};
54
+
55
+ // The failure-report POST fails (offline/429) — must be swallowed.
56
+ const api = require('../src/api');
57
+ api.post = async () => {
58
+ if (reportThrows) throw new Error('offline / ECONNREFUSED');
59
+ return {};
60
+ };
61
+
62
+ return { calls };
63
+ }
64
+
65
+ async function runOnboard(env) {
66
+ const { calls } = env;
67
+ const { register } = require('../src/commands/onboard');
68
+ const program = new Command();
69
+ program.exitOverride();
70
+ register(program);
71
+ await program.parseAsync(['node', 'unbound', 'onboard', '--api-key', 'k', '--discovery-key', 'd']);
72
+ return calls;
73
+ }
74
+
75
+ test('onboard: a failed setup sets process.exitCode = 1 and does NOT run discovery', async () => {
76
+ const saved = process.exitCode;
77
+ process.exitCode = 0;
78
+ try {
79
+ const env = loadOnboard({ isRoot: false, setupOk: false, reportThrows: false });
80
+ const calls = await runOnboard(env);
81
+ assert.equal(process.exitCode, 1, 'failed setup must exit non-zero');
82
+ assert.equal(calls.setup, 1, 'setup bundle ran');
83
+ assert.equal(calls.discovery, 0, 'discovery must not run after a failed setup');
84
+ } finally {
85
+ process.exitCode = saved;
86
+ }
87
+ });
88
+
89
+ test('onboard (sudo/MDM): a failed MDM setup also sets process.exitCode = 1', async () => {
90
+ const saved = process.exitCode;
91
+ process.exitCode = 0;
92
+ try {
93
+ const env = loadOnboard({ isRoot: true, setupOk: false, reportThrows: false });
94
+ await runOnboard(env);
95
+ assert.equal(process.exitCode, 1, 'failed MDM setup must exit non-zero');
96
+ } finally {
97
+ process.exitCode = saved;
98
+ }
99
+ });
100
+
101
+ test('onboard: a failing failure-report POST is swallowed — exit code stays 1, not masked', async () => {
102
+ const saved = process.exitCode;
103
+ process.exitCode = 0;
104
+ try {
105
+ const env = loadOnboard({ isRoot: false, setupOk: false, reportThrows: true });
106
+ const calls = await runOnboard(env);
107
+ assert.equal(calls.report, 1, 'a failure report was attempted');
108
+ assert.equal(process.exitCode, 1, 'a failing telemetry POST must not change the exit code');
109
+ } finally {
110
+ process.exitCode = saved;
111
+ }
112
+ });
113
+
114
+ test('onboard: a successful setup proceeds to discovery and exits 0', async () => {
115
+ const saved = process.exitCode;
116
+ process.exitCode = 0;
117
+ try {
118
+ const env = loadOnboard({ isRoot: false, setupOk: true, reportThrows: false });
119
+ const calls = await runOnboard(env);
120
+ assert.equal(calls.discovery, 1, 'discovery runs after a successful setup');
121
+ assert.equal(process.exitCode, 0, 'a clean onboard keeps exit code 0');
122
+ } finally {
123
+ process.exitCode = saved;
124
+ }
125
+ });
@@ -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
+ });