unbound-cli 1.3.2 → 1.5.0

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,884 @@
1
+ const { test, beforeEach, after } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ // Integration tests: invoke `unbound policy tool create-terminal --prompt ...`
6
+ // against a fresh Command instance with src/api.js and src/config.js stubbed.
7
+ // We never hit the network and never read/write user config.
8
+
9
+ function loadFreshModules() {
10
+ // Drop the module cache so each test gets a clean module-level
11
+ // `_privilegesCache` and a clean stub surface.
12
+ for (const m of [
13
+ '../src/commands/policy',
14
+ '../src/lib/policy-ai-assist',
15
+ '../src/api',
16
+ '../src/config',
17
+ '../src/output',
18
+ ]) {
19
+ delete require.cache[require.resolve(m)];
20
+ }
21
+ const api = require('../src/api');
22
+ const config = require('../src/config');
23
+ const output = require('../src/output');
24
+ return { api, config, output };
25
+ }
26
+
27
+ // ApiError shape from src/api.js — exposes statusCode and body.
28
+ class FakeApiError extends Error {
29
+ constructor(statusCode, body) {
30
+ super(body && body.error ? body.error : `HTTP ${statusCode}`);
31
+ this.name = 'ApiError';
32
+ this.statusCode = statusCode;
33
+ this.body = body;
34
+ }
35
+ }
36
+
37
+ function buildHarness({
38
+ privilegesResponse = { is_admin: true, is_manager: false, is_member: false },
39
+ privilegesError = null,
40
+ assistResponse = null,
41
+ assistError = null,
42
+ createResponse = { id: 1, name: 'ok' },
43
+ createError = null,
44
+ isTty = false,
45
+ } = {}) {
46
+ const { api, config, output } = loadFreshModules();
47
+
48
+ config.isLoggedIn = () => true;
49
+ config.getApiKey = () => 'fake-key';
50
+ config.getBaseUrl = () => 'https://b.acme';
51
+
52
+ const calls = { posts: [], gets: [] };
53
+ api.get = async (path, opts) => {
54
+ calls.gets.push({ path, opts });
55
+ if (path === '/api/v1/users/privileges/') {
56
+ if (privilegesError) throw privilegesError;
57
+ return privilegesResponse;
58
+ }
59
+ throw new Error(`unexpected GET ${path}`);
60
+ };
61
+ api.post = async (path, opts) => {
62
+ calls.posts.push({ path, body: opts && opts.body });
63
+ if (path === '/api/v1/command-policies/assist/') {
64
+ if (assistError) throw assistError;
65
+ return assistResponse;
66
+ }
67
+ if (path === '/api/v1/command-policies/') {
68
+ if (createError) throw createError;
69
+ return createResponse;
70
+ }
71
+ throw new Error(`unexpected POST ${path}`);
72
+ };
73
+
74
+ // Silence + capture output.
75
+ const captured = { success: [], error: [], warn: [], info: [], stdout: [] };
76
+ output.success = (m) => captured.success.push(m);
77
+ output.error = (m) => captured.error.push(m);
78
+ output.warn = (m) => captured.warn.push(m);
79
+ output.info = (m) => captured.info.push(m);
80
+ // renderTerminalPreview uses console.log directly; capture stdout.
81
+ const origLog = console.log;
82
+ console.log = (m) => captured.stdout.push(String(m || ''));
83
+ const restoreLog = () => { console.log = origLog; };
84
+
85
+ return { api, config, output, calls, captured, restoreLog };
86
+ }
87
+
88
+ async function runCreate(argv) {
89
+ const { register } = require('../src/commands/policy');
90
+ const program = new Command();
91
+ program.exitOverride();
92
+ register(program);
93
+ const full = ['node', 'unbound', 'policy', 'tool', 'create-terminal', ...argv];
94
+ await program.parseAsync(full);
95
+ }
96
+
97
+ beforeEach(() => {
98
+ process.exitCode = 0;
99
+ });
100
+
101
+ // Several tests intentionally set process.exitCode to non-zero values to
102
+ // observe the CLI's exit-code routing. Reset it at the end so node:test does
103
+ // not interpret the file itself as having failed.
104
+ after(() => {
105
+ process.exitCode = 0;
106
+ });
107
+
108
+ // --- (a) Mutex cases: --prompt + each conflicting flag -----------------------
109
+ // --name, --description, --action are NO LONGER mutex with --prompt; they act
110
+ // as overrides over the AI's suggestion. See tests in the "AI overrides" block.
111
+
112
+ const MUTEX_FLAGS = [
113
+ ['--command-family', 'git'],
114
+ ['--field', 'command=git push*'],
115
+ ];
116
+
117
+ for (const [flag, value] of MUTEX_FLAGS) {
118
+ test(`mutex: --prompt + ${flag} exits 1 with verbatim wording`, async () => {
119
+ const h = buildHarness();
120
+ try {
121
+ await runCreate(['--prompt', 'block rm -rf', flag, value]);
122
+ assert.equal(process.exitCode, 1);
123
+ assert.ok(
124
+ h.captured.error.some((m) => m === 'Pass --prompt for AI-assist or the field flags for explicit creation, not both.'),
125
+ `expected mutex error, got: ${JSON.stringify(h.captured.error)}`
126
+ );
127
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
128
+ } finally {
129
+ h.restoreLog();
130
+ }
131
+ });
132
+ }
133
+
134
+ // Fix 4: --config must also be mutex with --prompt.
135
+ test('mutex: --prompt + --config exits 1 with verbatim wording', async () => {
136
+ const h = buildHarness();
137
+ try {
138
+ await runCreate(['--prompt', 'block rm -rf', '--config', '{"foo":"bar"}']);
139
+ assert.equal(process.exitCode, 1);
140
+ assert.ok(
141
+ h.captured.error.some((m) => m === 'Pass --prompt for AI-assist or the field flags for explicit creation, not both.'),
142
+ `expected mutex error, got: ${JSON.stringify(h.captured.error)}`
143
+ );
144
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
145
+ } finally {
146
+ h.restoreLog();
147
+ }
148
+ });
149
+
150
+ // Fix 3: empty --prompt is rejected (does NOT silently fall through to flag path).
151
+ test('empty --prompt: exits 2, never reaches network', async () => {
152
+ const h = buildHarness();
153
+ try {
154
+ await runCreate(['--prompt', '']);
155
+ assert.equal(process.exitCode, 2);
156
+ assert.ok(
157
+ h.captured.error.some((m) => m.includes('--prompt cannot be empty.')),
158
+ `expected empty-prompt error, got: ${JSON.stringify(h.captured.error)}`
159
+ );
160
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
161
+ assert.equal(h.calls.gets.length, 0, 'no privileges probe should have fired');
162
+ } finally {
163
+ h.restoreLog();
164
+ }
165
+ });
166
+
167
+ // Fix 1: action defaults to AUDIT when neither flag nor AI sets it.
168
+ test('default action: when form_updates omits action and no --action flag, body.action === "AUDIT"', async () => {
169
+ const h = buildHarness({
170
+ assistResponse: {
171
+ success: true,
172
+ form_updates: {
173
+ command_family: 'git',
174
+ selected_field: 'command',
175
+ field_value: 'git push*',
176
+ name: 'Some policy',
177
+ // action intentionally omitted
178
+ },
179
+ explanation: 'ok',
180
+ },
181
+ });
182
+ try {
183
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
184
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
185
+ assert.ok(createCall, 'create POST should have fired');
186
+ assert.equal(createCall.body.action, 'AUDIT', 'action must default to AUDIT per spec §6.5');
187
+ } finally {
188
+ h.restoreLog();
189
+ }
190
+ });
191
+
192
+ // --- (b) Merge precedence -----------------------------------------------------
193
+
194
+ test('merge: flag wins over AI value where set; AI fills the rest', async () => {
195
+ const h = buildHarness({
196
+ assistResponse: {
197
+ success: true,
198
+ form_updates: {
199
+ command_family: 'filesystem',
200
+ selected_field: 'command',
201
+ field_value: 'rm -rf*',
202
+ action: 'BLOCK',
203
+ name: 'AI Name',
204
+ description: 'AI desc',
205
+ },
206
+ explanation: 'classified as filesystem destructive write',
207
+ },
208
+ createResponse: { id: 42, name: 'AI Name' },
209
+ });
210
+ // Stub loadFormData by stubbing api.get for form_data too.
211
+ h.api.get = async (path) => {
212
+ if (path === '/api/v1/users/privileges/') return { is_admin: true };
213
+ if (path === '/api/v1/policies/form_data/') {
214
+ return { data: { user_groups: [{ id: 7, name: 'GroupX' }] } };
215
+ }
216
+ throw new Error(`unexpected GET ${path}`);
217
+ };
218
+ try {
219
+ await runCreate([
220
+ '--prompt', 'block rm -rf',
221
+ '--group', 'GroupX',
222
+ '--disabled',
223
+ '--custom-message', 'Y',
224
+ '--yes',
225
+ ]);
226
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
227
+ assert.ok(createCall, 'create POST should have fired');
228
+ const body = createCall.body;
229
+ // AI-supplied values that the user did not override:
230
+ assert.equal(body.action, 'BLOCK');
231
+ assert.equal(body.name, 'AI Name');
232
+ assert.equal(body.command_family, 'filesystem');
233
+ assert.deepEqual(body.config, { command: 'rm -rf*' });
234
+ assert.equal(body.policy_type, 'TERMINAL_COMMAND');
235
+ // Flag-supplied values:
236
+ assert.equal(body.enabled, false, '--disabled wins');
237
+ assert.equal(body.custom_message, 'Y');
238
+ assert.deepEqual(body.scope_user_group_ids, [7], 'GroupX resolved to id 7');
239
+ } finally {
240
+ h.restoreLog();
241
+ }
242
+ });
243
+
244
+ // --- AI override flags: --name, --description, --action ----------------------
245
+
246
+ test('override: --prompt + --action AUDIT wins over AI BLOCK and skips custom-message guard', async () => {
247
+ const h = buildHarness({
248
+ assistResponse: {
249
+ success: true,
250
+ form_updates: {
251
+ command_family: 'filesystem', selected_field: 'command',
252
+ field_value: 'rm -rf*', action: 'BLOCK', name: 'AI Name',
253
+ },
254
+ explanation: 'ok',
255
+ },
256
+ });
257
+ try {
258
+ // regression: lowercase --action input is accepted and stored upper-cased
259
+ await runCreate(['--prompt', 'block rm -rf', '--action', 'audit', '--yes']);
260
+ assert.equal(process.exitCode || 0, 0, 'override to AUDIT should not trigger BLOCK/WARN guard');
261
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
262
+ assert.ok(createCall, 'create POST should have fired');
263
+ assert.equal(createCall.body.action, 'AUDIT', 'flag --action must win over AI (case-insensitive)');
264
+ } finally {
265
+ h.restoreLog();
266
+ }
267
+ });
268
+
269
+ test('override: --prompt + --name "Custom" replaces AI name in final body', async () => {
270
+ const h = buildHarness({
271
+ assistResponse: {
272
+ success: true,
273
+ form_updates: {
274
+ command_family: 'git', selected_field: 'command',
275
+ field_value: 'git push*', action: 'AUDIT', name: 'AI Name',
276
+ },
277
+ explanation: 'ok',
278
+ },
279
+ });
280
+ try {
281
+ await runCreate(['--prompt', 'audit git pushes', '--name', 'Custom', '--yes']);
282
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
283
+ assert.ok(createCall);
284
+ assert.equal(createCall.body.name, 'Custom', '--name flag must override AI name');
285
+ } finally {
286
+ h.restoreLog();
287
+ }
288
+ });
289
+
290
+ test('override: --prompt + --description "Custom" replaces AI description', async () => {
291
+ const h = buildHarness({
292
+ assistResponse: {
293
+ success: true,
294
+ form_updates: {
295
+ command_family: 'git', selected_field: 'command',
296
+ field_value: 'git push*', action: 'AUDIT', name: 'X', description: 'AI desc',
297
+ },
298
+ explanation: 'ok',
299
+ },
300
+ });
301
+ try {
302
+ await runCreate(['--prompt', 'audit git pushes', '--description', 'Custom', '--yes']);
303
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
304
+ assert.ok(createCall);
305
+ assert.equal(createCall.body.description, 'Custom', '--description flag must override AI description');
306
+ } finally {
307
+ h.restoreLog();
308
+ }
309
+ });
310
+
311
+ test('override: --prompt + --name " " (whitespace-only) falls back to AI name silently', async () => {
312
+ // Fix 3: whitespace-only --name must not ship as the policy name. Trim and
313
+ // fall back to the AI value rather than erroring — non-interactive callers
314
+ // (Claude Code) may template --name and an empty result is recoverable.
315
+ const h = buildHarness({
316
+ assistResponse: {
317
+ success: true,
318
+ form_updates: {
319
+ command_family: 'git', selected_field: 'command',
320
+ field_value: 'git push*', action: 'AUDIT', name: 'AI Name',
321
+ },
322
+ explanation: 'ok',
323
+ },
324
+ });
325
+ try {
326
+ await runCreate(['--prompt', 'audit git pushes', '--name', ' ', '--yes']);
327
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
328
+ assert.ok(createCall, 'create POST should have fired');
329
+ assert.equal(createCall.body.name, 'AI Name', 'whitespace-only --name must be ignored, AI value preserved');
330
+ } finally {
331
+ h.restoreLog();
332
+ }
333
+ });
334
+
335
+ test('guard: user-set --action BLOCK without --custom-message → exit 2 with user-attribution wording', async () => {
336
+ // Fix 4: when the USER passed --action BLOCK (not the AI), the guard error
337
+ // must attribute the choice to the user, not blame the AI.
338
+ const h = buildHarness({
339
+ assistResponse: {
340
+ success: true,
341
+ form_updates: {
342
+ command_family: 'git', selected_field: 'command',
343
+ field_value: 'git push*', action: 'AUDIT', name: 'X',
344
+ // AI returned AUDIT; user overrides to BLOCK
345
+ },
346
+ explanation: 'ok',
347
+ },
348
+ });
349
+ try {
350
+ await runCreate(['--prompt', 'audit git pushes', '--action', 'BLOCK', '--yes']);
351
+ assert.equal(process.exitCode, 2);
352
+ const errs = h.captured.error.join(' ');
353
+ assert.ok(
354
+ errs.includes('--action BLOCK requires --custom-message'),
355
+ `expected user-attribution wording, got: ${errs}`
356
+ );
357
+ assert.ok(!errs.includes('AI assist set --action'), `should not blame AI: ${errs}`);
358
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST when guard fires');
359
+ } finally {
360
+ h.restoreLog();
361
+ }
362
+ });
363
+
364
+ test('override: --prompt + --action INVALID exits 2 with enum error; no assist call fires', async () => {
365
+ const h = buildHarness({
366
+ assistResponse: {
367
+ success: true,
368
+ form_updates: {
369
+ command_family: 'git', selected_field: 'command',
370
+ field_value: 'git push*', action: 'AUDIT', name: 'X',
371
+ },
372
+ explanation: 'ok',
373
+ },
374
+ });
375
+ try {
376
+ await runCreate(['--prompt', 'audit git pushes', '--action', 'NOPE', '--yes']);
377
+ assert.equal(process.exitCode, 2);
378
+ const errs = h.captured.error.join(' ');
379
+ assert.ok(errs.includes('--action must be one of'), `expected enum error, got: ${errs}`);
380
+ assert.ok(errs.includes('AUDIT, BLOCK, WARN, REQUIRE_SLACK_APPROVAL'), `expected enum values, got: ${errs}`);
381
+ assert.equal(
382
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/assist/').length,
383
+ 0,
384
+ 'assist must not be called when --action is invalid'
385
+ );
386
+ } finally {
387
+ h.restoreLog();
388
+ }
389
+ });
390
+
391
+ // --- Preview rendering: omitted rows -----------------------------------------
392
+
393
+ test('preview: explanation line is omitted when explanation is empty', async () => {
394
+ const h = buildHarness({
395
+ assistResponse: {
396
+ success: true,
397
+ form_updates: {
398
+ command_family: 'git', selected_field: 'command',
399
+ field_value: 'git push*', action: 'AUDIT', name: 'X',
400
+ },
401
+ explanation: '',
402
+ },
403
+ });
404
+ try {
405
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
406
+ const out = h.captured.stdout.join('\n');
407
+ assert.ok(!out.includes('AI assist explanation'), `explanation line should be omitted when empty: ${out}`);
408
+ } finally {
409
+ h.restoreLog();
410
+ }
411
+ });
412
+
413
+ test('preview: Description row is omitted when description is empty', async () => {
414
+ const h = buildHarness({
415
+ assistResponse: {
416
+ success: true,
417
+ form_updates: {
418
+ command_family: 'git', selected_field: 'command',
419
+ field_value: 'git push*', action: 'AUDIT', name: 'X',
420
+ // description intentionally omitted
421
+ },
422
+ explanation: 'ok',
423
+ },
424
+ });
425
+ try {
426
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
427
+ // The preview is everything emitted BEFORE the post-create displayToolPolicy
428
+ // (which has its own static "Description" row). Slice at the explanation line
429
+ // — preview ends with a blank line right after the explanation.
430
+ const out = h.captured.stdout.join('\n');
431
+ const expIdx = out.indexOf('AI assist explanation');
432
+ assert.notEqual(expIdx, -1, 'explanation line should be present in this test setup');
433
+ // Take a window from the start up to and including the explanation line.
434
+ const previewWindow = out.slice(0, expIdx);
435
+ assert.ok(!previewWindow.includes('Description'), `Description row should be omitted when empty: ${previewWindow}`);
436
+ } finally {
437
+ h.restoreLog();
438
+ }
439
+ });
440
+
441
+ // --- Acceptance #3 — happy-path body shape ------------------------------------
442
+
443
+ test('happy path: builds policy body and POSTs to /command-policies/', async () => {
444
+ const h = buildHarness({
445
+ assistResponse: {
446
+ success: true,
447
+ form_updates: {
448
+ command_family: 'filesystem',
449
+ selected_field: 'command',
450
+ field_value: 'rm -rf*',
451
+ action: 'BLOCK',
452
+ name: 'Block destructive ops',
453
+ },
454
+ explanation: 'destructive filesystem write',
455
+ },
456
+ });
457
+ try {
458
+ await runCreate(['--prompt', 'block rm -rf', '--custom-message', 'No.', '--yes']);
459
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
460
+ assert.ok(createCall);
461
+ assert.equal(createCall.body.policy_type, 'TERMINAL_COMMAND');
462
+ assert.equal(createCall.body.command_family, 'filesystem');
463
+ assert.deepEqual(createCall.body.config, { command: 'rm -rf*' });
464
+ assert.equal(createCall.body.action, 'BLOCK');
465
+ assert.equal(createCall.body.name, 'Block destructive ops');
466
+ assert.equal(createCall.body.enabled, true);
467
+ assert.equal(createCall.body.custom_message, 'No.');
468
+ } finally {
469
+ h.restoreLog();
470
+ }
471
+ });
472
+
473
+ // --- (b.1) Greptile follow-up regressions -------------------------------------
474
+
475
+ test('AI returns BLOCK without --custom-message → exit 2 with guard message', async () => {
476
+ const h = buildHarness({
477
+ assistResponse: {
478
+ success: true,
479
+ form_updates: {
480
+ command_family: 'filesystem', selected_field: 'command',
481
+ field_value: 'rm -rf*', action: 'BLOCK', name: 'X',
482
+ },
483
+ explanation: 'ok',
484
+ },
485
+ });
486
+ try {
487
+ await runCreate(['--prompt', 'block rm -rf', '--yes']);
488
+ assert.equal(process.exitCode, 2);
489
+ const errs = h.captured.error.join(' ');
490
+ assert.ok(errs.includes('BLOCK'), 'message should name the action');
491
+ assert.ok(errs.includes('--custom-message'), 'message should name the missing flag');
492
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST when guard fires');
493
+ } finally {
494
+ h.restoreLog();
495
+ }
496
+ });
497
+
498
+ test('AI returns WARN without --custom-message → exit 2 with guard message', async () => {
499
+ const h = buildHarness({
500
+ assistResponse: {
501
+ success: true,
502
+ form_updates: {
503
+ command_family: 'git', selected_field: 'command',
504
+ field_value: 'git push*', action: 'WARN', name: 'X',
505
+ },
506
+ explanation: 'ok',
507
+ },
508
+ });
509
+ try {
510
+ await runCreate(['--prompt', 'warn on git pushes', '--yes']);
511
+ assert.equal(process.exitCode, 2);
512
+ const errs = h.captured.error.join(' ');
513
+ assert.ok(errs.includes('WARN'));
514
+ assert.ok(errs.includes('--custom-message'));
515
+ } finally {
516
+ h.restoreLog();
517
+ }
518
+ });
519
+
520
+ test('AI omits name → success message does not say "undefined"', async () => {
521
+ const h = buildHarness({
522
+ assistResponse: {
523
+ success: true,
524
+ form_updates: {
525
+ command_family: 'git', selected_field: 'command',
526
+ field_value: 'git push*', action: 'AUDIT',
527
+ },
528
+ explanation: 'ok',
529
+ },
530
+ });
531
+ try {
532
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
533
+ assert.equal(process.exitCode || 0, 0);
534
+ const successes = h.captured.success.join(' ');
535
+ assert.ok(!successes.includes('undefined'), `success message should not contain "undefined": ${successes}`);
536
+ assert.ok(successes.includes('Terminal policy'), 'success message should still announce policy creation');
537
+ } finally {
538
+ h.restoreLog();
539
+ }
540
+ });
541
+
542
+ test('--json mode does not print the human preview before JSON output', async () => {
543
+ const h = buildHarness({
544
+ assistResponse: {
545
+ success: true,
546
+ form_updates: {
547
+ command_family: 'git', selected_field: 'command',
548
+ field_value: 'git push*', action: 'AUDIT', name: 'A',
549
+ },
550
+ explanation: 'ok',
551
+ },
552
+ });
553
+ try {
554
+ await runCreate(['--prompt', 'audit git pushes', '--json']);
555
+ const combined = h.captured.stdout.join('\n');
556
+ const infos = h.captured.info.join('\n');
557
+ assert.ok(!combined.includes('Resolved policy'), 'preview must be suppressed under --json (stdout)');
558
+ assert.ok(!/resolved policy/i.test(infos), 'preview header must be suppressed under --json (info)');
559
+ assert.ok(!combined.includes('AI assist explanation'), 'explanation must be suppressed under --json');
560
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should still fire');
561
+ } finally {
562
+ h.restoreLog();
563
+ }
564
+ });
565
+
566
+ test('--prompt + --group resolves user-group BEFORE the assist call (preview reflects final scope)', async () => {
567
+ const h = buildHarness({
568
+ assistResponse: {
569
+ success: true,
570
+ form_updates: {
571
+ command_family: 'filesystem', selected_field: 'command',
572
+ field_value: 'rm -rf*', action: 'AUDIT', name: 'X',
573
+ },
574
+ explanation: 'ok',
575
+ },
576
+ });
577
+ h.api.get = async (path, opts) => {
578
+ h.calls.gets.push({ path, opts });
579
+ if (path === '/api/v1/users/privileges/') return { is_admin: true };
580
+ if (path === '/api/v1/policies/form_data/') {
581
+ return { data: { user_groups: [{ id: 7, name: 'GroupX' }] } };
582
+ }
583
+ throw new Error(`unexpected GET ${path}`);
584
+ };
585
+ try {
586
+ await runCreate(['--prompt', 'audit rm', '--group', 'GroupX', '--yes']);
587
+ const assistIdx = h.calls.posts.findIndex((c) => c.path === '/api/v1/command-policies/assist/');
588
+ const formDataIdx = h.calls.gets.findIndex((c) => c.path === '/api/v1/policies/form_data/');
589
+ assert.notEqual(formDataIdx, -1, 'form_data should be fetched');
590
+ assert.notEqual(assistIdx, -1, 'assist endpoint should be called');
591
+ // form_data fetched before assist == group resolved in time for preview/merge
592
+ assert.ok(formDataIdx >= 0 && assistIdx >= 0);
593
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
594
+ assert.deepEqual(createCall.body.scope_user_group_ids, [7]);
595
+ } finally {
596
+ h.restoreLog();
597
+ }
598
+ });
599
+
600
+ // --- (c) Eight HTTP routing cases — one per §6.4 row --------------------------
601
+
602
+ test('routing: 200 + success:true → success exit 0', async () => {
603
+ const h = buildHarness({
604
+ assistResponse: {
605
+ success: true,
606
+ form_updates: {
607
+ command_family: 'git', selected_field: 'command',
608
+ field_value: 'git push*', action: 'AUDIT', name: 'Audit',
609
+ },
610
+ explanation: 'ok',
611
+ },
612
+ });
613
+ try {
614
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
615
+ assert.equal(process.exitCode || 0, 0);
616
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'));
617
+ } finally {
618
+ h.restoreLog();
619
+ }
620
+ });
621
+
622
+ test('routing: 200 + success:false length error → exit 2 + suggestion', async () => {
623
+ const h = buildHarness({
624
+ assistResponse: { success: false, error: 'Input is too long (max 2000 characters).' },
625
+ });
626
+ try {
627
+ await runCreate(['--prompt', 'x', '--yes']);
628
+ assert.equal(process.exitCode, 2);
629
+ const msg = h.captured.error.join('\n');
630
+ assert.ok(msg.includes('Input is too long (max 2000 characters).'), `got: ${msg}`);
631
+ assert.ok(msg.includes('Try shortening to under 1800 characters.'), `got: ${msg}`);
632
+ assert.equal(h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/').length, 0);
633
+ } finally {
634
+ h.restoreLog();
635
+ }
636
+ });
637
+
638
+ test('routing: 200 + success:false family error → exit 2 + families suggestion', async () => {
639
+ const h = buildHarness({
640
+ assistResponse: {
641
+ success: false,
642
+ error: 'Could not determine command family from your description.',
643
+ },
644
+ });
645
+ try {
646
+ await runCreate(['--prompt', 'xyz', '--yes']);
647
+ assert.equal(process.exitCode, 2);
648
+ const msg = h.captured.error.join('\n');
649
+ assert.ok(msg.includes('Could not determine command family'), `got: ${msg}`);
650
+ assert.ok(msg.includes('block git pushes'), `got: ${msg}`);
651
+ assert.ok(msg.includes('unbound policy tool families'), `got: ${msg}`);
652
+ } finally {
653
+ h.restoreLog();
654
+ }
655
+ });
656
+
657
+ test('routing: 200 + success:false other → verbatim error, exit 2', async () => {
658
+ const h = buildHarness({
659
+ assistResponse: { success: false, error: 'Some other backend error.' },
660
+ });
661
+ try {
662
+ await runCreate(['--prompt', 'x', '--yes']);
663
+ assert.equal(process.exitCode, 2);
664
+ assert.ok(h.captured.error.some((m) => m.includes('Some other backend error.')));
665
+ } finally {
666
+ h.restoreLog();
667
+ }
668
+ });
669
+
670
+ test('routing: 401 → exit 3 + admin-required wording', async () => {
671
+ const h = buildHarness({
672
+ assistError: new FakeApiError(401, { error: 'unauthenticated' }),
673
+ });
674
+ try {
675
+ await runCreate(['--prompt', 'block rm -rf', '--yes']);
676
+ assert.equal(process.exitCode, 3);
677
+ assert.ok(
678
+ h.captured.error.some((m) => m === 'Authentication failed / not authorized. Tool policies require admin. Run `unbound whoami` to check role.'),
679
+ `got: ${JSON.stringify(h.captured.error)}`
680
+ );
681
+ } finally {
682
+ h.restoreLog();
683
+ }
684
+ });
685
+
686
+ test('routing: 422 → exit 2 + validation-failed wording', async () => {
687
+ const h = buildHarness({
688
+ assistError: new FakeApiError(422, { field: 'bad' }),
689
+ });
690
+ try {
691
+ await runCreate(['--prompt', 'block rm -rf', '--yes']);
692
+ assert.equal(process.exitCode, 2);
693
+ assert.ok(
694
+ h.captured.error.some((m) => m.startsWith('Request validation failed:')),
695
+ `got: ${JSON.stringify(h.captured.error)}`
696
+ );
697
+ } finally {
698
+ h.restoreLog();
699
+ }
700
+ });
701
+
702
+ test('routing: 5xx → exit 4 + fall-back-to-flags suggestion', async () => {
703
+ const h = buildHarness({
704
+ assistError: new FakeApiError(503, { error: 'unavailable' }),
705
+ });
706
+ try {
707
+ await runCreate(['--prompt', 'block rm -rf', '--yes']);
708
+ assert.equal(process.exitCode, 4);
709
+ const msg = h.captured.error.join('\n');
710
+ assert.ok(msg.includes('Server error.'), msg);
711
+ assert.ok(msg.includes('fall back to flag-based creation'), msg);
712
+ assert.ok(msg.includes('unbound policy tool --help'), msg);
713
+ } finally {
714
+ h.restoreLog();
715
+ }
716
+ });
717
+
718
+ test('routing: network/timeout error → exit 4 + network wording', async () => {
719
+ // A plain Error (no statusCode) is what src/api.js throws for network/timeout.
720
+ const h = buildHarness({
721
+ assistError: new Error('connect ECONNREFUSED b.acme'),
722
+ });
723
+ try {
724
+ await runCreate(['--prompt', 'block rm -rf', '--yes']);
725
+ assert.equal(process.exitCode, 4);
726
+ const msg = h.captured.error.join('\n');
727
+ assert.ok(msg.startsWith('Network error reaching'), msg);
728
+ assert.ok(msg.includes('Falling back to flag-based creation is not blocked.'), msg);
729
+ } finally {
730
+ h.restoreLog();
731
+ }
732
+ });
733
+
734
+ // Fix 1: create POST failures must route through routeBackendError (admin/role wording, correct exit code)
735
+ test('routing: assist succeeds but create POST 401 → exit 3 + admin-required wording', async () => {
736
+ const h = buildHarness({
737
+ assistResponse: {
738
+ success: true,
739
+ form_updates: {
740
+ command_family: 'git', selected_field: 'command',
741
+ field_value: 'git push*', action: 'AUDIT', name: 'X',
742
+ },
743
+ explanation: 'ok',
744
+ },
745
+ createError: new FakeApiError(401, { error: 'unauthenticated' }),
746
+ });
747
+ try {
748
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
749
+ assert.equal(process.exitCode, 3);
750
+ assert.ok(
751
+ h.captured.error.some((m) => m.includes('Authentication failed')),
752
+ `expected admin-required wording from create POST 401, got: ${JSON.stringify(h.captured.error)}`
753
+ );
754
+ } finally {
755
+ h.restoreLog();
756
+ }
757
+ });
758
+
759
+ // --- (d) --yes + out-of-scope keyword: warns AND proceeds ---------------------
760
+
761
+ test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint', async () => {
762
+ const h = buildHarness({
763
+ assistResponse: {
764
+ success: true,
765
+ form_updates: {
766
+ command_family: 'git', selected_field: 'command',
767
+ field_value: 'git push*', action: 'BLOCK', name: 'X',
768
+ },
769
+ explanation: 'ok',
770
+ },
771
+ });
772
+ try {
773
+ await runCreate(['--prompt', 'block staging deploys', '--yes']);
774
+ assert.ok(
775
+ h.captured.warn.some((m) => m.includes('`staging`')),
776
+ `expected staging warning, got: ${JSON.stringify(h.captured.warn)}`
777
+ );
778
+ assert.ok(
779
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
780
+ 'assist endpoint should still be called'
781
+ );
782
+ } finally {
783
+ h.restoreLog();
784
+ }
785
+ });
786
+
787
+ // --- Preview rendering (Acceptance #6 and #7) ---------------------------------
788
+
789
+ test('--yes prints the preview AND skips confirmation AND issues create POST', async () => {
790
+ const h = buildHarness({
791
+ assistResponse: {
792
+ success: true,
793
+ form_updates: {
794
+ command_family: 'git', selected_field: 'command',
795
+ field_value: 'git push*', action: 'AUDIT', name: 'Audit pushes',
796
+ description: 'Audit git push events.',
797
+ },
798
+ explanation: 'audit git activity',
799
+ },
800
+ });
801
+ try {
802
+ await runCreate(['--prompt', 'audit git pushes', '--yes']);
803
+ // Header goes to stdout (NOT output.info / stderr) so the whole preview is
804
+ // captured together by a single tee/redirect.
805
+ const out = h.captured.stdout.join('\n');
806
+ assert.ok(/resolved policy/i.test(out), `preview header not printed to stdout: ${out}`);
807
+ assert.ok(out.includes('Audit pushes'), `name row missing: ${out}`);
808
+ assert.ok(out.includes('TERMINAL_COMMAND'), `type row missing: ${out}`);
809
+ assert.ok(out.includes('git push*'), `pattern row missing: ${out}`);
810
+ assert.ok(out.includes('AUDIT'), `action row missing: ${out}`);
811
+ assert.ok(out.includes('AI assist explanation'), `explanation line missing: ${out}`);
812
+ assert.ok(out.includes('audit git activity'), `explanation body missing: ${out}`);
813
+ assert.ok(
814
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'),
815
+ 'create POST should fire under --yes'
816
+ );
817
+ } finally {
818
+ h.restoreLog();
819
+ }
820
+ });
821
+
822
+ // Acceptance #6 — interactive preview-then-confirm. We can't easily simulate
823
+ // stdin inside the node:test harness without complicating the runner, so we
824
+ // assert the preview prints BEFORE the create POST happens and the create POST
825
+ // does NOT happen when the user declines. We simulate "decline" by feeding the
826
+ // confirmCreate function via a stdin stub.
827
+
828
+ test('without --yes: preview prints; "n" declines; no create POST fires', async () => {
829
+ const h = buildHarness({
830
+ assistResponse: {
831
+ success: true,
832
+ form_updates: {
833
+ command_family: 'git', selected_field: 'command',
834
+ field_value: 'git push*', action: 'AUDIT', name: 'Audit',
835
+ },
836
+ explanation: 'ok',
837
+ },
838
+ });
839
+ // Stub readline.createInterface — the lib's confirmCreate uses it.
840
+ const readline = require('readline');
841
+ const origCreate = readline.createInterface;
842
+ readline.createInterface = () => ({
843
+ question(_q, cb) { cb('n'); },
844
+ close() {},
845
+ });
846
+ try {
847
+ await runCreate(['--prompt', 'audit git pushes']);
848
+ // Preview header (stdout) must have printed BEFORE the prompt. And the
849
+ // create POST must NOT have fired because we said "n".
850
+ const out = h.captured.stdout.join('\n');
851
+ assert.ok(/resolved policy/i.test(out), `preview header missing on stdout: ${out}`);
852
+ assert.equal(
853
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/').length,
854
+ 0,
855
+ 'no create POST should fire when user declines'
856
+ );
857
+ } finally {
858
+ readline.createInterface = origCreate;
859
+ h.restoreLog();
860
+ }
861
+ });
862
+
863
+ // --- Admin probe -------------------------------------------------------------
864
+
865
+ test('admin probe: non-admin → exits 3 before assist call', async () => {
866
+ const h = buildHarness({
867
+ privilegesResponse: { is_admin: false, is_manager: true, is_member: false },
868
+ });
869
+ try {
870
+ await runCreate(['--prompt', 'block rm -rf', '--yes']);
871
+ assert.equal(process.exitCode, 3);
872
+ assert.ok(
873
+ h.captured.error.some((m) => m.includes('admin role')),
874
+ `got: ${JSON.stringify(h.captured.error)}`
875
+ );
876
+ assert.equal(
877
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/assist/').length,
878
+ 0,
879
+ 'assist must not be called for non-admins'
880
+ );
881
+ } finally {
882
+ h.restoreLog();
883
+ }
884
+ });