#!/usr/bin/env bash # Self-contained checks for scripts/config-edit.sh — fleetd ticket #635. # # Drives the REAL config-edit.sh as a subprocess against a FIXTURE config and a FIXTURE log in a # throwaway temp directory this file creates and removes. Never touches fleetd/fleetd.yaml or # fleetd/fleetd.out, and never starts, stops, or contacts a daemon — there is no daemon here, so # each test PLAYS the daemon: it starts config-edit.sh in the background (it is waiting on the # log), appends the verdict line it wants, then collects the real exit code. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" EDIT="$ROOT/scripts/config-edit.sh" TMP="$(mktemp -d "$ROOT/.config-edit-test.XXXXXX")" trap 'rm -rf "$TMP"' EXIT fail() { printf 'FAIL: %s\n' "$*" >&2 return 1 } assert_equals() { local expected="$1" actual="$2" description="$3" [ "$expected" = "$actual" ] || fail "$description: expected $expected, got $actual" } assert_contains() { local needle="$1" text="$2" description="$3" printf '%s' "$text" | grep -qF -- "$needle" || fail "$description: missing [$needle]" } assert_not_contains() { local needle="$1" text="$2" description="$3" if printf '%s' "$text" | grep -qF -- "$needle"; then fail "$description: must NOT contain [$needle], but it does" fi return 0 } # A fresh fixture pair per test: $1/fleetd.yaml (the config) and $1/fleetd.out (the log), plus a # small wait-seconds budget so no test takes long. Returns the fixture dir via stdout. new_fixture() { local dir dir="$(mktemp -d "$TMP/fixture.XXXXXX")" cat > "$dir/fleetd.yaml" <<'YAML' bind: host: 127.0.0.1 port: 19999 broker: uri: amqp://user:hunter2@host/vhost profiles: sonnet: weight: 3 maxLoad: 5 YAML : > "$dir/fleetd.out" printf '%s' "$dir" } # Runs config-edit.sh in the background against $dir's fixtures, with the given extra args, and # a short --wait-seconds. Sets RUN_PID. Caller appends to $dir/fleetd.out (or not, for the # silence test) and then calls collect_run to block for the exit code. start_run() { local dir="$1" wait_s="$2"; shift 2 ( # config-edit.sh deliberately exits 3/4/5 on several of these tests. This subshell inherits # the parent's `set -e`, and without disabling it here the FIRST nonzero exit would kill the # subshell before the `echo $? > rc` line ever ran — the real code would never reach the file. set +e "$EDIT" --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds "$wait_s" "$@" \ > "$dir/stdout.log" 2>&1 echo $? > "$dir/rc" ) & RUN_PID=$! } collect_run() { local dir="$1" # wait echoes back the backgrounded subshell's own exit status (here, deliberately 3/4/5 on # several tests) — under `set -e` a bare nonzero `wait` would abort this whole test script, so # it is neutralized with `|| true`; the real code is read from $dir/rc right after. wait "$RUN_PID" || true RUN_OUTPUT="$(cat "$dir/stdout.log")" RUN_RC="$(cat "$dir/rc")" } # -------------------------------------------------------------- acceptance criterion 1: refusal test_refusal_restores_byte_for_byte() { local dir dir="$(new_fixture)" cp "$dir/fleetd.yaml" "$dir/pre-edit.yaml" start_run "$dir" 5 --set '.broker.uri=amqp://changed@host/x' sleep 1 printf 'config reload refused — these keys cannot change under a running daemon: broker. Restart fleetd to apply them.\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 4 "$RUN_RC" "refusal exit code" cmp -s "$dir/fleetd.yaml" "$dir/pre-edit.yaml" \ || fail "refusal must restore the config byte for byte onto the pre-edit backup" } # -------------------------------------------------------------- acceptance criterion 2: clean test_clean_reload_keeps_the_edit() { local dir dir="$(new_fixture)" start_run "$dir" 5 --set '.profiles.sonnet.weight=7' sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "clean reload exit code" assert_equals "7" "$(yq eval '.profiles.sonnet.weight' "$dir/fleetd.yaml")" "clean reload live value" } # ----------------------------------------------------- acceptance criterion 3: deferred != clean test_deferred_reload_is_told_apart_from_clean() { local dir dir="$(new_fixture)" start_run "$dir" 5 --set '.profiles.sonnet.weight=9' sleep 1 printf 'config reloaded; these changes need a restart to take effect: profiles\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 3 "$RUN_RC" "deferred reload exit code" [ "$RUN_RC" != 0 ] || fail "deferred reload must not report exit 0" assert_contains "profiles" "$RUN_OUTPUT" "deferred reload names the key" assert_contains "restart" "$RUN_OUTPUT" "deferred reload says a restart is needed" } # -------------------------------------------------------------- acceptance criterion 4: silence test_silence_is_its_own_answer() { local dir dir="$(new_fixture)" start_run "$dir" 2 --set '.profiles.sonnet.weight=11' # Feed the log nothing. collect_run "$dir" assert_equals 5 "$RUN_RC" "silence exit code" assert_equals "11" "$(yq eval '.profiles.sonnet.weight' "$dir/fleetd.yaml")" "the edited value must still be on disk" assert_contains '--restore' "$RUN_OUTPUT" "silence prints the --restore command" local backup restore_cmd backup="$(ls -t "$dir"/.config-backups/fleetd.yaml.bak.* | head -1)" [ -n "$backup" ] || fail "silence must still have taken a backup" restore_cmd="$(printf '%s\n' "$RUN_OUTPUT" | grep -F -- '--restore --config' | sed -E 's/^[[:space:]]*//')" [ -n "$restore_cmd" ] || fail "could not find the printed --restore invocation in the output" # Running this --restore invocation installs the backup, then itself waits for a confirming # verdict that this fixture never feeds — so it legitimately exits 5 ("cannot tell") here, same # as any edit with no daemon on the other end. Only a usage/internal error (1 or 2) is a real # failure of the command itself; the actual assertion is the byte-for-byte cmp below. local restore_rc=0 eval "$restore_cmd" > "$dir/restore.log" 2>&1 || restore_rc=$? case "$restore_rc" in 0|3|4|5) : ;; *) fail "the printed --restore command errored out (exit $restore_rc): $(cat "$dir/restore.log")" ;; esac cmp -s "$dir/fleetd.yaml" "$backup" \ || fail "running the printed --restore command must put the file back to the original backup" } # --------------------------------------------------------- acceptance criterion 5: bad candidate test_broken_candidate_never_reaches_live_path() { local dir rc=0 dir="$(new_fixture)" printf 'foo: [unclosed\n' > "$dir/broken.yaml" "$EDIT" --from "$dir/broken.yaml" --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds 2 \ > "$dir/stdout.log" 2>&1 || rc=$? [ "$rc" -ne 0 ] || fail "a broken --from candidate must exit non-zero" cmp -s "$dir/fleetd.yaml" <(new_fixture_yaml) \ || fail "the broken candidate must never reach the live fixture config" } new_fixture_yaml() { cat <<'YAML' bind: host: 127.0.0.1 port: 19999 broker: uri: amqp://user:hunter2@host/vhost profiles: sonnet: weight: 3 maxLoad: 5 YAML } # -------------------------------------------------------------------- acceptance criterion 6 test_marker_skips_lines_before_it() { local dir dir="$(new_fixture)" printf 'config reload refused — something ancient\n' > "$dir/fleetd.out" start_run "$dir" 5 --set '.profiles.sonnet.weight=5' sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "a stale refusal before the marker must not be read as this edit's verdict" } # ------------------------------------------------------------------- acceptance criterion 7 (+13) # fleetd #635 follow-up (ticket comment 17659) — the two assertions below this comment were the # WHOLE test before the follow-up, and both are negative-only: they pass just as happily when the # diff is never printed at all as when it is printed and correctly redacted. A mutant that deletes # `diff -u "$backup" "$cand" | redact` from the edit path survives them, because an absent output # contains neither "hunter2" nor "user:" either — see the mutation-and-revert proof in the reply. # Criterion 13 is the fix: a LOUD positive control that only passes when a diff was demonstrably # printed AND the redaction demonstrably ran on real content, not merely that nothing leaked. test_redaction_holds() { local dir dir="$(new_fixture)" start_run "$dir" 5 --set '.profiles.sonnet.weight=4' sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "redaction-case reload exit code" assert_not_contains "hunter2" "$RUN_OUTPUT" "full output must never contain the password" assert_not_contains "user:" "$RUN_OUTPUT" "full output must never contain the userinfo" # acceptance criterion 13 — positive control: the diff's default 3-line context around the # changed "weight" key also covers the fixture's "uri:" line, so a genuinely-printed, genuinely- # redacted diff must contain BOTH the redaction marker and the changed key's name. A test that # only ever asserts absence cannot tell "redacted" from "never printed" apart; this can. assert_contains "" "$RUN_OUTPUT" "the redaction must be PROVEN to have run on real content, not merely absent" assert_contains "weight" "$RUN_OUTPUT" "a diff must have been demonstrably printed at all" } # ------------------------------------------------------- acceptance criterion 9: forgotten value # `--set .a.b=` is a plausible typo (the value simply forgotten), and it must be refused outright # rather than silently nulling the field — a null numeric field falls back to its default, which # widens capacity instead of failing loudly. No background verdict feeder here: a refused --set # must never even reach the daemon, so this never starts a background run at all. test_forgotten_value_refuses_and_installs_nothing() { local dir rc=0 dir="$(new_fixture)" cp "$dir/fleetd.yaml" "$dir/pre-edit.yaml" "$EDIT" --set '.profiles.sonnet.maxLoad=' \ --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds 2 \ > "$dir/stdout.log" 2>&1 || rc=$? RUN_OUTPUT="$(cat "$dir/stdout.log")" [ "$rc" -ne 0 ] || fail "an empty --set value must exit non-zero, got 0" cmp -s "$dir/fleetd.yaml" "$dir/pre-edit.yaml" \ || fail "an empty --set value must install nothing — the live fixture changed" assert_contains "EMPTY value" "$RUN_OUTPUT" "the refusal must name the empty value" } # ---------------------------------------------------------- acceptance criterion 10: explicit null # `--set .a.b=null` is the deliberate-clear spelling, and it must write a REAL yaml null, never # the string "''" — those are different values to the daemon's loader (fleetd ticket #635's # follow-up comment measured `""` reading back as a null field anyway, which is exactly why the # two forms must not collapse onto each other: `--set path=` refuses instead of silently reaching # this same null outcome through the back door). Read the RAW line with grep, never only through # `yq` — `yq eval` reports `null` for both an actual null and a missing/absent key, so it cannot # tell "wrote null" apart from "wrote nothing"; only the literal line on disk can. test_explicit_null_writes_bare_null_not_empty_string() { local dir dir="$(new_fixture)" start_run "$dir" 5 --set '.profiles.sonnet.maxLoad=null' sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "explicit null clear exit code" local raw_line raw_line="$(grep -E 'maxLoad' "$dir/fleetd.yaml")" assert_contains "null" "$raw_line" "the installed line must spell a bare null" assert_not_contains '""' "$raw_line" "the installed line must NOT be a quoted empty string" } # ------------------------------------------------------- acceptance criterion 11: backup never committable # A backup of fleetd.yaml inherits fleetd.yaml's own "never commit this" requirement (fleetd #635 # follow-up, ticket comment 17655). Proves two things: the backup lands somewhere `git # check-ignore` reports as ignored (equivalently, a path `git status --porcelain` never lists as # untracked), AND that --restore still finds and uses it from that location. test_backup_is_never_committable() { local dir backup dir="$(new_fixture)" cp "$dir/fleetd.yaml" "$dir/pre-edit.yaml" start_run "$dir" 5 --set '.profiles.sonnet.weight=55' sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "setup edit exit code" backup="$(ls -t "$dir"/.config-backups/fleetd.yaml.bak.* 2>/dev/null | head -1)" [ -n "$backup" ] || fail "no backup found under .config-backups/ — did the location change?" git -C "$ROOT" check-ignore -q -- "$backup" \ || fail "the backup at $backup is NOT gitignored — it would survive a git add -A" if git -C "$ROOT" status --porcelain -- "$backup" 2>/dev/null | grep -q '^??'; then fail "git status still lists the backup as untracked: $backup" fi start_run "$dir" 5 --restore sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "--restore after the backup-location change exit code" cmp -s "$dir/fleetd.yaml" "$dir/pre-edit.yaml" \ || fail "--restore from the new backup location must still put the file back byte for byte" } # --------------------------------------------------------- acceptance criterion 12: file mode # `mv` from a mktemp candidate carries mktemp's 0600 forever, and a plain `cp` onto an existing # file keeps the DESTINATION's mode rather than the source's, so a restore does not undo the # narrowing either (fleetd #635 follow-up, ticket comment 17657). Proves the mode survives an edit # AND a subsequent restore, from two different starting points — 644 is the common case, 600 # proves the fix PRESERVES whatever mode was there rather than hardcoding 644. test_file_mode_survives_edit_and_restore() { local dir want got for want in 644 600; do dir="$(new_fixture)" chmod "$want" "$dir/fleetd.yaml" start_run "$dir" 5 --set ".profiles.sonnet.weight=${want}" sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "mode-preservation setup edit exit code ($want)" got="$(stat -f '%Lp' "$dir/fleetd.yaml" 2>/dev/null || stat -c '%a' "$dir/fleetd.yaml")" assert_equals "$want" "$got" "mode must survive a --set ($want)" start_run "$dir" 5 --restore sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "mode-preservation restore exit code ($want)" got="$(stat -f '%Lp' "$dir/fleetd.yaml" 2>/dev/null || stat -c '%a' "$dir/fleetd.yaml")" assert_equals "$want" "$got" "mode must survive a --restore ($want)" done } # ----------------------------------------- acceptance criterion 14: restore message names the real directory # fleetd #635 follow-up (ticket comment 17664, defect 6) — the --restore "no backup found" # message used to print the OLD beside-the-config glob even though newest_backup had already # moved to searching the managed directory. Proves BOTH directions: the not-found message names # the directory actually searched (not merely that it says SOMETHING), and that a real backup # sitting in that directory still lets --restore succeed — otherwise the fix could regress into # a message that is always printed regardless of whether a backup exists. test_restore_message_names_the_searched_directory() { local dir rc=0 # Direction 1: no backup anywhere — the message must name .config-backups/, not the bare # beside-the-config glob the OLD code printed. dir="$(new_fixture)" "$EDIT" --restore --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds 2 \ > "$dir/stdout.log" 2>&1 || rc=$? RUN_OUTPUT="$(cat "$dir/stdout.log")" assert_equals 1 "$rc" "--restore with no backup anywhere exit code" assert_contains ".config-backups/fleetd.yaml.bak.*" "$RUN_OUTPUT" \ "the not-found message must name the directory actually searched, not the old beside-the-config glob" # Direction 2: a real backup IS present in .config-backups/ — --restore must still succeed, so # the message fix cannot have turned into one that prints regardless of whether a backup exists. dir="$(new_fixture)" cp "$dir/fleetd.yaml" "$dir/pre-edit.yaml" start_run "$dir" 5 --set '.profiles.sonnet.weight=77' sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "setup edit exit code for criterion 14's second half" start_run "$dir" 5 --restore sleep 1 printf 'config reloaded\n' >> "$dir/fleetd.out" collect_run "$dir" assert_equals 0 "$RUN_RC" "--restore with a real backup present must still succeed" cmp -s "$dir/fleetd.yaml" "$dir/pre-edit.yaml" \ || fail "--restore with a real backup present must put the file back byte for byte" } # dry-run must never touch the live file and must still redact. test_dry_run_never_installs_and_redacts() { local dir before dir="$(new_fixture)" before="$(cat "$dir/fleetd.yaml")" "$EDIT" --dry-run --set '.profiles.sonnet.weight=99' \ --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" --wait-seconds 2 \ > "$dir/stdout.log" 2>&1 local rc=$? RUN_OUTPUT="$(cat "$dir/stdout.log")" assert_equals 0 "$rc" "dry-run exit code" assert_equals "$before" "$(cat "$dir/fleetd.yaml")" "dry-run must never write the live config" assert_not_contains "hunter2" "$RUN_OUTPUT" "dry-run diff must also be redacted" assert_contains "99" "$RUN_OUTPUT" "dry-run diff must show the candidate value" # Same positive-control reasoning as acceptance criterion 13, applied to the dry-run diff path. assert_contains "" "$RUN_OUTPUT" "the dry-run diff's redaction must be PROVEN to have run, not merely absent" } # --check is read-only and always exits 0, even against a dead "daemon". test_check_is_read_only_and_exits_zero() { local dir before rc=0 dir="$(new_fixture)" before="$(cat "$dir/fleetd.yaml")" "$EDIT" --check --config "$dir/fleetd.yaml" --log "$dir/fleetd.out" \ > "$dir/stdout.log" 2>&1 || rc=$? assert_equals 0 "$rc" "--check exit code" assert_equals "$before" "$(cat "$dir/fleetd.yaml")" "--check must never modify the config" } test_refusal_shape_from_parse_failure_wording_is_recognised() { local dir dir="$(new_fixture)" start_run "$dir" 5 --set '.profiles.sonnet.weight=6' sleep 1 printf 'config reload from %s refused, keeping the running config: boom\n' "$dir/fleetd.yaml" >> "$dir/fleetd.out" collect_run "$dir" assert_equals 4 "$RUN_RC" "the parse-failure refusal shape must also exit 4, not be read as silence" } echo "== acceptance criterion 1: refusal restores byte for byte ==" test_refusal_restores_byte_for_byte echo "== acceptance criterion 2: clean reload keeps the edit ==" test_clean_reload_keeps_the_edit echo "== acceptance criterion 3: deferred reload told apart from clean ==" test_deferred_reload_is_told_apart_from_clean echo "== acceptance criterion 4: silence is its own answer ==" test_silence_is_its_own_answer echo "== acceptance criterion 5: broken candidate never reaches the live path ==" test_broken_candidate_never_reaches_live_path echo "== acceptance criterion 6: the marker works ==" test_marker_skips_lines_before_it echo "== acceptance criterion 7 (+13: redaction is proven to have run) ==" test_redaction_holds echo "== acceptance criterion 9: a forgotten value refuses and installs nothing ==" test_forgotten_value_refuses_and_installs_nothing echo "== acceptance criterion 10: an explicit clear writes a bare null ==" test_explicit_null_writes_bare_null_not_empty_string echo "== acceptance criterion 11: a backup is never committable ==" test_backup_is_never_committable echo "== acceptance criterion 12: the file mode survives an edit and a restore ==" test_file_mode_survives_edit_and_restore echo "== acceptance criterion 14: the restore message names the directory actually searched ==" test_restore_message_names_the_searched_directory echo "== extra: dry-run never installs, and redacts ==" test_dry_run_never_installs_and_redacts echo "== extra: --check is read-only and always exits 0 ==" test_check_is_read_only_and_exits_zero echo "== extra: the parse-failure refusal shape is also recognised ==" test_refusal_shape_from_parse_failure_wording_is_recognised printf 'PASS: config-edit acceptance criteria\n'