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,101 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const { Command } = require('commander');
|
|
4
|
+
|
|
5
|
+
// WEB-4941, root cause #2: telemetry.init() runs for every command at startup,
|
|
6
|
+
// but flush()/Sentry.close() only happened inside wrapAction (setup/onboard/
|
|
7
|
+
// discover). Every other command left Sentry's background timers holding the
|
|
8
|
+
// event loop ~1min when a DSN was set. The fix wraps program.parseAsync in an
|
|
9
|
+
// IIFE whose finally always calls telemetry.flush().
|
|
10
|
+
//
|
|
11
|
+
// These tests prove the two invariants that IIFE relies on:
|
|
12
|
+
// A) flush() is a no-op + idempotent when telemetry is disabled (no DSN), so
|
|
13
|
+
// the common path adds zero latency and a double-flush can't throw.
|
|
14
|
+
// B) the try/parseAsync/finally/flush pattern preserves the action's exit code.
|
|
15
|
+
// No real Sentry, no network.
|
|
16
|
+
|
|
17
|
+
function freshTelemetry() {
|
|
18
|
+
delete require.cache[require.resolve('../src/telemetry')];
|
|
19
|
+
return require('../src/telemetry');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function withEnv(vars, fn) {
|
|
23
|
+
const saved = {};
|
|
24
|
+
for (const k of Object.keys(vars)) {
|
|
25
|
+
saved[k] = process.env[k];
|
|
26
|
+
if (vars[k] === undefined) delete process.env[k];
|
|
27
|
+
else process.env[k] = vars[k];
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
} finally {
|
|
32
|
+
for (const k of Object.keys(vars)) {
|
|
33
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
34
|
+
else process.env[k] = saved[k];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test('cli flush: telemetry.flush() is a no-op and idempotent when no DSN is set', async () => {
|
|
40
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined, UNBOUND_TELEMETRY: undefined }, async () => {
|
|
41
|
+
const t = freshTelemetry();
|
|
42
|
+
// Both calls resolve without throwing — the no-op + idempotency the IIFE
|
|
43
|
+
// (and a possible double-flush with wrapAction) rely on.
|
|
44
|
+
await t.flush();
|
|
45
|
+
await t.flush();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('cli flush: the entry pattern preserves a command-set exit code', async () => {
|
|
50
|
+
const savedExitCode = process.exitCode;
|
|
51
|
+
process.exitCode = 0;
|
|
52
|
+
try {
|
|
53
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
54
|
+
const t = freshTelemetry();
|
|
55
|
+
const p = new Command();
|
|
56
|
+
p.exitOverride();
|
|
57
|
+
p.command('x').action(async () => { process.exitCode = 7; });
|
|
58
|
+
|
|
59
|
+
// The exact shape index.js uses around program.parseAsync.
|
|
60
|
+
try {
|
|
61
|
+
await p.parseAsync(['node', 'cli', 'x']);
|
|
62
|
+
} finally {
|
|
63
|
+
await t.flush();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assert.equal(process.exitCode, 7, 'flush in finally must not clobber the command exit code');
|
|
67
|
+
});
|
|
68
|
+
} finally {
|
|
69
|
+
process.exitCode = savedExitCode;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('cli flush: an action that throws past its own handler exits non-zero, not an unhandled crash', async () => {
|
|
74
|
+
const savedExitCode = process.exitCode;
|
|
75
|
+
process.exitCode = 0;
|
|
76
|
+
let caught = false;
|
|
77
|
+
try {
|
|
78
|
+
await withEnv({ UNBOUND_CLI_SENTRY_DSN: undefined }, async () => {
|
|
79
|
+
const t = freshTelemetry();
|
|
80
|
+
const p = new Command();
|
|
81
|
+
p.exitOverride();
|
|
82
|
+
p.command('boom').action(async () => { throw new Error('escaped'); });
|
|
83
|
+
|
|
84
|
+
// The exact try/catch/finally shape index.js uses — a rejection must be
|
|
85
|
+
// turned into a non-zero exit + flush, never an UnhandledPromiseRejection.
|
|
86
|
+
try {
|
|
87
|
+
await p.parseAsync(['node', 'cli', 'boom']);
|
|
88
|
+
} catch {
|
|
89
|
+
caught = true;
|
|
90
|
+
process.exitCode = process.exitCode || 1;
|
|
91
|
+
} finally {
|
|
92
|
+
await t.flush();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
assert.equal(caught, true, 'the rejection must be caught, not left unhandled');
|
|
96
|
+
assert.equal(process.exitCode, 1, 'a thrown action must yield a non-zero exit code');
|
|
97
|
+
});
|
|
98
|
+
} finally {
|
|
99
|
+
process.exitCode = savedExitCode;
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -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
|
+
});
|
package/test/setup-args.test.js
CHANGED
|
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const os = require('os');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS } = require('../src/commands/setup');
|
|
6
|
+
const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
|
|
7
7
|
|
|
8
8
|
// shellEscape single-quotes every value, so a real key surfaces as
|
|
9
9
|
// --api-key '<key>' at the head of the argv tail.
|
|
@@ -219,3 +219,180 @@ if (process.platform !== 'win32') {
|
|
|
219
219
|
}
|
|
220
220
|
});
|
|
221
221
|
}
|
|
222
|
+
|
|
223
|
+
// All assertions below route through path overrides into a tmp dir and force
|
|
224
|
+
// runLaunchctl:false — the real /opt/unbound + /Library/LaunchDaemons/... are
|
|
225
|
+
// never touched (the dev host has a real binary install).
|
|
226
|
+
function makeFakeBinaryInstall() {
|
|
227
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-'));
|
|
228
|
+
const optDir = path.join(tmp, 'opt', 'unbound');
|
|
229
|
+
const versionDir = path.join(optDir, '0.1.5');
|
|
230
|
+
fs.mkdirSync(path.join(versionDir, 'unbound-hook'), { recursive: true });
|
|
231
|
+
fs.mkdirSync(path.join(versionDir, 'unbound-discovery'), { recursive: true });
|
|
232
|
+
fs.writeFileSync(path.join(versionDir, 'unbound-hook', 'unbound-hook'), '#!fake binary\n');
|
|
233
|
+
fs.writeFileSync(path.join(versionDir, 'unbound-discovery', 'unbound-discovery'), '#!fake binary\n');
|
|
234
|
+
fs.mkdirSync(path.join(optDir, 'etc'), { recursive: true });
|
|
235
|
+
fs.writeFileSync(path.join(optDir, 'etc', 'discovery.json'), '{}');
|
|
236
|
+
fs.symlinkSync(versionDir, path.join(optDir, 'current'));
|
|
237
|
+
const daemonPlist = path.join(tmp, 'Library', 'LaunchDaemons', 'ai.getunbound.discovery.plist');
|
|
238
|
+
fs.mkdirSync(path.dirname(daemonPlist), { recursive: true });
|
|
239
|
+
fs.writeFileSync(daemonPlist, '<plist/>');
|
|
240
|
+
const newsyslogConf = path.join(tmp, 'etc', 'newsyslog.d', 'ai.getunbound.conf');
|
|
241
|
+
fs.mkdirSync(path.dirname(newsyslogConf), { recursive: true });
|
|
242
|
+
fs.writeFileSync(newsyslogConf, '# rotate\n');
|
|
243
|
+
const logDir = path.join(tmp, 'var', 'log', 'unbound');
|
|
244
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
245
|
+
fs.writeFileSync(path.join(logDir, 'discovery.log'), 'old logs\n');
|
|
246
|
+
return { tmp, optDir, daemonPlist, newsyslogConf, logDir };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
test('removeBinaryInstall: full install layout — wipes plist + newsyslog + logs + /opt/unbound', () => {
|
|
250
|
+
const fx = makeFakeBinaryInstall();
|
|
251
|
+
try {
|
|
252
|
+
const { removed, failed } = removeBinaryInstall({
|
|
253
|
+
optDir: fx.optDir,
|
|
254
|
+
daemonPlist: fx.daemonPlist,
|
|
255
|
+
newsyslogConf: fx.newsyslogConf,
|
|
256
|
+
logDir: fx.logDir,
|
|
257
|
+
runLaunchctl: false,
|
|
258
|
+
});
|
|
259
|
+
assert.equal(removed.length, 4, `expected 4 removed, got ${JSON.stringify(removed)}`);
|
|
260
|
+
assert.deepEqual(failed, [], `unexpected failures: ${JSON.stringify(failed)}`);
|
|
261
|
+
assert.ok(!fs.existsSync(fx.optDir), 'optDir survived');
|
|
262
|
+
assert.ok(!fs.existsSync(fx.daemonPlist), 'plist survived');
|
|
263
|
+
assert.ok(!fs.existsSync(fx.newsyslogConf), 'newsyslog survived');
|
|
264
|
+
assert.ok(!fs.existsSync(fx.logDir), 'logDir survived');
|
|
265
|
+
// Each label appears in the summary so `unbound nuke` output names what went.
|
|
266
|
+
for (const label of ['LaunchDaemon plist', 'newsyslog conf', 'log dir', 'install tree']) {
|
|
267
|
+
assert.ok(removed.some(s => s.startsWith(label)), `summary missing ${label}: ${removed.join('; ')}`);
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
fs.rmSync(fx.tmp, { recursive: true, force: true });
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('removeBinaryInstall: nothing present (python-only install) — returns empty lists silently', () => {
|
|
275
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-empty-'));
|
|
276
|
+
try {
|
|
277
|
+
const { removed, failed } = removeBinaryInstall({
|
|
278
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
279
|
+
daemonPlist: path.join(tmp, 'Library', 'LaunchDaemons', 'ai.getunbound.discovery.plist'),
|
|
280
|
+
newsyslogConf: path.join(tmp, 'etc', 'newsyslog.d', 'ai.getunbound.conf'),
|
|
281
|
+
logDir: path.join(tmp, 'var', 'log', 'unbound'),
|
|
282
|
+
runLaunchctl: false,
|
|
283
|
+
});
|
|
284
|
+
assert.deepEqual(removed, []);
|
|
285
|
+
assert.deepEqual(failed, []);
|
|
286
|
+
} finally {
|
|
287
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('removeBinaryInstall: partial install (only /opt/unbound, no daemon/log) — removes what exists', () => {
|
|
292
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-partial-'));
|
|
293
|
+
const optDir = path.join(tmp, 'opt', 'unbound');
|
|
294
|
+
fs.mkdirSync(optDir, { recursive: true });
|
|
295
|
+
fs.writeFileSync(path.join(optDir, 'marker'), 'x');
|
|
296
|
+
try {
|
|
297
|
+
const { removed, failed } = removeBinaryInstall({
|
|
298
|
+
optDir,
|
|
299
|
+
daemonPlist: path.join(tmp, 'Library', 'LaunchDaemons', 'ai.getunbound.discovery.plist'),
|
|
300
|
+
newsyslogConf: path.join(tmp, 'etc', 'newsyslog.d', 'ai.getunbound.conf'),
|
|
301
|
+
logDir: path.join(tmp, 'var', 'log', 'unbound'),
|
|
302
|
+
runLaunchctl: false,
|
|
303
|
+
});
|
|
304
|
+
assert.equal(removed.length, 1);
|
|
305
|
+
assert.ok(removed[0].startsWith('install tree'), removed[0]);
|
|
306
|
+
assert.deepEqual(failed, []);
|
|
307
|
+
assert.ok(!fs.existsSync(optDir));
|
|
308
|
+
} finally {
|
|
309
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// Bugbot: when a removal step fails (e.g. plist locked by launchd), it must
|
|
314
|
+
// land in the returned `failed` list AND not appear in `removed`, so the
|
|
315
|
+
// outer nuke summary surfaces a partial wipe instead of printing "success".
|
|
316
|
+
test('removeBinaryInstall: rm failure surfaces in `failed`, not `removed`', () => {
|
|
317
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-rmfail-'));
|
|
318
|
+
// Pass an existsSync-true path that fs.rmSync can't remove. The simplest
|
|
319
|
+
// way to force rmSync to throw is to point optDir at a regular file owned
|
|
320
|
+
// by another process — but cross-platform that's flaky. Instead, monkey-
|
|
321
|
+
// patch fs.rmSync just for this call to simulate the real-world EBUSY.
|
|
322
|
+
const optDir = path.join(tmp, 'opt', 'unbound');
|
|
323
|
+
fs.mkdirSync(optDir, { recursive: true });
|
|
324
|
+
const realRmSync = fs.rmSync;
|
|
325
|
+
fs.rmSync = (p, opts) => {
|
|
326
|
+
if (p === optDir) throw new Error('EBUSY: resource busy or locked');
|
|
327
|
+
return realRmSync(p, opts);
|
|
328
|
+
};
|
|
329
|
+
try {
|
|
330
|
+
const { removed, failed } = removeBinaryInstall({
|
|
331
|
+
optDir,
|
|
332
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
333
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
334
|
+
logDir: path.join(tmp, 'log'),
|
|
335
|
+
runLaunchctl: false,
|
|
336
|
+
});
|
|
337
|
+
assert.deepEqual(removed, [], 'install tree must not be in removed when rmSync threw');
|
|
338
|
+
assert.equal(failed.length, 1, `expected 1 failure, got ${JSON.stringify(failed)}`);
|
|
339
|
+
assert.ok(failed[0].startsWith('install tree'), failed[0]);
|
|
340
|
+
} finally {
|
|
341
|
+
fs.rmSync = realRmSync;
|
|
342
|
+
realRmSync(tmp, { recursive: true, force: true });
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Guards against accidental drift of the hard-coded paths.
|
|
347
|
+
test('BINARY_INSTALL_PATHS: defaults match setup/packaging/pkg/postinstall layout', () => {
|
|
348
|
+
assert.equal(BINARY_INSTALL_PATHS.optDir, '/opt/unbound');
|
|
349
|
+
assert.equal(BINARY_INSTALL_PATHS.daemonPlist, '/Library/LaunchDaemons/ai.getunbound.discovery.plist');
|
|
350
|
+
assert.equal(BINARY_INSTALL_PATHS.daemonLabel, 'ai.getunbound.discovery');
|
|
351
|
+
assert.equal(BINARY_INSTALL_PATHS.newsyslogConf, '/etc/newsyslog.d/ai.getunbound.conf');
|
|
352
|
+
assert.equal(BINARY_INSTALL_PATHS.logDir, '/var/log/unbound');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Safety net: dev machines often have a real /opt/unbound install. Calling
|
|
356
|
+
// removeBinaryInstall with ANY production-default path requires the explicit
|
|
357
|
+
// `_confirmProductionPaths:true` ack, so a no-override test (or refactor
|
|
358
|
+
// drift) fails loudly instead of wiping the dev's real install. This works
|
|
359
|
+
// regardless of NODE_ENV — production sets the ack, tests don't.
|
|
360
|
+
test('removeBinaryInstall: refuses production-default paths without _confirmProductionPaths', () => {
|
|
361
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-bin-guard-'));
|
|
362
|
+
try {
|
|
363
|
+
assert.throws(() => removeBinaryInstall(),
|
|
364
|
+
/_confirmProductionPaths/, 'no-arg call must throw');
|
|
365
|
+
// Partial override (logDir left at production) must throw.
|
|
366
|
+
assert.throws(() => removeBinaryInstall({
|
|
367
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
368
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
369
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
370
|
+
runLaunchctl: false,
|
|
371
|
+
}), /_confirmProductionPaths/, 'partial override leaving logDir defaulted must throw');
|
|
372
|
+
// Greptile P2: daemonLabel must be checked when runLaunchctl is true.
|
|
373
|
+
// All four paths overridden, but default daemonLabel + runLaunchctl:true
|
|
374
|
+
// → would call `launchctl bootout system/ai.getunbound.discovery` on the
|
|
375
|
+
// real host. Must throw.
|
|
376
|
+
assert.throws(() => removeBinaryInstall({
|
|
377
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
378
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
379
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
380
|
+
logDir: path.join(tmp, 'log'),
|
|
381
|
+
runLaunchctl: true,
|
|
382
|
+
}), /_confirmProductionPaths/, 'default daemonLabel with runLaunchctl:true must throw');
|
|
383
|
+
// Full override with runLaunchctl:false does NOT throw (no prod surface).
|
|
384
|
+
assert.doesNotThrow(() => removeBinaryInstall({
|
|
385
|
+
optDir: path.join(tmp, 'opt', 'unbound'),
|
|
386
|
+
daemonPlist: path.join(tmp, 'plist'),
|
|
387
|
+
newsyslogConf: path.join(tmp, 'newsyslog'),
|
|
388
|
+
logDir: path.join(tmp, 'log'),
|
|
389
|
+
runLaunchctl: false,
|
|
390
|
+
}));
|
|
391
|
+
} finally {
|
|
392
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('BINARY_INSTALL_PATHS: is frozen', () => {
|
|
397
|
+
assert.ok(Object.isFrozen(BINARY_INSTALL_PATHS));
|
|
398
|
+
});
|