mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +00:00
client_test.go reproduces the cancellation precisely, but in-process against an httptest PDS. The failure this range fixes — mass sign-outs — happened against a real one, through the real refresher and the real oauth_sessions row, so the unit tests alone are a thinner sign-off than the batch deserves. Staling the access token in the live row and restarting the appview (the refresher caches sessions in memory, so editing the DB alone changes nothing) forces the real refresh path. Four concurrent pulls then advance rev 1 to 3 rather than 1 to 4: the compare-and-swap collapses four racing refreshes into two rotations, with the losers adopting the winner's token instead of each burning one. Killing a pull 150ms into that window and retrying still succeeds. Backs ui.db up first, since a burned refresh token would otherwise leave the account signed out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
102 lines
3.8 KiB
Bash
Executable File
102 lines
3.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# oauth-e2e.sh — the end-to-end half of batch 04 that the unit tests cannot reach.
|
|
#
|
|
# client_test.go reproduces the cancellation precisely, but in-process against an
|
|
# httptest PDS. This drives the real refresher, the real oauth_sessions row and a
|
|
# real PDS (jarrett.app), which is where "mass sign-outs" actually happened.
|
|
#
|
|
# Covers both commits:
|
|
# 37bab32 — a cancelled refresh must not burn the refresh token
|
|
# e75b2e2 — concurrent refreshes must not delete a live session (CAS on rev)
|
|
#
|
|
# Safety: ui.db is snapshotted first. If the refresh path burns the token this
|
|
# restores rather than leaving the account logged out.
|
|
set -uo pipefail
|
|
|
|
SCRATCH=/tmp/claude-1000/-home-data-atcr-io/3675ee68-5b8d-4bc1-adf1-1738577d3238/scratchpad
|
|
IMG=127.0.0.1:5000/evan.jarrett.net/valtest:v4
|
|
DB=/var/lib/atcr/ui.db
|
|
|
|
q() { docker exec atcr-appview sh -c "sqlite3 $DB \"$1\"" 2>/dev/null; }
|
|
|
|
state() {
|
|
q "select rev || '|' || substr(json_extract(session_data,'\$.access_token'),1,12) || '|' || substr(json_extract(session_data,'\$.refresh_token'),1,12) from oauth_sessions limit 1"
|
|
}
|
|
|
|
echo "==> backing up ui.db"
|
|
docker exec atcr-appview sh -c "sqlite3 $DB \".backup /var/lib/atcr/ui.e2e-backup.db\"" || exit 1
|
|
|
|
BEFORE=$(state)
|
|
echo " before: rev|access|refresh = $BEFORE"
|
|
|
|
echo "==> staling the access token so the next call must refresh"
|
|
q "select session_data from oauth_sessions limit 1" > "$SCRATCH/sd.json"
|
|
python3 - "$SCRATCH/sd.json" <<'PY'
|
|
import json, sys
|
|
p = sys.argv[1]
|
|
d = json.load(open(p))
|
|
d["access_token"] = "expired-by-validation-" + d["access_token"][-8:]
|
|
json.dump(d, open(p, "w"))
|
|
PY
|
|
docker cp "$SCRATCH/sd.json" atcr-appview:/tmp/sd.json >/dev/null
|
|
q "update oauth_sessions set session_data = readfile('/tmp/sd.json')"
|
|
echo " staled: $(state)"
|
|
|
|
echo "==> restarting appview (the refresher caches sessions in memory)"
|
|
docker compose restart atcr-appview >/dev/null 2>&1
|
|
for i in $(seq 1 60); do
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/v2/)" = "401" ] && break
|
|
sleep 1
|
|
done
|
|
|
|
echo "==> firing 4 concurrent pulls (forces concurrent refresh — the e75b2e2 case)"
|
|
pids=(); rc=0
|
|
for i in 1 2 3 4; do
|
|
( crane pull --insecure "$IMG" "$SCRATCH/e2e-$i.tar" >/dev/null 2>&1 ) &
|
|
pids+=($!)
|
|
done
|
|
for p in "${pids[@]}"; do wait "$p" || rc=1; done
|
|
echo " concurrent pulls all succeeded: $([ $rc -eq 0 ] && echo YES || echo NO)"
|
|
|
|
AFTER=$(state)
|
|
echo " after: rev|access|refresh = $AFTER"
|
|
|
|
ROWS=$(q "select count(*) from oauth_sessions")
|
|
echo " session rows still present: $ROWS"
|
|
|
|
echo
|
|
echo "==> cancellation case (37bab32): kill a pull mid-refresh, then retry"
|
|
q "select session_data from oauth_sessions limit 1" > "$SCRATCH/sd2.json"
|
|
python3 - "$SCRATCH/sd2.json" <<'PY'
|
|
import json, sys
|
|
p = sys.argv[1]
|
|
d = json.load(open(p))
|
|
d["access_token"] = "expired-again-" + d["access_token"][-8:]
|
|
json.dump(d, open(p, "w"))
|
|
PY
|
|
docker cp "$SCRATCH/sd2.json" atcr-appview:/tmp/sd2.json >/dev/null
|
|
q "update oauth_sessions set session_data = readfile('/tmp/sd2.json')"
|
|
docker compose restart atcr-appview >/dev/null 2>&1
|
|
for i in $(seq 1 60); do
|
|
[ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/v2/)" = "401" ] && break
|
|
sleep 1
|
|
done
|
|
|
|
crane pull --insecure "$IMG" "$SCRATCH/e2e-cancel.tar" >/dev/null 2>&1 &
|
|
KILLPID=$!
|
|
sleep 0.15
|
|
kill -9 $KILLPID 2>/dev/null
|
|
wait $KILLPID 2>/dev/null
|
|
echo " killed a pull 150ms in (mid-refresh window)"
|
|
|
|
if crane pull --insecure "$IMG" "$SCRATCH/e2e-retry.tar" >/dev/null 2>&1; then
|
|
echo " retry after cancellation SUCCEEDED"
|
|
else
|
|
echo " retry after cancellation FAILED — refresh token was burned"
|
|
fi
|
|
|
|
echo " final: rev|access|refresh = $(state)"
|
|
echo " session rows: $(q 'select count(*) from oauth_sessions')"
|
|
echo
|
|
echo "restore if needed: docker exec atcr-appview sh -c 'cp /var/lib/atcr/ui.e2e-backup.db $DB'"
|