#!/usr/bin/env bash
# Regression harness for pr-review.sh's Gitea review + comment writes (#865,
# #812, #835).
#
# The #865 defect class: tea 0.11.1 can silently no-op while exiting 0 and
# cannot emit the id of a record it creates, so its exit code is worthless as
# proof of a durable write. The wrapper therefore does NOT write reviews or
# comments via tea. approve/request-changes POST to /pulls/{n}/reviews (with the
# event, the PR head commit_id, and the review body) and read the created review
# back by its EXACT provider-returned id; the `comment` action POSTs to
# /issues/{n}/comments and reads that created comment back by its exact id.
# Because verification keys on the id the create returned, no concurrent record
# can masquerade as this write and a no-op create fails closed. tea is only ever
# consulted for the login list.
#
# The curl stub models a REAL server with persistent review/comment state on
# disk: a POST actually CREATES and PERSISTS a record and returns its id, and
# the read-back reads that same state. There is no independently fabricated
# record for the wrapper to "find" — verification passes only when the POST
# genuinely created the record the read-back retrieves.
#
# #865 Round-4: the curl stub also maps the presented bearer token to the
# identity it authenticates as and logs it per request, so tests can prove
# credential attribution. An explicit --login override must drive the entire
# write→read-back chain under THAT login's token (resolvable case) or FAIL
# CLOSED (unresolvable case) — never silently downgrade to the host-default
# identity. The host-default best-effort fallback is reserved for the
# no-override default path.
#
# #865 Round-5: the exact-id read-back is the SOLE authority — the wrapper does
# NO follow-up list enumeration (the stub exposes no review/comment list
# endpoint, so a residual enumeration would fail the run). A --login override is
# host-bound: an override configured for a DIFFERENT host than the repo remote
# FAILS CLOSED rather than leaking a cross-host credential. And every run leaves
# no scratch temp files behind on any exit path (POST/GET bodies + metadata).

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/pr-review-gitea-comment}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
STATE_DIR="$WORK_DIR/state"
REVIEWS_FILE="$STATE_DIR/reviews.json"
COMMENTS_FILE="$STATE_DIR/comments.json"
SUBMIT_PAYLOAD_FILE="$STATE_DIR/review_payload.json"
# Counts GET /pulls/{n} calls within a single run so a race mode can advance the
# reported head between the pre-submit read and the post-verify re-read.
HEAD_CALLS_FILE="$STATE_DIR/head_calls"
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"
# A dedicated scratch dir the wrapper is pointed at via TMPDIR, so the leak
# check can assert every POST/GET body + metadata temp file is cleaned up.
TMP_SCRATCH="$WORK_DIR/scratch"
# Sandboxed HOME so nothing under the real $HOME (notably the per-slot Gitea token
# store at ~/.config/mosaic/secrets/gitea-tokens/) is reachable from the wrapper.
HOME_DIR="$WORK_DIR/home"

cleanup() {
    rm -rf "$WORK_DIR"
}
trap cleanup EXIT

ACTING_LOGIN="review-bot"
FOREIGN_LOGIN="other-writer"
HEAD_SHA="HEADSHA_FEEDFACE"
# A dedicated per-role --login override identity with its own token in tea's
# config (the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="primary-reviewer"
DEFAULT_TOKEN="test-only-placeholder"
OVERRIDE_TOKEN="override-token-placeholder"
# A --login override whose tea config URL points at a DIFFERENT Gitea host than
# the repo remote (git.mosaicstack.dev). Host-bound selection must reject it
# rather than send its token cross-host.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"

mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$STATE_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
# HERMETICITY: get_gitea_token() step 0 resolves a per-agent identity from
# `git config --get mosaic.gitIdentity`, which on a provisioned agent seat is set
# GLOBALLY and therefore leaks into this fresh repo. It then reads a REAL per-slot
# token from $HOME and returns it WITHOUT ever consulting MOSAIC_CREDENTIALS_FILE,
# so the fixture credentials below are silently ignored and the suite runs against
# production credentials. An empty repo-local value shadows the global one and reads
# back as empty at rc=0, restoring the shared-credential path this suite intends to
# exercise. Paired with the sandboxed HOME in run_review().
git -C "$REPO_DIR" config mosaic.gitIdentity ""

# tea config: the override login carries its own token here. The default login
# name ("mosaicstack") is deliberately absent, so the no-override default path
# resolves via the host credential fallback while an explicit --login must
# resolve from this file or fail closed. A second login is configured for a
# DIFFERENT host to exercise host-bound rejection.
mkdir -p "$XDG_DIR/tea"
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
    CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" CROSS_HOST_TOKEN="$CROSS_HOST_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")
    handle.write(f"  - name: {os.environ['CROSS_HOST_LOGIN']}\n")
    handle.write("    url: https://git.uscllc.com\n")
    handle.write(f"    token: {os.environ['CROSS_HOST_TOKEN']}\n")
PY

write_credentials() {
    local configured_url="$1"
    CONFIGURED_GITEA_URL="$configured_url" 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: only ever answers the login list. The wrapper must never write a
# review or comment through tea (#865 defect class); any other tea invocation is
# an error.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' "$*" >> "$PR_REVIEW_TEA_LOG"

if [[ "$*" == "login list --output json" ]]; then
    printf '[{"name":"mosaicstack","url":"%s"}]\n' "$PR_REVIEW_LOGIN_URL"
    exit 0
fi

echo "Unexpected tea command (wrapper must not write via tea): $*" >&2
exit 92
SH
chmod +x "$BIN_DIR/tea"

# curl stub: a small REST server backed by persistent on-disk review/comment
# state.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail

# Record the FULL argv exactly as spawned, BEFORE any 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. (#865 ITEM 3a credential-in-argv exposure.)
printf '%s\n' "$*" >> "$PR_REVIEW_CURL_ARGV_LOG"

output_file=""
method="GET"
payload=""
url=""
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) payload="$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);
# only fall back to an -H header for defense in depth. The config line is
# `header = "Authorization: token <value>"`.
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%%\?*}"
query="${url#*\?}"
[[ "$query" == "$url" ]] && query=""
printf '%s %s\n' "$method" "$url" >> "$PR_REVIEW_CURL_LOG"

# Map the presented bearer token to the identity it authenticates as (as Gitea's
# /user does). The write, /user lookup, and read-back must all carry the SAME
# token, so the identity logged here reveals which credential performed each
# request — proving an explicit --login override is honored, not downgraded.
acting_identity=""
case "$auth_token" in
    "$PR_REVIEW_DEFAULT_TOKEN") acting_identity="$PR_REVIEW_ACTING_LOGIN" ;;
    "$PR_REVIEW_OVERRIDE_TOKEN") acting_identity="$PR_REVIEW_OVERRIDE_LOGIN" ;;
    "$PR_REVIEW_CROSS_HOST_TOKEN") acting_identity="$PR_REVIEW_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$PR_REVIEW_AUTH_LOG"

write_response() {
    local status="$1" body="$2"
    [[ -n "$output_file" ]] || exit 96
    printf '%s' "$body" > "$output_file"
    printf '%s' "$status"
}

emit() {
    # Split a two-line "status\n<json body>" python result into the response.
    local result="$1"
    write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
}

mode="${PR_REVIEW_TEST_MODE:-}"

if [[ "$method" == "GET" && "$path" == "$PR_REVIEW_API_ROOT/user" ]]; then
    [[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
    write_response 200 "$(PR_REVIEW_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["PR_REVIEW_LOGIN"]}))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123" ]]; then
    write_response 200 "$(PR_REVIEW_HEAD_SHA="$PR_REVIEW_HEAD_SHA" python3 - <<'PY'
import json
import os

head = os.environ["PR_REVIEW_HEAD_SHA"]
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
calls_path = os.environ.get("PR_REVIEW_HEAD_CALLS", "")
# Count GET /pulls/{n} calls within this run: call 1 is the pre-submit head read
# that pins the review; call 2+ is the post-verify re-read (current-head TOCTOU
# close-out). In the race mode the branch "advances" after the pin.
n = 1
if calls_path:
    try:
        with open(calls_path, encoding="utf-8") as handle:
            n = int(handle.read() or "0") + 1
    except (OSError, ValueError):
        n = 1
    with open(calls_path, "w", encoding="utf-8") as handle:
        handle.write(str(n))
if mode == "head-advanced-race" and n >= 2:
    head = "HEADSHA_ADVANCED_DEADBEEF"
print(json.dumps({"head": {"sha": head}}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/pulls/123/reviews" ]]; then
    printf '%s' "$payload" > "$PR_REVIEW_SUBMIT_PAYLOAD"
    emit "$(PR_REVIEW_ACTING_LOGIN="${acting_identity:-$PR_REVIEW_ACTING_LOGIN}" PR_REVIEW_PAYLOAD="$payload" python3 - <<'PY'
import json
import os

state_path = os.environ["PR_REVIEW_REVIEWS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
foreign = os.environ["PR_REVIEW_FOREIGN_LOGIN"]
submitted = json.loads(os.environ["PR_REVIEW_PAYLOAD"])

with open(state_path, encoding="utf-8") as handle:
    reviews = json.load(handle)

# review-refused-422 (#1004): the server REFUSES the submit outright with a
# definite, correct, machine-readable reason in the body — the shape Gitea
# returns when the acting credential authored the PR. Nothing is created. The
# wrapper must surface what the server said and must NOT relabel this as the
# #865 silent-no-op defect class, which is precisely what it is not.
if mode == "review-refused-422":
    print("422")
    print(json.dumps({"message": "Cannot approve your own pull request"}))
    raise SystemExit(0)

# review-refused-html (#1004): a non-JSON error body, as a fronting proxy or
# gateway emits. The detail extraction must degrade to the first raw line rather
# than silently dropping the only explanation available.
if mode == "review-refused-html":
    print("502")
    print("<html><head><title>502 Bad Gateway</title></head>\n<body>nginx</body></html>")
    raise SystemExit(0)

# no-op-concurrent-review: the wrapper's own submit is SUPPRESSED (200, no
# created object) even though a concurrent same-identity, same-state review at
# the same head already exists. Nothing is persisted; no created id to verify.
if mode == "no-op-concurrent-review":
    print("200")
    print(json.dumps({}))
    raise SystemExit(0)

# review-body-reuse (#865 Blocker 4): Gitea v1.25.4's SubmitReview can finalize
# and REUSE a pending review id whose Content was authored earlier — NOT this
# submit's body. id/author/state/head all line up with the request; only the
# persisted body diverges, so only body verification catches it. The read-back
# GET returns this same divergent-body record.
if mode == "review-body-reuse":
    new_id = (max((r["id"] for r in reviews), default=0)) + 1
    record = {
        "id": new_id,
        "state": submitted.get("event"),
        "commit_id": submitted.get("commit_id"),
        "body": "leftover-pending-content-not-this-submit",
        "user": {"login": acting},
    }
    reviews.append(record)
    with open(state_path, "w", encoding="utf-8") as handle:
        json.dump(reviews, handle)
    print("201")
    print(json.dumps(record))
    raise SystemExit(0)

# review-body-null (#865 ITEM 3b): a non-empty body was submitted but the
# persisted review carries body == null. id/author/state/head all line up; only
# strict presence + string-type body verification catches the lost body. The old
# `(body or "")` coalesce would have treated null as an empty string and passed.
if mode == "review-body-null":
    new_id = (max((r["id"] for r in reviews), default=0)) + 1
    record = {
        "id": new_id,
        "state": submitted.get("event"),
        "commit_id": submitted.get("commit_id"),
        "body": None,
        "user": {"login": acting},
    }
    reviews.append(record)
    with open(state_path, "w", encoding="utf-8") as handle:
        json.dump(reviews, handle)
    print("201")
    print(json.dumps(record))
    raise SystemExit(0)

author = foreign if mode == "author-mismatch-review" else acting
new_id = (max((r["id"] for r in reviews), default=0)) + 1
record = {
    "id": new_id,
    "state": submitted.get("event"),
    "commit_id": submitted.get("commit_id"),
    "body": submitted.get("body"),
    "user": {"login": author},
}
reviews.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
    json.dump(reviews, handle)
print("201")
print(json.dumps(record))
PY
)"
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/pulls/123/reviews/* ]]; then
    emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os

state_path = os.environ["PR_REVIEW_REVIEWS"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
    reviews = json.load(handle)
match = next((r for r in reviews if r["id"] == wanted), None)
if match is None:
    print("404")
    print(json.dumps({"message": "not found"}))
else:
    print("200")
    print(json.dumps(match))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE/issues/123/comments" ]]; then
    case "$mode" in
        write-transport-failure)
            echo "simulated transport failure" >&2
            exit 7
            ;;
        write-http-failure)
            write_response 500 '{"message":"simulated rejection"}'
            ;;
        *)
            emit "$(PR_REVIEW_PAYLOAD="$payload" PR_REVIEW_TEST_MODE="$mode" python3 - <<'PY'
import json
import os
from urllib.parse import urlparse

state_path = os.environ["PR_REVIEW_COMMENTS"]
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
web_base = os.environ["PR_REVIEW_WEB_BASE"]
mode = os.environ.get("PR_REVIEW_TEST_MODE", "")
body = json.loads(os.environ["PR_REVIEW_PAYLOAD"]).get("body")
# REAL Gitea shape for a comment posted to a PR's conversation
# (/issues/{n}/comments on a PR): pull_request_url is the WEB pulls path and
# issue_url is left empty. This is what the wrapper must tolerate — it must NOT
# require an API-shaped issue_url.
pr_url = f"{web_base}/pulls/123"
# comment-plain-issue (#865 ITEM 2): #123 is a plain ISSUE, not a PR. POST
# /issues/123/comments lands an issue comment whose issue_url is set and
# pull_request_url is empty. The pr-review `comment` action MUST reject this — it
# claimed a PR comment, so a bare issue_url is not acceptable proof.
if mode == "comment-plain-issue":
    record = {
        "id": 456,
        "body": body,
        "user": {"login": acting},
        "issue_url": f"{web_base}/issues/123",
        "pull_request_url": "",
    }
    with open(state_path, "w", encoding="utf-8") as handle:
        json.dump([record], handle)
    print("201")
    print(json.dumps(record))
    raise SystemExit(0)
# URL-injection modes (#865 Blocker 3): id/author/body are all correct but the
# provider-returned pull_request_url is forged, so ONLY origin+full-path
# verification can catch them.
_p = urlparse(web_base)
_origin = f"{_p.scheme}://{_p.netloc}"
_slug = _p.path  # /<owner>/<repo>
if mode == "comment-url-wrong-host":
    pr_url = f"https://evil.example{_slug}/pulls/123"
elif mode == "comment-url-wrong-owner":
    pr_url = f"{_origin}/attacker/stack/pulls/123"
elif mode == "comment-url-wrong-repo":
    pr_url = f"{_origin}/mosaicstack/other/pulls/123"
elif mode == "comment-url-suffix-injection":
    # Prefix-injected: a bare endswith("/<slug>/pulls/123") test would ACCEPT it.
    pr_url = f"{_origin}/deceptive{_slug}/pulls/123"
elif mode == "comment-mixed-case-slug":
    # #875: EXPECTED_REPO_SLUG is taken verbatim from GITEA_API_BASE and can be
    # mixed-case (e.g. "USC/uconnect"), but Gitea canonicalizes the returned
    # pull_request_url's owner/repo segment to LOWERCASE. Model that here by
    # lowercasing only the slug path, independent of the (possibly mixed-case)
    # web_base the wrapper was configured with.
    pr_url = f"{_origin}{_slug.lower()}/pulls/123"
record = {
    "id": 456,
    "body": body,
    "user": {"login": acting},
    "issue_url": "",
    "pull_request_url": pr_url,
}
with open(state_path, "w", encoding="utf-8") as handle:
    json.dump([record], handle)
print("201")
print(json.dumps(record))
PY
)"
            ;;
    esac
elif [[ "$method" == "GET" && "$path" == "$PR_REVIEW_EXPECTED_API_BASE"/issues/comments/* ]]; then
    emit "$(PR_REVIEW_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os

state_path = os.environ["PR_REVIEW_COMMENTS"]
mode = os.environ["PR_REVIEW_TEST_MODE"]
wanted = int(os.environ["PR_REVIEW_GET_ID"])
with open(state_path, encoding="utf-8") as handle:
    comments = json.load(handle)
match = next((c for c in comments if c["id"] == wanted), None)
if match is None:
    print("404")
    print(json.dumps({"message": "not found"}))
    raise SystemExit(0)
if mode == "readback-failure":
    # The server returns a DIFFERENT body than was created — a genuine
    # provider-side mismatch the wrapper must reject.
    match = dict(match, body="different-body")
print("200")
print(json.dumps(match))
PY
)"
else
    echo "Unexpected curl request: $method $url" >&2
    exit 97
fi
SH
chmod +x "$BIN_DIR/curl"

# Seed persistent server state for a mode before the wrapper runs.
seed_state() {
    local mode="$1"
    printf '[]' > "$COMMENTS_FILE"
    rm -f "$SUBMIT_PAYLOAD_FILE" "$HEAD_CALLS_FILE"
    PR_REVIEW_SEED_MODE="$mode" PR_REVIEW_SEED_ACTING="$ACTING_LOGIN" \
        PR_REVIEW_SEED_HEAD="$HEAD_SHA" python3 - "$REVIEWS_FILE" <<'PY'
import json
import os
import sys

mode = os.environ["PR_REVIEW_SEED_MODE"]
acting = os.environ["PR_REVIEW_SEED_ACTING"]
head = os.environ["PR_REVIEW_SEED_HEAD"]


def review(rid, state, commit, login):
    return {"id": rid, "state": state, "commit_id": commit, "user": {"login": login}}


if mode == "many-prior-approve":
    # 50 pre-existing reviews already exist; the review this run submits becomes
    # id 51, proving exact-id read-back works regardless of how many reviews
    # precede it (no list enumeration is involved).
    reviews = [review(i, "COMMENT", "oldsha0000", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent-review":
    # A concurrent SAME-IDENTITY APPROVED review at the CURRENT head already
    # exists. The wrapper's own submit will be a no-op; it must fail closed
    # because no created id is returned — it must not scan and accept this one.
    reviews = [review(77, "APPROVED", head, acting)]
else:
    reviews = [review(100, "COMMENT", "oldsha0000", acting)]

with open(sys.argv[1], "w", encoding="utf-8") as handle:
    json.dump(reviews, handle)
PY
}

run_review() {
    local mode="$1" action="$2" comment="${3:-}"
    local configured_url="${4:-https://git.mosaicstack.dev}"
    local remote_url="${5:-https://git.mosaicstack.dev/mosaicstack/stack.git}"
    local expected_repo="${6:-mosaicstack/stack}"
    local login_override="${7:-}"
    local expected_api_base="${configured_url%/}/api/v1/repos/$expected_repo"
    local expected_api_root="${configured_url%/}/api/v1"
    local expected_web_base="${configured_url%/}/$expected_repo"
    git -C "$REPO_DIR" remote set-url origin "$remote_url"
    write_credentials "$configured_url"
    : > "$TEA_LOG"
    : > "$CURL_LOG"
    : > "$CURL_ARGV_LOG"
    : > "$AUTH_LOG"
    : > "$OUTPUT_FILE"
    seed_state "$mode"
    (
        cd "$REPO_DIR"
        PATH="$BIN_DIR:$PATH" \
        TMPDIR="$TMP_SCRATCH" \
        HOME="$HOME_DIR" \
        MOSAIC_GIT_IDENTITY="" \
        XDG_CONFIG_HOME="$XDG_DIR" \
        MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
        PR_REVIEW_TEA_LOG="$TEA_LOG" \
        PR_REVIEW_LOGIN_URL="${configured_url%/}" \
        PR_REVIEW_CURL_LOG="$CURL_LOG" \
        PR_REVIEW_CURL_ARGV_LOG="$CURL_ARGV_LOG" \
        PR_REVIEW_AUTH_LOG="$AUTH_LOG" \
        PR_REVIEW_REVIEWS="$REVIEWS_FILE" \
        PR_REVIEW_COMMENTS="$COMMENTS_FILE" \
        PR_REVIEW_SUBMIT_PAYLOAD="$SUBMIT_PAYLOAD_FILE" \
        PR_REVIEW_HEAD_CALLS="$HEAD_CALLS_FILE" \
        PR_REVIEW_TEST_MODE="$mode" \
        PR_REVIEW_EXPECTED_BODY="$comment" \
        PR_REVIEW_EXPECTED_API_BASE="$expected_api_base" \
        PR_REVIEW_API_ROOT="$expected_api_root" \
        PR_REVIEW_WEB_BASE="$expected_web_base" \
        PR_REVIEW_HEAD_SHA="$HEAD_SHA" \
        PR_REVIEW_ACTING_LOGIN="$ACTING_LOGIN" \
        PR_REVIEW_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
        PR_REVIEW_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
        PR_REVIEW_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
        PR_REVIEW_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
        PR_REVIEW_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
        PR_REVIEW_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
        "$SCRIPT_DIR/pr-review.sh" -n 123 -a "$action" ${comment:+-c "$comment"} ${login_override:+--login "$login_override"}
    ) > "$OUTPUT_FILE" 2>&1
}

# Assert the wrapper left no scratch temp files behind in TMPDIR (POST/GET
# request bodies + metadata). Called after both success and failure paths so a
# clobbered/leaked RETURN trap is caught on every exit route.
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-pr-review-*' -o -name 'mosaic-gitea-auth-*' \) 2>/dev/null || true)
    if [[ -n "$leaked" ]]; then
        echo "FAIL: pr-review temp files leaked ($context):" >&2
        printf '%s\n' "$leaked" >&2
        exit 1
    fi
}

# Assert the presented bearer token NEVER appeared in curl's argv (it must travel
# via a curl --config file), and that --config auth was actually used. On the
# expected path grep matches nothing, so no token value is ever printed.
assert_token_not_in_argv() {
    local context="$1"
    if grep -qF -e "$DEFAULT_TOKEN" -e "$OVERRIDE_TOKEN" -e "$CROSS_HOST_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_tea_write() {
    # tea must only ever be used for the login list, never to write.
    if grep -qvE '^login list --output json$' "$TEA_LOG"; then
        echo "FAIL: wrapper invoked tea for something other than the login list" >&2
        cat "$TEA_LOG" >&2
        exit 1
    fi
}

# Case 1: a plain approve submits a review via REST and verifies it by its exact
# provider-returned id (id 101), attributed to the acting identity, pinned to
# the PR head, with no separate comment.
run_review approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/user$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
# No review-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
    echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
    exit 1
fi
# No-override default path: the write, /user lookup, and read-back all resolve
# via the host-default credential and authenticate as the acting identity.
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $ACTING_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $ACTING_LOGIN\$" "$AUTH_LOG"
assert_no_tea_write
assert_no_temp_leak "approve"
# ITEM 3a: the host-default token drove this whole chain, yet never appeared in
# any curl argv — it was passed via a curl --config file.
assert_token_not_in_argv "approve default-token"
# The submitted review payload carries the event and the PR head commit_id.
PR_REVIEW_HEAD_SHA="$HEAD_SHA" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys

payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "APPROVED", payload
assert payload["commit_id"] == os.environ["PR_REVIEW_HEAD_SHA"], payload
PY
# A plain approve (no body) must not POST a comment.
if grep -q '/issues/123/comments' "$CURL_LOG"; then
    echo "FAIL: plain approve unexpectedly posted a comment" >&2
    exit 1
fi

# Case 2: a submitted review NOT authored by the acting identity must FAIL
# CLOSED — the exact-id read-back enforces authorship.
if run_review author-mismatch-review approve; then
    echo "FAIL: approve accepted a review authored by a different identity" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: read-back did not enforce acting-identity authorship" >&2
    exit 1
fi
# Failure-after-read-back path must ALSO leave no scratch temp files behind.
assert_no_temp_leak "author-mismatch-review"

# Case 3: a no-op submit with a concurrent SAME-IDENTITY, same-state review at
# the current head already present must FAIL CLOSED — the closed concurrency
# window. The wrapper must not read back (or accept) the concurrent id 77.
if run_review no-op-concurrent-review approve; then
    echo "FAIL: approve reported success when its submit no-opped but a concurrent review existed" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: approve accepted a concurrent review for a no-op submit (window not closed)" >&2
    exit 1
fi
if grep -q '/pulls/123/reviews/77$' "$CURL_LOG"; then
    echo "FAIL: wrapper read back the concurrent review id 77 (illegitimate fallback)" >&2
    exit 1
fi

# Case 4: a genuine matching review (id 51) created after 50 pre-existing reviews
# is still verified by its EXACT provider-returned id — no list enumeration is
# needed regardless of how many reviews precede it.
run_review many-prior-approve approve
grep -q 'Approved and verified Gitea PR #123 (review ID 51)' "$OUTPUT_FILE"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/51$' "$CURL_LOG"
if grep -Eq '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews(\?|$)' "$CURL_LOG"; then
    echo "FAIL: wrapper performed a redundant review-list enumeration" >&2
    exit 1
fi

# Case 5: an approve WITH a body carries that body in the review submit itself —
# there is no separate detached comment POST.
run_review approve approve approve-note
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
PR_REVIEW_EXPECTED_BODY="approve-note" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys

payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
if grep -q '/issues/123/comments' "$CURL_LOG"; then
    echo "FAIL: approve-with-body posted a separate comment instead of carrying the body on the review" >&2
    exit 1
fi

# Case 6: request-changes requires a body and carries it on the REQUEST_CHANGES
# review submit.
run_review request-changes request-changes changes-required
grep -q 'Requested changes and verified on Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101$' "$CURL_LOG"
PR_REVIEW_EXPECTED_BODY="changes-required" python3 - "$SUBMIT_PAYLOAD_FILE" <<'PY'
import json
import os
import sys

payload = json.load(open(sys.argv[1], encoding="utf-8"))
assert payload["event"] == "REQUEST_CHANGES", payload
assert payload["body"] == os.environ["PR_REVIEW_EXPECTED_BODY"], payload
PY
assert_no_tea_write

# Case 7: the `comment` action creates a comment via REST and verifies it by its
# exact created id, attributed to the acting identity. This also exercises
# owner/repo + base-URL resolution across clone-URL shapes.
complex_body=$'durable "body"\n-- marker'
run_review comment-success comment "$complex_body"
grep -q '^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_tea_write
assert_no_temp_leak "comment-success"

run_review http-success comment durable-body http://git.mosaicstack.dev
grep -q '^POST http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET http://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"

run_review prefix-success comment durable-body https://git.mosaicstack.dev/gitea/
grep -q '^POST https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.mosaicstack.dev/gitea/api/v1/repos/mosaicstack/stack/issues/comments/456$' "$CURL_LOG"

run_review subpath-success comment durable-body https://git.example/gitea https://git.example/gitea/owner/repo.git owner/repo
grep -q '^POST https://git.example/gitea/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
grep -q '^GET https://git.example/gitea/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"
if grep -q '/repos/gitea/owner/repo/' "$CURL_LOG"; then
    echo "Configured Gitea path prefix leaked into the repository slug" >&2
    exit 1
fi

run_review port-success comment durable-body http://git.example:3000 http://git.example:3000/owner/repo.git owner/repo
grep -q '^POST http://git.example:3000/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"
grep -q '^GET http://git.example:3000/api/v1/repos/owner/repo/issues/comments/456$' "$CURL_LOG"

run_review scp-ssh-success comment durable-body https://git.example git@git.example:owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"

run_review url-ssh-success comment durable-body https://git.example ssh://git@git.example/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"

# #850: an SSH remote's transport port must not be compared against the
# configured HTTP(S) API URL's port.
run_review ssh-transport-port-success comment durable-body https://git.example ssh://git@git.example:2222/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"

# #850: an explicit default HTTP(S) port on the remote must equal an implicit
# (portless) configured URL.
run_review explicit-default-port-success comment durable-body https://git.example https://git.example:443/owner/repo.git owner/repo
grep -q '^POST https://git.example/api/v1/repos/owner/repo/issues/123/comments$' "$CURL_LOG"

# Comment write/read-back failure modes must all fail closed.
if run_review write-transport-failure comment durable-body; then
    echo "Expected provider transport failure to return nonzero" >&2
    exit 1
fi
if run_review write-http-failure comment durable-body; then
    echo "Expected non-201 provider write to return nonzero" >&2
    exit 1
fi
if run_review readback-failure comment durable-body; then
    echo "Expected mismatched provider read-back to return nonzero" >&2
    exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
    echo "Read-back mismatch reported durable success" >&2
    exit 1
fi

# Case 8 (#865 Round-4): a RESOLVABLE explicit --login override must attribute
# the entire write→read-back chain to THAT login's token/identity, never the
# host-default identity. The override login carries its own token in the tea
# config, so /user, the review POST, and the exact-id read-back all authenticate
# as the override identity — and NOTHING is performed under the default identity.
run_review override-success approve "" https://git.mosaicstack.dev \
    https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$OVERRIDE_LOGIN"
grep -q 'Approved and verified Gitea PR #123 (review ID 101)' "$OUTPUT_FILE"
grep -q "^GET https://git.mosaicstack.dev/api/v1/user $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^POST https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews $OVERRIDE_LOGIN\$" "$AUTH_LOG"
grep -q "^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123/reviews/101 $OVERRIDE_LOGIN\$" "$AUTH_LOG"
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
    echo "FAIL: an explicit --login override was silently downgraded to the host-default identity" >&2
    cat "$AUTH_LOG" >&2
    exit 1
fi
assert_no_tea_write
# ITEM 3a: the override token likewise never leaked into curl argv.
assert_token_not_in_argv "override-success override-token"

# Case 9 (#865 Round-4): an UNRESOLVABLE explicit --login override (a name absent
# from the tea config) must FAIL CLOSED — nonzero exit, no success line, no review
# POST, and above all NO request performed under the host-default identity. The
# host-default best-effort fallback is reserved for the no-override path only.
if run_review override-unresolvable approve "" https://git.mosaicstack.dev \
    https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "nonexistent-typo-login"; then
    echo "FAIL: an unresolvable --login override was not rejected (silently used the host default)" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: unresolvable --login override reported success" >&2
    exit 1
fi
if grep -q '/pulls/123/reviews ' "$AUTH_LOG" && grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
    echo "FAIL: unresolvable --login override performed a review POST" >&2
    cat "$AUTH_LOG" >&2
    exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
    echo "FAIL: unresolvable --login override fell back to the host-default identity" >&2
    cat "$AUTH_LOG" >&2
    exit 1
fi

# Case 10 (#865 Round-5): a --login override that IS present in tea config but
# whose URL is a DIFFERENT host than the repo remote must FAIL CLOSED (host-bound
# selection). The cross-host token must NEVER be sent to the repo host, and no
# review POST occurs.
if run_review cross-host approve "" https://git.mosaicstack.dev \
    https://git.mosaicstack.dev/mosaicstack/stack.git mosaicstack/stack "$CROSS_HOST_LOGIN"; then
    echo "FAIL: cross-host --login override did not fail closed" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: cross-host --login override reported success" >&2
    exit 1
fi
# The cross-host credential must not have performed ANY request against the repo
# host — no request may be attributed to the cross-host identity.
if grep -q " $CROSS_HOST_LOGIN\$" "$AUTH_LOG"; then
    echo "FAIL: cross-host credential was sent to the repo host (cross-host leak)" >&2
    cat "$AUTH_LOG" >&2
    exit 1
fi
if grep -qE '^POST .*/pulls/123/reviews ' "$AUTH_LOG"; then
    echo "FAIL: cross-host --login override performed a review POST" >&2
    cat "$AUTH_LOG" >&2
    exit 1
fi
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
    echo "FAIL: cross-host --login override fell back to the host-default identity" >&2
    cat "$AUTH_LOG" >&2
    exit 1
fi
assert_no_temp_leak "cross-host"

# Case 11 (#865 Blocker 4): SubmitReview finalizes/reuses a pending review id
# whose persisted body is NOT this submit's body. id/author/state/head all match
# the request, so ONLY body verification can catch the divergence — it must FAIL
# CLOSED. (Submit a non-empty body so the mismatch is meaningful.)
if run_review review-body-reuse approve real-submitted-review-body; then
    echo "FAIL: review with a reused/foreign body was accepted (body not verified)" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: read-back did not enforce the submitted review body" >&2
    exit 1
fi
assert_no_temp_leak "review-body-reuse"

# Cases 12-15 (#865 Blocker 3): a PR comment whose id/author/body are all correct
# but whose provider-returned pull_request_url is forged must FAIL CLOSED.
# Verification pins the URL's ORIGIN (scheme+host+effective-port) and FULL path
# (deployment prefix + exact owner/repo + kind + number); a bare endswith/suffix
# test would wrongly accept the look-alike-host and prefix-injection variants.
for bad_mode in comment-url-wrong-host comment-url-wrong-owner comment-url-wrong-repo comment-url-suffix-injection; do
    if run_review "$bad_mode" comment durable-body; then
        echo "FAIL: forged comment URL ($bad_mode) was accepted" >&2
        cat "$OUTPUT_FILE" >&2
        exit 1
    fi
    if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
        echo "FAIL: forged comment URL ($bad_mode) passed verification" >&2
        exit 1
    fi
    assert_no_temp_leak "$bad_mode"
done

# Case 15b (#875): a MIXED-CASE repo slug (as embedded verbatim in
# GITEA_API_BASE, e.g. "USC/uconnect") must still verify when Gitea returns the
# comment's pull_request_url with its owner/repo segment canonicalized to
# LOWERCASE ("usc/uconnect"). This is a legitimate, unforged provider response —
# not a spoof — so `_belongs` must accept it (case-insensitive slug compare)
# while still requiring the origin (scheme+host+port) and the rest of the path
# to match in full. Pre-#875-fix this fails closed on a real success
# (false-negative); post-fix it verifies.
run_review comment-mixed-case-slug comment durable-body https://git.mosaicstack.dev \
    https://git.mosaicstack.dev/USC/uconnect.git USC/uconnect
grep -q 'Added and verified comment on Gitea PR #123' "$OUTPUT_FILE"
assert_no_temp_leak "comment-mixed-case-slug"

# Case 16 (#865 ITEM 1, current-head TOCTOU): the PR head advances between the
# pre-submit head read (which pins the review) and the post-verify re-read. The
# review is genuinely created and verified as pinned to the OLD head, but the
# live tip has moved on, so the wrapper must FAIL CLOSED rather than report a
# review that no longer covers the PR's current commit.
if run_review head-advanced-race approve; then
    echo "FAIL: approve reported success though the PR head advanced after submit" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: current-head TOCTOU close-out did not fail closed on an advanced head" >&2
    exit 1
fi
# The head was re-read after the submit/verify (2nd GET /pulls/123).
if [[ "$(grep -c '^GET https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls/123$' "$CURL_LOG")" -lt 2 ]]; then
    echo "FAIL: wrapper did not re-read the PR head after review verification" >&2
    cat "$CURL_LOG" >&2
    exit 1
fi
assert_no_temp_leak "head-advanced-race"

# Case 17 (#865 ITEM 2): a claimed PR comment that actually lands as a plain
# ISSUE comment (issue #123 exists, PR #123 does not — issue_url set,
# pull_request_url empty) must FAIL CLOSED. The pr-review `comment` verifier
# requires a pull_request_url (kind=pulls) and rejects a bare issue_url.
if run_review comment-plain-issue comment durable-body; then
    echo "FAIL: pr-review accepted a plain issue comment as a verified PR comment" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
    echo "FAIL: a plain issue_url satisfied the PR comment verifier" >&2
    exit 1
fi
assert_no_temp_leak "comment-plain-issue"

# Case 18 (#865 ITEM 3b): a non-empty review body submitted but persisted as null
# must FAIL CLOSED. id/author/state/head all match; only strict presence +
# string-type + exact body equality (not the old `(body or "")` coalesce) catches
# the lost body.
if run_review review-body-null approve real-submitted-review-body; then
    echo "FAIL: review whose non-empty body persisted as null was accepted" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q 'Approved and verified' "$OUTPUT_FILE"; then
    echo "FAIL: a null persisted body passed strict review-body verification" >&2
    exit 1
fi
assert_no_temp_leak "review-body-null"

# Case 19 (#1004): an outright server REFUSAL must report the provider's own
# reason and must NOT be relabelled as the #865 silent-no-op defect class. The
# old arm hardcoded "(#865: no durable review created)" for EVERY non-2xx, so a
# 422/403/404 — all of them definite, correct refusals the server explained in
# the discarded body — arrived at the caller wearing the name of the one defect
# they are not. That misdirection is what makes an operator re-issue the request
# by hand against the live object to find out what actually happened.
if run_review review-refused-422 approve; then
    echo "FAIL: approve reported success when the server refused the submit" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
grep -q 'HTTP 422' "$OUTPUT_FILE"
if ! grep -q 'Cannot approve your own pull request' "$OUTPUT_FILE"; then
    echo "FAIL: the provider's stated reason was discarded (#1004)" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
if grep -q '#865: no durable review created' "$OUTPUT_FILE"; then
    echo "FAIL: a server refusal was misattributed to the #865 defect class (#1004)" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
assert_no_temp_leak "review-refused-422"

# Case 20 (#1004): a non-JSON error body (a fronting proxy's HTML page) must
# still yield something the caller can act on, rather than a bare status code.
if run_review review-refused-html approve; then
    echo "FAIL: approve reported success on a 502" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
grep -q 'HTTP 502' "$OUTPUT_FILE"
if ! grep -q '502 Bad Gateway' "$OUTPUT_FILE"; then
    echo "FAIL: a non-JSON error body was dropped instead of degrading to its first line (#1004)" >&2
    cat "$OUTPUT_FILE" >&2
    exit 1
fi
assert_no_temp_leak "review-refused-html"

echo "pr-review.sh REST review + comment create/read-back regression passed"
