All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
CI-red root cause (classification a: my round-4 change fails in the clean/cold CI env): get_gitea_token_for_login hard-required PyYAML (`import yaml`), which is absent on CI's node:24-alpine (python3 without py3-yaml). Round-4's --login override cases were the first to exercise that path, turning the mosaic package test (test:framework-shell -> test-pr-review-gitea-comment.sh) RED. Fix: add an indentation-aware line-parser fallback that resolves the SAME per-name token PyYAML would from tea's flat `logins:` list; PyYAML stays the fast path. This also repairs a latent production defect (--login overrides were silently unusable on any PyYAML-less host). Auditor blockers folded into the same round-5: 1. issue_url vs pull_request_url shape (correctness): Gitea populates WEB (html) URLs in issue_url/pull_request_url, not API paths, and a PR-conversation comment carries pull_request_url (issue_url empty). Verification now accepts either web shape scoped to the repo slug + number, so a durable write is never rejected for URL shape. Test stubs now emit the REAL Gitea web shapes. 2. Cross-host credential binding (security): get_gitea_token_for_login now takes the repo host and requires the matched login's configured URL host to equal it; an override login configured for a different host FAILS CLOSED instead of sending a cross-host credential. Regression tests added to both suites. 3. Non-exhaustive enumeration (false-fail): removed the redundant, non-exhaustive post-verification list enumeration (gitea_fetch_all + confirm_*_enumerable) from both wrappers; the exact-id GET is authoritative. Pagination cases dropped; a guard asserts no list enumeration is performed. 4. Trap clobbering / temp-file leak (security/hygiene): removing the nested enumeration eliminates the RETURN-trap nesting that clobbered caller cleanup; remaining RETURN traps are single/non-nested and clean up on all exit paths. Temp-file leak regression tests (success + failure paths) added to both suites. 5. README: corrected the exhaustive-pagination claim and documented host-bound --login selection. Preserves every round-2/3/4 fix (explicit --login fail-closed at all write sites, token->identity attribution seam). Gates: cold `pnpm turbo run test --filter=@mosaicstack/mosaic` green (14/14); full test-*.sh suite green with AND without PyYAML; bash -n, shellcheck -x -S warning, prettier --check README clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
697 lines
29 KiB
Bash
697 lines
29 KiB
Bash
#!/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"
|
|
TEA_LOG="$WORK_DIR/tea.log"
|
|
CURL_LOG="$WORK_DIR/curl.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
|
|
|
|
output_file=""
|
|
method="GET"
|
|
payload=""
|
|
url=""
|
|
auth_token=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-o) output_file="$2"; shift 2 ;;
|
|
-H)
|
|
[[ "$2" == Authorization:* ]] && auth_token="${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
|
|
|
|
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
|
|
print(json.dumps({"head": {"sha": os.environ["PR_REVIEW_HEAD_SHA"]}}))
|
|
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)
|
|
|
|
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" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
state_path = os.environ["PR_REVIEW_COMMENTS"]
|
|
acting = os.environ["PR_REVIEW_ACTING_LOGIN"]
|
|
web_base = os.environ["PR_REVIEW_WEB_BASE"]
|
|
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.
|
|
record = {
|
|
"id": 456,
|
|
"body": body,
|
|
"user": {"login": acting},
|
|
"issue_url": "",
|
|
"pull_request_url": f"{web_base}/pulls/123",
|
|
}
|
|
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"
|
|
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"
|
|
: > "$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_AUTH_LOG="$AUTH_LOG" \
|
|
PR_REVIEW_REVIEWS="$REVIEWS_FILE" \
|
|
PR_REVIEW_COMMENTS="$COMMENTS_FILE" \
|
|
PR_REVIEW_SUBMIT_PAYLOAD="$SUBMIT_PAYLOAD_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
|
|
leaked=$(find "$TMP_SCRATCH" -type f -name 'mosaic-pr-review-*' 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_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"
|
|
# 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
|
|
|
|
# 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"
|
|
|
|
echo "pr-review.sh REST review + comment create/read-back regression passed"
|