Files
stack/packages/mosaic/framework/tools/git/test-issue-comment-readback.sh
Hermes Agent 2bb3ac4549
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
fix(git-tools): env-robust token parse, host-bound creds, real Gitea URL verify (#865 round-5)
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>
2026-07-21 20:49:48 -05:00

495 lines
20 KiB
Bash
Executable File

#!/usr/bin/env bash
# Regression harness for issue-comment.sh's Gitea comment write + verification
# (#865).
#
# The #865 defect class: tea 0.11.1's `tea issue comment ...` (a nonexistent
# subcommand) silently no-ops yet exits 0, and tea cannot emit the id of a
# record it created — so an exit code is worthless as proof of a durable write.
# The wrapper therefore does NOT write via tea at all. It POSTs the comment to
# the Gitea REST API (which returns the created comment object, including its
# id), then GETs THAT EXACT id back and requires it to match on id, author
# (acting identity), body, and issue. Because verification is keyed to the id
# the create returned, no concurrent comment can masquerade as this write, and a
# suppressed/no-op create yields no id and fails closed.
#
# This harness models a REAL server: the curl stub keeps persistent comment
# state on disk, the POST actually CREATES and PERSISTS a record and returns its
# id, and the read-back GET reads that same state. There is no independently
# fabricated record for the wrapper to "find" — the only way verification
# passes is if the POST genuinely created the record the read-back retrieves.
# It proves the wrapper:
# 1. never shells out to tea to write (no `tea comment` / `tea issue comment`);
# 2. creates the comment via REST POST and learns the provider-returned id;
# 3. verifies THAT EXACT id by direct GET, attributed to the acting identity;
# 4. fails closed when the write is a no-op even though a concurrent
# SAME-IDENTITY comment with the same body already exists (the closed
# concurrency window — no fallback list scan can rescue a no-op);
# 5. fails closed when the created record is not authored by the acting
# identity;
# 6. treats the exact-id GET as the SOLE authority — it performs NO follow-up
# list enumeration (the stub exposes no comment-list endpoint, so any
# residual enumeration attempt would fail the run);
# 7. with a RESOLVABLE --login override, performs the write, the /user identity
# lookup, and the read-back ALL under THAT login's token/identity — never
# the host default;
# 8. with an UNRESOLVABLE --login override, FAILS CLOSED (nonzero, no write, no
# success line) instead of silently downgrading to the host default
# identity — the token seam maps each bearer token to the identity it
# authenticates as, so a misattributed write is caught;
# 9. with a --login override whose tea config URL is a DIFFERENT host than the
# repo remote, FAILS CLOSED (host-bound token selection) rather than sending
# that other host's credential cross-host;
# 10. leaves NO temp files behind (POST/GET bodies + metadata) on either the
# success or the failure path — nested function-scoped RETURN traps do not
# clobber each other and every scratch file is removed on all exit paths.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-comment-readback}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
XDG_DIR="$WORK_DIR/xdg"
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"
STATE_FILE="$WORK_DIR/comments.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
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR" "$TMP_SCRATCH"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
ISSUE_NUMBER=7
REPO_SLUG="mosaicstack/stack"
API_BASE="https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack"
API_ROOT="https://git.mosaicstack.dev/api/v1"
BODY='durable "note" -- marker'
ACTING_LOGIN="primary-reviewer"
FOREIGN_LOGIN="other-writer"
# A dedicated per-role --login override identity, with its own token stored in
# tea's config (exactly the author-not-equal-reviewer hardening path).
OVERRIDE_LOGIN="delegated-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). Its token must NEVER be sent to the
# repo host: host-bound selection must fail closed on the host mismatch.
CROSS_HOST_LOGIN="foreign-host-reviewer"
CROSS_HOST_TOKEN="cross-host-token-placeholder"
# tea config: the override login has its own token here (as tea itself stores
# per-login tokens). The default login name ("mosaicstack") is deliberately NOT
# present, 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
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
import json
import os
import sys
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
json.dump({
"gitea": {
"mosaicstack": {
"url": os.environ["CONFIGURED_GITEA_URL"],
"token": "test-only-placeholder",
}
}
}, credentials)
PY
# tea stub: only ever answers the login list (used to resolve the default login
# name). It must NEVER be asked to write a comment — the wrapper writes via REST.
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >> "$ISSUE_COMMENT_TEA_LOG"
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
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 comment state.
# GET /user -> acting identity
# POST /issues/7/comments -> CREATE + PERSIST, return created object
# GET /issues/comments/{id} -> read the persisted record by exact id
# There is deliberately NO comment-LIST endpoint: exact-id read-back is the sole
# authority, so any residual list enumeration attempt hits the unexpected-request
# guard and fails the test.
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
output_file=""
method="GET"
url=""
data=""
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) data="$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" >> "$ISSUE_COMMENT_CURL_LOG"
# Map the presented bearer token to the identity it authenticates as — the same
# derivation Gitea's own /user does. The wrapper's write, /user lookup, and
# read-back must all carry the SAME token, so the acting identity recorded here
# reveals which credential actually performed the request.
acting_identity=""
case "$auth_token" in
"$ISSUE_COMMENT_DEFAULT_TOKEN") acting_identity="$ISSUE_COMMENT_ACTING_LOGIN" ;;
"$ISSUE_COMMENT_OVERRIDE_TOKEN") acting_identity="$ISSUE_COMMENT_OVERRIDE_LOGIN" ;;
"$ISSUE_COMMENT_CROSS_HOST_TOKEN") acting_identity="$ISSUE_COMMENT_CROSS_HOST_LOGIN" ;;
esac
printf '%s %s %s\n' "$method" "$path" "${acting_identity:-<unauthenticated>}" >> "$ISSUE_COMMENT_AUTH_LOG"
write_response() {
local status="$1" body="$2"
[[ -n "$output_file" ]] || exit 96
printf '%s' "$body" > "$output_file"
printf '%s' "$status"
}
if [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_ROOT/user" ]]; then
[[ -n "$acting_identity" ]] || { write_response 401 '{"message":"unauthenticated"}'; exit 0; }
write_response 200 "$(ISSUE_COMMENT_LOGIN="$acting_identity" python3 - <<'PY'
import json
import os
print(json.dumps({"login": os.environ["ISSUE_COMMENT_LOGIN"]}))
PY
)"
elif [[ "$method" == "POST" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
result=$(ISSUE_COMMENT_ACTING_LOGIN="${acting_identity:-$ISSUE_COMMENT_ACTING_LOGIN}" ISSUE_COMMENT_DATA="$data" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
mode = os.environ["ISSUE_COMMENT_TEST_MODE"]
acting = os.environ["ISSUE_COMMENT_ACTING_LOGIN"]
foreign = os.environ["ISSUE_COMMENT_FOREIGN_LOGIN"]
repo = os.environ["ISSUE_COMMENT_REPO_SLUG"]
body = json.loads(os.environ["ISSUE_COMMENT_DATA"]).get("body")
with open(state_path, encoding="utf-8") as handle:
comments = json.load(handle)
# no-op-concurrent: the wrapper's own write is SUPPRESSED (returns 200 with no
# created object) even though a concurrent same-identity comment already exists
# in state. Nothing is persisted; there is no created id to verify.
if mode == "no-op-concurrent":
print("200")
print(json.dumps({}))
raise SystemExit(0)
author = foreign if mode == "author-mismatch" else acting
new_id = (max((c["id"] for c in comments), default=0)) + 1
record = {
"id": new_id,
"body": body,
"user": {"login": author},
# REAL Gitea comment shape: issue_url is the WEB (html) path, not an API
# path, and a plain issue comment leaves pull_request_url empty.
"issue_url": f"https://git.mosaicstack.dev/{repo}/issues/7",
"pull_request_url": "",
}
comments.append(record)
with open(state_path, "w", encoding="utf-8") as handle:
json.dump(comments, handle)
print("201")
print(json.dumps(record))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE"/issues/comments/* ]]; then
result=$(ISSUE_COMMENT_GET_ID="${path##*/}" python3 - <<'PY'
import json
import os
state_path = os.environ["ISSUE_COMMENT_STATE"]
wanted = int(os.environ["ISSUE_COMMENT_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"}))
else:
print("200")
print(json.dumps(match))
PY
)
write_response "$(printf '%s' "$result" | head -n1)" "$(printf '%s' "$result" | tail -n +2)"
else
echo "Unexpected curl request: $method $url" >&2
exit 97
fi
SH
chmod +x "$BIN_DIR/curl"
# Seed persistent server state for a mode, then run the wrapper against it.
seed_state() {
local mode="$1"
ISSUE_COMMENT_SEED_MODE="$mode" ISSUE_COMMENT_SEED_BODY="$BODY" \
ISSUE_COMMENT_SEED_ACTING="$ACTING_LOGIN" ISSUE_COMMENT_SEED_REPO="$REPO_SLUG" \
python3 - "$STATE_FILE" <<'PY'
import json
import os
import sys
mode = os.environ["ISSUE_COMMENT_SEED_MODE"]
body = os.environ["ISSUE_COMMENT_SEED_BODY"]
acting = os.environ["ISSUE_COMMENT_SEED_ACTING"]
repo = os.environ["ISSUE_COMMENT_SEED_REPO"]
# REAL Gitea comment shape: issue_url is the WEB path, pull_request_url empty.
issue_url = f"https://git.mosaicstack.dev/{repo}/issues/7"
def comment(cid, text, author):
return {
"id": cid,
"body": text,
"user": {"login": author},
"issue_url": issue_url,
"pull_request_url": "",
}
if mode == "fresh-success":
# 50 pre-existing comments already exist; the comment this run creates
# becomes id 51, proving exact-id read-back works regardless of how many
# comments precede it (no list enumeration is involved).
comments = [comment(i, f"prior {i}", acting) for i in range(1, 51)]
elif mode == "no-op-concurrent":
# A concurrent SAME-IDENTITY comment with the IDENTICAL body already exists.
# The wrapper's own write will be a no-op; it must still fail closed because
# no created id is returned — it must not scan and accept this record.
comments = [comment(55, body, acting)]
else: # author-mismatch
comments = []
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(comments, handle)
PY
}
run_comment() {
local mode="$1"
shift
: > "$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" \
ISSUE_COMMENT_TEA_LOG="$TEA_LOG" \
ISSUE_COMMENT_CURL_LOG="$CURL_LOG" \
ISSUE_COMMENT_AUTH_LOG="$AUTH_LOG" \
ISSUE_COMMENT_STATE="$STATE_FILE" \
ISSUE_COMMENT_TEST_MODE="$mode" \
ISSUE_COMMENT_ACTING_LOGIN="$ACTING_LOGIN" \
ISSUE_COMMENT_FOREIGN_LOGIN="$FOREIGN_LOGIN" \
ISSUE_COMMENT_OVERRIDE_LOGIN="$OVERRIDE_LOGIN" \
ISSUE_COMMENT_CROSS_HOST_LOGIN="$CROSS_HOST_LOGIN" \
ISSUE_COMMENT_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_TOKEN" \
ISSUE_COMMENT_CROSS_HOST_TOKEN="$CROSS_HOST_TOKEN" \
ISSUE_COMMENT_REPO_SLUG="$REPO_SLUG" \
ISSUE_COMMENT_API_BASE="$API_BASE" \
ISSUE_COMMENT_API_ROOT="$API_ROOT" \
"$SCRIPT_DIR/issue-comment.sh" -i "$ISSUE_NUMBER" -c "$BODY" "$@"
) > "$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-issue-comment-*' 2>/dev/null || true)
if [[ -n "$leaked" ]]; then
echo "FAIL: issue-comment temp files leaked ($context):" >&2
printf '%s\n' "$leaked" >&2
exit 1
fi
}
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
# provider-returned id — no list enumeration is involved.
run_comment fresh-success
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 51)' "$OUTPUT_FILE"
# The write is a REST POST, never a tea comment.
grep -q "^POST $API_BASE/issues/7/comments$" "$CURL_LOG"
if grep -Eq '^comment |^issue comment ' "$TEA_LOG"; then
echo "FAIL: wrapper wrote a comment via tea instead of REST" >&2
exit 1
fi
# Read-back is a DIRECT GET of the exact created id.
grep -q "^GET $API_BASE/issues/comments/51$" "$CURL_LOG"
# Acting identity resolved via GET /user.
grep -q "^GET $API_ROOT/user$" "$CURL_LOG"
# No comment-list enumeration is performed — the exact-id GET is authoritative.
if grep -Eq "^GET $API_BASE/issues/7/comments(\?|$)" "$CURL_LOG"; then
echo "FAIL: wrapper performed a redundant comment-list enumeration" >&2
exit 1
fi
# Default path (no --login): the host credential fallback resolves, and the
# write is performed AND self-verified under the host-default acting identity.
grep -q "^POST $API_BASE/issues/7/comments $ACTING_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/51 $ACTING_LOGIN$" "$AUTH_LOG"
# Success path leaves no scratch temp files behind.
assert_no_temp_leak "fresh-success"
# Case 2: a no-op write with a concurrent SAME-IDENTITY, same-body comment
# already present must FAIL CLOSED — the closed concurrency window.
if run_comment no-op-concurrent; then
echo "FAIL: wrapper reported success when its write no-opped but a concurrent same-identity comment existed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: wrapper accepted a concurrent record for a no-op write (window not closed)" >&2
exit 1
fi
# It must NOT have fallen back to a list scan that could find the concurrent id.
if grep -q "^GET $API_BASE/issues/comments/55$" "$CURL_LOG"; then
echo "FAIL: wrapper read back the concurrent comment id 55 (illegitimate fallback)" >&2
exit 1
fi
# Case 3: a created record NOT authored by the acting identity must FAIL CLOSED.
if run_comment author-mismatch; then
echo "FAIL: wrapper accepted a created comment authored by a different identity" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$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
# (proves the RETURN traps clean up on the error-return route, not just success).
assert_no_temp_leak "author-mismatch"
# Case 4: a RESOLVABLE --login override — the write, the /user identity lookup,
# and the read-back must ALL be performed under THAT login's token/identity, not
# the host default. The override login has id 1 (empty seed).
run_comment override-success --login "$OVERRIDE_LOGIN"
grep -q 'Added and verified comment on Gitea issue #7 (comment ID 1)' "$OUTPUT_FILE"
grep -q "^GET $API_ROOT/user $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^POST $API_BASE/issues/7/comments $OVERRIDE_LOGIN$" "$AUTH_LOG"
grep -q "^GET $API_BASE/issues/comments/1 $OVERRIDE_LOGIN$" "$AUTH_LOG"
# The host-default identity must NOT have performed ANY request in this run.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: an explicit --login override request was performed under the host default identity" >&2
cat "$AUTH_LOG" >&2
exit 1
fi
# Case 5: an UNRESOLVABLE --login override (name absent from tea config) must
# FAIL CLOSED — no silent downgrade to the host default identity: nonzero exit,
# no success line, and NO write performed.
if run_comment override-unresolvable --login "nonexistent-typo-login"; then
echo "FAIL: unresolvable --login override did not fail closed" >&2
cat "$OUTPUT_FILE" >&2
exit 1
fi
if grep -q 'Added and verified comment' "$OUTPUT_FILE"; then
echo "FAIL: unresolvable --login override reported success" >&2
exit 1
fi
if grep -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: unresolvable --login override still performed a write" >&2
exit 1
fi
# And it must not have silently fallen back to the host default identity.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: unresolvable --login override fell back to the host default identity" >&2
exit 1
fi
# Case 6: 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 write occurs.
if run_comment cross-host --login "$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 'Added and verified comment' "$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 -q "^POST $API_BASE/issues/7/comments" "$CURL_LOG"; then
echo "FAIL: cross-host --login override still performed a write" >&2
exit 1
fi
# It must not have silently downgraded to the host default identity either.
if grep -q " $ACTING_LOGIN\$" "$AUTH_LOG"; then
echo "FAIL: cross-host --login override fell back to the host default identity" >&2
exit 1
fi
assert_no_temp_leak "cross-host"
echo "issue-comment.sh REST create + exact-id read-back regression passed"