#!/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" 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" git -C "$REPO_DIR" init -q git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git # 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 "`. 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:-}" >> "$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" 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) # 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 # // 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("//pulls/123") test would ACCEPT it. pr_url = f"{_origin}/deceptive{_slug}/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" \ 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 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" echo "pr-review.sh REST review + comment create/read-back regression passed"