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,606 @@
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-mcp --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
+ for (const m of [
11
+ '../src/commands/policy',
12
+ '../src/lib/policy-ai-assist',
13
+ '../src/api',
14
+ '../src/config',
15
+ '../src/output',
16
+ ]) {
17
+ delete require.cache[require.resolve(m)];
18
+ }
19
+ const api = require('../src/api');
20
+ const config = require('../src/config');
21
+ const output = require('../src/output');
22
+ return { api, config, output };
23
+ }
24
+
25
+ class FakeApiError extends Error {
26
+ constructor(statusCode, body) {
27
+ super(body && body.error ? body.error : `HTTP ${statusCode}`);
28
+ this.name = 'ApiError';
29
+ this.statusCode = statusCode;
30
+ this.body = body;
31
+ }
32
+ }
33
+
34
+ function buildHarness({
35
+ privilegesResponse = { is_admin: true, is_manager: false, is_member: false },
36
+ privilegesError = null,
37
+ assistMcpResponse = null,
38
+ assistMcpError = null,
39
+ createResponse = { id: 1, name: 'ok' },
40
+ createError = null,
41
+ } = {}) {
42
+ const { api, config, output } = loadFreshModules();
43
+
44
+ config.isLoggedIn = () => true;
45
+ config.getApiKey = () => 'fake-key';
46
+ config.getBaseUrl = () => 'https://b.acme';
47
+
48
+ const calls = { posts: [], gets: [] };
49
+ api.get = async (path, opts) => {
50
+ calls.gets.push({ path, opts });
51
+ if (path === '/api/v1/users/privileges/') {
52
+ if (privilegesError) throw privilegesError;
53
+ return privilegesResponse;
54
+ }
55
+ throw new Error(`unexpected GET ${path}`);
56
+ };
57
+ api.post = async (path, opts) => {
58
+ calls.posts.push({ path, body: opts && opts.body });
59
+ if (path === '/api/v1/command-policies/assist-mcp/') {
60
+ if (assistMcpError) throw assistMcpError;
61
+ return assistMcpResponse;
62
+ }
63
+ if (path === '/api/v1/command-policies/') {
64
+ if (createError) throw createError;
65
+ return createResponse;
66
+ }
67
+ throw new Error(`unexpected POST ${path}`);
68
+ };
69
+
70
+ const captured = { success: [], error: [], warn: [], info: [], stdout: [] };
71
+ output.success = (m) => captured.success.push(m);
72
+ output.error = (m) => captured.error.push(m);
73
+ output.warn = (m) => captured.warn.push(m);
74
+ output.info = (m) => captured.info.push(m);
75
+ const origLog = console.log;
76
+ console.log = (m) => captured.stdout.push(String(m || ''));
77
+ const restoreLog = () => { console.log = origLog; };
78
+
79
+ return { api, config, output, calls, captured, restoreLog };
80
+ }
81
+
82
+ async function runCreateMcp(argv) {
83
+ const { register } = require('../src/commands/policy');
84
+ const program = new Command();
85
+ program.exitOverride();
86
+ register(program);
87
+ const full = ['node', 'unbound', 'policy', 'tool', 'create-mcp', ...argv];
88
+ await program.parseAsync(full);
89
+ }
90
+
91
+ function successAssistResponse(overrides = {}) {
92
+ return {
93
+ success: true,
94
+ canonical_group_id: 12,
95
+ mcp_tools: ['create_issue', 'update_issue'],
96
+ name: 'Audit Linear writes',
97
+ description: 'Audit write operations on Linear.',
98
+ action: 'AUDIT',
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ beforeEach(() => {
104
+ process.exitCode = 0;
105
+ });
106
+
107
+ after(() => {
108
+ process.exitCode = 0;
109
+ });
110
+
111
+ // --- (a) Mutex cases: --prompt + each conflicting flag -----------------------
112
+
113
+ const MUTEX_FLAGS = [
114
+ ['--mcp-server', 'linear'],
115
+ ['--mcp-tool', 'create_issue'],
116
+ ['--mcp-action-type', 'write'],
117
+ ['--config', '{"foo":"bar"}'],
118
+ ];
119
+
120
+ for (const [flag, value] of MUTEX_FLAGS) {
121
+ test(`mutex: --prompt + ${flag} exits 1 with verbatim wording`, async () => {
122
+ const h = buildHarness();
123
+ try {
124
+ await runCreateMcp(['--prompt', 'audit linear writes', flag, value]);
125
+ assert.equal(process.exitCode, 1);
126
+ assert.ok(
127
+ h.captured.error.some((m) => m === 'Pass --prompt for AI-assist or the field flags for explicit creation, not both.'),
128
+ `expected mutex error, got: ${JSON.stringify(h.captured.error)}`
129
+ );
130
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
131
+ } finally {
132
+ h.restoreLog();
133
+ }
134
+ });
135
+ }
136
+
137
+ test('empty --prompt: exits 2, never reaches network', async () => {
138
+ const h = buildHarness();
139
+ try {
140
+ await runCreateMcp(['--prompt', '']);
141
+ assert.equal(process.exitCode, 2);
142
+ assert.ok(
143
+ h.captured.error.some((m) => m.includes('--prompt cannot be empty.')),
144
+ `expected empty-prompt error, got: ${JSON.stringify(h.captured.error)}`
145
+ );
146
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
147
+ assert.equal(h.calls.gets.length, 0, 'no privileges probe should have fired');
148
+ } finally {
149
+ h.restoreLog();
150
+ }
151
+ });
152
+
153
+ // --- (b) Happy path -----------------------------------------------------------
154
+
155
+ test('happy path: builds MCP policy body and POSTs to /command-policies/', async () => {
156
+ const h = buildHarness({
157
+ assistMcpResponse: successAssistResponse(),
158
+ });
159
+ try {
160
+ await runCreateMcp(['--prompt', 'audit all linear writes', '--yes']);
161
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
162
+ assert.ok(createCall, 'create POST should have fired');
163
+ assert.equal(createCall.body.policy_type, 'MCP_TOOL');
164
+ assert.equal(createCall.body.mcp_canonical_group_id, 12);
165
+ assert.deepEqual(createCall.body.mcp_tools, ['create_issue', 'update_issue']);
166
+ assert.equal(createCall.body.name, 'Audit Linear writes');
167
+ assert.equal(createCall.body.description, 'Audit write operations on Linear.');
168
+ assert.equal(createCall.body.action, 'AUDIT');
169
+ assert.equal(createCall.body.enabled, true);
170
+ assert.equal(createCall.body.custom_message, undefined);
171
+ assert.equal(createCall.body.scope_user_group_ids, undefined);
172
+ } finally {
173
+ h.restoreLog();
174
+ }
175
+ });
176
+
177
+ test('happy path: create body includes config:{} so backend MCP_TOOL serializer never 422s', async () => {
178
+ const h = buildHarness({
179
+ assistMcpResponse: successAssistResponse(),
180
+ });
181
+ try {
182
+ await runCreateMcp(['--prompt', 'audit all linear writes', '--yes']);
183
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
184
+ assert.ok(createCall, 'create POST should have fired');
185
+ assert.deepEqual(createCall.body.config, {}, 'MCP_TOOL body must carry config:{} (matches flag-path behavior)');
186
+ } finally {
187
+ h.restoreLog();
188
+ }
189
+ });
190
+
191
+ // --- (c) Merge precedence -----------------------------------------------------
192
+
193
+ test('merge: flag wins over AI value; AI fills the rest', async () => {
194
+ const h = buildHarness({
195
+ assistMcpResponse: successAssistResponse({
196
+ mcp_tools: ['t1'],
197
+ name: 'AI Name',
198
+ description: 'AI desc',
199
+ action: 'BLOCK',
200
+ custom_message: 'AI msg',
201
+ }),
202
+ createResponse: { id: 42, name: 'Custom' },
203
+ });
204
+ h.api.get = async (path) => {
205
+ if (path === '/api/v1/users/privileges/') return { is_admin: true };
206
+ if (path === '/api/v1/policies/form_data/') {
207
+ return { data: { user_groups: [{ id: 7, name: 'GroupX' }] } };
208
+ }
209
+ throw new Error(`unexpected GET ${path}`);
210
+ };
211
+ try {
212
+ await runCreateMcp([
213
+ '--prompt', 'audit linear writes',
214
+ '--name', 'Custom',
215
+ '--custom-message', 'Override',
216
+ '--action', 'AUDIT',
217
+ '--group', 'GroupX',
218
+ '--disabled',
219
+ '--yes',
220
+ ]);
221
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
222
+ assert.ok(createCall, 'create POST should have fired');
223
+ const body = createCall.body;
224
+ assert.equal(body.name, 'Custom', '--name flag wins');
225
+ assert.equal(body.action, 'AUDIT', '--action flag wins');
226
+ assert.equal(body.custom_message, 'Override', '--custom-message flag wins');
227
+ assert.equal(body.enabled, false, '--disabled wins');
228
+ assert.deepEqual(body.scope_user_group_ids, [7], 'GroupX resolved to id 7');
229
+ assert.equal(body.mcp_canonical_group_id, 12, 'AI canonical_group_id preserved');
230
+ assert.deepEqual(body.mcp_tools, ['t1'], 'AI mcp_tools preserved');
231
+ } finally {
232
+ h.restoreLog();
233
+ }
234
+ });
235
+
236
+ test('override: whitespace-only --name falls back to AI name silently', async () => {
237
+ const h = buildHarness({
238
+ assistMcpResponse: successAssistResponse({ name: 'AI Name' }),
239
+ });
240
+ try {
241
+ await runCreateMcp(['--prompt', 'audit linear writes', '--name', ' ', '--yes']);
242
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
243
+ assert.ok(createCall, 'create POST should have fired');
244
+ assert.equal(createCall.body.name, 'AI Name', 'whitespace-only --name must be ignored, AI value preserved');
245
+ } finally {
246
+ h.restoreLog();
247
+ }
248
+ });
249
+
250
+ // --- (d) Soft-fail: empty mcp_tools or missing canonical_group_id -----------
251
+
252
+ test('soft fail: mcp_tools:[] → exit 2 with helpful message; no create POST', async () => {
253
+ const h = buildHarness({
254
+ assistMcpResponse: {
255
+ success: true,
256
+ canonical_group_id: 12,
257
+ mcp_tools: [],
258
+ name: 'X',
259
+ action: 'AUDIT',
260
+ },
261
+ });
262
+ try {
263
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
264
+ assert.equal(process.exitCode, 2);
265
+ const msg = h.captured.error.join('\n');
266
+ assert.ok(msg.includes('could not match any tools'), `expected soft-fail message, got: ${msg}`);
267
+ assert.ok(msg.includes('unbound policy tool create-mcp --name'), `expected manual escape hatch hint, got: ${msg}`);
268
+ assert.equal(
269
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/').length,
270
+ 0,
271
+ 'no create POST when soft-fail fires',
272
+ );
273
+ } finally {
274
+ h.restoreLog();
275
+ }
276
+ });
277
+
278
+ test('soft fail: AI returns mcp_tools: null → exit 2 with helpful fallback message', async () => {
279
+ const h = buildHarness({
280
+ assistMcpResponse: {
281
+ success: true,
282
+ canonical_group_id: 1,
283
+ mcp_tools: null,
284
+ name: 'X',
285
+ action: 'AUDIT',
286
+ custom_message: '',
287
+ },
288
+ });
289
+ try {
290
+ await runCreateMcp(['--prompt', 'audit Linear', '--yes']);
291
+ assert.equal(process.exitCode, 2);
292
+ const errs = h.captured.error.join(' ');
293
+ assert.ok(errs.includes('could not match any tools'));
294
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST when soft-fail fires');
295
+ } finally {
296
+ h.restoreLog();
297
+ }
298
+ });
299
+
300
+ test('soft fail: missing canonical_group_id → exit 2 with helpful message; no create POST', async () => {
301
+ const h = buildHarness({
302
+ assistMcpResponse: {
303
+ success: true,
304
+ mcp_tools: ['create_issue'],
305
+ name: 'X',
306
+ action: 'AUDIT',
307
+ },
308
+ });
309
+ try {
310
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
311
+ assert.equal(process.exitCode, 2);
312
+ const msg = h.captured.error.join('\n');
313
+ assert.ok(msg.includes('could not match any tools'), `expected soft-fail message, got: ${msg}`);
314
+ assert.equal(
315
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/').length,
316
+ 0,
317
+ 'no create POST when soft-fail fires',
318
+ );
319
+ } finally {
320
+ h.restoreLog();
321
+ }
322
+ });
323
+
324
+ // --- (e) BLOCK/WARN guard ----------------------------------------------------
325
+
326
+ test('AI returns BLOCK without --custom-message → exit 2 with AI-attributed guard message', async () => {
327
+ const h = buildHarness({
328
+ assistMcpResponse: successAssistResponse({ action: 'BLOCK' }),
329
+ });
330
+ try {
331
+ await runCreateMcp(['--prompt', 'block linear writes', '--yes']);
332
+ assert.equal(process.exitCode, 2);
333
+ const errs = h.captured.error.join(' ');
334
+ assert.ok(errs.includes('AI assist set --action'), `expected AI-attribution wording, got: ${errs}`);
335
+ assert.ok(errs.includes('BLOCK'), 'message should name the action');
336
+ assert.ok(errs.includes('--custom-message'), 'message should name the missing flag');
337
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST when guard fires');
338
+ } finally {
339
+ h.restoreLog();
340
+ }
341
+ });
342
+
343
+ test('user-set --action BLOCK without --custom-message → exit 2 with user-attributed guard message', async () => {
344
+ const h = buildHarness({
345
+ assistMcpResponse: successAssistResponse({ action: 'AUDIT' }),
346
+ });
347
+ try {
348
+ await runCreateMcp(['--prompt', 'audit linear writes', '--action', 'BLOCK', '--yes']);
349
+ assert.equal(process.exitCode, 2);
350
+ const errs = h.captured.error.join(' ');
351
+ assert.ok(errs.includes('--action BLOCK requires --custom-message'), `expected user-attribution wording, got: ${errs}`);
352
+ assert.ok(!errs.includes('AI assist set --action'), `should not blame AI: ${errs}`);
353
+ assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST when guard fires');
354
+ } finally {
355
+ h.restoreLog();
356
+ }
357
+ });
358
+
359
+ // --- (f) HTTP routing matrix -------------------------------------------------
360
+
361
+ test('routing: 200 + success:false length → exit 2 + suggestion', async () => {
362
+ const h = buildHarness({
363
+ assistMcpResponse: { success: false, error: 'Input is too long (max 2000 characters).' },
364
+ });
365
+ try {
366
+ await runCreateMcp(['--prompt', 'x', '--yes']);
367
+ assert.equal(process.exitCode, 2);
368
+ const msg = h.captured.error.join('\n');
369
+ assert.ok(msg.includes('Input is too long (max 2000 characters).'), `got: ${msg}`);
370
+ assert.ok(msg.includes('Try shortening to under 1800 characters.'), `got: ${msg}`);
371
+ assert.equal(h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/').length, 0);
372
+ } finally {
373
+ h.restoreLog();
374
+ }
375
+ });
376
+
377
+ test('routing: 200 + success:false generic → exit 2, verbatim error', async () => {
378
+ const h = buildHarness({
379
+ assistMcpResponse: { success: false, error: 'Some other backend error.' },
380
+ });
381
+ try {
382
+ await runCreateMcp(['--prompt', 'x', '--yes']);
383
+ assert.equal(process.exitCode, 2);
384
+ assert.ok(h.captured.error.some((m) => m.includes('Some other backend error.')));
385
+ } finally {
386
+ h.restoreLog();
387
+ }
388
+ });
389
+
390
+ test('routing: 401 → exit 3 + admin-required wording', async () => {
391
+ const h = buildHarness({
392
+ assistMcpError: new FakeApiError(401, { error: 'unauthenticated' }),
393
+ });
394
+ try {
395
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
396
+ assert.equal(process.exitCode, 3);
397
+ assert.ok(
398
+ h.captured.error.some((m) => m === 'Authentication failed / not authorized. Tool policies require admin. Run `unbound whoami` to check role.'),
399
+ `got: ${JSON.stringify(h.captured.error)}`
400
+ );
401
+ } finally {
402
+ h.restoreLog();
403
+ }
404
+ });
405
+
406
+ test('routing: 422 → exit 2 + validation-failed wording', async () => {
407
+ const h = buildHarness({
408
+ assistMcpError: new FakeApiError(422, { field: 'bad' }),
409
+ });
410
+ try {
411
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
412
+ assert.equal(process.exitCode, 2);
413
+ assert.ok(
414
+ h.captured.error.some((m) => m.startsWith('Request validation failed:')),
415
+ `got: ${JSON.stringify(h.captured.error)}`
416
+ );
417
+ } finally {
418
+ h.restoreLog();
419
+ }
420
+ });
421
+
422
+ test('routing: 5xx → exit 4 + fall-back-to-flags suggestion', async () => {
423
+ const h = buildHarness({
424
+ assistMcpError: new FakeApiError(503, { error: 'unavailable' }),
425
+ });
426
+ try {
427
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
428
+ assert.equal(process.exitCode, 4);
429
+ const msg = h.captured.error.join('\n');
430
+ assert.ok(msg.includes('Server error.'), msg);
431
+ assert.ok(msg.includes('fall back to flag-based creation'), msg);
432
+ } finally {
433
+ h.restoreLog();
434
+ }
435
+ });
436
+
437
+ test('routing: network/timeout error → exit 4 + network wording', async () => {
438
+ const h = buildHarness({
439
+ assistMcpError: new Error('connect ECONNREFUSED b.acme'),
440
+ });
441
+ try {
442
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
443
+ assert.equal(process.exitCode, 4);
444
+ const msg = h.captured.error.join('\n');
445
+ assert.ok(msg.startsWith('Network error reaching'), msg);
446
+ assert.ok(msg.includes('Falling back to flag-based creation is not blocked.'), msg);
447
+ } finally {
448
+ h.restoreLog();
449
+ }
450
+ });
451
+
452
+ // Fix 1: create POST failures must route through routeBackendError (admin/role wording, correct exit code)
453
+ test('routing: assist succeeds but create POST 401 → exit 3 + admin-required wording', async () => {
454
+ const h = buildHarness({
455
+ assistMcpResponse: successAssistResponse(),
456
+ createError: new FakeApiError(401, { error: 'unauthenticated' }),
457
+ });
458
+ try {
459
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
460
+ assert.equal(process.exitCode, 3);
461
+ assert.ok(
462
+ h.captured.error.some((m) => m.includes('Authentication failed')),
463
+ `expected admin-required wording from create POST 401, got: ${JSON.stringify(h.captured.error)}`
464
+ );
465
+ } finally {
466
+ h.restoreLog();
467
+ }
468
+ });
469
+
470
+ // --- (g) Preview rendering ---------------------------------------------------
471
+
472
+ test('--yes prints MCP preview AND skips confirmation AND issues create POST', async () => {
473
+ const h = buildHarness({
474
+ assistMcpResponse: successAssistResponse(),
475
+ });
476
+ try {
477
+ await runCreateMcp(['--prompt', 'audit all linear writes', '--yes']);
478
+ const out = h.captured.stdout.join('\n');
479
+ assert.ok(/Resolved MCP policy/i.test(out), `preview header not printed to stdout: ${out}`);
480
+ assert.ok(out.includes('Audit Linear writes'), `name row missing: ${out}`);
481
+ assert.ok(out.includes('MCP_TOOL'), `type row missing: ${out}`);
482
+ assert.ok(out.includes('canonical_group_id: 12'), `service row missing: ${out}`);
483
+ assert.ok(out.includes('create_issue, update_issue'), `tools row missing: ${out}`);
484
+ assert.ok(out.includes('AUDIT'), `action row missing: ${out}`);
485
+ assert.ok(
486
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'),
487
+ 'create POST should fire under --yes'
488
+ );
489
+ } finally {
490
+ h.restoreLog();
491
+ }
492
+ });
493
+
494
+ test('--json suppresses preview but still POSTs create', async () => {
495
+ const h = buildHarness({
496
+ assistMcpResponse: successAssistResponse(),
497
+ });
498
+ try {
499
+ await runCreateMcp(['--prompt', 'audit linear writes', '--json']);
500
+ const combined = h.captured.stdout.join('\n');
501
+ const infos = h.captured.info.join('\n');
502
+ assert.ok(!combined.includes('Resolved MCP policy'), 'preview must be suppressed under --json (stdout)');
503
+ assert.ok(!/resolved mcp policy/i.test(infos), 'preview header must be suppressed under --json (info)');
504
+ assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should still fire');
505
+ } finally {
506
+ h.restoreLog();
507
+ }
508
+ });
509
+
510
+ test('without --yes: preview prints; "n" declines; no create POST fires', async () => {
511
+ const h = buildHarness({
512
+ assistMcpResponse: successAssistResponse(),
513
+ });
514
+ const readline = require('readline');
515
+ const origCreate = readline.createInterface;
516
+ readline.createInterface = () => ({
517
+ question(_q, cb) { cb('n'); },
518
+ close() {},
519
+ });
520
+ try {
521
+ await runCreateMcp(['--prompt', 'audit linear writes']);
522
+ const out = h.captured.stdout.join('\n');
523
+ assert.ok(/Resolved MCP policy/i.test(out), `preview header missing on stdout: ${out}`);
524
+ assert.equal(
525
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/').length,
526
+ 0,
527
+ 'no create POST should fire when user declines'
528
+ );
529
+ } finally {
530
+ readline.createInterface = origCreate;
531
+ h.restoreLog();
532
+ }
533
+ });
534
+
535
+ // --- (h) Out-of-scope keyword warning ----------------------------------------
536
+
537
+ test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint', async () => {
538
+ const h = buildHarness({
539
+ assistMcpResponse: successAssistResponse(),
540
+ });
541
+ try {
542
+ await runCreateMcp(['--prompt', 'audit staging linear writes', '--yes']);
543
+ assert.ok(
544
+ h.captured.warn.some((m) => m.includes('`staging`')),
545
+ `expected staging warning, got: ${JSON.stringify(h.captured.warn)}`
546
+ );
547
+ assert.ok(
548
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist-mcp/'),
549
+ 'assist-mcp endpoint should still be called'
550
+ );
551
+ } finally {
552
+ h.restoreLog();
553
+ }
554
+ });
555
+
556
+ // --- (i) --group resolved BEFORE assist call ---------------------------------
557
+
558
+ test('--prompt + --group resolves user-group BEFORE the assist call', async () => {
559
+ const h = buildHarness({
560
+ assistMcpResponse: successAssistResponse(),
561
+ });
562
+ h.api.get = async (path, opts) => {
563
+ h.calls.gets.push({ path, opts });
564
+ if (path === '/api/v1/users/privileges/') return { is_admin: true };
565
+ if (path === '/api/v1/policies/form_data/') {
566
+ return { data: { user_groups: [{ id: 7, name: 'GroupX' }] } };
567
+ }
568
+ throw new Error(`unexpected GET ${path}`);
569
+ };
570
+ try {
571
+ await runCreateMcp(['--prompt', 'audit linear writes', '--group', 'GroupX', '--yes']);
572
+ const assistIdx = h.calls.posts.findIndex((c) => c.path === '/api/v1/command-policies/assist-mcp/');
573
+ const formDataIdx = h.calls.gets.findIndex((c) => c.path === '/api/v1/policies/form_data/');
574
+ assert.notEqual(formDataIdx, -1, 'form_data should be fetched');
575
+ assert.notEqual(assistIdx, -1, 'assist-mcp endpoint should be called');
576
+ // The gets array (form_data) is populated before the posts array (assist-mcp)
577
+ // because the handler awaits loadFormData() before calling runMcpPromptCreate.
578
+ const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
579
+ assert.deepEqual(createCall.body.scope_user_group_ids, [7]);
580
+ } finally {
581
+ h.restoreLog();
582
+ }
583
+ });
584
+
585
+ // --- (j) Admin probe ---------------------------------------------------------
586
+
587
+ test('admin probe: non-admin → exits 3 before assist-mcp call', async () => {
588
+ const h = buildHarness({
589
+ privilegesResponse: { is_admin: false, is_manager: true, is_member: false },
590
+ });
591
+ try {
592
+ await runCreateMcp(['--prompt', 'audit linear writes', '--yes']);
593
+ assert.equal(process.exitCode, 3);
594
+ assert.ok(
595
+ h.captured.error.some((m) => m.includes('admin role')),
596
+ `got: ${JSON.stringify(h.captured.error)}`
597
+ );
598
+ assert.equal(
599
+ h.calls.posts.filter((c) => c.path === '/api/v1/command-policies/assist-mcp/').length,
600
+ 0,
601
+ 'assist-mcp must not be called for non-admins'
602
+ );
603
+ } finally {
604
+ h.restoreLog();
605
+ }
606
+ });
@@ -0,0 +1,66 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+
4
+ const {
5
+ validatePromptPreflight,
6
+ OUT_OF_SCOPE_KEYWORDS,
7
+ MAX_PROMPT_LEN,
8
+ } = require('../src/lib/policy-ai-assist');
9
+
10
+ // Spec §6.2 step 3 / §6.9: trim then check length; 1800 chars passes, 1801 rejects
11
+ // with backend-equivalent wording.
12
+
13
+ test('preflight: prompt of 1799 chars passes', () => {
14
+ const r = validatePromptPreflight({ prompt: 'a'.repeat(1799) });
15
+ assert.equal(r.warnings.length, 0);
16
+ });
17
+
18
+ test('preflight: prompt of 1800 chars passes (boundary)', () => {
19
+ const r = validatePromptPreflight({ prompt: 'a'.repeat(MAX_PROMPT_LEN) });
20
+ assert.equal(r.warnings.length, 0);
21
+ });
22
+
23
+ test('preflight: prompt of 1801 chars rejects with verbatim wording', () => {
24
+ assert.throws(
25
+ () => validatePromptPreflight({ prompt: 'a'.repeat(1801) }),
26
+ new RegExp(`^Error: Input is too long \\(max ${MAX_PROMPT_LEN} characters\\)\\.$`)
27
+ );
28
+ });
29
+
30
+ // §6.2 step 4: parametrized over the keyword list — every token surfaces a warning.
31
+ for (const token of OUT_OF_SCOPE_KEYWORDS) {
32
+ test(`preflight: out-of-scope keyword "${token}" produces a warning`, () => {
33
+ const r = validatePromptPreflight({ prompt: `block X in ${token} mode` });
34
+ assert.ok(r.warnings.includes(token), `expected warning for ${token}, got ${JSON.stringify(r.warnings)}`);
35
+ });
36
+ }
37
+
38
+ // §6.2 step 5: collapse runs of >=2 newlines to a single newline.
39
+ test('preflight: collapses three consecutive newlines to one', () => {
40
+ const r = validatePromptPreflight({ prompt: 'line1\n\n\nline2' });
41
+ assert.equal(r.sanitizedPrompt, 'line1\nline2');
42
+ });
43
+
44
+ test('preflight: collapses two consecutive newlines to one', () => {
45
+ const r = validatePromptPreflight({ prompt: 'a\n\nb' });
46
+ assert.equal(r.sanitizedPrompt, 'a\nb');
47
+ });
48
+
49
+ test('preflight: a single newline is left alone', () => {
50
+ const r = validatePromptPreflight({ prompt: 'a\nb' });
51
+ assert.equal(r.sanitizedPrompt, 'a\nb');
52
+ });
53
+
54
+ // §6.2: warning is informational only; under --yes the caller logs it and
55
+ // continues. The helper itself never throws for warnings — it just reports.
56
+ test('preflight: warnings do not throw', () => {
57
+ // Use a keyword that is NOT a substring of "production" (the keyword set
58
+ // contains "prod" which would also match): pick a non-overlapping one.
59
+ const r = validatePromptPreflight({ prompt: 'block weekend pushes' });
60
+ assert.ok(r.warnings.includes('weekend'));
61
+ // No throw — the function returned and we got here.
62
+ });
63
+
64
+ test('preflight: missing prompt throws', () => {
65
+ assert.throws(() => validatePromptPreflight({}), /--prompt is required/);
66
+ });