Some checks failed
ci/woodpecker/pr/ci Pipeline failed
gitea_resolve_api_for_login silently fell back to the host-default identity whenever the named login could not be resolved for ANY reason -- including when the name came from an EXPLICIT --login override. A caller passing a dedicated per-role credential could thus have its write attributed to the shared default identity while being told it succeeded as requested. Thread an "override was explicit" signal into gitea_resolve_api_for_login (second param, "explicit" when LOGIN_OVERRIDE is non-empty). When the override is explicit and that login's token cannot be resolved, FAIL CLOSED (return 1, clear error naming the login, no host-default fallback). The best-effort host-default fallback now applies ONLY on the no-override default path. Applied symmetrically to issue-comment.sh and all three pr-review.sh dispatch sites (approve / request-changes / comment). Tests: both scripts' write flows now assert credential attribution via a token->identity seam in the curl stub -- (a) resolvable --login override drives the entire write/read-back chain under THAT login's token, nothing under the default; (b) unresolvable --login override fails closed (nonzero, no success line, no write, no default-identity request); (c) no-override default path still succeeds under the host-default best-effort credential. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
424 lines
17 KiB
Bash
Executable File
424 lines
17 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. enumerates the created id in the issue's FULLY PAGINATED comment list,
|
|
# finding it even when it lands beyond page 1;
|
|
# 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.
|
|
|
|
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"
|
|
|
|
cleanup() {
|
|
rm -rf "$WORK_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
mkdir -p "$REPO_DIR" "$BIN_DIR" "$XDG_DIR"
|
|
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"
|
|
|
|
# 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.
|
|
mkdir -p "$XDG_DIR/tea"
|
|
OVERRIDE_LOGIN="$OVERRIDE_LOGIN" OVERRIDE_TOKEN="$OVERRIDE_TOKEN" python3 - "$XDG_DIR/tea/config.yml" <<'PY'
|
|
import os
|
|
import sys
|
|
|
|
with open(sys.argv[1], "w", encoding="utf-8") as handle:
|
|
handle.write("logins:\n")
|
|
handle.write(f" - name: {os.environ['OVERRIDE_LOGIN']}\n")
|
|
handle.write(" url: https://git.mosaicstack.dev\n")
|
|
handle.write(f" token: {os.environ['OVERRIDE_TOKEN']}\n")
|
|
PY
|
|
|
|
CONFIGURED_GITEA_URL="https://git.mosaicstack.dev" python3 - "$CREDENTIALS_FILE" <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
with open(sys.argv[1], "w", encoding="utf-8") as credentials:
|
|
json.dump({
|
|
"gitea": {
|
|
"mosaicstack": {
|
|
"url": os.environ["CONFIGURED_GITEA_URL"],
|
|
"token": "test-only-placeholder",
|
|
}
|
|
}
|
|
}, credentials)
|
|
PY
|
|
|
|
# tea stub: 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
|
|
# GET /issues/7/comments?page=&.. -> paginated listing of persisted state
|
|
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" ;;
|
|
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},
|
|
"issue_url": f"https://git.mosaicstack.dev/api/v1/repos/{repo}/issues/7",
|
|
}
|
|
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)"
|
|
elif [[ "$method" == "GET" && "$path" == "$ISSUE_COMMENT_API_BASE/issues/7/comments" ]]; then
|
|
result=$(ISSUE_COMMENT_QUERY="$query" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
from urllib.parse import parse_qs
|
|
|
|
state_path = os.environ["ISSUE_COMMENT_STATE"]
|
|
params = parse_qs(os.environ["ISSUE_COMMENT_QUERY"])
|
|
limit = int(params.get("limit", ["50"])[0])
|
|
page = int(params.get("page", ["1"])[0])
|
|
with open(state_path, encoding="utf-8") as handle:
|
|
comments = json.load(handle)
|
|
start = (page - 1) * limit
|
|
print("200")
|
|
print(json.dumps(comments[start:start + limit]))
|
|
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"]
|
|
issue_url = f"https://git.mosaicstack.dev/api/v1/repos/{repo}/issues/7"
|
|
|
|
if mode == "fresh-success":
|
|
# 50 pre-existing comments fill page 1 (limit 50); the comment this run
|
|
# creates becomes id 51 and lands ALONE on page 2, exercising >page-1
|
|
# pagination in the enumeration check.
|
|
comments = [
|
|
{"id": i, "body": f"prior {i}", "user": {"login": acting}, "issue_url": issue_url}
|
|
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 = [
|
|
{"id": 55, "body": body, "user": {"login": acting}, "issue_url": issue_url}
|
|
]
|
|
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" \
|
|
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_DEFAULT_TOKEN="$DEFAULT_TOKEN" \
|
|
ISSUE_COMMENT_OVERRIDE_TOKEN="$OVERRIDE_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
|
|
}
|
|
|
|
# Case 1: a genuine REST create (id 51) is verified end to end via its exact
|
|
# provider-returned id and enumerated on page 2 of the paginated listing.
|
|
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"
|
|
# Enumeration paginated beyond page 1 to find the created comment.
|
|
grep -q "^GET $API_BASE/issues/7/comments?limit=[0-9]*&page=2$" "$CURL_LOG"
|
|
# 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"
|
|
|
|
# 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
|
|
|
|
# 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
|
|
|
|
echo "issue-comment.sh REST create + exact-id read-back regression passed"
|