#!/usr/bin/env bash # Regression harness for grant-reviewer.sh (#1415): org-team reviewer grant # with fail-closed read-back verification. # # This harness models a REAL server: the curl stub keeps persistent team/ # member/repo state on disk, the POST actually CREATES and PERSISTS the team, # the member/repo PUTs persist (except in the sabotage modes), and the # read-back GETs answer from that same state. There is no fabricated record # for the wrapper to "find" — verification passes only if the PUTs genuinely # persisted what the read-back retrieves. It proves the wrapper: # 1. creates the team with the EXACT reviewer payload (permission: read, # units_map {repo.code: read, repo.issues: write, repo.pulls: write}) — # the stub rejects any other payload; # 2. is idempotent: an existing team is found by EXACT name (a decoy team # whose name merely CONTAINS the wanted name is listed first and must # not be matched) and no create POST is issued; # 3. refuses to run against a GitHub-remoted repo (Gitea only); # 4. refuses when the owner is not an organization; # 5. maps HTTP 403 to "org admin required on " and stops before any # partial grant; # 6. fails closed when the member PUT returns 204 without persisting (the # #865 defect class: an exit code is not evidence of a durable write); # 7. fails closed when the repo PUT returns 204 without persisting; # 8. with GITEA_LOGIN set, performs EVERY request under that login's token # (never the host default), and with an UNRESOLVABLE GITEA_LOGIN fails # closed with ZERO API calls instead of downgrading; # 9. never lets the bearer token ride in curl argv (curl --config only); # 10. leaves no temp files behind on success or failure paths. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/grant-reviewer}" REPO_DIR="$WORK_DIR/repo" GH_REPO_DIR="$WORK_DIR/gh-repo" BIN_DIR="$WORK_DIR/bin" XDG_DIR="$WORK_DIR/xdg" TEA_LOG="$WORK_DIR/tea.log" CURL_LOG="$WORK_DIR/curl.log" # Full curl argv per invocation — proves the bearer token never rides in argv. CURL_ARGV_LOG="$WORK_DIR/curl-argv.log" AUTH_LOG="$WORK_DIR/auth.log" OUTPUT_FILE="$WORK_DIR/output.log" CREDENTIALS_FILE="$WORK_DIR/credentials.json" STATE_FILE="$WORK_DIR/grants.json" PAYLOAD_VIOLATION_FILE="$WORK_DIR/payload-violation" TMP_SCRATCH="$WORK_DIR/scratch" HOME_DIR="$WORK_DIR/home" cleanup() { rm -rf "$WORK_DIR" } trap cleanup EXIT mkdir -p "$REPO_DIR" "$GH_REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH" "$HOME_DIR" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git git -C "$GH_REPO_DIR" init -q git -C "$GH_REPO_DIR" remote add origin https://github.com/someorg/somerepo.git # HERMETICITY (#1007): get_gitea_token() step 0 resolves a per-agent identity # from `git config --get mosaic.gitIdentity`, which on a provisioned seat is # set GLOBALLY and leaks into this fresh repo, after which a REAL per-slot # token is read from $HOME and the fixture credential is silently ignored. An # empty repo-local value shadows the global one and reads back empty at rc=0. # (The env-var route does NOT neutralize step 0's git-config read — but the # run env below still pins MOSAIC_GIT_IDENTITY= empty so the ENV rung of the # ladder cannot resolve either: `${MOSAIC_GIT_IDENTITY:-}` treats set-but-empty # as unset.) git -C "$REPO_DIR" config mosaic.gitIdentity "" git -C "$GH_REPO_DIR" config mosaic.gitIdentity "" ORG="mosaicstack" REPO_SLUG="mosaicstack/stack" API_ROOT="https://git.mosaicstack.dev/api/v1" REVIEWER="rev-user" TEAM_NAME="fleet-reviewers" TEAM_ID=42 DECOY_TEAM_ID=99 DEFAULT_TOKEN="test-only-placeholder" DEFAULT_IDENTITY="seat-default" OVERRIDE_LOGIN="granter" OVERRIDE_TOKEN="override-token-placeholder" # tea config: the GITEA_LOGIN override login has its own host-bound token here. mkdir -p "$XDG_DIR/tea" OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \ python3 - "$XDG_DIR/tea/config.yml" <<'PY' import os import sys with open(sys.argv[1], "w", encoding="utf-8") as handle: handle.write("logins:\n") handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n") handle.write(" url: https://git.mosaicstack.dev\n") handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n") PY CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY' import json import os import sys with open(sys.argv[1], "w", encoding="utf-8") as credentials: json.dump({ "gitea": { "mosaicstack": { "url": os.environ["CONFIGURED_GITEA_URL"], "token": "test-only-placeholder", } } }, credentials) PY # tea stub: grant-reviewer.sh must never shell out to tea at all. cat > "$BIN_DIR/tea" <<'SH' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >> "$GRANT_REVIEWER_TEA_LOG" echo "Unexpected tea command (grant-reviewer must not use tea): $*" >&2 exit 92 SH chmod +x "$BIN_DIR/tea" # curl stub: a small REST server backed by persistent on-disk grant state. # GET /orgs/{org} -> org existence (404 in not-an-org mode) # GET /orgs/{org}/teams/search -> teams from state (decoy always listed FIRST) # POST /orgs/{org}/teams -> validate EXACT payload, CREATE + PERSIST # PUT /teams/{id}/members/{user} -> 204; persists unless member-put-noop # PUT /teams/{id}/repos/{org}/{repo} -> 204; persists unless repo-put-noop # GET /teams/{id}/members/{user} -> answers from persisted state only # GET /teams/{id}/repos/{org}/{repo} -> answers from persisted state only cat > "$BIN_DIR/curl" <<'SH' #!/usr/bin/env bash set -euo pipefail # Record the FULL argv exactly as spawned, before consumption. The bearer token # must NOT appear here — it is delivered via a curl --config file, so only the # config file PATH may show up. printf '%s\n' "$*" >> "$GRANT_REVIEWER_CURL_ARGV_LOG" output_file="" method="GET" url="" data="" auth_token="" config_file="" while [[ $# -gt 0 ]]; do case "$1" in -o) output_file="$2"; shift 2 ;; -H) [[ "$2" == Authorization:* ]] && auth_token="${2##* }" shift 2 ;; -K|--config) config_file="$2"; shift 2 ;; -w) shift 2 ;; -X) method="$2"; shift 2 ;; -d|--data) data="$2"; shift 2 ;; -s|-S|-sS) shift ;; http://*|https://*) url="$1"; shift ;; *) shift ;; esac done # Resolve the bearer token from the curl --config file (its real, secure # source). The config line is `header = "Authorization: token "`. if [[ -z "$auth_token" && -n "$config_file" && -f "$config_file" ]]; then config_hdr="$(grep -i 'Authorization' "$config_file" 2>/dev/null || true)" if [[ "$config_hdr" == *"token "* ]]; then auth_token="${config_hdr##*token }" auth_token="${auth_token%\"}" fi fi path="${url%%\?*}" printf '%s %s\n' "$method" "$url" >> "$GRANT_REVIEWER_CURL_LOG" # Map the presented bearer token to the identity it authenticates as. Every # request the wrapper makes must carry the SAME credential, so the identity # recorded here reveals which credential actually performed each request. acting_identity="" case "$auth_token" in "$GRANT_REVIEWER_DEFAULT_TOKEN") acting_identity="$GRANT_REVIEWER_DEFAULT_IDENTITY" ;; "$GRANT_REVIEWER_OVERRIDE_TOKEN") acting_identity="$GRANT_REVIEWER_OVERRIDE_LOGIN" ;; esac printf '%s %s %s\n' "$method" "$path" "${acting_identity:-}" >> "$GRANT_REVIEWER_AUTH_LOG" write_response() { local status="$1" body="$2" [[ -n "$output_file" ]] || exit 96 printf '%s' "$body" > "$output_file" printf '%s' "$status" } [[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; } mode="$GRANT_REVIEWER_TEST_MODE" org="$GRANT_REVIEWER_ORG" api="$GRANT_REVIEWER_API_ROOT" if [[ "$method" == "GET" && "$path" == "$api/orgs/$org" ]]; then if [[ "$mode" == "not-an-org" ]]; then write_response 404 '{"message":"not found"}' else write_response 200 "{\"username\":\"$org\"}" fi elif [[ "$method" == "GET" && "$path" == "$api/orgs/$org/teams/search" ]]; then result=$(python3 - "$GRANT_REVIEWER_STATE" <<'PY' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: state = json.load(handle) print(json.dumps({"ok": True, "data": state["teams"]})) PY ) write_response 200 "$result" elif [[ "$method" == "POST" && "$path" == "$api/orgs/$org/teams" ]]; then if [[ "$mode" == "create-403" ]]; then write_response 403 '{"message":"forbidden"}' exit 0 fi result=$(GRANT_REVIEWER_DATA="$data" python3 - "$GRANT_REVIEWER_STATE" <<'PY' import json import os import sys payload = json.loads(os.environ["GRANT_REVIEWER_DATA"]) expected = { "name": os.environ["GRANT_REVIEWER_TEAM_NAME"], "description": "review seats: code read + issues/pulls write", "permission": "read", "includes_all_repositories": False, "can_create_org_repo": False, "units_map": { "repo.code": "read", "repo.issues": "write", "repo.pulls": "write", }, } if payload != expected: with open(os.environ["GRANT_REVIEWER_PAYLOAD_VIOLATION"], "w", encoding="utf-8") as handle: json.dump({"got": payload, "expected": expected}, handle, indent=2) print("422") print(json.dumps({"message": "payload mismatch"})) raise SystemExit(0) state_path = sys.argv[1] with open(state_path, encoding="utf-8") as handle: state = json.load(handle) team = {"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": payload["name"]} state["teams"].append(team) with open(state_path, "w", encoding="utf-8") as handle: json.dump(state, handle) print("201") print(json.dumps(team)) PY ) response_status="${result%%$'\n'*}" response_body="${result#*$'\n'}" write_response "$response_status" "$response_body" elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then # Sabotage mode member-put-noop: 204 WITHOUT persisting — the exit-code lie. if [[ "$mode" != "member-put-noop" ]]; then python3 - "$GRANT_REVIEWER_STATE" <<'PY' import json import os import sys state_path = sys.argv[1] with open(state_path, encoding="utf-8") as handle: state = json.load(handle) member = os.environ["GRANT_REVIEWER_REVIEWER"] if member not in state["members"]: state["members"].append(member) with open(state_path, "w", encoding="utf-8") as handle: json.dump(state, handle) PY fi write_response 204 '' elif [[ "$method" == "PUT" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then # Sabotage mode repo-put-noop: 204 WITHOUT persisting. if [[ "$mode" != "repo-put-noop" ]]; then python3 - "$GRANT_REVIEWER_STATE" <<'PY' import json import os import sys state_path = sys.argv[1] with open(state_path, encoding="utf-8") as handle: state = json.load(handle) slug = os.environ["GRANT_REVIEWER_REPO_SLUG"] if slug not in state["repos"]: state["repos"].append(slug) with open(state_path, "w", encoding="utf-8") as handle: json.dump(state, handle) PY fi write_response 204 '' elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/members/$GRANT_REVIEWER_REVIEWER" ]]; then if python3 - "$GRANT_REVIEWER_STATE" <<'PY' import json import os import sys with open(sys.argv[1], encoding="utf-8") as handle: state = json.load(handle) raise SystemExit(0 if os.environ["GRANT_REVIEWER_REVIEWER"] in state["members"] else 1) PY then write_response 200 "{\"login\":\"$GRANT_REVIEWER_REVIEWER\"}" else write_response 404 '{"message":"not a member"}' fi elif [[ "$method" == "GET" && "$path" == "$api/teams/$GRANT_REVIEWER_TEAM_ID/repos/$GRANT_REVIEWER_REPO_SLUG" ]]; then if python3 - "$GRANT_REVIEWER_STATE" <<'PY' import json import os import sys with open(sys.argv[1], encoding="utf-8") as handle: state = json.load(handle) raise SystemExit(0 if os.environ["GRANT_REVIEWER_REPO_SLUG"] in state["repos"] else 1) PY then write_response 200 "{\"full_name\":\"$GRANT_REVIEWER_REPO_SLUG\"}" else write_response 404 '{"message":"repo not on team"}' fi else echo "Unexpected curl request: $method $url" >&2 exit 97 fi SH chmod +x "$BIN_DIR/curl" # Seed persistent server state for a mode: fresh (no team yet) or a pre-seeded # team. The DECOY team — whose name CONTAINS the wanted name — is always listed # FIRST, so a first-result or substring match would grab the wrong team. seed_state() { local seeded_team="$1" GRANT_REVIEWER_SEEDED_TEAM="$seeded_team" GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \ GRANT_REVIEWER_TEAM_ID="$TEAM_ID" GRANT_REVIEWER_DECOY_TEAM_ID="$DECOY_TEAM_ID" \ python3 - "$STATE_FILE" <<'PY' import json import os import sys wanted = os.environ["GRANT_REVIEWER_TEAM_NAME"] teams = [{"id": int(os.environ["GRANT_REVIEWER_DECOY_TEAM_ID"]), "name": wanted + "-archive"}] if os.environ["GRANT_REVIEWER_SEEDED_TEAM"] == "yes": teams.append({"id": int(os.environ["GRANT_REVIEWER_TEAM_ID"]), "name": wanted}) with open(sys.argv[1], "w", encoding="utf-8") as handle: json.dump({"teams": teams, "members": [], "repos": []}, handle) PY } # run_grant [extra env VAR=value ...] -- [wrapper args ...] run_grant() { local mode="$1" seeded="$2" shift 2 local -a extra_env=() while [[ $# -gt 0 && "$1" != "--" ]]; do extra_env+=("$1") shift done [[ $# -gt 0 ]] && shift : > "$TEA_LOG" : > "$CURL_LOG" : > "$CURL_ARGV_LOG" : > "$AUTH_LOG" : > "$OUTPUT_FILE" rm -f "$PAYLOAD_VIOLATION_FILE" seed_state "$seeded" ( cd "$RUN_REPO_DIR" env \ PATH="$BIN_DIR:$PATH" \ TMPDIR="$TMP_SCRATCH" \ HOME="$HOME_DIR" \ XDG_CONFIG_HOME="$XDG_DIR" \ MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \ MOSAIC_BRAIN_HOME="$HOME_DIR/.mosaic" \ MOSAIC_GIT_IDENTITY= \ GITEA_LOGIN= \ GITEA_TOKEN= \ GITEA_URL= \ GRANT_REVIEWER_TEA_LOG="$TEA_LOG" \ GRANT_REVIEWER_CURL_LOG="$CURL_LOG" \ GRANT_REVIEWER_CURL_ARGV_LOG="$CURL_ARGV_LOG" \ GRANT_REVIEWER_AUTH_LOG="$AUTH_LOG" \ GRANT_REVIEWER_STATE="$STATE_FILE" \ GRANT_REVIEWER_TEST_MODE="$mode" \ GRANT_REVIEWER_ORG="$ORG" \ GRANT_REVIEWER_API_ROOT="$API_ROOT" \ GRANT_REVIEWER_TEAM_NAME="$TEAM_NAME" \ GRANT_REVIEWER_TEAM_ID="$TEAM_ID" \ GRANT_REVIEWER_REVIEWER="$REVIEWER" \ GRANT_REVIEWER_REPO_SLUG="$REPO_SLUG" \ GRANT_REVIEWER_DEFAULT_TOKEN="$DEFAULT_TOKEN" \ GRANT_REVIEWER_DEFAULT_IDENTITY="$DEFAULT_IDENTITY" \ GRANT_REVIEWER_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \ GRANT_REVIEWER_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \ GRANT_REVIEWER_PAYLOAD_VIOLATION="$PAYLOAD_VIOLATION_FILE" \ "${extra_env[@]}" \ "$SCRIPT_DIR/grant-reviewer.sh" -u "$REVIEWER" "$@" ) > "$OUTPUT_FILE" 2>&1 } assert_no_temp_leak() { local context="$1" leaked # Includes the curl auth-config files (mosaic-gitea-auth-*), which carry the # bearer token and must be unlinked on every exit path. leaked=$(find "$TMP_SCRATCH" -type f \( -name 'mosaic-grant-reviewer-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true) if [[ -n "$leaked" ]]; then echo "FAIL: grant-reviewer temp files leaked ($context):" >&2 printf '%s\n' "$leaked" >&2 exit 1 fi } assert_token_not_in_argv() { local context="$1" if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" "$CURL_ARGV_LOG"; then echo "FAIL: a Gitea bearer token leaked into curl argv ($context)" >&2 exit 1 fi if ! grep -q -- '--config' "$CURL_ARGV_LOG"; then echo "FAIL: curl was not invoked with --config file auth ($context)" >&2 exit 1 fi } assert_no_payload_violation() { local context="$1" if [[ -f "$PAYLOAD_VIOLATION_FILE" ]]; then echo "FAIL: team create payload deviated from the reviewer contract ($context):" >&2 cat "$PAYLOAD_VIOLATION_FILE" >&2 exit 1 fi } RUN_REPO_DIR="$REPO_DIR" # Case 1: fresh grant — team absent, created with the exact reviewer payload, # member + repo PUTs persist, both read-backs verify against server state. run_grant normal no -- || { echo "FAIL: fresh grant exited nonzero" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } grep -q "Created team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || { echo "FAIL: fresh grant did not create the team" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } grep -q "Granted: '$REVIEWER' is a member of team '$TEAM_NAME' (id $TEAM_ID) with access to '$REPO_SLUG'" "$OUTPUT_FILE" || { echo "FAIL: fresh grant did not report a verified grant" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } assert_no_payload_violation "fresh" assert_token_not_in_argv "fresh" assert_no_temp_leak "fresh" # The default path must have acted as the host-default identity on EVERY request. if grep -qv " $DEFAULT_IDENTITY\$" "$AUTH_LOG"; then echo "FAIL: fresh grant made a request under an unexpected identity" >&2 cat "$AUTH_LOG" >&2 exit 1 fi # grant-reviewer must never shell out to tea. if [[ -s "$TEA_LOG" ]]; then echo "FAIL: grant-reviewer invoked tea" >&2 cat "$TEA_LOG" >&2 exit 1 fi # Case 2: idempotent — the team already exists. It must be found by EXACT name # (the decoy is listed first), no create POST issued, and the decoy team must # never be touched. run_grant normal yes -- || { echo "FAIL: idempotent grant exited nonzero" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } grep -q "Found existing team '$TEAM_NAME' (id $TEAM_ID) on org '$ORG'" "$OUTPUT_FILE" || { echo "FAIL: idempotent grant did not find the existing team" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || { echo "FAIL: idempotent grant did not report a verified grant" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } if grep -q "^POST " "$CURL_LOG"; then echo "FAIL: idempotent grant issued a create POST for an existing team" >&2 cat "$CURL_LOG" >&2 exit 1 fi if grep -q "/teams/$DECOY_TEAM_ID/" "$CURL_LOG"; then echo "FAIL: substring-named decoy team was operated on" >&2 cat "$CURL_LOG" >&2 exit 1 fi assert_no_temp_leak "idempotent" # Case 3: GITEA_LOGIN override — every request must carry the override login's # token, never the host default credential. run_grant normal no GITEA_LOGIN="$OVERRIDE_LOGIN" -- || { echo "FAIL: GITEA_LOGIN override grant exited nonzero" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } grep -q "Granted: '$REVIEWER'" "$OUTPUT_FILE" || { echo "FAIL: GITEA_LOGIN override grant did not succeed" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } if grep -qv " $OVERRIDE_LOGIN\$" "$AUTH_LOG"; then echo "FAIL: GITEA_LOGIN override made a request under a different identity" >&2 cat "$AUTH_LOG" >&2 exit 1 fi assert_token_not_in_argv "override" assert_no_temp_leak "override" # Case 4: unresolvable GITEA_LOGIN — fail closed BEFORE any API call; no # downgrade to the host default identity. if run_grant normal no GITEA_LOGIN="no-such-login" --; then echo "FAIL: unresolvable GITEA_LOGIN did not fail" >&2 cat "$OUTPUT_FILE" >&2 exit 1 fi grep -q "refusing to fall back to the host default identity" "$OUTPUT_FILE" || { echo "FAIL: unresolvable GITEA_LOGIN missing the fail-closed message" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } if [[ -s "$CURL_LOG" ]]; then echo "FAIL: unresolvable GITEA_LOGIN still made API calls" >&2 cat "$CURL_LOG" >&2 exit 1 fi assert_no_temp_leak "unresolvable-login" # Case 5: GitHub-remoted repo — refuse before any API call. RUN_REPO_DIR="$GH_REPO_DIR" if run_grant normal no --; then echo "FAIL: GitHub repo was not refused" >&2 cat "$OUTPUT_FILE" >&2 exit 1 fi grep -q "Gitea only" "$OUTPUT_FILE" || { echo "FAIL: GitHub refusal missing the 'Gitea only' message" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } if [[ -s "$CURL_LOG" ]]; then echo "FAIL: GitHub refusal still made API calls" >&2 cat "$CURL_LOG" >&2 exit 1 fi RUN_REPO_DIR="$REPO_DIR" # Case 6: owner is not an organization — clear refusal. if run_grant not-an-org no --; then echo "FAIL: non-org owner was not refused" >&2 cat "$OUTPUT_FILE" >&2 exit 1 fi grep -q "is not an organization" "$OUTPUT_FILE" || { echo "FAIL: non-org refusal missing its message" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } assert_no_temp_leak "not-an-org" # Case 7: HTTP 403 on team create — reported as an org-admin requirement, and # the run stops before any member/repo PUT (no partial grant). if run_grant create-403 no --; then echo "FAIL: 403 on team create did not fail the run" >&2 cat "$OUTPUT_FILE" >&2 exit 1 fi grep -q "org admin required on '$ORG'" "$OUTPUT_FILE" || { echo "FAIL: 403 was not mapped to the org-admin message" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } if grep -q "^PUT " "$CURL_LOG"; then echo "FAIL: run continued into PUTs after a 403 (partial grant)" >&2 cat "$CURL_LOG" >&2 exit 1 fi assert_no_temp_leak "create-403" # Cases 8-9: the exit-code lie — a PUT answers 204 without persisting. The # read-back must fail closed; no success line may appear. for noop_mode in member-put-noop repo-put-noop; do if run_grant "$noop_mode" no --; then echo "FAIL: $noop_mode was reported as success" >&2 cat "$OUTPUT_FILE" >&2 exit 1 fi grep -q "NOT verified" "$OUTPUT_FILE" || { echo "FAIL: $noop_mode missing the fail-closed verification message" >&2 cat "$OUTPUT_FILE" >&2 exit 1 } if grep -q "^Granted:" "$OUTPUT_FILE"; then echo "FAIL: $noop_mode still printed the success line" >&2 exit 1 fi assert_no_temp_leak "$noop_mode" done echo "grant-reviewer.sh org-team grant + fail-closed read-back regression passed"