test(tools/git): stop the fail-closed test escaping its sandbox; cover the API path
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
rev-974 (#1085 review 130) found three blockers. This closes the first two. [1] SANDBOX ESCAPE. The test ran under `set -uo pipefail` with unchecked mkdir, calls-log redirect and `cd "$REPO_DIR"`, then prepended a possibly-nonexistent $MOCK_BIN to PATH -- while `git remote add origin` names the REAL repository. rev-974 forced setup failure with an unwritable AGENT_WORK_ROOT and the test continued past every error, ran `git init` in its CALLER's directory, added the real origin, and invoked its target: TARGET_REACHED args=-i 42 -c closing note With the committed target that is the real, provider-mutating issue-close.sh. ShellCheck flagged the unguarded cd as SC2164 independently. Now: `set -euo pipefail`, every setup step checked with a legible reason, and assert_mocked() proves BOTH `tea` and `curl` resolve inside $MOCK_BIN before any target invocation. Control: unwritable AGENT_WORK_ROOT -> rc=1 at mkdir, target never reached. [2] THE API/no-login BRANCH HAD NO DISCRIMINATING COVERAGE. The mock always returned a tea login, so `gitea_issue_comment_api || fail-closed` was never executed. rev-974 replaced the whole fallback contract with an unconditional close -- silently dropping the comment -- and the committed test still passed rc=0. Added three cases asserting the POSTCONDITION (which HTTP calls happened, in what order) rather than that a command ran: comment POST fails -> no PATCH and non-zero; comment succeeds -> strictly POST,PATCH; no comment requested -> PATCH only, never a POST. The curl mock now records method and URL. Control: replaying rev-974's contract destruction now fails with "API path: no comment POST attempted". Two self-inflicted traps hit while adding `set -e`, both the same family as the #1086 defect be-coder-08 found, and both silent: - `grep -q X "$CALLS" && fail "..."` -- the ABSENT case (grep rc=1, the PASSING case for a must-not-appear assertion) is the last command of an && list and terminates the script with no message. All four converted to if-blocks. - `run_target ...; rc=$?` -- the function's non-zero RETURN trips set -e in the CALLER before rc is read; run_target's internal `set +e` protects the target, not the caller. All five call sites now `rc=0; run_target ... || rc=$?`. Controls: unchanged main -> rc=1 "used 'tea issue comment'" fallback contract destroyed -> rc=1 "API path: no comment POST attempted" unwritable AGENT_WORK_ROOT -> rc=1 at setup, target never invoked fixed source -> rc=0 [3] remains open: with MOSAIC_GIT_IDENTITY=rev-974 the wrapper resolves GITEA_LOGIN_NAME=mosaicstack-mos, so one principal holds but the operation is attributed to Mos rather than the requested seat. That changes identity resolution shared by every wrapper in this directory and is not folded in here. Reported-by: rev-974
This commit is contained in:
@@ -1,17 +1,26 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Regression: issue-close.sh must NOT close an issue when the closing comment
|
# Regression: issue-close.sh must NOT close an issue when the closing comment could not
|
||||||
# could not be posted, and comment+close must be made by ONE principal.
|
# be posted, and comment+close must be made by ONE principal.
|
||||||
#
|
#
|
||||||
# Guards two defects fixed together (see #1081):
|
# Guards two defects fixed together (see #1081):
|
||||||
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the
|
# 1. `tea issue comment` is not a subcommand -- tea exposes comments as the TOP-LEVEL
|
||||||
# TOP-LEVEL `tea comment`. The old call always failed, was unchecked, and
|
# `tea comment`. The old call always failed, was unchecked, and the issue closed
|
||||||
# the issue closed anyway, losing the record of WHY it was closed.
|
# anyway, losing the record of WHY it was closed.
|
||||||
# 2. Routing the comment through the token-authenticated API helper while the
|
# 2. Routing the comment through the token-authenticated API helper while the close
|
||||||
# close used --login would attribute one operation to two principals.
|
# used --login would attribute one operation to two principals.
|
||||||
#
|
#
|
||||||
# Fully offline: `tea` and `curl` are mocked onto PATH, the repo is a throwaway
|
# SAFETY (rev-974, #1085 review 130): this test previously ran under `set -uo pipefail`
|
||||||
# `git init`, everything lives under $SANDBOX, removed on EXIT.
|
# with unchecked mkdir/redirect/cd, then prepended a possibly-nonexistent $MOCK_BIN to
|
||||||
set -uo pipefail
|
# PATH -- while `git remote add origin` names the REAL repository. Forcing setup failure
|
||||||
|
# with an unwritable AGENT_WORK_ROOT made it `git init` in its CALLER's directory and
|
||||||
|
# invoke the real, provider-mutating issue-close.sh. Setup now fails closed, and both
|
||||||
|
# `tea` and `curl` are asserted to resolve INSIDE $MOCK_BIN before any target run.
|
||||||
|
set -euo pipefail
|
||||||
|
# NOTE: with `set -e`, `grep -q X && fail "..."` is a trap -- the ABSENT case (grep rc=1,
|
||||||
|
# which is the PASSING case for a must-not-appear assertion) is the last command of an &&
|
||||||
|
# list and silently terminates the script with no message. Every must-not-appear check
|
||||||
|
# below is therefore an if-block. This is the same set -e + &&-list defect be-coder-08
|
||||||
|
# found in #1086, reintroduced here by adding `set -e` for the sandbox-safety fix.
|
||||||
|
|
||||||
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
WORK_ROOT="${AGENT_WORK_ROOT:-${TMPDIR:-/tmp}}"
|
||||||
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
SANDBOX="$WORK_ROOT/issue-close-fail-closed-test-$$"
|
||||||
@@ -24,56 +33,118 @@ TARGET="$SCRIPT_DIR/issue-close.sh"
|
|||||||
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
[ -f "$TARGET" ] || { echo "FAIL: issue-close.sh not found beside this test"; exit 1; }
|
||||||
fail() { echo "FAIL: $*"; exit 1; }
|
fail() { echo "FAIL: $*"; exit 1; }
|
||||||
|
|
||||||
mkdir -p "$MOCK_BIN" "$REPO_DIR"; : > "$CALLS"
|
# Every setup step is checked. Under `set -e` these abort; the explicit || fail keeps the
|
||||||
cd "$REPO_DIR"; git init -q
|
# reason legible instead of a bare non-zero exit.
|
||||||
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
mkdir -p "$MOCK_BIN" "$REPO_DIR" || fail "setup: cannot create sandbox under $WORK_ROOT"
|
||||||
|
: > "$CALLS" || fail "setup: cannot write calls log at $CALLS"
|
||||||
|
cd "$REPO_DIR" || fail "setup: cannot cd into $REPO_DIR"
|
||||||
|
git init -q || fail "setup: git init failed"
|
||||||
|
git remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git || fail "setup: git remote add failed"
|
||||||
export PATH="$MOCK_BIN:$PATH" CALLS
|
export PATH="$MOCK_BIN:$PATH" CALLS
|
||||||
export GITEA_LOGIN="git.mosaicstack.dev"
|
|
||||||
export GITEA_URL="https://git.mosaicstack.dev"
|
export GITEA_URL="https://git.mosaicstack.dev"
|
||||||
export GITEA_TOKEN="redacted-test-token"
|
export GITEA_TOKEN="redacted-test-token"
|
||||||
|
|
||||||
cat > "$MOCK_BIN/curl" <<'EOF'
|
cat > "$MOCK_BIN/curl" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'curl %q ' "$@" >> "$CALLS"; printf '\n' >> "$CALLS"; exit 0
|
method=GET; url=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
-X) method="$2"; shift 2 ;;
|
||||||
|
http*|https*) url="$1"; shift ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf 'curl %s %s\n' "$method" "$url" >> "$CALLS"
|
||||||
|
[ "${MOCK_CURL_FAIL:-}" = "1" ] && [ "$method" = "POST" ] && exit 22
|
||||||
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$MOCK_BIN/curl"
|
chmod +x "$MOCK_BIN/curl"
|
||||||
|
|
||||||
mk_tea() { # $1 = exit code for `tea comment`
|
mk_tea() { # $1 = exit code for a comment attempt; $2 = login list (empty => no login)
|
||||||
|
local rc="$1" login="${2-}"
|
||||||
cat > "$MOCK_BIN/tea" <<EOF
|
cat > "$MOCK_BIN/tea" <<EOF
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf 'tea %s\n' "\$*" >> "$CALLS"
|
printf 'tea %s\n' "\$*" >> "$CALLS"
|
||||||
if [[ "\$*" == *"login list"* ]]; then
|
if [[ "\$*" == *"login list"* ]]; then
|
||||||
echo '[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'; exit 0
|
printf '%s\n' '${login}'; exit 0
|
||||||
fi
|
fi
|
||||||
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the
|
# Fail ANY comment attempt -- both the correct top-level \`tea comment\` and the broken
|
||||||
# broken \`tea issue comment\` -- so the test exercises the DEFECT on an unfixed
|
# \`tea issue comment\` -- so an unfixed script exercises the DEFECT rather than tripping
|
||||||
# script rather than failing on a setup assertion.
|
# a setup assertion.
|
||||||
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $1; fi
|
if [[ "\$1" == "comment" || ( "\$1" == "issue" && "\$2" == "comment" ) ]]; then exit $rc; fi
|
||||||
exit 0
|
exit 0
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$MOCK_BIN/tea"
|
chmod +x "$MOCK_BIN/tea"
|
||||||
}
|
}
|
||||||
|
LOGIN_JSON='[{"name":"git.mosaicstack.dev","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
|
||||||
# ── 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
# The mocks must be the ones that run. Without this, a failed setup silently falls through
|
||||||
mk_tea 1; : > "$CALLS"
|
# to the real tea/curl and the "test" mutates the real provider.
|
||||||
bash "$TARGET" -i 42 -c "closing note" >/dev/null 2>&1; rc=$?
|
assert_mocked() {
|
||||||
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt at all -- setup did not reach the tea branch"
|
local w
|
||||||
grep -q 'tea issue close' "$CALLS" && fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"
|
for w in tea curl; do
|
||||||
|
p=$(command -v "$w" || true)
|
||||||
|
[ -n "$p" ] || fail "SAFETY: $w does not resolve at all"
|
||||||
|
case "$p" in
|
||||||
|
"$MOCK_BIN"/*) : ;;
|
||||||
|
*) fail "SAFETY: $w resolves to $p, OUTSIDE the sandbox -- refusing to invoke the target" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_target() { # never let a target failure abort the test; we assert on rc
|
||||||
|
# Call sites MUST use `rc=0; run_target ... || rc=$?` -- a bare `run_target ...; rc=$?`
|
||||||
|
# lets the non-zero RETURN trip set -e in the CALLER before rc is ever read.
|
||||||
|
set +e; bash "$TARGET" "$@" >/dev/null 2>&1; local rc=$?; set -e; return $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── tea path ────────────────────────────────────────────────────────────────────────
|
||||||
|
# 1. NEGATIVE (the regression): comment fails => must NOT close, must exit non-zero
|
||||||
|
mk_tea 1 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
grep -qE 'tea (issue )?comment' "$CALLS" || fail "no comment attempt -- setup did not reach the tea branch"
|
||||||
|
if grep -q 'tea issue close' "$CALLS"; then fail "ISSUE CLOSED AFTER THE COMMENT FAILED -- the regression"; fi
|
||||||
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
[ "$rc" -ne 0 ] || fail "comment failed but issue-close exited 0 -- FAIL-OPEN"
|
||||||
|
|
||||||
# ── 2. POSITIVE (rule MET must pass): comment succeeds => close proceeds, exit 0
|
# 2. POSITIVE: comment succeeds => close proceeds, exit 0
|
||||||
mk_tea 0; : > "$CALLS"
|
mk_tea 0 "$LOGIN_JSON"; : > "$CALLS"; assert_mocked
|
||||||
bash "$TARGET" -i 42 -c "closing note" >/dev/null 2>&1; rc=$?
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
[ "$rc" -eq 0 ] || fail "comment succeeded but issue-close exited $rc"
|
||||||
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
grep -q 'tea issue close' "$CALLS" || fail "issue not closed even though the comment succeeded"
|
||||||
|
|
||||||
# ── 3. must use top-level `tea comment`, never `tea issue comment`
|
# 3. must use top-level `tea comment`, never `tea issue comment`
|
||||||
grep -q 'tea issue comment' "$CALLS" && fail "used 'tea issue comment' -- not a valid subcommand"
|
if grep -q 'tea issue comment' "$CALLS"; then fail "used 'tea issue comment' -- not a valid subcommand"; fi
|
||||||
|
|
||||||
# ── 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
# 4. ONE PRINCIPAL: comment and close must carry the SAME --login
|
||||||
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
c=$(grep -m1 '^tea comment' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||||
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
k=$(grep -m1 '^tea issue close' "$CALLS" | grep -o -- '--login [^ ]*' | awk '{print $2}')
|
||||||
[ -n "$c" ] || fail "comment carried no --login"
|
[ -n "$c" ] || fail "comment carried no --login"
|
||||||
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
[ "$c" = "$k" ] || fail "MIXED PRINCIPALS: comment=$c close=$k"
|
||||||
|
|
||||||
|
# ── no-login / API fallback path ────────────────────────────────────────────────────
|
||||||
|
# rev-974: the delta also adds fail-closed behaviour to this branch, and the suite never
|
||||||
|
# reached it -- replacing the whole fallback contract with an unconditional close still
|
||||||
|
# passed. These assert the POSTCONDITION (which HTTP calls happened, in what order),
|
||||||
|
# not merely that a command ran.
|
||||||
|
# 5. no login + comment FAILS => POST attempted, NO PATCH, non-zero
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; MOCK_CURL_FAIL=1 run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
grep -q 'curl POST' "$CALLS" || fail "API path: no comment POST attempted"
|
||||||
|
if grep -q 'curl PATCH' "$CALLS"; then fail "API path: ISSUE CLOSED (PATCH) AFTER THE COMMENT POST FAILED"; fi
|
||||||
|
[ "$rc" -ne 0 ] || fail "API path: comment failed but exited 0 -- FAIL-OPEN"
|
||||||
|
|
||||||
|
# 6. no login + comment SUCCEEDS => POST strictly BEFORE PATCH, exit 0
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 -c "closing note" || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "API path: comment succeeded but exited $rc"
|
||||||
|
order=$(grep -oE 'curl (POST|PATCH)' "$CALLS" | awk '{print $2}' | paste -sd, -)
|
||||||
|
[ "$order" = "POST,PATCH" ] || fail "API path: expected POST,PATCH -- got '${order:-<none>}'"
|
||||||
|
|
||||||
|
# 7. no login + NO comment => PATCH only, never a POST
|
||||||
|
mk_tea 0 ""; : > "$CALLS"; assert_mocked
|
||||||
|
rc=0; run_target -i 42 || rc=$?
|
||||||
|
[ "$rc" -eq 0 ] || fail "API path: no-comment close exited $rc"
|
||||||
|
if grep -q 'curl POST' "$CALLS"; then fail "API path: posted a comment when none was requested"; fi
|
||||||
|
grep -q 'curl PATCH' "$CALLS" || fail "API path: issue not closed when no comment was requested"
|
||||||
|
|
||||||
echo "issue-close.sh fail-closed + single-principal regression passed"
|
echo "issue-close.sh fail-closed + single-principal regression passed"
|
||||||
|
|||||||
Reference in New Issue
Block a user