diff --git a/testing/lib/wait-converge-test.sh b/testing/lib/wait-converge-test.sh index fda25c4..7b7c0ab 100755 --- a/testing/lib/wait-converge-test.sh +++ b/testing/lib/wait-converge-test.sh @@ -180,6 +180,47 @@ check "case4: returns 0 with 4 args" "$c4_rc_ok" "rc=$rc" c4_hold_ok=1; echo "$out" | grep -q "$HOLD_MSG" && c4_hold_ok=0 check "case4: default slack triggered near-converged hold" "$c4_hold_ok" +# --- Case 5: wait_for_peers refuses a floor of zero ------------------- +# +# The reader inside wait_for_peers falls back to 0 when a container does +# not answer. That is safe against a floor of 1 or more, where 0 reads as +# "not converged yet", and unsafe against a floor of 0, where the first +# read from a dead container satisfies the wait immediately. This case is +# the break-what-it-guards check for the rejection: drive the guard with a +# floor of 0 and confirm it refuses, then drive the same dead container +# with a floor of 1 and confirm the refusal is specific to the dangerous +# input rather than a blanket failure. +# +# `docker` is stubbed to fail, which is what an unreachable container looks +# like to this reader, so no container or network is involved and the suite +# stays hermetic. +echo +echo "== Case 5: wait_for_peers refuses a zero floor ==" +docker() { return 1; } + +out=$(wait_for_peers stub-container 0 2 2>&1); rc=$? +echo "$out" +c5_reject_ok=1; [ "$rc" -eq 2 ] && c5_reject_ok=0 +check "case5: floor of 0 is refused" "$c5_reject_ok" "rc=$rc" +c5_msg_ok=1; echo "$out" | grep -q "refusing a minimum" && c5_msg_ok=0 +check "case5: refusal names the reason" "$c5_msg_ok" +# The pre-guard behaviour, asserted so a regression is visible rather than +# quiet: without the rejection this returned 0 on its first iteration +# against a container that never answered. +c5_notpass_ok=1; [ "$rc" -ne 0 ] && c5_notpass_ok=0 +check "case5: floor of 0 does not report success" "$c5_notpass_ok" "rc=$rc" + +start=$SECONDS +out=$(wait_for_peers stub-container 1 2 2>&1); rc=$? +elapsed=$((SECONDS - start)) +echo "$out" +c5_floor1_ok=1; [ "$rc" -eq 1 ] && c5_floor1_ok=0 +check "case5: floor of 1 times out rather than being refused" "$c5_floor1_ok" "rc=$rc" +c5_polled_ok=1; [ "$elapsed" -ge 2 ] && c5_polled_ok=0 +check "case5: floor of 1 polled its full budget" "$c5_polled_ok" "elapsed=${elapsed}s >= 2s" + +unset -f docker + # --- Summary ---------------------------------------------------------- echo echo "==============================================" diff --git a/testing/lib/wait-converge.sh b/testing/lib/wait-converge.sh index 8832b67..17933a5 100644 --- a/testing/lib/wait-converge.sh +++ b/testing/lib/wait-converge.sh @@ -1,44 +1,47 @@ #!/bin/bash # Shared convergence wait helpers for FIPS integration tests. # -# Source this file to get wait_for_links(), wait_for_peers(), and -# wait_until_connected(). +# Source this file to get wait_for_peers() and wait_until_connected(). # # Usage: # source "$(dirname "$0")/../../lib/wait-converge.sh" -# wait_for_links [timeout_secs] # wait_for_peers [timeout_secs] # wait_until_connected [poll_secs] \ # [near_converged_slack] - -# Wait until a container has at least min_links active links. -# Returns 0 on success, 1 on timeout. -wait_for_links() { - local container="$1" - local min_links="$2" - local timeout="${3:-30}" - - for i in $(seq 1 "$timeout"); do - local count - count=$(docker exec "$container" fipsctl show links 2>/dev/null \ - | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('links',[])))" 2>/dev/null || echo 0) - if [ "$count" -ge "$min_links" ]; then - echo " $container: $count link(s) after ${i}s" - return 0 - fi - sleep 1 - done - echo " $container: TIMEOUT waiting for $min_links link(s) after ${timeout}s" - return 1 -} +# +# There was a wait_for_links() here. It was removed rather than kept for +# symmetry: it had no caller anywhere in the tree on any branch, and its +# reader carried the same failure-to-zero fallback wait_for_peers does. An +# uncalled helper cannot be wrong today, so the risk was that the first +# caller to appear would inherit the hazard below without the reasoning +# that goes with it. `git log` has the implementation if one is needed. # Wait until a container has at least min_peers connected peers. # Returns 0 on success, 1 on timeout. +# +# The read below falls back to 0 when the container does not answer, which +# is safe ONLY because this is a floor: a fallback of 0 reads as "not +# converged yet", the loop keeps polling, and a container that never answers +# times out and returns 1. That safety is a property of the comparison, not +# of the reader. +# +# A minimum of 0 inverts it. `[ 0 -ge 0 ]` is true, so the first read from a +# dead container would satisfy the wait immediately and the caller would +# proceed as though convergence had been observed. No caller passes 0, and +# rejecting it here means none can start to — which is cheaper than auditing +# every future caller, and is why this is a hard error rather than a warning. +# A caller that genuinely wants to assert "exactly zero peers" needs a reader +# that distinguishes no-answer from zero, not this floor. wait_for_peers() { local container="$1" local min_peers="$2" local timeout="${3:-30}" + if [ "$min_peers" -lt 1 ]; then + echo " wait_for_peers: refusing a minimum of $min_peers for $container — a floor of 0 is satisfied by a container that never answered" >&2 + return 2 + fi + for i in $(seq 1 "$timeout"); do local count count=$(docker exec "$container" fipsctl show peers 2>/dev/null \