feat(xce-test-cli-ux): wire confirmation gate to the five destructive commands

Gates jobkill and shutdown unconditionally, qctrl only when action is
QUEUE_CLOSE/QUEUE_INACTIVE (not ACTIVE/OPEN), hostctrl only when
HOST_CLOSE (not OPEN), and upgradeconfig unconditionally -- exactly
Requirement 9.5's enumeration, with jstart/jfinish/jfail (worker
context) correctly untouched. Each gains a -y/--yes bypass flag via
flagspec_t; cmd_set gains -y/+y calling confirm_set_skip(). A decline
maps to a plain `return 0` (success-coded, distinct from a negative
failure return) per confirm.h's own documented contract -- this means
a declined destructive command inside a source'd file under `set -e`
won't trip abort-on-failure, a real but narrow interaction only
reachable when a human is actively declining; left for task 4.3 to
test explicitly. 1321/1321 across the full controller suite (+3),
0 regressions.

Task 3.3 of .kiro/specs/xce-test-cli-ux/tasks.md.
Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 9.9

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
This commit is contained in:
Rongsong Shen 2026-09-09 21:47:55 +00:00
parent 45120af2cc
commit a57c04716b
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
3 changed files with 635 additions and 5 deletions

View file

@ -361,6 +361,7 @@ static const char USAGE_SET[] =
"set\n"
"set <key> <value>\n"
"set -e | +e | continue-on-error\n"
"set -y | +y\n"
" (no args) list all settings in the current context\n"
" (current value + default)\n"
" <key> <value> set setting <key> (e.g. masters, secret,\n"
@ -369,6 +370,12 @@ static const char USAGE_SET[] =
" command (errexit)\n"
" +e | continue-on-error keep the read loop running after a\n"
" failing command\n"
" -y skip the confirmation prompt before a\n"
" destructive command (jobkill, shutdown,\n"
" qctrl -i/-c, hostctrl -c, upgradeconfig)\n"
" regardless of TTY state, until +y\n"
" +y require confirmation before those\n"
" commands again (default)\n"
"example: set masters ctrl1=localhost:9991,ctrl2=localhost:9992\n";
static const char USAGE_JOIN_WORKER[] =
@ -1897,10 +1904,59 @@ static int shutdown_thunk(void* ctxp)
return rc;
}
/* xce-test-cli-ux task 3.3, Requirement 9.3: confirmation bypass. shutdown
* had no flags at all before this task (task 3.1's getopt migration
* explicitly left it out of scope for exactly that reason) -- this is the
* FIRST flagspec_t table it gets, added solely to carry --yes. Same
* short/long spelling as the other four destructive commands
* (Requirement 6.2). */
static const flagspec_t shutdown_flags[] = {
{ 'y', "yes", "yes", no_argument, 0, NULL },
};
#define SHUTDOWN_NFLAGS (sizeof(shutdown_flags) / sizeof(shutdown_flags[0]))
int cmd_shutdown(int argc, char* argv[])
{
int rc;
shutdown_call_t* call = (shutdown_call_t*)calloc(1, sizeof(*call));
int opt;
int yes = 0;
struct option long_opts[SHUTDOWN_NFLAGS + 1];
char optstring[16];
shutdown_call_t* call;
optind = 1;
opterr = 0;
if (flagspec_build_opts(shutdown_flags, SHUTDOWN_NFLAGS, long_opts,
optstring, sizeof(optstring))
< 0) {
fprintf(stderr,
"xce_test: shutdown: internal error building flag table\n");
return -EINVAL;
}
while ((opt = getopt_long(argc, argv, optstring, long_opts, NULL))
!= -1) {
switch (opt) {
case 'y':
yes = 1;
break;
case '?':
return flagspec_reject("shutdown", shutdown_flags,
SHUTDOWN_NFLAGS, argv[optind - 1],
"shutdown");
}
}
/* xce-test-cli-ux task 3.3, Requirement 9.5: shutdown is unconditionally
* destructive. See cmd_jobkill's comment above for why this call site
* only needs to check its own --yes flag. */
if (!yes && !confirm_destructive("shut down the xce controller")) {
fprintf(stderr, "shutdown: not run (declined)\n");
return 0;
}
call = (shutdown_call_t*)calloc(1, sizeof(*call));
if (call == NULL) {
return -ENOMEM;
@ -3529,6 +3585,12 @@ int cmd_resval(int argc, char* argv[])
static const flagspec_t jobkill_flags[] = {
{ 's', "signal", "signal", required_argument, 0, "SIGTERM" },
{ 'j', "jobid", "jobid", required_argument, 1, NULL },
/* xce-test-cli-ux task 3.3, Requirement 9.3: per-command confirmation
* bypass -- jobkill is unconditionally destructive (Requirement 9.5), so
* this flag's only effect is to skip the confirm_destructive() prompt
* below. Same short/long spelling used across all five destructive
* commands (Requirement 6.2 cross-command consistency). */
{ 'y', "yes", "yes", no_argument, 0, NULL },
};
#define JOBKILL_NFLAGS (sizeof(jobkill_flags) / sizeof(jobkill_flags[0]))
@ -3554,6 +3616,7 @@ int cmd_jobkill(int argc, char* argv[])
uint64_t jobid = 0;
int opt;
int rc;
int yes = 0;
xce_jobctrl_t jobctrl;
xce_signal_t siginfo;
char jobidStr[64];
@ -3581,6 +3644,9 @@ int cmd_jobkill(int argc, char* argv[])
case 'j':
parse_jobid(&jobid, optarg);
break;
case 'y':
yes = 1;
break;
case '?':
return flagspec_reject("jobkill", jobkill_flags, JOBKILL_NFLAGS,
argv[optind - 1],
@ -3593,6 +3659,28 @@ int cmd_jobkill(int argc, char* argv[])
return -1;
}
/* xce-test-cli-ux task 3.3, Requirement 9.5: jobkill is unconditionally
* destructive -- no action-dependent check like qctrl/hostctrl below.
* confirm_destructive() already owns the session-toggle and TTY checks
* internally (confirm.h); this call site's only job is to short-circuit
* around it when jobkill's OWN --yes flag was given (confirm_destructive
* has no way to know about a per-command flag). A 0 return means the
* operator declined (or Ctrl-C'd the prompt, Requirement 9.9) -- report
* "not run" (a plain, non-negative return so the session verdict
* machinery in preprocess_and_run treats this as success, not failure --
* see confirm.h's return-contract note), distinct from a "failed" RPC. */
fmt_jobid(jobidStr, sizeof(jobidStr) - 1, jobid);
if (!yes) {
char desc[96];
snprintf(desc, sizeof(desc), "kill job %s", jobidStr);
if (!confirm_destructive(desc)) {
fprintf(stderr, "jobkill: not run (declined)\n");
return 0;
}
}
jobctrl.action = JobControl_u_siginfo;
jobctrl.jobid = jobid;
jobctrl.u.siginfo = &siginfo;
@ -3624,7 +3712,6 @@ int cmd_jobkill(int argc, char* argv[])
return rc;
}
fmt_jobid(jobidStr, sizeof(jobidStr) - 1, jobid);
printf("Job %s signaled\n", jobidStr);
return 0;
@ -3755,6 +3842,12 @@ static const flagspec_t qctrl_flags[] = {
{ 'o', "open", "open", no_argument, 0, NULL },
{ 'c', "close", "close", no_argument, 0, NULL },
{ 'q', "queue", "queue", required_argument, 1, NULL },
/* xce-test-cli-ux task 3.3, Requirement 9.3: confirmation bypass.
* qctrl is destructive ONLY for -i/--deactivate and -c/--close
* (Requirement 9.5); -a/--activate and -o/--open are reversal/
* reactivation actions and never prompt. Same short/long spelling as
* the other four destructive commands (Requirement 6.2). */
{ 'y', "yes", "yes", no_argument, 0, NULL },
};
#define QCTRL_NFLAGS (sizeof(qctrl_flags) / sizeof(qctrl_flags[0]))
@ -3786,6 +3879,7 @@ int cmd_qctrl(int argc, char* argv[])
* indeterminate stack memory as the queue action. */
int action = QUEUE_DO_NONE;
char* queue = NULL;
int yes = 0;
struct option long_opts[QCTRL_NFLAGS + 1];
char optstring[16];
@ -3818,6 +3912,9 @@ int cmd_qctrl(int argc, char* argv[])
case 'q':
queue = strdup(optarg);
break;
case 'y':
yes = 1;
break;
case '?':
FREEUP(queue);
return flagspec_reject("qctrl", qctrl_flags, QCTRL_NFLAGS,
@ -3841,6 +3938,23 @@ int cmd_qctrl(int argc, char* argv[])
return -1;
}
/* xce-test-cli-ux task 3.3, Requirement 9.5: qctrl is destructive ONLY
* for -i/--deactivate and -c/--close -- -a/--activate and -o/--open are
* reversal/reactivation actions and never prompt, regardless of short
* or long spelling. See cmd_jobkill's comment above for why this call
* site only needs to check its own --yes flag. */
if (((action == QUEUE_INACTIVE) || (action == QUEUE_CLOSE)) && !yes) {
char desc[160];
const char* verb = (action == QUEUE_CLOSE) ? "close" : "deactivate";
snprintf(desc, sizeof(desc), "%s queue %s", verb, queue);
if (!confirm_destructive(desc)) {
fprintf(stderr, "qctrl: not run (declined)\n");
free(queue);
return 0;
}
}
queuectrl.action = action;
queuectrl.queue = queue;
@ -3890,6 +4004,11 @@ static const flagspec_t hostctrl_flags[] = {
{ 'o', "open", "open", no_argument, 0, NULL },
{ 'c', "close", "close", no_argument, 0, NULL },
{ 'h', "host", "host", required_argument, 1, NULL },
/* xce-test-cli-ux task 3.3, Requirement 9.3: confirmation bypass.
* hostctrl is destructive ONLY for -c/--close (Requirement 9.5);
* -o/--open is a reactivation action and never prompts. Same short/long
* spelling as the other four destructive commands (Requirement 6.2). */
{ 'y', "yes", "yes", no_argument, 0, NULL },
};
#define HOSTCTRL_NFLAGS (sizeof(hostctrl_flags) / sizeof(hostctrl_flags[0]))
@ -3915,6 +4034,7 @@ int cmd_hostctrl(int argc, char* argv[])
xce_hostctrl_t hostctrl;
int action = HOST_DO_NONE;
char* hostname = NULL;
int yes = 0;
struct option long_opts[HOSTCTRL_NFLAGS + 1];
char optstring[16];
@ -3941,6 +4061,9 @@ int cmd_hostctrl(int argc, char* argv[])
case 'h':
hostname = strdup(optarg);
break;
case 'y':
yes = 1;
break;
case '?':
FREEUP(hostname);
return flagspec_reject("hostctrl", hostctrl_flags,
@ -3960,6 +4083,22 @@ int cmd_hostctrl(int argc, char* argv[])
return -1;
}
/* xce-test-cli-ux task 3.3, Requirement 9.5: hostctrl is destructive
* ONLY for -c/--close -- -o/--open is a reactivation action and never
* prompts, regardless of short or long spelling. See cmd_jobkill's
* comment above for why this call site only needs to check its own
* --yes flag. */
if ((action == HOST_CLOSE) && !yes) {
char desc[160];
snprintf(desc, sizeof(desc), "close host %s", hostname);
if (!confirm_destructive(desc)) {
fprintf(stderr, "hostctrl: not run (declined)\n");
free(hostname);
return 0;
}
}
hostctrl.action = action;
hostctrl.hostname = hostname;
@ -4029,14 +4168,58 @@ static int upgradeconfig_thunk(void* ctxp)
return rc;
}
/* xce-test-cli-ux task 3.3, Requirement 9.3: confirmation bypass.
* upgradeconfig had no flags at all before this task (task 3.1's getopt
* migration explicitly left it out of scope for exactly that reason,
* alongside shutdown) -- this is the FIRST flagspec_t table it gets, added
* solely to carry --yes; the <cluster.bin> path stays a plain positional
* argument (argv[optind] once getopt_long has consumed --yes). Same
* short/long spelling as the other four destructive commands
* (Requirement 6.2). */
static const flagspec_t upgradeconfig_flags[] = {
{ 'y', "yes", "yes", no_argument, 0, NULL },
};
#define UPGRADECONFIG_NFLAGS \
(sizeof(upgradeconfig_flags) / sizeof(upgradeconfig_flags[0]))
int cmd_upgradeconfig(int argc, char* argv[])
{
if (argc != 2) {
int opt;
int yes = 0;
struct option long_opts[UPGRADECONFIG_NFLAGS + 1];
char optstring[16];
optind = 1;
opterr = 0;
if (flagspec_build_opts(upgradeconfig_flags, UPGRADECONFIG_NFLAGS,
long_opts, optstring, sizeof(optstring))
< 0) {
fprintf(
stderr,
"xce_test: upgradeconfig: internal error building flag table\n");
return -EINVAL;
}
while ((opt = getopt_long(argc, argv, optstring, long_opts, NULL))
!= -1) {
switch (opt) {
case 'y':
yes = 1;
break;
case '?':
return flagspec_reject("upgradeconfig", upgradeconfig_flags,
UPGRADECONFIG_NFLAGS, argv[optind - 1],
"upgradeconfig <cluster.bin>");
}
}
if ((argc - optind) != 1) {
fprintf(stderr, "usage: upgradeconfig <cluster.bin>\n");
return -1;
}
const char* path = argv[1];
const char* path = argv[optind];
FILE* fp = fopen(path, "rb");
if (fp == NULL) {
fprintf(stderr, "upgradeconfig: cannot open %s: %s\n", path,
@ -4078,6 +4261,23 @@ int cmd_upgradeconfig(int argc, char* argv[])
uint8_t sha256[SHA256_DIGEST_LENGTH];
SHA256(bytes, (size_t)fsz, sha256);
/* xce-test-cli-ux task 3.3, Requirement 9.5: upgradeconfig is
* unconditionally destructive. See cmd_jobkill's comment above for why
* this call site only needs to check its own --yes flag. Deliberately
* placed after the file has already been read/hashed (a purely local,
* read-only step) and right before the network call itself. */
if (!yes) {
char desc[PATH_MAX + 64];
snprintf(desc, sizeof(desc),
"upgrade the cluster configuration from %s", path);
if (!confirm_destructive(desc)) {
fprintf(stderr, "upgradeconfig: not run (declined)\n");
free(bytes);
return 0;
}
}
upgradeconfig_call_t* call
= (upgradeconfig_call_t*)calloc(1, sizeof(*call));
int rc;
@ -5467,6 +5667,22 @@ int cmd_set(int argc, char* argv[])
return 0;
}
/* Confirmation-skip toggle (xce-test-cli-ux task 3.3, Requirement
* 9.6/9.7), same shell-style on/off pair as `-e`/`+e` immediately
* above: `-y` enables skipping (assume yes to every destructive
* command's prompt, regardless of TTY state), `+y` restores the default
* (confirmation required). Wraps confirm_set_skip()/confirm_is_skip_set()
* (confirm.h, task 2.1), which the five destructive commands' own
* confirm_destructive() calls already consult internally. */
if ((argc == 2) && (strcmp(argv[1], "-y") == 0)) {
confirm_set_skip(1); /* -y: skip destructive-command confirmation */
return 0;
}
if ((argc == 2) && (strcmp(argv[1], "+y") == 0)) {
confirm_set_skip(0); /* +y: require confirmation again (default) */
return 0;
}
if (argc != 3) {
printf("set <key> <value>\n");
return -EINVAL;

View file

@ -1897,6 +1897,416 @@ static void test_interrupt_then_immediate_teardown_no_uaf(void)
clitest_buf_free(&err);
}
/*
* test_qctrl_hostctrl_confirm_gate_destructiveness_determination --
* xce-test-cli-ux task 3.3, Requirement 9.5: qctrl and hostctrl are the two
* CONDITIONALLY destructive commands -- only -i/-c (qctrl) and -c
* (hostctrl) require confirmation; -a/-o (qctrl) and -o (hostctrl) are
* reversal/reactivation actions and must never prompt. This test proves
* the command-specific determination logic itself (each command's own
* `action == ...` check), not confirm_destructive()'s generic mechanism
* (already covered by test_confirm.c, task 2.1). Declining also proves
* Requirement 9.1/9.2: a prompt appears, and declining reports "not run"
* rather than "failed", without disrupting session responsiveness.
*/
static void test_qctrl_hostctrl_confirm_gate_destructiveness_determination(
void)
{
char xce_test_path[PATH_MAX];
char* const argv[] = { "xce_test", "--norc", NULL };
clitest_session_t sess;
clitest_buf_t out;
clitest_buf_t err;
int exit_code = -999;
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)),
"must be able to resolve the sibling xce_test binary's path");
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn(&sess, xce_test_path, argv),
"clitest_spawn must launch xce_test behind a real pty");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000),
"expected to enter the client context");
/* qctrl -a (ACTIVE) is a reversal/reactivation action and must never
* prompt (Requirement 9.5). */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "qctrl -q normal -a"));
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_qctrl_a_done"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "marker_qctrl_a_done",
false, 5000),
"qctrl -a must run to completion without blocking on a prompt");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "About to"),
"qctrl -a (ACTIVE) is a reversal action and must never prompt "
"(Requirement 9.5)");
/* qctrl -o (OPEN) likewise must never prompt. */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "qctrl -q normal -o"));
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_qctrl_o_done"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "marker_qctrl_o_done",
false, 5000),
"qctrl -o must run to completion without blocking on a prompt");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "About to"),
"qctrl -o (OPEN) is a reversal action and must never prompt "
"(Requirement 9.5)");
/* qctrl -c (CLOSE) IS destructive: must prompt, and declining must
* report "not run" (Requirement 9.1, 9.2, 9.5). */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "qctrl -q normal -c"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err,
"About to close queue normal. Continue?",
true, 5000),
"qctrl -c (CLOSE) must prompt for confirmation (Requirement 9.1, "
"9.5)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "n"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "not run", true, 5000),
"declining the prompt must report the command as not run "
"(Requirement 9.2)");
/* The session must remain fully responsive in the same context after a
* decline. */
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_after_qctrl_decline"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err,
"marker_after_qctrl_decline", false, 5000),
"the session must stay responsive after declining a destructive "
"command");
/* hostctrl -o (OPEN) is a reactivation action and must never prompt. */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0,
clitest_send_line(&sess, "hostctrl -h node01 -o"));
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_hostctrl_o_done"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "marker_hostctrl_o_done",
false, 5000),
"hostctrl -o must run to completion without blocking on a prompt");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "About to"),
"hostctrl -o (OPEN) is a reactivation action and must never prompt "
"(Requirement 9.5)");
/* hostctrl -c (CLOSE) IS destructive: must prompt, and declining must
* report "not run". */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0,
clitest_send_line(&sess, "hostctrl -h node01 -c"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err,
"About to close host node01. Continue?", true,
5000),
"hostctrl -c (CLOSE) must prompt for confirmation (Requirement 9.1, "
"9.5)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "n"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "not run", true, 5000),
"declining the prompt must report the command as not run "
"(Requirement 9.2)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit"));
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code),
"xce_test did not exit within the harness's safety timeout after "
"'quit'");
clitest_kill_and_reap(&sess);
clitest_close(&sess);
clitest_buf_free(&out);
clitest_buf_free(&err);
}
/*
* test_destructive_commands_prompt_by_default_and_yes_bypasses --
* xce-test-cli-ux task 3.3, Requirement 9.1, 9.2, 9.3, 9.5: jobkill,
* shutdown, and upgradeconfig are UNCONDITIONALLY destructive (no
* qctrl/hostctrl-style action check). This proves the identical
* confirm_destructive() wiring at all three call sites -- each prompts by
* default and declining reports "not run" without ever reaching the
* network call -- plus jobkill's own --yes flag proving the per-command
* bypass (Requirement 9.3) skips the prompt and lets the command proceed.
* One test covers all three rather than duplicating the same mechanism
* five times over (see this task's own brief on proportionate coverage).
*/
static void test_destructive_commands_prompt_by_default_and_yes_bypasses(
void)
{
char xce_test_path[PATH_MAX];
char* const argv[] = { "xce_test", "--norc", NULL };
clitest_session_t sess;
clitest_buf_t out;
clitest_buf_t err;
char fixture_path[320];
int exit_code = -999;
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)),
"must be able to resolve the sibling xce_test binary's path");
clitest_fixture_fresh_dir();
snprintf(fixture_path, sizeof(fixture_path), "%s/cluster.bin",
clitest_fixture_dir);
clitest_fixture_write_file(
fixture_path,
"not a real capnp blob -- upgradeconfig only needs to read+hash it "
"before the confirmation gate is reached\n");
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn(&sess, xce_test_path, argv),
"clitest_spawn must launch xce_test behind a real pty");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000),
"expected to enter the client context");
/* jobkill: unconditionally destructive -- prompts by default.
* "1-1-1-1" (not a bare "1") is required: parse_jobid() only accepts
* the dash-delimited cluster-apiserver-timestamp-job encoding
* (REGEX_JOBID, config.h) or its bracketed array-index form
* (REGEX_JOBARRAY_ID) -- a bare decimal never matches either regex and
* silently leaves jobid at 0, which would hit cmd_jobkill's OWN "you
* need to specify jobid with -j" rejection before ever reaching the
* confirmation gate under test here. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "jobkill -j 1-1-1-1"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "About to kill job", true,
5000),
"jobkill must prompt for confirmation by default (Requirement 9.1, "
"9.5)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "n"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "jobkill: not run", true,
5000),
"declining jobkill's prompt must report it as not run, not failed "
"(Requirement 9.2)");
/* jobkill --yes: the per-command bypass flag (Requirement 9.3) must
* skip the prompt entirely and let the command proceed to the RPC
* (which fails fast here since no live controller is running in this
* unit-test environment -- that failure itself is not under test). */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "jobkill -j 1-1-1-1 --yes"));
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_jobkill_yes_done"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "marker_jobkill_yes_done",
false, 5000),
"jobkill --yes must run to completion without blocking on a "
"prompt");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "About to"),
"jobkill --yes must skip the confirmation prompt entirely "
"(Requirement 9.3)");
TEST_ASSERT_NULL_MESSAGE(strstr(err.data, "not run"),
"jobkill --yes must proceed to the RPC, not "
"report itself as not run");
/* shutdown: unconditionally destructive -- prompts by default;
* declining must NOT leave the client context (the context switch is
* gated behind an actually-successful RPC, never behind the decline
* path -- Requirement 9.2). */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "shutdown"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(
&sess, &out, &err,
"About to shut down the xce controller. Continue?", true, 5000),
"shutdown must prompt for confirmation by default (Requirement 9.1, "
"9.5)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "n"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "shutdown: not run", true,
5000),
"declining shutdown's prompt must report it as not run, not failed "
"(Requirement 9.2)");
/* Proves the client context was never left: qctrl only exists in the
* client context's command table, so it being recognized (rather than
* "No such command") proves the session is still there. */
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "qctrl -q normal -a"));
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_still_in_client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "marker_still_in_client",
false, 5000),
"the session must still be in the client context after declining "
"shutdown");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "No such command"),
"qctrl must still be recognized -- the client context must not "
"have been left by a declined shutdown");
/* upgradeconfig: unconditionally destructive -- prompts by default. */
clitest_buf_free(&err);
clitest_buf_init(&err);
{
char cmd[400];
snprintf(cmd, sizeof(cmd), "upgradeconfig %s", fixture_path);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, cmd));
}
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(
&sess, &out, &err,
"About to upgrade the cluster configuration from", true, 5000),
"upgradeconfig must prompt for confirmation by default "
"(Requirement 9.1, 9.5)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "n"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "upgradeconfig: not run",
true, 5000),
"declining upgradeconfig's prompt must report it as not run, not "
"failed (Requirement 9.2)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit"));
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code),
"xce_test did not exit within the harness's safety timeout after "
"'quit'");
clitest_kill_and_reap(&sess);
clitest_close(&sess);
clitest_buf_free(&out);
clitest_buf_free(&err);
clitest_fixture_cleanup_dir();
}
/*
* test_confirm_gate_session_toggle_skips_and_restores -- xce-test-cli-ux
* task 3.3, Requirement 9.6/9.7: `set -y` must make every destructive
* command skip its confirmation prompt regardless of TTY state (proven
* here against a genuinely interactive pty session -- the strictest
* case), and `set +y` must restore the default prompting behavior.
*/
static void test_confirm_gate_session_toggle_skips_and_restores(void)
{
char xce_test_path[PATH_MAX];
char* const argv[] = { "xce_test", "--norc", NULL };
clitest_session_t sess;
clitest_buf_t out;
clitest_buf_t err;
int exit_code = -999;
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)),
"must be able to resolve the sibling xce_test binary's path");
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn(&sess, xce_test_path, argv),
"clitest_spawn must launch xce_test behind a real pty");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
/* `set` only exists in the top-level context's command table -- must
* be issued before entering "client". */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "set -y"));
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000),
"expected to enter the client context");
/* With the toggle set, hostctrl -c must proceed WITHOUT prompting even
* though no --yes flag is given and this is a genuinely interactive
* pty session (Requirement 9.6/9.7). */
TEST_ASSERT_EQUAL_INT(0,
clitest_send_line(&sess, "hostctrl -h node01 -c"));
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo marker_toggle_on_done"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "marker_toggle_on_done",
false, 5000),
"hostctrl -c must run to completion without blocking on a prompt "
"while the session-scoped skip toggle is set (Requirement 9.6)");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "About to"),
"the session-scoped skip toggle must suppress the confirmation "
"prompt regardless of TTY state (Requirement 9.6, 9.7)");
/* `set +y` must restore the default prompting behavior. `set` only
* exists in the top-level context's command table (see the "set -y"
* comment above) -- leave the client context first, exactly like any
* other command sequence that needs a top-level-only command
* mid-session. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, ".."));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected to return to the top-level context after '..'");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "set +y"));
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000),
"expected to re-enter the client context");
clitest_buf_free(&err);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0,
clitest_send_line(&sess, "hostctrl -h node01 -c"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err,
"About to close host node01. Continue?", true,
5000),
"'set +y' must restore confirmation prompting (Requirement 9.7)");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "n"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "not run", true, 5000),
"declining after the toggle is restored must report not run");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit"));
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code),
"xce_test did not exit within the harness's safety timeout after "
"'quit'");
clitest_kill_and_reap(&sess);
clitest_close(&sess);
clitest_buf_free(&out);
clitest_buf_free(&err);
}
void test_test_cli(void)
{
UnitySetTestFile(__FILE__);
@ -1912,4 +2322,7 @@ void test_test_cli(void)
RUN_TEST(test_interrupt_abandons_blocked_rpc_well_before_timeout);
RUN_TEST(test_interrupt_at_empty_prompt_discards_line_without_exiting);
RUN_TEST(test_interrupt_then_immediate_teardown_no_uaf);
RUN_TEST(test_qctrl_hostctrl_confirm_gate_destructiveness_determination);
RUN_TEST(test_destructive_commands_prompt_by_default_and_yes_bypasses);
RUN_TEST(test_confirm_gate_session_toggle_skips_and_restores);
}