unbound-cli 1.16.21 → 1.16.23
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/package.json +1 -1
- package/src/commands/doctor.js +7 -5
- package/src/lib/policy-ai-assist.js +147 -19
- package/src/toolHealth.js +32 -4
- package/test/codex-managed-hooks-only.test.js +58 -0
- package/test/policy-ai-assist-preflight.test.js +45 -11
- package/test/policy-ai-assist.test.js +752 -12
- package/test/tool-health-mdm-paths.test.js +1 -1
|
@@ -94,6 +94,20 @@ async function runCreate(argv) {
|
|
|
94
94
|
await program.parseAsync(full);
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
// Answer every readline question (promptYesNo) with `answer`; records the
|
|
98
|
+
// questions asked.
|
|
99
|
+
function stubReadline(answer) {
|
|
100
|
+
const readline = require('readline');
|
|
101
|
+
const origCreate = readline.createInterface;
|
|
102
|
+
const questions = [];
|
|
103
|
+
readline.createInterface = () => ({
|
|
104
|
+
on() {},
|
|
105
|
+
question(q, cb) { questions.push(q); cb(answer); },
|
|
106
|
+
close() {},
|
|
107
|
+
});
|
|
108
|
+
return { questions, restore: () => { readline.createInterface = origCreate; } };
|
|
109
|
+
}
|
|
110
|
+
|
|
97
111
|
beforeEach(() => {
|
|
98
112
|
process.exitCode = 0;
|
|
99
113
|
});
|
|
@@ -470,6 +484,112 @@ test('happy path: builds policy body and POSTs to /command-policies/', async ()
|
|
|
470
484
|
}
|
|
471
485
|
});
|
|
472
486
|
|
|
487
|
+
// --- Multi-condition replies (form_updates.conditions) ------------------------
|
|
488
|
+
// The assist endpoint returns every AND condition in `conditions`; selected_field
|
|
489
|
+
// and field_value only mirror conditions[0], which need not be the environment.
|
|
490
|
+
|
|
491
|
+
const TERRAFORM_PROD_REPLY = {
|
|
492
|
+
success: true,
|
|
493
|
+
form_updates: {
|
|
494
|
+
command_family: 'cloud_destroy',
|
|
495
|
+
selected_field: 'provider',
|
|
496
|
+
field_value: '*terraform*',
|
|
497
|
+
conditions: [
|
|
498
|
+
{ field: 'provider', value: '*terraform*' },
|
|
499
|
+
{ field: 'environment', value: '*prod*' },
|
|
500
|
+
],
|
|
501
|
+
action: 'block',
|
|
502
|
+
name: 'Block terraform destroy in production',
|
|
503
|
+
},
|
|
504
|
+
explanation: 'terraform destroy, production only',
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
test('conditions: every AND condition reaches the create body and the preview', async () => {
|
|
508
|
+
const h = buildHarness({ assistResponse: TERRAFORM_PROD_REPLY });
|
|
509
|
+
try {
|
|
510
|
+
await runCreate([
|
|
511
|
+
'--prompt', 'block terraform destroy in production',
|
|
512
|
+
'--custom-message', 'Not in prod.', '--yes',
|
|
513
|
+
]);
|
|
514
|
+
const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
515
|
+
assert.ok(createCall, 'create POST should have fired');
|
|
516
|
+
assert.deepEqual(createCall.body.config, { provider: '*terraform*', environment: '*prod*' });
|
|
517
|
+
const out = h.captured.stdout.join('\n');
|
|
518
|
+
const preview = out.slice(0, out.indexOf('AI assist explanation'));
|
|
519
|
+
for (const text of ['provider', '*terraform*', 'environment', '*prod*']) {
|
|
520
|
+
assert.ok(preview.includes(text), `preview missing ${text}: ${preview}`);
|
|
521
|
+
}
|
|
522
|
+
const andIdx = preview.indexOf('all must match (AND)');
|
|
523
|
+
assert.ok(andIdx !== -1 && andIdx < preview.indexOf('provider'), `AND line must precede the conditions: ${preview}`);
|
|
524
|
+
assert.deepEqual(h.captured.warn, [], 'environment is applied, so no environment warning');
|
|
525
|
+
} finally {
|
|
526
|
+
h.restoreLog();
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('conditions: an empty conditions list falls back to selected_field / field_value', async () => {
|
|
531
|
+
const h = buildHarness({
|
|
532
|
+
assistResponse: {
|
|
533
|
+
success: true,
|
|
534
|
+
form_updates: {
|
|
535
|
+
command_family: 'git', selected_field: 'command', field_value: 'git push*',
|
|
536
|
+
conditions: [], action: 'AUDIT', name: 'Audit pushes',
|
|
537
|
+
},
|
|
538
|
+
explanation: 'ok',
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
try {
|
|
542
|
+
await runCreate(['--prompt', 'audit git pushes', '--yes']);
|
|
543
|
+
const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
544
|
+
assert.ok(createCall, 'create POST should have fired');
|
|
545
|
+
assert.deepEqual(createCall.body.config, { command: 'git push*' });
|
|
546
|
+
} finally {
|
|
547
|
+
h.restoreLog();
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
for (const [source, conditions] of [['conditions', [{ field: 'command', value: ' git push* ' }]], ['selected_field', undefined]]) {
|
|
552
|
+
test(`conditions: values from ${source} are trimmed, as the dashboard does`, async () => {
|
|
553
|
+
const h = buildHarness({
|
|
554
|
+
assistResponse: {
|
|
555
|
+
success: true,
|
|
556
|
+
form_updates: {
|
|
557
|
+
command_family: 'git', selected_field: 'command', field_value: ' git push* ',
|
|
558
|
+
conditions, action: 'AUDIT', name: 'Audit pushes',
|
|
559
|
+
},
|
|
560
|
+
explanation: 'ok',
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
try {
|
|
564
|
+
await runCreate(['--prompt', 'audit git pushes', '--yes']);
|
|
565
|
+
const createCall = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
566
|
+
assert.ok(createCall, 'create POST should have fired');
|
|
567
|
+
assert.deepEqual(createCall.body.config, { command: 'git push*' });
|
|
568
|
+
} finally {
|
|
569
|
+
h.restoreLog();
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// --field / --config stay mutually exclusive with --prompt: they never merge
|
|
575
|
+
// into, or override, the AI's conditions.
|
|
576
|
+
for (const [flag, value] of [['--field', 'environment=*staging*'], ['--config', '{"environment":"*staging*"}']]) {
|
|
577
|
+
test(`conditions: --prompt + ${flag} is still rejected before the assist call`, async () => {
|
|
578
|
+
const h = buildHarness({ assistResponse: TERRAFORM_PROD_REPLY });
|
|
579
|
+
try {
|
|
580
|
+
await runCreate(['--prompt', 'block terraform destroy in production', flag, value, '--yes']);
|
|
581
|
+
assert.equal(process.exitCode, 1);
|
|
582
|
+
assert.ok(
|
|
583
|
+
h.captured.error.includes('Pass --prompt for AI-assist or the field flags for explicit creation, not both.'),
|
|
584
|
+
`expected mutex error, got: ${JSON.stringify(h.captured.error)}`
|
|
585
|
+
);
|
|
586
|
+
assert.equal(h.calls.posts.length, 0, 'neither assist nor create should fire');
|
|
587
|
+
} finally {
|
|
588
|
+
h.restoreLog();
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
473
593
|
// --- (b.1) Greptile follow-up regressions -------------------------------------
|
|
474
594
|
|
|
475
595
|
test('AI returns BLOCK without --custom-message → exit 2 with guard message', async () => {
|
|
@@ -758,7 +878,7 @@ test('routing: assist succeeds but create POST 401 → exit 3 + admin-required w
|
|
|
758
878
|
|
|
759
879
|
// --- (d) --yes + out-of-scope keyword: warns AND proceeds ---------------------
|
|
760
880
|
|
|
761
|
-
test('out-of-scope: --yes + "
|
|
881
|
+
test('out-of-scope: --yes + "weekend" warns but still calls the assist endpoint', async () => {
|
|
762
882
|
const h = buildHarness({
|
|
763
883
|
assistResponse: {
|
|
764
884
|
success: true,
|
|
@@ -770,10 +890,14 @@ test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint'
|
|
|
770
890
|
},
|
|
771
891
|
});
|
|
772
892
|
try {
|
|
773
|
-
await runCreate(['--prompt', 'block
|
|
893
|
+
await runCreate(['--prompt', 'block git pushes on weekends', '--yes']);
|
|
774
894
|
assert.ok(
|
|
775
|
-
h.captured.warn.some((m) => m.includes('`
|
|
776
|
-
`expected
|
|
895
|
+
h.captured.warn.some((m) => m.includes('`weekend`')),
|
|
896
|
+
`expected weekend warning, got: ${JSON.stringify(h.captured.warn)}`
|
|
897
|
+
);
|
|
898
|
+
assert.ok(
|
|
899
|
+
!h.captured.warn.some((m) => m.includes('environment')),
|
|
900
|
+
`terminal warning must not claim environment is out of scope, got: ${JSON.stringify(h.captured.warn)}`
|
|
777
901
|
);
|
|
778
902
|
assert.ok(
|
|
779
903
|
h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
|
|
@@ -784,6 +908,627 @@ test('out-of-scope: --yes + "staging" warns but still calls the assist endpoint'
|
|
|
784
908
|
}
|
|
785
909
|
});
|
|
786
910
|
|
|
911
|
+
test('out-of-scope: without --yes, "except" asks to continue; "n" aborts before the assist call', async () => {
|
|
912
|
+
const h = buildHarness();
|
|
913
|
+
const rl = stubReadline('n');
|
|
914
|
+
try {
|
|
915
|
+
await runCreate(['--prompt', 'block git pushes except on main']);
|
|
916
|
+
assert.equal(process.exitCode, 0);
|
|
917
|
+
assert.ok(
|
|
918
|
+
h.captured.warn.some((m) => m.includes('`except`')),
|
|
919
|
+
`expected except warning, got: ${JSON.stringify(h.captured.warn)}`
|
|
920
|
+
);
|
|
921
|
+
assert.equal(rl.questions.length, 1);
|
|
922
|
+
assert.ok(rl.questions[0].includes('Continue anyway?'), `got: ${JSON.stringify(rl.questions)}`);
|
|
923
|
+
assert.equal(h.calls.posts.length, 0, 'assist must not be called after the user aborts');
|
|
924
|
+
} finally {
|
|
925
|
+
rl.restore();
|
|
926
|
+
h.restoreLog();
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// Terminal policies can scope by environment (cloud_*, database_* and
|
|
931
|
+
// build_operation carry an `environment` field), so environment words are in
|
|
932
|
+
// scope here.
|
|
933
|
+
for (const [word, prompt, environment] of [
|
|
934
|
+
['production', 'audit aws deletes in production', '*prod*'],
|
|
935
|
+
['prod', 'block terraform destroy against prod', '*prod*'],
|
|
936
|
+
['staging', 'audit database drops in staging', '*stg*'],
|
|
937
|
+
]) {
|
|
938
|
+
test(`in-scope: "${word}" in a terminal prompt neither warns nor asks to continue`, async () => {
|
|
939
|
+
const h = buildHarness({
|
|
940
|
+
assistResponse: {
|
|
941
|
+
success: true,
|
|
942
|
+
form_updates: {
|
|
943
|
+
command_family: 'cloud_destroy', selected_field: 'environment',
|
|
944
|
+
field_value: environment, action: 'AUDIT', name: `Audit ${word} cloud deletes`,
|
|
945
|
+
},
|
|
946
|
+
explanation: 'ok',
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
const rl = stubReadline('y');
|
|
950
|
+
try {
|
|
951
|
+
await runCreate(['--prompt', prompt]);
|
|
952
|
+
assert.equal(process.exitCode, 0);
|
|
953
|
+
assert.deepEqual(h.captured.warn, []);
|
|
954
|
+
assert.deepEqual(rl.questions, ['Create policy? [Y/n] '], 'only the create confirmation should be asked');
|
|
955
|
+
const assist = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/assist/');
|
|
956
|
+
assert.ok(assist, 'assist endpoint should be called');
|
|
957
|
+
assert.equal(assist.body.user_input, prompt);
|
|
958
|
+
const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
959
|
+
assert.ok(create, 'create POST should fire after confirmation');
|
|
960
|
+
assert.deepEqual(create.body.config, { environment });
|
|
961
|
+
} finally {
|
|
962
|
+
rl.restore();
|
|
963
|
+
h.restoreLog();
|
|
964
|
+
}
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Only some families carry `environment`; when the resolved policy has none,
|
|
969
|
+
// an environment named in the prompt was dropped. Warn and, interactively,
|
|
970
|
+
// default the confirmation to No; never block --yes / --json.
|
|
971
|
+
const GIT_PUSH_REPLY = {
|
|
972
|
+
success: true,
|
|
973
|
+
form_updates: {
|
|
974
|
+
command_family: 'git_action', selected_field: 'operation', field_value: 'push',
|
|
975
|
+
conditions: [{ field: 'operation', value: 'push' }],
|
|
976
|
+
action: 'block', name: 'Block git push',
|
|
977
|
+
},
|
|
978
|
+
explanation: 'ok',
|
|
979
|
+
};
|
|
980
|
+
|
|
981
|
+
for (const [mode, flags] of [['interactive', []], ['--yes', ['--yes']], ['--json', ['--json']]]) {
|
|
982
|
+
test(`environment (${mode}): an environment the resolved family cannot scope by warns; policy still created`, async () => {
|
|
983
|
+
const h = buildHarness({ assistResponse: GIT_PUSH_REPLY });
|
|
984
|
+
const rl = stubReadline('y');
|
|
985
|
+
try {
|
|
986
|
+
await runCreate(['--prompt', 'block git push to production', '--custom-message', 'No.', ...flags]);
|
|
987
|
+
assert.equal(process.exitCode, 0);
|
|
988
|
+
assert.deepEqual(h.captured.warn, [
|
|
989
|
+
'Your prompt mentions `production`, but this git_action policy has no environment condition, so it applies in all environments.',
|
|
990
|
+
]);
|
|
991
|
+
const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
992
|
+
assert.ok(create, 'create POST should still fire');
|
|
993
|
+
assert.deepEqual(create.body.config, { operation: 'push' });
|
|
994
|
+
assert.deepEqual(rl.questions, mode === 'interactive' ? ['Create policy anyway? [y/N] '] : []);
|
|
995
|
+
if (mode === '--json') {
|
|
996
|
+
assert.deepEqual(JSON.parse(h.captured.stdout.join('\n')), { id: 1, name: 'ok' }, 'stdout stays pure JSON');
|
|
997
|
+
}
|
|
998
|
+
} finally {
|
|
999
|
+
rl.restore();
|
|
1000
|
+
h.restoreLog();
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
test('environment (interactive): Enter on the warned confirmation aborts; no create POST', async () => {
|
|
1006
|
+
const h = buildHarness({ assistResponse: GIT_PUSH_REPLY });
|
|
1007
|
+
const rl = stubReadline('');
|
|
1008
|
+
try {
|
|
1009
|
+
await runCreate(['--prompt', 'block git push to production', '--custom-message', 'No.']);
|
|
1010
|
+
assert.equal(process.exitCode, 0);
|
|
1011
|
+
assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
|
|
1012
|
+
assert.ok(h.captured.warn.includes('Aborted.'), `expected Aborted., got: ${JSON.stringify(h.captured.warn)}`);
|
|
1013
|
+
assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
|
|
1014
|
+
} finally {
|
|
1015
|
+
rl.restore();
|
|
1016
|
+
h.restoreLog();
|
|
1017
|
+
}
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test('environment: warns once for each prompt environment no condition applies', async () => {
|
|
1021
|
+
const h = buildHarness({
|
|
1022
|
+
assistResponse: {
|
|
1023
|
+
success: true,
|
|
1024
|
+
form_updates: {
|
|
1025
|
+
command_family: 'git_action', selected_field: 'operation', field_value: 'push',
|
|
1026
|
+
conditions: [
|
|
1027
|
+
{ field: 'operation', value: 'push' },
|
|
1028
|
+
{ field: 'branch', value: '*dev*' },
|
|
1029
|
+
],
|
|
1030
|
+
action: 'block', name: 'Block git push to dev',
|
|
1031
|
+
},
|
|
1032
|
+
explanation: 'ok',
|
|
1033
|
+
},
|
|
1034
|
+
});
|
|
1035
|
+
try {
|
|
1036
|
+
await runCreate([
|
|
1037
|
+
'--prompt', 'block git push to dev, staging and production; production especially',
|
|
1038
|
+
'--custom-message', 'No.', '--yes',
|
|
1039
|
+
]);
|
|
1040
|
+
assert.equal(process.exitCode, 0);
|
|
1041
|
+
assert.deepEqual(h.captured.warn, [
|
|
1042
|
+
'Your prompt mentions `staging`, but this git_action policy has no environment condition, so it applies in all environments.',
|
|
1043
|
+
'Your prompt mentions `production`, but this git_action policy has no environment condition, so it applies in all environments.',
|
|
1044
|
+
]);
|
|
1045
|
+
assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
|
|
1046
|
+
} finally {
|
|
1047
|
+
h.restoreLog();
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
for (const [mode, flags] of [['--yes', ['--yes']], ['interactive', []]]) {
|
|
1052
|
+
test(`environment (${mode}): an environment condition of qa warns that qa is not a recognised label`, async () => {
|
|
1053
|
+
const h = buildHarness({
|
|
1054
|
+
assistResponse: {
|
|
1055
|
+
success: true,
|
|
1056
|
+
form_updates: {
|
|
1057
|
+
command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
|
|
1058
|
+
conditions: [
|
|
1059
|
+
{ field: 'provider', value: '*terraform*' },
|
|
1060
|
+
{ field: 'environment', value: '*QA*' },
|
|
1061
|
+
],
|
|
1062
|
+
action: 'audit', name: 'Audit terraform destroy in qa',
|
|
1063
|
+
},
|
|
1064
|
+
explanation: 'ok',
|
|
1065
|
+
},
|
|
1066
|
+
});
|
|
1067
|
+
const rl = stubReadline('y');
|
|
1068
|
+
try {
|
|
1069
|
+
await runCreate(['--prompt', 'audit terraform destroy in qa', ...flags]);
|
|
1070
|
+
assert.equal(process.exitCode, 0);
|
|
1071
|
+
assert.deepEqual(h.captured.warn, [
|
|
1072
|
+
'`qa` is not a recognised environment label (recognised: production, staging, dev, test, local, and their aliases prod, prd, live, mainnet, stg, stage, uat, preprod, preproduction, development, sandbox, sbx, testing, tst, localhost, 127.0.0.1), so this policy may not match.',
|
|
1073
|
+
]);
|
|
1074
|
+
assert.deepEqual(rl.questions, mode === 'interactive' ? ['Create policy anyway? [y/N] '] : []);
|
|
1075
|
+
const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
1076
|
+
assert.ok(create, 'create POST should still fire');
|
|
1077
|
+
assert.equal(create.body.config.environment, '*QA*');
|
|
1078
|
+
} finally {
|
|
1079
|
+
rl.restore();
|
|
1080
|
+
h.restoreLog();
|
|
1081
|
+
}
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// An environment condition for a different environment than the prompt names.
|
|
1086
|
+
test('environment (interactive): a prompt environment the environment condition does not cover warns; Enter aborts', async () => {
|
|
1087
|
+
const h = buildHarness({
|
|
1088
|
+
assistResponse: {
|
|
1089
|
+
success: true,
|
|
1090
|
+
form_updates: {
|
|
1091
|
+
command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
|
|
1092
|
+
conditions: [
|
|
1093
|
+
{ field: 'provider', value: '*terraform*' },
|
|
1094
|
+
{ field: 'environment', value: '*prod*' },
|
|
1095
|
+
],
|
|
1096
|
+
action: 'block', name: 'Block terraform destroy in production',
|
|
1097
|
+
},
|
|
1098
|
+
explanation: 'ok',
|
|
1099
|
+
},
|
|
1100
|
+
});
|
|
1101
|
+
const rl = stubReadline('');
|
|
1102
|
+
try {
|
|
1103
|
+
await runCreate(['--prompt', 'block terraform destroy in staging and production', '--custom-message', 'No.']);
|
|
1104
|
+
assert.equal(process.exitCode, 0);
|
|
1105
|
+
assert.deepEqual(h.captured.warn, [
|
|
1106
|
+
"Your prompt mentions `staging`, but this policy's environment condition is `*prod*`, so it does not apply in staging.",
|
|
1107
|
+
'Aborted.',
|
|
1108
|
+
]);
|
|
1109
|
+
assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
|
|
1110
|
+
assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
|
|
1111
|
+
} finally {
|
|
1112
|
+
rl.restore();
|
|
1113
|
+
h.restoreLog();
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// An environment condition decides scope by itself, so another field holding
|
|
1118
|
+
// the prompt's word must not count as evidence that the scope was applied.
|
|
1119
|
+
test('environment: another condition holding the word does not excuse a mismatched environment', async () => {
|
|
1120
|
+
const h = buildHarness({
|
|
1121
|
+
assistResponse: {
|
|
1122
|
+
success: true,
|
|
1123
|
+
form_updates: {
|
|
1124
|
+
command_family: 'cloud_destroy', selected_field: 'service', field_value: '*prod-api*',
|
|
1125
|
+
conditions: [
|
|
1126
|
+
{ field: 'service', value: '*prod-api*' },
|
|
1127
|
+
{ field: 'environment', value: '*staging*' },
|
|
1128
|
+
],
|
|
1129
|
+
action: 'block', name: 'Block prod-api destroy',
|
|
1130
|
+
},
|
|
1131
|
+
explanation: 'ok',
|
|
1132
|
+
},
|
|
1133
|
+
});
|
|
1134
|
+
const rl = stubReadline('');
|
|
1135
|
+
try {
|
|
1136
|
+
await runCreate(['--prompt', 'block cloud destroy in prod', '--custom-message', 'No.']);
|
|
1137
|
+
assert.equal(process.exitCode, 0);
|
|
1138
|
+
assert.deepEqual(h.captured.warn, [
|
|
1139
|
+
"Your prompt mentions `prod`, but this policy's environment condition is `*staging*`, so it does not apply in prod.",
|
|
1140
|
+
'Aborted.',
|
|
1141
|
+
]);
|
|
1142
|
+
assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
|
|
1143
|
+
} finally {
|
|
1144
|
+
rl.restore();
|
|
1145
|
+
h.restoreLog();
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
// `pre-prod` is pre-production, which maps to staging, never to production.
|
|
1150
|
+
test('environment: pre-prod given a production condition warns', async () => {
|
|
1151
|
+
const h = buildHarness({
|
|
1152
|
+
assistResponse: {
|
|
1153
|
+
success: true,
|
|
1154
|
+
form_updates: {
|
|
1155
|
+
command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
|
|
1156
|
+
conditions: [
|
|
1157
|
+
{ field: 'provider', value: '*terraform*' },
|
|
1158
|
+
{ field: 'environment', value: '*prod*' },
|
|
1159
|
+
],
|
|
1160
|
+
action: 'block', name: 'Block terraform destroy',
|
|
1161
|
+
},
|
|
1162
|
+
explanation: 'ok',
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
const rl = stubReadline('');
|
|
1166
|
+
try {
|
|
1167
|
+
await runCreate(['--prompt', 'block terraform destroy in pre-prod', '--custom-message', 'No.']);
|
|
1168
|
+
assert.equal(process.exitCode, 0);
|
|
1169
|
+
assert.deepEqual(h.captured.warn, [
|
|
1170
|
+
"Your prompt mentions `pre-prod`, but this policy's environment condition is `*prod*`, so it does not apply in pre-prod.",
|
|
1171
|
+
'Aborted.',
|
|
1172
|
+
]);
|
|
1173
|
+
} finally {
|
|
1174
|
+
rl.restore();
|
|
1175
|
+
h.restoreLog();
|
|
1176
|
+
}
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
// A separated pre-production condition does not cover production, however it is
|
|
1180
|
+
// spelled: the `prod` inside it must not read as the environment asked for.
|
|
1181
|
+
for (const value of ['*pre-prod*', '*pre_prod*', '*pre production*', '*pre-production*']) {
|
|
1182
|
+
test(`environment: a production prompt given ${value} warns`, async () => {
|
|
1183
|
+
const h = buildHarness({
|
|
1184
|
+
assistResponse: {
|
|
1185
|
+
success: true,
|
|
1186
|
+
form_updates: {
|
|
1187
|
+
command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
|
|
1188
|
+
conditions: [
|
|
1189
|
+
{ field: 'provider', value: '*terraform*' },
|
|
1190
|
+
{ field: 'environment', value },
|
|
1191
|
+
],
|
|
1192
|
+
action: 'block', name: 'Block terraform destroy',
|
|
1193
|
+
},
|
|
1194
|
+
explanation: 'ok',
|
|
1195
|
+
},
|
|
1196
|
+
});
|
|
1197
|
+
const rl = stubReadline('');
|
|
1198
|
+
try {
|
|
1199
|
+
await runCreate(['--prompt', 'block terraform destroy in production', '--custom-message', 'No.']);
|
|
1200
|
+
assert.equal(process.exitCode, 0);
|
|
1201
|
+
assert.deepEqual(h.captured.warn, [
|
|
1202
|
+
`Your prompt mentions \`production\`, but this policy's environment condition is \`${value}\`, so it does not apply in production.`,
|
|
1203
|
+
'Aborted.',
|
|
1204
|
+
]);
|
|
1205
|
+
} finally {
|
|
1206
|
+
rl.restore();
|
|
1207
|
+
h.restoreLog();
|
|
1208
|
+
}
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// A separator inside an alias survives, so these spellings are already scoped.
|
|
1213
|
+
for (const [prompt, field, value] of [
|
|
1214
|
+
['block terraform destroy in pre-prod', 'environment', '*preprod*'],
|
|
1215
|
+
['block terraform destroy in pre-prod', 'environment', '*pre-prod*'],
|
|
1216
|
+
['block terraform destroy in pre-prod', 'environment', '*staging*'],
|
|
1217
|
+
['block terraform destroy in staging', 'environment', '*pre-prod*'],
|
|
1218
|
+
['block db writes in local', 'host', '127.0.0.1'],
|
|
1219
|
+
['block db writes in localhost', 'host', '127.0.0.1'],
|
|
1220
|
+
['block terraform destroy in production', 'environment', '*'],
|
|
1221
|
+
]) {
|
|
1222
|
+
test(`environment: "${prompt}" with ${field} ${value} does not warn`, async () => {
|
|
1223
|
+
const h = buildHarness({
|
|
1224
|
+
assistResponse: {
|
|
1225
|
+
success: true,
|
|
1226
|
+
form_updates: {
|
|
1227
|
+
command_family: 'database_write', selected_field: field, field_value: value,
|
|
1228
|
+
conditions: [{ field, value }],
|
|
1229
|
+
action: 'block', name: 'Block it',
|
|
1230
|
+
},
|
|
1231
|
+
explanation: 'ok',
|
|
1232
|
+
},
|
|
1233
|
+
});
|
|
1234
|
+
try {
|
|
1235
|
+
await runCreate(['--prompt', prompt, '--custom-message', 'No.', '--yes']);
|
|
1236
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1237
|
+
} finally {
|
|
1238
|
+
h.restoreLog();
|
|
1239
|
+
}
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// A missed warning is the dangerous direction: both of these name an
|
|
1244
|
+
// environment the resolved policy does not scope to.
|
|
1245
|
+
for (const [label, prompt, conditions, word] of [
|
|
1246
|
+
['an underscore-separated name', 'block schema drops on prod_db', [{ field: 'command', value: 'drop table' }], 'prod'],
|
|
1247
|
+
[
|
|
1248
|
+
'a blank environment condition no other field can vouch for',
|
|
1249
|
+
'block schema drops in production',
|
|
1250
|
+
[{ field: 'environment', value: '' }, { field: 'command', value: '*prod*' }],
|
|
1251
|
+
'production',
|
|
1252
|
+
],
|
|
1253
|
+
]) {
|
|
1254
|
+
test(`environment: ${label} warns`, async () => {
|
|
1255
|
+
const h = buildHarness({
|
|
1256
|
+
assistResponse: {
|
|
1257
|
+
success: true,
|
|
1258
|
+
form_updates: {
|
|
1259
|
+
command_family: 'database_write',
|
|
1260
|
+
selected_field: conditions[0].field, field_value: conditions[0].value,
|
|
1261
|
+
conditions,
|
|
1262
|
+
action: 'block', name: 'Block it',
|
|
1263
|
+
},
|
|
1264
|
+
explanation: 'ok',
|
|
1265
|
+
},
|
|
1266
|
+
});
|
|
1267
|
+
try {
|
|
1268
|
+
await runCreate(['--prompt', prompt, '--custom-message', 'No.', '--yes']);
|
|
1269
|
+
assert.deepEqual(h.captured.warn, [
|
|
1270
|
+
`Your prompt mentions \`${word}\`, but this database_write policy has no environment condition, so it applies in all environments.`,
|
|
1271
|
+
]);
|
|
1272
|
+
} finally {
|
|
1273
|
+
h.restoreLog();
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
test('environment: a blank environment condition counts as none; warns', async () => {
|
|
1279
|
+
const h = buildHarness({
|
|
1280
|
+
assistResponse: {
|
|
1281
|
+
success: true,
|
|
1282
|
+
form_updates: {
|
|
1283
|
+
command_family: 'cloud_destroy', selected_field: 'provider', field_value: '*terraform*',
|
|
1284
|
+
conditions: [
|
|
1285
|
+
{ field: 'provider', value: '*terraform*' },
|
|
1286
|
+
{ field: 'environment', value: ' ' },
|
|
1287
|
+
],
|
|
1288
|
+
action: 'audit', name: 'Audit terraform destroy',
|
|
1289
|
+
},
|
|
1290
|
+
explanation: 'ok',
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
try {
|
|
1294
|
+
await runCreate(['--prompt', 'audit terraform destroy in production', '--yes']);
|
|
1295
|
+
assert.equal(process.exitCode, 0);
|
|
1296
|
+
assert.deepEqual(h.captured.warn, [
|
|
1297
|
+
'Your prompt mentions `production`, but this cloud_destroy policy has no environment condition, so it applies in all environments.',
|
|
1298
|
+
]);
|
|
1299
|
+
} finally {
|
|
1300
|
+
h.restoreLog();
|
|
1301
|
+
}
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1304
|
+
// Condition values are matched by whole token, so a value that merely contains
|
|
1305
|
+
// the word ('product-catalog', 'device') does not count as applying it.
|
|
1306
|
+
test('environment (interactive): "prod" is not applied by a *product-catalog* path; warns and Enter aborts', async () => {
|
|
1307
|
+
const h = buildHarness({
|
|
1308
|
+
assistResponse: {
|
|
1309
|
+
success: true,
|
|
1310
|
+
form_updates: {
|
|
1311
|
+
command_family: 'write_file', selected_field: 'path', field_value: '*product-catalog*',
|
|
1312
|
+
action: 'block', name: 'Block product-catalog writes',
|
|
1313
|
+
},
|
|
1314
|
+
explanation: 'ok',
|
|
1315
|
+
},
|
|
1316
|
+
});
|
|
1317
|
+
const rl = stubReadline('');
|
|
1318
|
+
try {
|
|
1319
|
+
await runCreate(['--prompt', 'block filesystem writes to product-catalog in prod', '--custom-message', 'No.']);
|
|
1320
|
+
assert.equal(process.exitCode, 0);
|
|
1321
|
+
assert.deepEqual(h.captured.warn, [
|
|
1322
|
+
'Your prompt mentions `prod`, but this write_file policy has no environment condition, so it applies in all environments.',
|
|
1323
|
+
'Aborted.',
|
|
1324
|
+
]);
|
|
1325
|
+
assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
|
|
1326
|
+
assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
|
|
1327
|
+
} finally {
|
|
1328
|
+
rl.restore();
|
|
1329
|
+
h.restoreLog();
|
|
1330
|
+
}
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
test('environment: "dev" is not applied by a *device* path; warns', async () => {
|
|
1334
|
+
const h = buildHarness({
|
|
1335
|
+
assistResponse: {
|
|
1336
|
+
success: true,
|
|
1337
|
+
form_updates: {
|
|
1338
|
+
command_family: 'write_file', selected_field: 'path', field_value: '*device*',
|
|
1339
|
+
action: 'audit', name: 'Audit device writes',
|
|
1340
|
+
},
|
|
1341
|
+
explanation: 'ok',
|
|
1342
|
+
},
|
|
1343
|
+
});
|
|
1344
|
+
try {
|
|
1345
|
+
await runCreate(['--prompt', 'audit writes to device files in dev', '--yes']);
|
|
1346
|
+
assert.equal(process.exitCode, 0);
|
|
1347
|
+
assert.deepEqual(h.captured.warn, [
|
|
1348
|
+
'Your prompt mentions `dev`, but this write_file policy has no environment condition, so it applies in all environments.',
|
|
1349
|
+
]);
|
|
1350
|
+
assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
|
|
1351
|
+
} finally {
|
|
1352
|
+
h.restoreLog();
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
test('environment: "prod" applied by a custom make release-prod pattern does not warn', async () => {
|
|
1357
|
+
const h = buildHarness({
|
|
1358
|
+
assistResponse: {
|
|
1359
|
+
success: true,
|
|
1360
|
+
form_updates: {
|
|
1361
|
+
command_family: 'custom', selected_field: 'command', field_value: 'make release-prod',
|
|
1362
|
+
action: 'audit', name: 'Audit prod releases',
|
|
1363
|
+
},
|
|
1364
|
+
explanation: 'ok',
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
1367
|
+
try {
|
|
1368
|
+
await runCreate(['--prompt', 'audit make release-prod', '--yes']);
|
|
1369
|
+
assert.equal(process.exitCode, 0);
|
|
1370
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1371
|
+
assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
|
|
1372
|
+
} finally {
|
|
1373
|
+
h.restoreLog();
|
|
1374
|
+
}
|
|
1375
|
+
});
|
|
1376
|
+
|
|
1377
|
+
test('environment: an environment word another condition matches on (namespace *prod*) does not warn', async () => {
|
|
1378
|
+
const h = buildHarness({
|
|
1379
|
+
assistResponse: {
|
|
1380
|
+
success: true,
|
|
1381
|
+
form_updates: {
|
|
1382
|
+
command_family: 'container', selected_field: 'operation', field_value: 'exec',
|
|
1383
|
+
conditions: [
|
|
1384
|
+
{ field: 'operation', value: 'exec' },
|
|
1385
|
+
{ field: 'namespace', value: '*prod*' },
|
|
1386
|
+
],
|
|
1387
|
+
action: 'audit', name: 'Audit kubectl exec in prod namespaces',
|
|
1388
|
+
},
|
|
1389
|
+
explanation: 'ok',
|
|
1390
|
+
},
|
|
1391
|
+
});
|
|
1392
|
+
try {
|
|
1393
|
+
await runCreate(['--prompt', 'audit kubectl exec in the prod namespace', '--yes']);
|
|
1394
|
+
assert.equal(process.exitCode, 0);
|
|
1395
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1396
|
+
const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
1397
|
+
assert.ok(create, 'create POST should fire');
|
|
1398
|
+
assert.deepEqual(create.body.config, { operation: 'exec', namespace: '*prod*' });
|
|
1399
|
+
} finally {
|
|
1400
|
+
h.restoreLog();
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
for (const [word, namespace] of [['production', '*prod*'], ['production', 'prod-*'], ['staging', '*stg*'], ['staging', '*stage*']]) {
|
|
1405
|
+
test(`environment: "${word}" with a namespace alias of the same environment (${namespace}) does not warn`, async () => {
|
|
1406
|
+
const h = buildHarness({
|
|
1407
|
+
assistResponse: {
|
|
1408
|
+
success: true,
|
|
1409
|
+
form_updates: {
|
|
1410
|
+
command_family: 'container', selected_field: 'operation', field_value: 'exec',
|
|
1411
|
+
conditions: [
|
|
1412
|
+
{ field: 'operation', value: 'exec' },
|
|
1413
|
+
{ field: 'namespace', value: namespace },
|
|
1414
|
+
],
|
|
1415
|
+
action: 'audit', name: `Audit kubectl exec in ${word} namespaces`,
|
|
1416
|
+
},
|
|
1417
|
+
explanation: 'ok',
|
|
1418
|
+
},
|
|
1419
|
+
});
|
|
1420
|
+
try {
|
|
1421
|
+
await runCreate(['--prompt', `audit kubectl exec in ${word} namespaces`, '--yes']);
|
|
1422
|
+
assert.equal(process.exitCode, 0);
|
|
1423
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1424
|
+
const create = h.calls.posts.find((c) => c.path === '/api/v1/command-policies/');
|
|
1425
|
+
assert.ok(create, 'create POST should fire');
|
|
1426
|
+
assert.deepEqual(create.body.config, { operation: 'exec', namespace });
|
|
1427
|
+
} finally {
|
|
1428
|
+
h.restoreLog();
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// The condition-value side of this is covered above; this pins the prompt scan.
|
|
1434
|
+
test('environment: "product-catalog" in the prompt is not the word prod; does not warn', async () => {
|
|
1435
|
+
const h = buildHarness({
|
|
1436
|
+
assistResponse: {
|
|
1437
|
+
success: true,
|
|
1438
|
+
form_updates: {
|
|
1439
|
+
command_family: 'filesystem', selected_field: 'command', field_value: '*product-catalog*',
|
|
1440
|
+
action: 'audit', name: 'Audit product-catalog writes',
|
|
1441
|
+
},
|
|
1442
|
+
explanation: 'ok',
|
|
1443
|
+
},
|
|
1444
|
+
});
|
|
1445
|
+
try {
|
|
1446
|
+
await runCreate(['--prompt', 'audit product-catalog writes', '--yes']);
|
|
1447
|
+
assert.equal(process.exitCode, 0);
|
|
1448
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1449
|
+
assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
|
|
1450
|
+
} finally {
|
|
1451
|
+
h.restoreLog();
|
|
1452
|
+
}
|
|
1453
|
+
});
|
|
1454
|
+
|
|
1455
|
+
// Environment words that are also everyday English (test, live, stage, local,
|
|
1456
|
+
// ...) count only in an environment context; the others count anywhere.
|
|
1457
|
+
function auditPushReply(conditions) {
|
|
1458
|
+
return {
|
|
1459
|
+
success: true,
|
|
1460
|
+
form_updates: {
|
|
1461
|
+
command_family: 'git_action', selected_field: 'operation', field_value: 'push',
|
|
1462
|
+
conditions, action: 'audit', name: 'Audit git push',
|
|
1463
|
+
},
|
|
1464
|
+
explanation: 'ok',
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
for (const [prompt, word] of [
|
|
1469
|
+
['audit git push to mainnet', 'mainnet'],
|
|
1470
|
+
['audit git push to the stg branch', 'stg'],
|
|
1471
|
+
['audit git push in the test environment', 'test'],
|
|
1472
|
+
['audit git push to live', 'live'],
|
|
1473
|
+
['audit git push in the local cluster', 'local'],
|
|
1474
|
+
]) {
|
|
1475
|
+
test(`environment: "${prompt}" warns about ${word}`, async () => {
|
|
1476
|
+
const h = buildHarness({ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }]) });
|
|
1477
|
+
try {
|
|
1478
|
+
await runCreate(['--prompt', prompt, '--yes']);
|
|
1479
|
+
assert.equal(process.exitCode, 0);
|
|
1480
|
+
assert.deepEqual(h.captured.warn, [
|
|
1481
|
+
`Your prompt mentions \`${word}\`, but this git_action policy has no environment condition, so it applies in all environments.`,
|
|
1482
|
+
]);
|
|
1483
|
+
assert.ok(h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'create POST should fire');
|
|
1484
|
+
} finally {
|
|
1485
|
+
h.restoreLog();
|
|
1486
|
+
}
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
for (const prompt of ['audit npm test runs', 'audit git stage files', 'audit live reload', 'audit scripts that run tests locally']) {
|
|
1491
|
+
test(`environment: "${prompt}" does not warn (everyday word, no environment context)`, async () => {
|
|
1492
|
+
const h = buildHarness({ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }]) });
|
|
1493
|
+
try {
|
|
1494
|
+
await runCreate(['--prompt', prompt, '--yes']);
|
|
1495
|
+
assert.equal(process.exitCode, 0);
|
|
1496
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1497
|
+
} finally {
|
|
1498
|
+
h.restoreLog();
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
for (const branch of ['*tst*', 'test-*']) {
|
|
1504
|
+
test(`environment: "in the test environment" applied by branch ${branch} does not warn`, async () => {
|
|
1505
|
+
const h = buildHarness({
|
|
1506
|
+
assistResponse: auditPushReply([{ field: 'operation', value: 'push' }, { field: 'branch', value: branch }]),
|
|
1507
|
+
});
|
|
1508
|
+
try {
|
|
1509
|
+
await runCreate(['--prompt', 'audit git push in the test environment', '--yes']);
|
|
1510
|
+
assert.equal(process.exitCode, 0);
|
|
1511
|
+
assert.deepEqual(h.captured.warn, []);
|
|
1512
|
+
} finally {
|
|
1513
|
+
h.restoreLog();
|
|
1514
|
+
}
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
test('environment (interactive): "in the test environment" warns and Enter aborts', async () => {
|
|
1519
|
+
const h = buildHarness({ assistResponse: auditPushReply([{ field: 'operation', value: 'push' }]) });
|
|
1520
|
+
const rl = stubReadline('');
|
|
1521
|
+
try {
|
|
1522
|
+
await runCreate(['--prompt', 'audit git push in the test environment']);
|
|
1523
|
+
assert.equal(process.exitCode, 0);
|
|
1524
|
+
assert.deepEqual(rl.questions, ['Create policy anyway? [y/N] ']);
|
|
1525
|
+
assert.ok(!h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'), 'no create POST after the default No');
|
|
1526
|
+
} finally {
|
|
1527
|
+
rl.restore();
|
|
1528
|
+
h.restoreLog();
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
|
|
787
1532
|
// --- Preview rendering (Acceptance #6 and #7) ---------------------------------
|
|
788
1533
|
|
|
789
1534
|
test('--yes prints the preview AND skips confirmation AND issues create POST', async () => {
|
|
@@ -807,6 +1552,7 @@ test('--yes prints the preview AND skips confirmation AND issues create POST', a
|
|
|
807
1552
|
assert.ok(out.includes('Audit pushes'), `name row missing: ${out}`);
|
|
808
1553
|
assert.ok(out.includes('TERMINAL_COMMAND'), `type row missing: ${out}`);
|
|
809
1554
|
assert.ok(out.includes('git push*'), `pattern row missing: ${out}`);
|
|
1555
|
+
assert.ok(!out.includes('all must match'), `single condition needs no AND line: ${out}`);
|
|
810
1556
|
assert.ok(out.includes('AUDIT'), `action row missing: ${out}`);
|
|
811
1557
|
assert.ok(out.includes('AI assist explanation'), `explanation line missing: ${out}`);
|
|
812
1558
|
assert.ok(out.includes('audit git activity'), `explanation body missing: ${out}`);
|
|
@@ -836,13 +1582,7 @@ test('without --yes: preview prints; "n" declines; no create POST fires', async
|
|
|
836
1582
|
explanation: 'ok',
|
|
837
1583
|
},
|
|
838
1584
|
});
|
|
839
|
-
|
|
840
|
-
const readline = require('readline');
|
|
841
|
-
const origCreate = readline.createInterface;
|
|
842
|
-
readline.createInterface = () => ({
|
|
843
|
-
question(_q, cb) { cb('n'); },
|
|
844
|
-
close() {},
|
|
845
|
-
});
|
|
1585
|
+
const rl = stubReadline('n');
|
|
846
1586
|
try {
|
|
847
1587
|
await runCreate(['--prompt', 'audit git pushes']);
|
|
848
1588
|
// Preview header (stdout) must have printed BEFORE the prompt. And the
|
|
@@ -855,7 +1595,7 @@ test('without --yes: preview prints; "n" declines; no create POST fires', async
|
|
|
855
1595
|
'no create POST should fire when user declines'
|
|
856
1596
|
);
|
|
857
1597
|
} finally {
|
|
858
|
-
|
|
1598
|
+
rl.restore();
|
|
859
1599
|
h.restoreLog();
|
|
860
1600
|
}
|
|
861
1601
|
});
|