git-trace

romele / git-trace public

62 commits · 1 branch · 1 tag · 1 contributor (top: Romele) · first commit 3d ago · last push 12m ago

tests/e2e.sh

#!/usr/bin/env bash
# git-trace end-to-end test harness.
# Runs from the host against a throwaway container whose data dir is
# fresh on every run.  Usage:
#   make && bash tests/e2e.sh            (from repo root)
# env: IMG (image), CT (container name), PORT (host port)
set -u

IMG=${IMG:-git-trace-dev}
CT=${CT:-git-trace-test}
PORT=${PORT:-8191}
BASE="http://localhost:$PORT"
SRC=$(cd "$(dirname "$0")/.." && (pwd -W 2>/dev/null || pwd))
# NB: docker.exe needs Windows paths; curl/git are fine either way.
# Do NOT export MSYS_NO_PATHCONV globally — it would break the native
# curl/git binaries' argument path translation.

pass=0; fail=0
ok()  { pass=$((pass+1)); echo "  ok   $1"; }
bad() { fail=$((fail+1)); echo "  FAIL $1 ($2)"; }
ck()  { [ "$2" = "$3" ] && ok "$1" || bad "$1" "want '$2' got '$3'"; }
has() { grep -q "$2" "$3" 2>/dev/null && ok "$1" ||
        { bad "$1" "'$2' not found"; tail -c 400 "$3" 2>/dev/null | tr '\n' ' '; echo; }; }
nth() { grep -q "$2" "$3" 2>/dev/null && bad "$1" "'$2' found" || ok "$1"; }

echo "== build =="
MSYS_NO_PATHCONV=1 docker run --rm -v "$SRC:/src" -w /src "$IMG" make >/dev/null 2>&1 \
    && ok build || { bad build "make failed"; exit 1; }

echo "== container =="
MSYS_NO_PATHCONV=1 docker rm -f "$CT" >/dev/null 2>&1
MSYS_NO_PATHCONV=1 docker run -d --name "$CT" -p "$PORT:8081" \
    -e GIT_TRACE_RL_MAXFAIL=4 -e GIT_TRACE_RL_WINDOW=120 \
    -e GIT_TRACE_OPEN_REG=1 \
    -e GIT_TRACE_TIER_GATE=1 \
    -e GIT_TRACE_SSH_URL=ssh://git@localhost:2222 \
    -e GIT_TRACE_BASE_URL=http://alt.example:9999 \
    -v "$SRC:/src" -w /srv "$IMG" sh -c '
        mkdir -p /srv/repos /srv/var
        export GIT_TRACE_REPOS=/srv/repos GIT_TRACE_DB=/srv/var/gt.db
        echo secretpw1 | /src/git-trace useradd --email=alice@e2e.test alice
        echo secretpw2 | /src/git-trace useradd bob
        echo secretpw0 | /src/git-trace useradd --admin root
        /src/git-trace tier alice pro never
        /src/git-trace mkrepo alice demo "public repo"
        /src/git-trace mkrepo --private alice priv
        /src/git-trace mkrepo bob work
        exec /src/git-trace serve -p 8081' >/dev/null || { bad container "docker run"; exit 1; }
sleep 1

T=$(mktemp -d)
if [ "${KEEP:-0}" = 1 ]; then
    trap 'rm -rf "$T"; echo "container kept: docker rm -f $CT"' EXIT
else
    trap 'rm -rf "$T"; MSYS_NO_PATHCONV=1 docker rm -f "$CT" >/dev/null 2>&1' EXIT
fi

echo "== anonymous access =="
OUT=$T/idx; curl -sS -o $OUT -w '%{http_code}' $BASE/ > $T/code; ck "index 200" 200 "$(cat $T/code)"

has "public repo listed"  "alice/demo" $OUT
nth "private repo hidden" "alice/priv" $OUT
# landing: brand hero, version badge, anon call-to-action
has "landing hero"        "trace every change"    $OUT
has "version badge"       "v1.0"                  $OUT
has "landing login cta"   "/login"                $OUT
has "landing reg cta"     "/register"             $OUT
has "landing logo svg"    "<svg"                  $OUT
curl -s -o /dev/null -w '%{http_code}' "$BASE/alice/demo.git/info/refs?service=git-upload-pack" > $T/code
ck "anon fetch advert" 200 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' "$BASE/alice/priv.git/info/refs?service=git-upload-pack" > $T/code
ck "anon priv fetch -> 401" 401 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/alice/demo.git/git-receive-pack" > $T/code
ck "anon push -> 401" 401 "$(cat $T/code)"

echo "== real clone/push =="
git clone -q "$BASE/alice/demo.git" $T/demo 2>/dev/null
cd $T/demo && git -c user.name=t -c user.email=t@t commit -q --allow-empty -m seed && cd - >/dev/null
git -C $T/demo push -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master 2>/dev/null
git ls-remote "$BASE/alice/demo.git" refs/heads/master > $T/lr
has "alice push lands" "refs/heads/master" $T/lr
curl -s -o /dev/null -w '%{http_code}' -u bob:secretpw2 "$BASE/alice/priv.git/info/refs?service=git-upload-pack" > $T/code
ck "bob no-ACL priv -> 403" 403 "$(cat $T/code)"
git clone -q "http://alice:secretpw1@localhost:$PORT/alice/priv.git" $T/priv 2>/dev/null
[ -d $T/priv ] && ok "alice clones priv" || bad "alice clones priv" "clone failed"

echo "== shallow negotiation =="
# Regression: git deflates RPC request bodies >~1KB and marks them
# Content-Encoding: deflate.  Without HTTP_CONTENT_ENCODING forwarded to
# git http-backend the compressed bytes were parsed as pkt-lines and the
# fetch died with "expected 'acknowledgments'".  A shallow pull on a repo
# with enough commits sends 30+ haves, crossing the threshold.
git clone -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" $T/deep 2>/dev/null
for i in $(seq 1 45); do
    git -C $T/deep -c user.name=t -c user.email=t@t commit -qm "d$i" --allow-empty
done
git -C $T/deep push -q origin master 2>/dev/null
git clone -q --depth 30 "$BASE/alice/demo.git" $T/shallow 2>/dev/null
[ -d $T/shallow ] && ok "shallow clone" || bad "shallow clone" "clone failed"
git -C $T/deep -c user.name=t -c user.email=t@t commit -qm newest --allow-empty
git -C $T/deep push -q origin master 2>/dev/null
git -C $T/shallow pull -q 2>$T/pullerr && ok "shallow pull negotiates" \
    || bad "shallow pull negotiates" "$(tail -1 $T/pullerr)"
git -C $T/shallow log --oneline 2>/dev/null | grep -q newest \
    && ok "shallow pull applied" || bad "shallow pull applied" "newest missing"
git -C $T/shallow fetch -q --depth 10 2>$T/fetcherr && ok "shallow fetch --depth" \
    || bad "shallow fetch --depth" "$(tail -1 $T/fetcherr)"

echo "== web session + CSRF =="
curl -s -X POST -d "name=alice&pass=wrongpw" $BASE/login > $T/login.html
has "wrong pw -> error" "invalid credentials" $T/login.html
CK=$(curl -s -D- -o /dev/null -X POST -d "name=alice&pass=secretpw1" $BASE/login \
     | grep -oi 'gt_session=[0-9a-f]*' | head -1 | cut -d= -f2)
[ -n "$CK" ] && ok "session cookie" || bad "session cookie" "none"
curl -s -b "gt_session=$CK" $BASE/ > $T/idx2
has "logged-in index shows priv" "alice/priv" $T/idx2
has "logged-in dashboard"      "dashboard"   $T/idx2
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" -X POST \
    -d "action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "POST w/o csrf -> 403" 403 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Origin: http://evil.example" -X POST \
    -d "csrf=$CK&action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "foreign Origin -> 403" 403 "$(cat $T/code)"
# reverse-proxy deployments: the configured public base URL is a
# valid Origin even when Host differs
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Origin: http://alt.example:9999" -X POST \
    -d "csrf=$CK&action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "base-url Origin allowed" 302 "$(cat $T/code)"
# dev ergonomics: a loopback-facing daemon accepts loopback origins
# on ANY port (IDE previews proxy 127.0.0.1:X -> localhost:8081)
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Origin: http://127.0.0.1:60363" -X POST \
    -d "csrf=$CK&action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "loopback Origin allowed" 302 "$(cat $T/code)"
# ...but a foreign-domain origin is still refused even on loopback
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Origin: http://evil.example" -X POST \
    -d "csrf=$CK&action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "foreign domain still 403" 403 "$(cat $T/code)"
# sandboxed embedders (IDE previews) send Origin: null — accepted on
# loopback hosts; session routes are still guarded by the CSRF token
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Origin: null" -X POST \
    -d "csrf=$CK&action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "null origin on loopback"      302 "$(cat $T/code)"
# ...but without the CSRF token a forged POST still dies at the form gate
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Origin: null" -X POST \
    -d "action=comment&body=x" $BASE/alice/demo/issues/1 > $T/code
ck "null origin + no csrf -> 403" 403 "$(cat $T/code)"

echo "== issues =="
curl -s -o /dev/null -w '%{redirect_url}' -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&title=e2e bug&body=body" $BASE/alice/demo/issues/new > $T/loc
has "issue created" "/issues/1" $T/loc
curl -s -b "gt_session=$CK" $BASE/alice/demo/issues > $T/il
has "issue listed" "e2e bug" $T/il
curl -s -o /dev/null -b "gt_session=$CK" -X POST -d "csrf=$CK&action=comment&body=worksforme" $BASE/alice/demo/issues/1
curl -s -o /dev/null -b "gt_session=$CK" -X POST -d "csrf=$CK&action=close" $BASE/alice/demo/issues/1
curl -s -b "gt_session=$CK" $BASE/alice/demo/issues/1 > $T/i1
has "comment visible" "worksforme" $T/i1
has "issue closed"   "closed"     $T/i1

echo "== XSS / output escaping =="
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    --data-urlencode "csrf=$CK" \
    --data-urlencode 'title=<script>alert(1)</script>' \
    --data-urlencode 'body=<img src=x onerror=alert(2)>' \
    $BASE/alice/demo/issues/new
curl -s -b "gt_session=$CK" $BASE/alice/demo/issues/2 > $T/i2
has "title escaped"     "script&gt;alert" $T/i2
nth "no raw <script>"   "<script>alert"   $T/i2
has "body escaped"      "&lt;img"         $T/i2
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    --data-urlencode "csrf=$CK" --data-urlencode "action=comment" \
    --data-urlencode 'body=<b onmouseover=alert(3)>x</b>' \
    $BASE/alice/demo/issues/2
curl -s -b "gt_session=$CK" $BASE/alice/demo/issues/2 > $T/i2b
has "comment escaped"   "&lt;b onmouseover" $T/i2b
nth "no raw attr"       "<b onmouseover"    $T/i2b

echo "== merge request =="
git -C $T/demo checkout -qb feat && echo x > $T/demo/feat.txt \
    && git -C $T/demo add feat.txt \
    && git -C $T/demo -c user.name=t -c user.email=t@t commit -qm feat \
    && git -C $T/demo push -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" feat 2>/dev/null
curl -s -o /dev/null -w '%{redirect_url}' -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&title=feat mr&src=feat&dst=master" $BASE/alice/demo/mr/new > $T/loc
has "mr created" "/mr/1" $T/loc
curl -s -o /dev/null -b "gt_session=$CK" -X POST -d "csrf=$CK&action=merge" $BASE/alice/demo/mr/1
git ls-remote "$BASE/alice/demo.git" refs/heads/master | cut -c1-8 > $T/m1
git -C $T/demo fetch -q origin 2>/dev/null || git -C $T/demo remote add origin "http://alice:secretpw1@localhost:$PORT/alice/demo.git"
sleep 0.2; git -C $T/demo fetch -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master:refs/remotes/t/m 2>/dev/null
git -C $T/demo log --format='%s' -1 $(cat $T/m1) 2>/dev/null > $T/ml || true
curl -s -b "gt_session=$CK" $BASE/alice/demo/mr/1 > $T/mr
has "mr merged state" "merged" $T/mr

echo "== patch sets (GNU flow) =="
git -C $T/demo checkout -qb ps1 HEAD~1 && echo y > $T/demo/ps.txt \
    && git -C $T/demo add ps.txt \
    && git -C $T/demo -c user.name="P Atch" -c user.email=p@t commit -qm "add ps file" \
    && git -C $T/demo format-patch --stdout HEAD~1 > $T/series.mbox
curl -s -u alice:secretpw1 -H "Content-Type: text/plain" \
    --data-binary @$T/series.mbox -X POST "$BASE/alice/demo/patches/new" > $T/ps
has "patch set submitted via API" "patch set #1" $T/ps
curl -s -o /dev/null -w '%{http_code}' -H "Content-Type: text/plain" \
    --data-binary @$T/series.mbox -X POST "$BASE/alice/demo/patches/new" > $T/code
ck "anon patch submit -> 401" 401 "$(cat $T/code)"
curl -s -b "gt_session=$CK" -X POST -d "csrf=$CK&action=check" $BASE/alice/demo/patches/1 > $T/pc
has "series applies cleanly" "applies cleanly" $T/pc
curl -s -o /dev/null -b "gt_session=$CK" -X POST -d "csrf=$CK&action=apply" $BASE/alice/demo/patches/1
curl -s -b "gt_session=$CK" $BASE/alice/demo/patches/1 > $T/pv
has "patch set applied" "applied" $T/pv

echo "== convergence: MR <-> patch set =="
# web MR -> mbox -> `git am` round-trip
git -C $T/demo fetch -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master
git -C $T/demo checkout -qb feat2 FETCH_HEAD
echo z > $T/demo/feat2.txt && git -C $T/demo add feat2.txt \
    && git -C $T/demo -c user.name=t -c user.email=t@t commit -qm "add feat2" \
    && git -C $T/demo push -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" feat2
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&title=second mr&src=feat2&dst=master" $BASE/alice/demo/mr/new
curl -s -u alice:secretpw1 "$BASE/alice/demo/mr/2.patch" > $T/mr2.patch
grep -q "^From [0-9a-f]\{40\}" $T/mr2.patch && ok "mr -> mbox" || bad "mr -> mbox" "no From line"
has "mbox has subject" "add feat2" $T/mr2.patch
git clone -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" $T/rt 2>/dev/null
git -C $T/rt -c user.name=t -c user.email=t@t am -q --keep-non-patch $T/mr2.patch
[ -f $T/rt/feat2.txt ] && ok "mbox round-trips via git am" || bad "mbox round-trip" "file missing after am"

# patch set -> materialize as MR (fresh series: ps.txt already landed)
git -C $T/demo checkout -qb ps2 FETCH_HEAD
echo w > $T/demo/ps2.txt && git -C $T/demo add ps2.txt \
    && git -C $T/demo -c user.name="P Atch" -c user.email=p@t commit -qm "add ps2 file"
git -C $T/demo format-patch --stdout HEAD~1 > $T/series2.mbox
curl -s -u alice:secretpw1 -H "Content-Type: text/plain" \
    --data-binary @$T/series2.mbox -X POST "$BASE/alice/demo/patches/new" > $T/ps2
has "second patch set" "patch set #2" $T/ps2
curl -s -o /dev/null -w '%{redirect_url}' -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=materialize" $BASE/alice/demo/patches/2 > $T/mloc
has "materialized to MR" "/mr/" $T/mloc
MRID=$(grep -o '[0-9]*' <<<"$(cat $T/mloc)" | tail -1)
curl -s -b "gt_session=$CK" $BASE/alice/demo/mr/$MRID > $T/mrv
has "MR back-link to patch set" "patch set" $T/mrv
curl -s -b "gt_session=$CK" $BASE/alice/demo/patches/2 > $T/pv2
has "patch-set link to MR" "merge request" $T/pv2
curl -s -o /dev/null -b "gt_session=$CK" -X POST -d "csrf=$CK&action=merge" $BASE/alice/demo/mr/$MRID
curl -s -b "gt_session=$CK" $BASE/alice/demo/mr/$MRID > $T/mrm
has "materialized MR merges" "merged" $T/mrm

echo "== patch-set revisions (v2/v3) =="
# bob (who cannot push master directly) iterates a series
git -C $T/demo checkout -qb revb FETCH_HEAD 2>/dev/null \
    || git -C $T/demo checkout -qb revb
echo one > $T/demo/rev.txt && git -C $T/demo add rev.txt \
    && git -C $T/demo -c user.name=bob -c user.email=b@t \
       commit -qm "rev series"
git -C $T/demo format-patch --stdout HEAD~1 > $T/rev1.mbox
curl -s -u bob:secretpw2 -H "Content-Type: text/plain" \
    --data-binary @$T/rev1.mbox -X POST \
    "$BASE/alice/demo/patches/new" > $T/r1
has "v1 creates new set" "patch set #" $T/r1
SID=$(grep -oE '#[0-9]+' $T/r1 | head -1 | tr -d '#')
# v2: same subject + [PATCH v2] tag -> auto-attaches to the open set
echo two > $T/demo/rev.txt && git -C $T/demo add rev.txt \
    && git -C $T/demo -c user.name=bob -c user.email=b@t \
       commit -q --amend -m "rev series"
git -C $T/demo format-patch --stdout -v2 HEAD~1 > $T/rev2.mbox
curl -s -u bob:secretpw2 -H "Content-Type: text/plain" \
    --data-binary @$T/rev2.mbox -X POST \
    "$BASE/alice/demo/patches/new" > $T/r2
has "v2 auto-attaches by tag" "patch set #$SID v2" $T/r2
# v3: explicit ?set=N attach (no version tag needed)
echo three > $T/demo/rev.txt && git -C $T/demo add rev.txt \
    && git -C $T/demo -c user.name=bob -c user.email=b@t \
       commit -q --amend -m "rev series"
git -C $T/demo format-patch --stdout HEAD~1 > $T/rev3.mbox
curl -s -u bob:secretpw2 -H "Content-Type: text/plain" \
    --data-binary @$T/rev3.mbox -X POST \
    "$BASE/alice/demo/patches/new?set=$SID" > $T/r3
has "v3 explicit set attach" "patch set #$SID v3" $T/r3
# timeline UI
curl -s $BASE/alice/demo/patches/$SID > $T/psv
has "view shows v3"          ">v3<"       $T/psv
has "revision nav"           "revisions:" $T/psv
has "latest diff is v3"      "+three"     $T/psv
curl -s "$BASE/alice/demo/patches/$SID?v=1" > $T/psv1
has "v1 shows its own diff"  "+one"       $T/psv1
nth "v1 hides latest diff"   "+three"     $T/psv1
has "old rev banner"         "latest is"  $T/psv1
curl -s -b "gt_session=$CK" $BASE/alice/demo/patches > $T/psl
has "list shows v3"          ">v3<"       $T/psl
CKBV=$(curl -s -D- -o /dev/null -X POST -d "name=bob&pass=secretpw2" \
     $BASE/login | grep -oi 'gt_session=[0-9a-f]*' | head -1 | cut -d= -f2)
curl -s -b "gt_session=$CKBV" $BASE/alice/demo/patches/$SID > $T/psvb
has "author sees new-version button" "submit new version" $T/psvb
# attach policy: non-author, closed set, missing set
curl -s -o /dev/null -w '%{http_code}' -u bob:secretpw2 \
    -H "Content-Type: text/plain" --data-binary @$T/rev1.mbox \
    -X POST "$BASE/alice/demo/patches/new?set=2" > $T/code
ck "non-author attach -> 403" 403 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -u alice:secretpw1 \
    -H "Content-Type: text/plain" --data-binary @$T/rev1.mbox \
    -X POST "$BASE/alice/demo/patches/new?set=1" > $T/code
ck "closed set attach -> 409" 409 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -u bob:secretpw2 \
    -H "Content-Type: text/plain" --data-binary @$T/rev1.mbox \
    -X POST "$BASE/alice/demo/patches/new?set=999" > $T/code
ck "missing set attach -> 404" 404 "$(cat $T/code)"
# the latest revision is the one checked/applied
curl -s -b "gt_session=$CK" -X POST -d "csrf=$CK&action=check" \
    $BASE/alice/demo/patches/$SID > $T/rc
has "v3 applies cleanly" "applies cleanly" $T/rc

echo "== mail-in endpoint (mailrecv) =="
# the MTA pipes each RFC822 message to `git-trace mailrecv` on stdin
MTOK=$(MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace mktoken alice mail \
    | grep -oE '[0-9a-f]{64}' | head -1)
gtmail() { MSYS_NO_PATHCONV=1 docker exec -i -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace mailrecv "$1" "$2"; }
# a send-email wire mail = an mbox minus the "From <sha>" separator;
# outer headers (Subject/Message-ID/token) take precedence
{ echo "Message-ID: <m1@e2e>"
  echo "X-Git-Trace-Token: $MTOK"
  echo "From: alice <alice@e2e.test>"
  echo "Date: Thu, 01 Jan 2026 00:00:00 +0000"
  sed '1{/^From /d;}' $T/rev1.mbox
} > $T/m1.eml
gtmail alice demo < $T/m1.eml > $T/mo1
has "single mail creates set" "patch set #" $T/mo1
MSID=$(grep -oE '#[0-9]+' $T/mo1 | head -1 | tr -d '#')
# auth failures
{ echo "From: Mallory <mal@evil.test>"
  echo "Subject: [PATCH] backdoor"
  echo "Message-ID: <evil@e2e>"; echo
  echo "diff --git a/x b/x"; echo "@@ -1 +1 @@"; } > $T/evil.eml
gtmail alice demo < $T/evil.eml > /dev/null 2>$T/me1
has "unauthenticated mail rejected" "unauthenticated" $T/me1
gtmail alice demo < $T/evil.eml > /dev/null 2>&1 && echo ok > $T/me2
nth "reject exit nonzero" "ok" $T/me2
printf 'garbage, not an email\n\n' | gtmail alice demo > /dev/null 2>&1 \
    || echo rejected > $T/mj
has "malformed mail rejected" "rejected" $T/mj
# From-address auth: registered users.email, no token needed
{ echo "From: alice <alice@e2e.test>"
  echo "Subject: [PATCH] mail from auth"
  echo "Message-ID: <mfa@e2e>"
  echo "Date: Thu, 01 Jan 2026 00:00:00 +0000"; echo
  echo "diff --git a/mfa.txt b/mfa.txt"
  echo "new file mode 100644"
  echo "--- /dev/null"
  echo "+++ b/mfa.txt"
  echo "@@ -0,0 +1 @@"
  echo "+x"; } > $T/mfa.eml
gtmail alice demo < $T/mfa.eml > $T/mo2
has "From-address auth creates set" "patch set #" $T/mo2
# non-patch mail from an authenticated sender -> 422 path
{ echo "From: alice <alice@e2e.test>"; echo "Subject: hello"; echo
  echo "no diff here"; } | gtmail alice demo > /dev/null 2>$T/mn \
    || echo rejected > $T/mn
has "non-patch mail rejected" "rejected" $T/mn
# send-email series: out-of-order parts collect in mail_items
{ echo "Subject: [PATCH 1/2] mail series part one"
  echo "Message-ID: <mp1@e2e>"; echo "In-Reply-To: <mcover@e2e>"
  echo "X-Git-Trace-Token: $MTOK"
  echo "From: alice <alice@e2e.test>"
  echo "Date: Thu, 01 Jan 2026 00:00:00 +0000"
  sed '1{/^From /d;}' $T/rev1.mbox ; } > $T/mp1.eml
{ echo "Subject: [PATCH 2/2] mail series part two"
  echo "Message-ID: <mp2@e2e>"; echo "In-Reply-To: <mcover@e2e>"
  echo "X-Git-Trace-Token: $MTOK"
  echo "From: alice <alice@e2e.test>"
  echo "Date: Thu, 01 Jan 2026 00:00:00 +0000"
  sed '1{/^From /d;}' $T/rev2.mbox ; } > $T/mp2.eml
{ echo "Subject: [PATCH 0/2] mail series cover"
  echo "Message-ID: <mcover@e2e>"
  echo "X-Git-Trace-Token: $MTOK"
  echo "From: alice <alice@e2e.test>"
  echo "Date: Thu, 01 Jan 2026 00:00:00 +0000"; echo
  echo "a two-part series by mail"; } > $T/mcov.eml
# send-email order: cover first, then numbered parts (arriving
# out of order is fine — completeness is parts 1..n present)
gtmail alice demo < $T/mcov.eml > $T/mq1
has "cover queued"         "queued"     $T/mq1
gtmail alice demo < $T/mp2.eml > $T/mq2
has "part 2 queued"        "queued"     $T/mq2
gtmail alice demo < $T/mp1.eml > $T/mq3
has "last part completes set" "patch set #" $T/mq3
SSID=$(grep -oE '#[0-9]+' $T/mq3 | head -1 | tr -d '#')
curl -s $BASE/alice/demo/patches/$SSID > $T/mps
has "assembled part 1"     "+one"       $T/mps
has "assembled part 2"     "+two"       $T/mps
has "mail cover letter"    "two-part series by mail" $T/mps
nth "mail token not stored" "$MTOK"     $T/mps
curl -s $BASE/alice/demo/patches > $T/ml
has "cover title in list"  "mail series cover" $T/ml
# an MTA redelivery must not open a duplicate set
gtmail alice demo < $T/mp1.eml > $T/mrd
nth "redelivery no dup"    "patch set"  $T/mrd
# iteration by mail: [PATCH v2] on alice's own mail-created set
{ echo "Message-ID: <mv2@e2e>"
  echo "X-Git-Trace-Token: $MTOK"
  echo "From: alice <alice@e2e.test>"
  echo "Date: Thu, 01 Jan 2026 00:00:00 +0000"
  sed '1{/^From /d;}' $T/rev2.mbox ; } > $T/mv2.eml
gtmail alice demo < $T/mv2.eml > $T/mo3
has "mail v2 auto-attaches" "patch set #$MSID v2" $T/mo3

echo "== self-registration =="
curl -s $BASE/register > $T/reg
has "register form" "Register" $T/reg
CK2=$(curl -s -D- -o /dev/null -X POST \
    --data-urlencode "name=carol" \
    --data-urlencode "pass=carolpass9" \
    --data-urlencode "pass2=carolpass9" $BASE/register \
    | grep -oi 'gt_session=[0-9a-f]*' | head -1 | cut -d= -f2)
[ -n "$CK2" ] && ok "register -> session" || bad "register -> session" "no cookie"
curl -s -X POST -d "name=carol&pass=carolpass9&pass2=carolpass9" \
    $BASE/register > $T/reg2
has "duplicate name rejected" "already taken" $T/reg2
curl -s -X POST -d "name=dave&pass=short&pass2=short" $BASE/register > $T/reg3
has "weak pw rejected" "too short" $T/reg3
curl -s -o /dev/null -w '%{http_code}' -u carol:carolpass9 \
    "$BASE/alice/demo.git/info/refs?service=git-upload-pack" > $T/code
ck "carol basic auth works" 200 "$(cat $T/code)"

echo "== storage quota =="
MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace quota alice 1 > $T/q1
has "quota set to 1 MiB" "1 MiB" $T/q1
git -C $T/demo checkout -qb qb FETCH_HEAD 2>/dev/null \
    || git -C $T/demo checkout -qb qb
head -c 2000000 /dev/urandom > $T/demo/big.bin
git -C $T/demo add big.bin \
    && git -C $T/demo -c user.name=t -c user.email=t@t commit -qm big
# 2 MiB push > git's 1 MiB postBuffer -> real chunked request body,
# exercises the dechunker end to end through git-receive-pack
git -C $T/demo push \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    qb > $T/push.out 2>&1
has "over-quota push rejected" "quota exceeded" $T/push.out
MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace quota alice 0 > /dev/null
git -C $T/demo push -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    qb > /dev/null 2>&1
git ls-remote "$BASE/alice/demo.git" refs/heads/qb > $T/lqb
has "chunked push lands" "refs/heads/qb" $T/lqb

echo "== chunked request bodies =="
curl -s -u alice:secretpw1 -H "Transfer-Encoding: chunked" \
    -H "Content-Type: text/plain" \
    --data-binary @$T/series2.mbox -X POST \
    "$BASE/alice/demo/patches/new" > $T/ps3
has "chunked patch submit" "patch set" $T/ps3
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    -H "Transfer-Encoding: chunked" \
    --data-binary "csrf=$CK&name=chunkrepo" -X POST $BASE/new > $T/code
ck "chunked form POST" 302 "$(cat $T/code)"
# a push that exceeds GIT_TRACE_BODY_MAX would 413; the cap is
# 256 MiB here, so the 2 MiB pushes above cover the happy path

echo "== audit log =="
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" $BASE/audit > $T/code
ck "non-admin /audit -> 403" 403 "$(cat $T/code)"
CKA=$(curl -s -D- -o /dev/null -X POST -d "name=root&pass=secretpw0" \
     $BASE/login | grep -oi 'gt_session=[0-9a-f]*' | head -1 | cut -d= -f2)
[ -n "$CKA" ] && ok "admin session" || bad "admin session" "none"
curl -s -b "gt_session=$CKA" $BASE/audit > $T/au
has "push audited"      "push"        $T/au
has "repo_create row"   "repo_create" $T/au
has "register audited"  "register"    $T/au
has "login audited"     "login"       $T/au
curl -s -b "gt_session=$CK" $BASE/alice/demo/activity > $T/act
has "repo activity shows push" "push" $T/act

echo "== M8: dashboard & repo UI =="
curl -s -b "gt_session=$CK" $BASE/ > $T/dash
has "dashboard storage bar"    "storage"         $T/dash
has "dashboard New button"     "New repository"  $T/dash
has "dashboard public badge"   "public"          $T/dash
has "dashboard private badge"  "private"         $T/dash
has "dashboard activity feed"  "Recent activity" $T/dash
has "feed shows push event"    "push"            $T/dash
curl -s $BASE/ > $T/dasha
nth "anon: no storage bar"     "account storage"  $T/dasha
nth "anon: no New button"      "New repository"  $T/dasha

echo "== M8: repo header & clone URLs =="
curl -s $BASE/alice/demo/tree > $T/tree
has "repo header public badge" "public"                    $T/tree
has "http clone url"  "git clone http://localhost:$PORT/alice/demo.git" $T/tree
has "ssh clone url"   "ssh://git@localhost:2222/alice/demo.git"         $T/tree
has "repo quota line" "this repo:"                         $T/tree
nth "anon: no settings tab"  "/settings"                   $T/tree
curl -s -b "gt_session=$CK" $BASE/alice/demo/tree > $T/atree
has "owner sees settings tab"  "/settings"                 $T/atree
has "sidebar layout"           "class=repo"                $T/atree
has "clone panel rows"         "class=clonerow"            $T/atree
has "active tab marked"        "class=on"                  $T/atree
CKB=$(curl -s -D- -o /dev/null -X POST -d "name=bob&pass=secretpw2" \
     $BASE/login | grep -oi 'gt_session=[0-9a-f]*' | head -1 | cut -d= -f2)
curl -s -b "gt_session=$CKB" $BASE/bob/work/tree > $T/btree
has "owner storage in sidebar" "class=sidestat"            $T/btree
nth "anon: no sidestat"        "class=sidestat"            $T/tree
curl -s -b "gt_session=$CK" $BASE/alice/priv/tree > $T/ptree
has "private repo badge"   "private"                       $T/ptree
has "empty repo panel"         "empty repository"          $T/ptree

echo "== M8: /new web repo creation =="
curl -s -o /dev/null -w '%{http_code}' $BASE/new > $T/code
ck "anon GET /new -> redirect" 302 "$(cat $T/code)"
curl -s -b "gt_session=$CK" $BASE/new > $T/nf
has "new repo form" "Create repository" $T/nf
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" -X POST \
    -d "name=webrepo" $BASE/new > $T/code
ck "POST /new no csrf -> 403" 403 "$(cat $T/code)"
curl -s -o /dev/null -w '%{redirect_url}' -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&name=webrepo&desc=via+web&private=on" $BASE/new > $T/nloc
has "web repo created" "/alice/webrepo/tree" $T/nloc
curl -s -b "gt_session=$CK" $BASE/alice/webrepo/tree > $T/wt
has "web repo page loads" "webrepo" $T/wt
has "web repo is private" "private" $T/wt
curl -s -b "gt_session=$CK" -X POST -d "csrf=$CK&name=webrepo" \
    $BASE/new > $T/nd
has "duplicate repo rejected" "already exists" $T/nd
curl -s -o /dev/null -w '%{http_code}' -u alice:secretpw1 \
    "$BASE/alice/webrepo.git/info/refs?service=git-upload-pack" > $T/code
ck "web repo serves git" 200 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' \
    "$BASE/alice/webrepo.git/info/refs?service=git-upload-pack" > $T/code
ck "web priv anon -> 401" 401 "$(cat $T/code)"

echo "== M8: unified /changes =="
curl -s -b "gt_session=$CK" $BASE/alice/demo/changes > $T/ch
has "changes: mr badge"    ">mr<"      $T/ch
has "changes: patch badge" ">patch<"   $T/ch
has "changes: mr link"     "/mr/"      $T/ch
has "changes: patch link"  "/patches/" $T/ch

echo "== M9: branch protection =="
# bob needs repo rank 'w' to reach receive-pack at all
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=acl_add&user=bob&role=w" \
    $BASE/alice/demo/settings
# master -> admin-only, ff-only, no-delete;  release/* -> write+ff+del
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=rule_add&pattern=master&role=a&ff=on&del=on" \
    $BASE/alice/demo/settings
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    --data-urlencode "csrf=$CK" --data-urlencode "action=rule_add" \
    --data-urlencode "pattern=release/*" --data-urlencode "role=w" \
    --data-urlencode "ff=on" --data-urlencode "del=on" \
    $BASE/alice/demo/settings
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/sett
has "settings: protection section" "branch protection" $T/sett
has "rule master listed"           "master"            $T/sett
has "rule glob listed"             "release/*"         $T/sett

# sync the local clone, then run the push x branch x rank matrix
git -C $T/demo fetch -q "$BASE/alice/demo.git" master
git -C $T/demo reset -q --hard FETCH_HEAD
git -C $T/demo -c user.name=t -c user.email=t@t commit -qm bobwork \
    --allow-empty
BOB="http://bob:secretpw2@localhost:$PORT/alice/demo.git"
git -C $T/demo push "$BOB" HEAD:master > $T/bp 2>&1
has "writer -> admin-only master denied" "requires admin" $T/bp
git -C $T/demo push -q "$BOB" HEAD:refs/heads/bobfeat \
    && ok "writer -> unprotected branch ok" \
    || bad "writer -> unprotected branch ok" "push failed"
git -C $T/demo push -q "$BOB" HEAD:refs/heads/release/1.0 \
    && ok "writer -> release glob (rank w) ok" \
    || bad "writer -> release glob (rank w) ok" "push failed"
# HEAD~1 doesn't contain the bobwork commit -> non-fast-forward
git -C $T/demo push "$BOB" HEAD~1:refs/heads/release/1.0 > $T/nff 2>&1
has "non-ff on ff-only branch denied" "non-fast-forward" $T/nff
git -C $T/demo push "$BOB" :refs/heads/release/1.0 > $T/del 2>&1
has "delete on no-delete branch denied" "deletion denied" $T/del
git -C $T/demo push -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    HEAD:master \
    && ok "admin -> protected master ok" \
    || bad "admin -> protected master ok" "push failed"
curl -s -b "gt_session=$CK" $BASE/alice/demo/activity > $T/act2
has "push_denied in repo activity" "push_denied" $T/act2

# rule_rm on a throwaway rule covers the removal path
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    --data-urlencode "csrf=$CK" --data-urlencode "action=rule_add" \
    --data-urlencode "pattern=exp/*" --data-urlencode "role=w" \
    $BASE/alice/demo/settings
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/sett3
has "throwaway rule added" "exp/*" $T/sett3
RID=$(grep -oE 'name=id value=[0-9]+' $T/sett3 | grep -oE '[0-9]+$' \
      | head -1)
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=rule_rm&id=$RID" $BASE/alice/demo/settings
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/sett4
nth "throwaway rule removed" "exp/\*" $T/sett4

echo "== web commits (new-file / edit / upload) =="
# writer with rank w, no session cookie -> Basic auth, no csrf needed
curl -s -o /dev/null -u bob:secretpw2 -X POST \
    --data-urlencode "ref=bobfeat" --data-urlencode "path=web.txt" \
    --data-urlencode "content=from the browser" \
    --data-urlencode "msg=web new file" \
    -w '%{http_code}' $BASE/alice/demo/new-file > $T/wc1
ck "web new-file -> 302" "302" "$(cat $T/wc1)"
curl -s "$BASE/alice/demo/blob?ref=bobfeat&path=web.txt&raw=1" > $T/wc2
has "web blob content" "from the browser" $T/wc2
curl -s "$BASE/alice/demo/log" > $T/wc3
# trace graph: the commit DAG renders as SVG lanes, nodes are links
has "trace graph rows"   "class=tg><svg"  $T/wc3
has "trace graph node"   "<circle"        $T/wc3
has "graph node links"   "/commit/"       $T/wc3
# repo identity stats on every repo page (repostat bar)
has "stats contributors" "contributor"    $T/wc3
has "stats top author"   "top:"           $T/wc3
has "stats first commit" "first commit"   $T/wc3
# the default-branch log may not show bobfeat; fetch the blob instead
curl -s -u bob:secretpw2 -X POST \
    --data-urlencode "ref=bobfeat" --data-urlencode "path=web.txt" \
    --data-urlencode "content=edited in browser" \
    -o /dev/null -w '%{http_code}' $BASE/alice/demo/edit > $T/wc4
ck "web edit -> 302" "302" "$(cat $T/wc4)"
curl -s "$BASE/alice/demo/blob?ref=bobfeat&path=web.txt&raw=1" > $T/wc5
has "edited content" "edited in browser" $T/wc5
# multipart upload: dir-only path picks up the client filename
printf 'web upload \x00\x01\x02 tail' > $T/up.bin
# relative -F path: MSYS path conversion can't see inside file=@<p>
(cd $T && curl -s -o /dev/null -u bob:secretpw2 -X POST \
    -F "ref=bobfeat" -F "path=files/" -F "msg=web upload" \
    -F "file=@up.bin;filename=data.bin" \
    -w '%{http_code}' $BASE/alice/demo/upload > wc6)
ck "web upload -> 302" "302" "$(cat $T/wc6)"
curl -s "$BASE/alice/demo/blob?ref=bobfeat&path=files/data.bin&raw=1" \
    > $T/wc7
has "upload content" "web upload" $T/wc7
# policy parity with pushes: protected master rejects rank w
curl -s -u bob:secretpw2 -X POST \
    --data-urlencode "ref=master" --data-urlencode "path=pwn.txt" \
    --data-urlencode "content=x" \
    $BASE/alice/demo/new-file > $T/wc8
has "protected branch web commit denied" "protected branch" $T/wc8
# readers and anonymous users cannot reach the write surface
curl -s -o /dev/null -w '%{http_code}' $BASE/alice/demo/new-file > $T/wc9
ck "anon new-file -> 302 login" "302" "$(cat $T/wc9)"
curl -s -o /dev/null -w '%{http_code}' -u carol:carolpass9 \
    $BASE/alice/demo/new-file > $T/wc10
ck "reader new-file -> 404" "404" "$(cat $T/wc10)"
# csrf + path hygiene on session-authed posts
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "ref=bobfeat&path=x.txt&content=hi" \
    -w '%{http_code}' $BASE/alice/demo/new-file > $T/wc11
ck "web commit no csrf -> 403" "403" "$(cat $T/wc11)"
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    --data-urlencode "csrf=$CK" --data-urlencode "ref=bobfeat" \
    --data-urlencode "path=../evil" --data-urlencode "content=x" \
    -w '%{http_code}' $BASE/alice/demo/new-file > $T/wc12
ck "web commit traversal -> 400" "400" "$(cat $T/wc12)"
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    --data-urlencode "csrf=$CK" --data-urlencode "ref=bobfeat" \
    --data-urlencode "path=.git/hooks/pwn" --data-urlencode "content=x" \
    -w '%{http_code}' $BASE/alice/demo/new-file > $T/wc13
ck "web commit .git path -> 400" "400" "$(cat $T/wc13)"

echo "== scrypt KDF + hash migration =="
MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace kdf-selftest > $T/kdf
has "scrypt vector 1" "vector 1: ok" $T/kdf
has "scrypt vector 2" "vector 2: ok" $T/kdf
has "scrypt vector 3" "vector 3: ok" $T/kdf
gtpw() { MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace pwhash-status "$1"; }
ck "new users default to scrypt" "scrypt" "$(gtpw alice)"
# a legacy pbkdf2 row still verifies, and upgrades on next login
MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db -e GIT_TRACE_KDF=pbkdf2 $CT \
    sh -c 'echo davepw99 | /src/git-trace useradd dave'
ck "legacy user minted on pbkdf2" "pbkdf2" "$(gtpw dave)"
curl -s -D- -o /dev/null -X POST -d "name=dave&pass=davepw99" \
    $BASE/login > $T/dl1
has "legacy login still works" "gt_session" $T/dl1
ck "rehash-on-login upgrades" "scrypt" "$(gtpw dave)"
curl -s -D- -o /dev/null -X POST -d "name=dave&pass=davepw99" \
    $BASE/login > $T/dl2
has "scrypt login works" "gt_session" $T/dl2
curl -s -X POST -d "name=dave&pass=wrongpw" $BASE/login > $T/dl3
has "wrong password still denied" "invalid credentials" $T/dl3

echo "== billing tiers (manual entitlement) =="
GT() { MSYS_NO_PATHCONV=1 docker exec -e GIT_TRACE_REPOS=/srv/repos \
    -e GIT_TRACE_DB=/srv/var/gt.db $CT /src/git-trace "$@"; }
ck "dave starts free" "dave: tier=free" "$(GT tier dave)"
DKC=$(curl -s -D- -o /dev/null -X POST -d "name=dave&pass=davepw99" \
    $BASE/login | grep -oi 'gt_session=[0-9a-f]*' | head -1 | cut -d= -f2)
curl -s -b "gt_session=$DKC" $BASE/new > $T/nf
has "free sees tier hint"     "requires the <b>pro</b> tier" $T/nf
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&name=dvpriv&private=on" $BASE/new > $T/dn1
has "free private repo denied" "require the pro tier" $T/dn1
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" \
    $BASE/dave/dvpriv/tree > $T/dc
ck "no repo created" 404 "$(cat $T/dc)"
GT tier dave pro +30 > $T/ts
has "tier set confirms"        "tier=pro until"  $T/ts
GT tier dave > $T/tq
has "tier query shows expiry"  "tier=pro (expires" $T/tq
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&name=dvpriv&private=on" $BASE/new > $T/dc
ck "pro private repo ok" 302 "$(cat $T/dc)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" \
    $BASE/dave/dvpriv/tree > $T/dc
ck "private repo reachable" 200 "$(cat $T/dc)"
# expiry is lazy: a past-dated grant sweeps back to free on next read
GT tier dave pro 2020-01-01 > /dev/null
ck "expired tier reads free" "dave: tier=free" "$(GT tier dave)"
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&name=dvpriv2&private=on" $BASE/new > $T/dn2
has "expired tier denied"      "require the pro tier" $T/dn2
GT tier dave pro never > /dev/null
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&name=dvpriv2&private=on" $BASE/new > $T/dc
ck "pro no-expiry ok" 302 "$(cat $T/dc)"
GT tier dave bogus > /dev/null 2>&1 && echo bad > $T/tb
nth "bogus tier rejected" "bad" $T/tb
GT tier nobody > /dev/null 2>&1 && echo bad > $T/tb
nth "tier on missing user" "bad" $T/tb
# the audit journal records every entitlement change
curl -s -b "gt_session=$CKA" $BASE/audit > $T/tau
has "tier_change audited" "tier_change" $T/tau
has "tier_denied audited" "tier_denied" $T/tau

echo "== account self-service (/account) =="
curl -s -o /dev/null -w '%{http_code}' $BASE/account > $T/code
ck "anon /account -> redirect" 302 "$(cat $T/code)"
curl -s -b "gt_session=$DKC" $BASE/account > $T/acct
has "account page"        "api tokens"              $T/acct
has "ssh keys section"    "ssh keys"                $T/acct
has "tier shown"          "tier <b>pro"             $T/acct
# email (feeds mail-in From auth)
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=email&email=dave@e2e.test" $BASE/account > $T/ae
has "email set"           "email updated"           $T/ae
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=email&email=not-an-email" $BASE/account > $T/aeb
has "bad email rejected"  "invalid email"           $T/aeb
# token lifecycle: create -> auth -> revoke
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=tok_add&label=laptop" $BASE/account > $T/ta
has "token shown once"    "new token"               $T/ta
WTOK=$(grep -oE 'new token \(shown once\): <code class=mono>[0-9a-f]{64}' \
    $T/ta | grep -oE '[0-9a-f]{64}')
curl -s -o /dev/null -w '%{http_code}' -u "dave:$WTOK" \
    "$BASE/dave/dvpriv.git/info/refs?service=git-upload-pack" > $T/code
ck "web token authenticates git" 200 "$(cat $T/code)"
TID=$(curl -s -b "gt_session=$DKC" $BASE/account \
    | grep -oE 'value=tok_rm><input type=hidden name=id value=[0-9]+' \
    | grep -oE '[0-9]+$' | tail -1)
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=tok_rm&id=$TID" $BASE/account > $T/tr
has "token revoked"       "token revoked"           $T/tr
curl -s -o /dev/null -w '%{http_code}' -u "dave:$WTOK" \
    "$BASE/dave/dvpriv.git/info/refs?service=git-upload-pack" > $T/code
ck "revoked token denied" 401 "$(cat $T/code)"
# ssh key lifecycle: add -> visible to ssh machinery -> remove
curl -s -b "gt_session=$DKC" -X POST \
    --data-urlencode "csrf=$DKC" --data-urlencode "action=key_add" \
    --data-urlencode "key=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILvMockKeyDataForE2E dave@e2e" \
    --data-urlencode "label=webkey" $BASE/account > $T/ka
has "key added"           "key added"               $T/ka
GT sshkey list > $T/skl
has "key reaches ssh keyfile" "webkey"              $T/skl
KID=$(curl -s -b "gt_session=$DKC" $BASE/account \
    | grep -oE 'value=key_rm><input type=hidden name=id value=[0-9]+' \
    | grep -oE '[0-9]+$' | tail -1)
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=key_rm&id=$KID" $BASE/account > $T/kr
has "key removed"         "key removed"             $T/kr
GT sshkey list > $T/skl2
nth "key gone from list"  "webkey"                  $T/skl2
curl -s -b "gt_session=$DKC" -X POST \
    --data-urlencode "csrf=$DKC" --data-urlencode "action=key_add" \
    --data-urlencode "key=not-a-key" $BASE/account > $T/kb
has "bogus key rejected"  "invalid or duplicate"    $T/kb
# password change: wrong current is refused, right one rotates
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=passwd&cur=nope&pass=newdavepw1&pass2=newdavepw1" \
    $BASE/account > $T/pf
has "wrong current denied" "current password incorrect" $T/pf
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=passwd&cur=davepw99&pass=newdavepw1&pass2=newdavepw1" \
    $BASE/account > $T/pp
has "password updated"    "password updated"        $T/pp
curl -s -D- -o /dev/null -X POST -d "name=dave&pass=newdavepw1" \
    $BASE/login > $T/nl
has "new password works"  "gt_session"              $T/nl
# repo delete: requires typing the name
curl -s -o /dev/null -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&name=throwaway" $BASE/new
curl -s -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=delete&confirm=wrong" \
    $BASE/dave/throwaway/settings > /dev/null
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" \
    $BASE/dave/throwaway/tree > $T/code
ck "wrong confirm keeps repo" 200 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" -X POST \
    -d "csrf=$DKC&action=delete&confirm=throwaway" \
    $BASE/dave/throwaway/settings > $T/code
ck "delete redirects" 302 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$DKC" \
    $BASE/dave/throwaway/tree > $T/code
ck "repo gone" 404 "$(cat $T/code)"
curl -s -b "gt_session=$CKA" $BASE/audit > $T/dau
has "repo_delete audited" "repo_delete" $T/dau

echo "== multi-tenancy hardening =="
# ownerless (_/) repos: managed by site admins, not by ordinary users;
# a global admin must NOT gain read on user-owned private repos
GT mkrepo _ site "platform repo" > /dev/null
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CKA" \
    $BASE/_/site/settings > $T/code
ck "admin manages _/ repo" 200 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CK" \
    $BASE/_/site/settings > $T/code
ck "non-admin denied _/ settings" 403 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' $BASE/_/site/tree > $T/code
ck "_ repo stays public read" 200 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CKA" \
    $BASE/alice/priv/tree > $T/code
ck "admin cannot read private repo" 404 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' -b "gt_session=$CKA" -X POST \
    -d "csrf=$CKA&action=delete&confirm=site" \
    $BASE/_/site/settings > $T/code
ck "admin deletes _ repo" 302 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' $BASE/_/site/tree > $T/code
ck "_ repo gone" 404 "$(cat $T/code)"

echo "== UX: highlight, atom feed, code search =="
# land a .c file on master (ff-only rule -> base on fetched master)
git -C $T/demo fetch -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master \
    2>/dev/null
git -C $T/demo checkout -qb uxtest FETCH_HEAD
printf '/* block */\n#include <stdio.h>\nint main(void) {\n    // line comment\n    char *s = "hi <there>";\n    return 42;\n}\n' > $T/demo/probe.c
git -C $T/demo add probe.c \
    && git -C $T/demo -c user.name=t -c user.email=t@t commit -qm probe \
    && git -C $T/demo push -q \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" HEAD:master
curl -s "$BASE/alice/demo/blob?path=probe.c&ref=master" > $T/hl
has "keyword span"    "<span class=hl-k>int</span>"       $T/hl
has "comment span"    "<span class=hl-c>"                 $T/hl
has "string span"     "<span class=hl-s>"                 $T/hl
has "number span"     "<span class=hl-n>42</span>"        $T/hl
has "preproc span"    "<span class=hl-p>#include"         $T/hl
has "string escaped"  "&lt;there&gt;"                     $T/hl
nth "no raw < in src" "hi <there>"                        $T/hl
curl -s "$BASE/alice/demo/blob?path=feat.txt&ref=master" > $T/h2
nth "no spans on .txt" "class=hl-"                        $T/h2
curl -s "$BASE/alice/demo/feed" > $T/feedh
has "feed page"         "subscribe"                       $T/feedh
has "feed atom url"     "feed.atom"                       $T/feedh
has "feed tab active"   "feed"                            $T/feedh
curl -s -D $T/fh "$BASE/alice/demo/feed.atom" -o $T/feed
has "atom content-type" "application/atom+xml"            $T/fh
has "atom stylesheet PI" "xml-stylesheet"                 $T/feed
has "atom relaxed csp"   "default-src 'self'"             $T/fh
curl -s -o $T/xsl -w '%{http_code}' $BASE/feed.xsl > $T/code
ck "feed.xsl served"    200 "$(cat $T/code)"
has "xsl is atom-aware" "xmlns:a='http://www.w3.org/2005/Atom'" $T/xsl
has "atom feed tag"     "<feed xmlns"                     $T/feed
has "atom entry"        "<entry>"                         $T/feed
has "atom commit link"  "/alice/demo/commit/"             $T/feed
has "atom rfc3339"      "T"                               $T/feed
curl -s -o /dev/null -w '%{http_code}' $BASE/alice/priv/feed.atom > $T/code
ck "private feed hidden" 404 "$(cat $T/code)"
curl -s -o /dev/null -w '%{http_code}' $BASE/alice/priv/feed > $T/code
ck "private feed page hidden" 404 "$(cat $T/code)"
curl -s "$BASE/alice/demo/search?q=main&ref=master" > $T/se
has "search hit"        "probe.c"                         $T/se
has "search blob link"  "/alice/demo/blob?path=probe.c"   $T/se
curl -s "$BASE/alice/demo/search?q=zzz_nomatch&ref=master" > $T/sn
has "no matches shown"  "no matches"                      $T/sn
curl -s -o /dev/null -w '%{http_code}' \
    "$BASE/alice/priv/search?q=x" > $T/code
ck "private search hidden" 404 "$(cat $T/code)"

echo "== static export =="
GT export -o /srv/var/xp -n 5 alice/demo > $T/xo
has "explicit export line"  "alice/demo -> "              $T/xo
MSYS_NO_PATHCONV=1 docker exec $CT test -f \
    /srv/var/xp/alice/demo/index.html && ok "repo index written" \
    || bad "repo index written" "missing"
MSYS_NO_PATHCONV=1 docker exec $CT test -f \
    /srv/var/xp/alice/demo/log.html && ok "log page written" \
    || bad "log page written" "missing"
MSYS_NO_PATHCONV=1 docker exec $CT sh -c \
    'ls /srv/var/xp/alice/demo/commit/*.html | head -1' > $T/xc
has "commit page written"   ".html"                       $T/xc
MSYS_NO_PATHCONV=1 docker exec $CT cat \
    /srv/var/xp/alice/demo/file/probe.c.html > $T/xf
has "file page highlighted" "class=hl-"                   $T/xf
GT export -o /srv/var/deep/nest/dir -n 2 bob/work > $T/xn
has "nested -o works"       "bob/work -> "                $T/xn
GT export -o /srv/var/xall -n 3 > $T/xa
has "auto lists public"     "alice/demo -> "              $T/xa
has "auto lists bob"        "bob/work -> "                $T/xa
nth "auto skips private"    "alice/priv -> "              $T/xa
nth "auto skips dave priv"  "dave/dvpriv -> "             $T/xa
GT export --private -o /srv/var/xprv -n 1 > $T/xp2
has "--private includes"    "alice/priv -> "              $T/xp2
MSYS_NO_PATHCONV=1 docker exec $CT cat /srv/var/xall/index.html > $T/xi
has "root index links repo" "alice/demo/index.html"       $T/xi

echo "== webhooks =="
# the forge doubles as receiver: POST /login always returns 200,
# which exercises the in-tree HTTP client end-to-end
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=wh_add&url=http://localhost:8081/login&secret=s3cret&ev_push=on" \
    $BASE/alice/demo/settings
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/ws
has "webhook listed"       "localhost:8081/login"         $T/ws
# push -> post-receive -> hook-post -> enqueue + detached dispatch
git -C $T/demo push -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    master:refs/heads/whprobe 2>/dev/null
sleep 1
GT webhook-run > /dev/null 2>&1
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/ws2
has "delivery ok"          ">ok<"                         $T/ws2
has "delivery http 200"    ">200<"                        $T/ws2
WID=$(grep -o 'name=id value=[0-9]*' $T/ws2 | head -1 | grep -o '[0-9]*')
[ -n "$WID" ] && curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=wh_del&id=$WID" $BASE/alice/demo/settings
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/ws3
nth "webhook removed"      "localhost:8081/login"         $T/ws3
# https endpoint without curl in the image -> graceful fail row
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=wh_add&url=https://example.invalid/hook&ev_push=on" \
    $BASE/alice/demo/settings
git -C $T/demo push -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    master:refs/heads/whprobe2 2>/dev/null
sleep 1
GT webhook-run > /dev/null 2>&1
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/ws4
has "https w/o curl fails" ">fail<"                       $T/ws4

echo "== ci runner =="
# opt-in via settings (meta keeps public=on)
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=meta&desc=public+repo&public=on&ci=on" \
    $BASE/alice/demo/settings
# local master is stale after earlier pushes — base on FETCH_HEAD
git -C $T/demo fetch -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master \
    && git -C $T/demo checkout -qb citest FETCH_HEAD
printf 'echo GTCI-PASS-MARKER\nexit 0\n' > $T/demo/.git-trace-ci
git -C $T/demo add .git-trace-ci \
    && git -C $T/demo -c user.email=t@t -c user.name=t \
       commit -qm "add ci script" \
    && git -C $T/demo push \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
       HEAD:master > $T/pc 2>&1
has "ci script push lands" "HEAD -> master"               $T/pc
sleep 1
GT ci-run > /dev/null 2>&1
curl -s -b "gt_session=$CK" $BASE/alice/demo/ci > $T/ci1
has "ci run pass"          ">pass<"                       $T/ci1
CID=$(grep -o "/alice/demo/ci/[0-9]*" $T/ci1 | head -1 | grep -o '[0-9]*$')
curl -s -b "gt_session=$CK" $BASE/alice/demo/ci/$CID > $T/ci2
has "ci log captured"      "GTCI-PASS-MARKER"             $T/ci2
# a hostile/buggy script on a feature branch CANNOT hijack the
# pipeline: the runner only reads .git-trace-ci from master
git -C $T/demo checkout -qb cihack
printf 'echo SHOULD-NEVER-RUN\nexit 1\n' > $T/demo/.git-trace-ci
git -C $T/demo add .git-trace-ci \
    && git -C $T/demo -c user.email=t@t -c user.name=t \
       commit -qm "evil ci" \
    && git -C $T/demo push -q \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
       cihack 2>/dev/null
sleep 1
GT ci-run > /dev/null 2>&1
curl -s -b "gt_session=$CK" $BASE/alice/demo/ci > $T/ci3
nth "branch cannot hijack" "SHOULD-NEVER-RUN"             $T/ci3
CID2=$(grep -o "/alice/demo/ci/[0-9]*" $T/ci3 | head -1 | grep -o '[0-9]*$')
curl -s -b "gt_session=$CK" $BASE/alice/demo/ci/$CID2 > $T/ci4
has "branch run still passes" "GTCI-PASS-MARKER"          $T/ci4
# failing script on master -> fail status
git -C $T/demo fetch -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master \
    && git -C $T/demo checkout -qb cibreak FETCH_HEAD
printf 'echo GTCI-FAIL-MARKER\nexit 1\n' > $T/demo/.git-trace-ci
git -C $T/demo add .git-trace-ci \
    && git -C $T/demo -c user.email=t@t -c user.name=t \
       commit -qm "break ci" \
    && git -C $T/demo push \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
       HEAD:master > $T/pc2 2>&1
has "break ci push lands"  "HEAD -> master"               $T/pc2
sleep 1
GT ci-run > /dev/null 2>&1
curl -s -b "gt_session=$CK" $BASE/alice/demo/ci > $T/ci5
has "ci run fail"          ">fail<"                       $T/ci5
# non-enabled repo: no tab, informative page
curl -s -b "gt_session=$CK" $BASE/bob/work/tree > $T/wt
nth "no ci tab when off"   "/ci'"                         $T/wt
curl -s -b "gt_session=$CK" $BASE/bob/work/ci > $T/ci6
has "ci disabled notice"   "not enabled"                  $T/ci6
# restore a green pipeline for later runs
git -C $T/demo fetch -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master \
    && git -C $T/demo checkout -qb cifix FETCH_HEAD
printf 'echo GTCI-PASS-MARKER\nexit 0\n' > $T/demo/.git-trace-ci
git -C $T/demo add .git-trace-ci \
    && git -C $T/demo -c user.email=t@t -c user.name=t \
       commit -qm "fix ci" \
    && git -C $T/demo push -q \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
       HEAD:master 2>/dev/null
sleep 1
GT ci-run > /dev/null 2>&1

echo "== v1.0 UX: dynamic nav, index search, dashboard =="
# anonymous header: log in + register (GIT_TRACE_OPEN_REG=1)
curl -s $BASE/ > $T/na0
has "anon nav: log in"        "href='/login'"        $T/na0
has "anon nav: register"      "href='/register'"     $T/na0
nth "anon nav: no logout"     "log out"              $T/na0
# logged-in header
curl -s -b "gt_session=$CK" $BASE/ > $T/na1
has "auth nav: account link"  "class=who"            $T/na1
has "auth nav: + new repo"    "href='/new'"          $T/na1
has "auth nav: log out"       "href='/logout'"       $T/na1
# instance metrics line
has "index metrics"           "hosted"               $T/na1
# global search filters
curl -s "$BASE/?q=demo" > $T/q1
has "search keeps match"      "alice/demo"           $T/q1
nth "search filters others"   "bob/work"             $T/q1
curl -s "$BASE/?q=no-such-thing" > $T/q9
has "empty search msg"        "No repositories match" $T/q9
# ordering: most recently touched repo sorts first —
# push a throwaway branch to alice/demo, then it must lead the table
git -C $T/demo push -q "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    HEAD:refs/heads/uxprobe 2>/dev/null
curl -s $BASE/ > $T/na2
FIRST=$(grep -oE "href='/[A-Za-z0-9._/-]+/tree'" $T/na2 | head -1)
ck "recent repo first"        "href='/alice/demo/tree'" "$FIRST"
# dashboard: own repos + shared repos listed on /account
curl -s -b "gt_session=$CK" $BASE/account > $T/acct2
has "acct: your repos"        "your repositories"    $T/acct2
has "acct: own repo listed"   "alice / demo"         $T/acct2
has "acct: settings link"     "/alice/demo/settings" $T/acct2

echo "== mirrors / federation =="
GT mkrepo alice mirror
# file:// must be refused (local path exfiltration)
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=mir_add&url=file:///srv/repos/alice/demo.git" \
    $BASE/alice/mirror/settings
curl -s -b "gt_session=$CK" $BASE/alice/mirror/settings > $T/ms0
nth "file:// url refused"   "file:///srv/repos"        $T/ms0
# real mirror: upstream demo over loopback smart-http, trigger secret set
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=mir_add&url=http://localhost:8081/alice/demo.git&secret=mirs3c" \
    $BASE/alice/mirror/settings
curl -s -b "gt_session=$CK" $BASE/alice/mirror/settings > $T/ms1
has "mirror listed"         "localhost:8081/alice/demo" $T/ms1
has "default refspec"       "refs/mirror/"           $T/ms1
# CLI sync: refs land under refs/mirror/, not refs/heads
GT mirror-sync alice/mirror > $T/mso 2>&1
has "sync ran"              "alice/mirror <-"         $T/mso
MREFS=$(MSYS_NO_PATHCONV=1 docker exec $CT \
    git -C /srv/repos/alice/mirror.git for-each-ref --format='%(refname)' \
    refs/mirror refs/heads | tr '\n' ' ')
case "$MREFS" in
    *refs/mirror/master*) ck "mirror refs present" ok ok ;;
    *) ck "mirror refs present" "refs/mirror/master" "$MREFS" ;;
esac
case "$MREFS" in
    *refs/heads/*) ck "no local branch leak" "mirror-only" "$MREFS" ;;
    *)             ck "no local branch leak" ok ok ;;
esac
# trigger endpoint: no mirrors on bob/work -> 404 (no existence oracle)
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
    $BASE/bob/work/mirror-sync)
ck "no mirrors -> 404"      404 "$code"
# bad signature -> 403
BAD=$(printf 'x%.0s' $(seq 64))
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
    -H "X-Git-Trace-Signature: sha256=$BAD" -d '{}' \
    $BASE/alice/mirror/mirror-sync)
ck "bad signature -> 403"   403 "$code"
# event-driven federation: push webhook on demo -> trigger -> detached sync
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=wh_add&url=http://localhost:8081/alice/mirror/mirror-sync&secret=mirs3c&ev_push=on" \
    $BASE/alice/demo/settings
git -C $T/demo fetch -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master \
    && git -C $T/demo checkout -qb mirprobe FETCH_HEAD \
    && git -C $T/demo -c user.email=t@t -c user.name=t \
       commit -q --allow-empty -m "mirror probe" \
    && git -C $T/demo push -q \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
       HEAD:refs/heads/mirprobe 2>/dev/null
# push -> webhook-run (detached) -> POST trigger -> mirror-sync (detached)
ok=""
for i in $(seq 1 10); do
    sleep 1
    MREFS=$(MSYS_NO_PATHCONV=1 docker exec $CT \
        git -C /srv/repos/alice/mirror.git for-each-ref \
        --format='%(refname)' refs/mirror | tr '\n' ' ')
    case "$MREFS" in *mirprobe*) ok=1; break ;; esac
done
ck "webhook-driven sync"    1 "${ok:-0}"
# refs page exposes mirror refs as read-only
curl -s $BASE/alice/mirror/refs > $T/mr
has "refs page: mirror ref" "refs/mirror/master"     $T/mr
has "refs page: read-only"  "read-only mirror"       $T/mr
# last sync status surfaced in settings
curl -s -b "gt_session=$CK" $BASE/alice/mirror/settings > $T/ms2
has "settings: sync status" "refs"                   $T/ms2

echo "== M15b: push mirroring =="
# downstream repo over loopback smart-http, creds in the url
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&name=down" $BASE/new
# bad push refspecs rejected: dst not heads, src not heads
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=mir_add&dir=push&url=http://localhost:8081/alice/down.git&refspec=%2Brefs%2Fheads%2F%2A%3Arefs%2Fmirror%2F%2A" \
    $BASE/alice/demo/settings
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=mir_add&dir=push&url=http://localhost:8081/alice/down.git&refspec=%2Brefs%2Fmirror%2F%2A%3Arefs%2Fheads%2F%2A" \
    $BASE/alice/demo/settings
# real push mirror: creds embedded — UI must mask the password
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=mir_add&dir=push&url=http%3A%2F%2Falice%3Asecretpw1%40localhost%3A8081%2Falice%2Fdown.git" \
    $BASE/alice/demo/settings
curl -s -b "gt_session=$CK" $BASE/alice/demo/settings > $T/pm
has "push mirror listed"      "alice:\*\*\*@localhost" $T/pm
nth "push creds not leaked"   "alice:secretpw1"        $T/pm
# manual run: all heads fan out to the downstream repo
GT mirror-push alice/demo > $T/pmo 2>&1
has "push-mirror ran"         "alice/demo ->"          $T/pmo
DREFS=$(MSYS_NO_PATHCONV=1 docker exec $CT \
    git -C /srv/repos/alice/down.git for-each-ref \
    --format='%(refname)' refs/heads | tr '\n' ' ')
case "$DREFS" in
    *refs/heads/master*) ck "heads pushed" ok ok ;;
    *) ck "heads pushed" "refs/heads/master" "$DREFS" ;;
esac
# event-driven: a real push on demo triggers mirror-push via
# post-receive — the new branch shows up downstream on its own
git -C $T/demo fetch -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" master \
    && git -C $T/demo checkout -qb pushmirror FETCH_HEAD \
    && git -C $T/demo -c user.email=t@t -c user.name=t \
       commit -q --allow-empty -m "push mirror probe" \
    && git -C $T/demo push -q \
       "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
       HEAD:refs/heads/pushmirror 2>/dev/null
ok=""
for i in $(seq 1 10); do
    sleep 1
    DREFS=$(MSYS_NO_PATHCONV=1 docker exec $CT \
        git -C /srv/repos/alice/down.git for-each-ref \
        --format='%(refname)' refs/heads | tr '\n' ' ')
    case "$DREFS" in *pushmirror*) ok=1; break ;; esac
done
ck "post-receive fans out"    1 "${ok:-0}"
# loop convergence: down -> demo push mirror must not ping-pong
curl -s -o /dev/null -b "gt_session=$CK" -X POST \
    -d "csrf=$CK&action=mir_add&dir=push&url=http%3A%2F%2Falice%3Asecretpw1%40localhost%3A8081%2Falice%2Fdemo.git" \
    $BASE/alice/down/settings
git -C $T/demo push -q \
    "http://alice:secretpw1@localhost:$PORT/alice/demo.git" \
    HEAD:refs/heads/loopprobe 2>/dev/null
sleep 6   # let the fan-out + bounce-back settle
ck "loop converges: server alive" 200 \
    "$(curl -s -o /dev/null -w '%{http_code}' $BASE/)"
DREFS=$(MSYS_NO_PATHCONV=1 docker exec $CT \
    git -C /srv/repos/alice/down.git for-each-ref \
    --format='%(refname)' refs/heads | tr '\n' ' ')
case "$DREFS" in
    *loopprobe*) ck "loop ref arrived" ok ok ;;
    *) ck "loop ref arrived" "loopprobe" "$DREFS" ;;
esac

echo "== ops: doctor + backup =="
MSYS_NO_PATHCONV=1 docker exec $CT sh -c \
    'GIT_TRACE_REPOS=/srv/repos GIT_TRACE_DB=/srv/var/gt.db \
     /src/git-trace doctor' > $T/doc 2>&1
has "doctor: db ok"        "ok  \] database"               $T/doc
has "doctor: repos ok"     "ok  \] repos"                  $T/doc
has "doctor: no fails"     "0 checks failed"               $T/doc
MSYS_NO_PATHCONV=1 docker exec $CT sh -c \
    'GIT_TRACE_REPOS=/srv/repos GIT_TRACE_DB=/srv/var/gt.db \
     /src/git-trace backup /tmp/e2ebk' > $T/bk 2>&1
has "backup ran"           "backup complete"               $T/bk
MSYS_NO_PATHCONV=1 docker exec $CT sh -c \
    'test -f /tmp/e2ebk/git-trace.db && test -f /tmp/e2ebk/repos.tar.gz \
     && echo ok' > $T/bkf
has "backup artifacts"     "ok"                            $T/bkf

echo "== version =="
MSYS_NO_PATHCONV=1 docker exec $CT /src/git-trace --version > $T/ver
has "version banner"   "git-trace v"                     $T/ver
has "build sha"        "("                               $T/ver

echo "== security headers + rate limit =="
curl -s -D$T/h -o /dev/null $BASE/
has "X-Frame-Options" "X-Frame-Options" $T/h
has "nosniff" "X-Content-Type-Options" $T/h
has "CSP" "Content-Security-Policy" $T/h
code=""
for i in 1 2 3 4 5 6; do
    code=$(curl -s -o /dev/null -w '%{http_code}' -u alice:WRONG \
           "$BASE/alice/demo.git/info/refs?service=git-upload-pack")
    [ "$code" = 429 ] && break
done
ck "rate limit ban -> 429" 429 "$code"

echo
echo "== $pass passed, $fail failed =="
[ $fail -eq 0 ]