diff --git a/test/e2e/oauth-refresh-e2e.sh b/test/e2e/oauth-refresh-e2e.sh new file mode 100755 index 0000000..fbde87d --- /dev/null +++ b/test/e2e/oauth-refresh-e2e.sh @@ -0,0 +1,101 @@ +#!/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'"