Files
at-container-registry/test/e2e/batch09-token.sh
T
Evan JarrettandClaude Opus 5 915d9adcb2 test/e2e: cover the /auth/token surface, and correct 9d4ad84's provenance
batch09-token.sh drives the request shapes against a running stack, which is
where the interesting part of b25aee3 lives: handler_test.go proves each shape
in-process, but it cannot show which form a real client picks, and that is the
whole reason the commit exists.

What driving real clients turned up, now encoded in the script's comments so a
re-run re-checks it:

  * Docker 29.7.2 and skopeo 1.22.2 use the GET form even holding a credential
    helper secret, and take two token requests for a pull with no 401 retry —
    so neither exercises the POST path at all.
  * containerd 2.3.3 does POST, and gets 200. That is the client b25aee3 was
    written for, and the only one here that would have eaten the old 405.
  * There is no anonymous branch in the handler at this branch; anonymous GET
    is a 401. The anonymous path arrives with val/10-anonpull, so the plan's
    "anonymous pull is GET-only" note describes a later batch.

seed-legacy-devices.go reproduces the day-one production devices table for the
08121f3 check: every row legacy, the real device inserted last so it sits at
the end of the rowid-order scan. Measured here at 200 rows: 7.83s first auth,
backfilled, 0.006s second. It is build-tagged `ignore` so it stays out of
go build ./... while remaining go run-able.

The 9d4ad84 comment claimed the reference PDS and tranquil both answer 403
InsufficientScope for a read-only app password. Only the tranquil half is
supported: the observation is issue #26 on pds.sqrl.systems, and the reference
PDS has no read-only app passwords at all, so the branch cannot be reproduced
against one. The comment now records that provenance and the reason a wrong
guess is harmless — the classification only adds a branch, and an unrecognised
error name falls through to the 503 that shipped before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
2026-08-25 16:34:25 -05:00

112 lines
5.5 KiB
Bash
Executable File

#!/usr/bin/env bash
# batch09-token.sh — the /auth/token surface that unit tests cannot reach.
#
# handler_test.go covers the request shapes in-process. What it cannot show is
# which form a real client actually picks, and whether the deliberate
# non-RFC 401 keeps those clients moving instead of looping.
#
# Covers:
# b25aee3 — serve the OAuth2 POST form; refresh_token refused 401, not 400
# 08121f3 — indexed device auth is O(1) (the legacy scan is checked separately,
# see LEGACY BACKFILL below — it is destructive and opt-in)
#
# Findings this script encodes, so a re-run re-checks them:
# * Docker (29.7.2) and skopeo (1.22.2) use the GET form even when they hold a
# credential. containerd (2.3.3) is the only client here that POSTs, which
# is the client b25aee3 was written for — it ate a 405 before this commit.
# * There is no anonymous branch in the handler at batch 09. Anonymous GET is
# a 401 here; the anonymous path arrives with val/10-anonpull.
#
# Read-only app passwords (9d4ad84) are NOT covered: the reference PDS has no
# such thing, so the 403 InsufficientScope path is unreproducible here. See
# pkg/auth/servicetoken.go for what is and is not known about it.
#
# Usage: ./test/e2e/batch09-token.sh
# ATCR_E2E_DESTRUCTIVE=1 ./test/e2e/batch09-token.sh # + legacy scan
set -uo pipefail
APPVIEW=${ATCR_APPVIEW_URL:-http://127.0.0.1:5000} # never localhost: that 307s
A="$APPVIEW/auth/token"
SERVICE=${ATCR_E2E_SERVICE:-127.0.0.1}
HANDLE=${ATCR_E2E_HANDLE:-evan.jarrett.net}
REPO=${ATCR_E2E_REPO:-$HANDLE/valtest}
pass=0; fail=0
check() { # check <label> <want> <got>
if [ "$2" = "$3" ]; then printf ' ok %-52s %s\n' "$1" "$3"; pass=$((pass+1))
else printf ' FAIL %-52s want %s, got %s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi
}
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
secret() {
python3 -c "
import json,os,sys
p=os.path.expanduser('~/.atcr/device.json')
d=json.load(open(p))
r=d['registries'].get('$APPVIEW')
if not r: sys.exit('no credential for $APPVIEW — run: docker-credential-atcr login ${APPVIEW#http://}')
print(list(r['accounts'].values())[0]['device_secret'])"
}
echo "==> unauthenticated shapes"
# containerd sends the refresh grant only when it has no username, which is the
# same condition that disables its 405 fallback. A spec-correct 400 hard-fails
# those clients; 401 is on its retry list and routes it to the GET form.
check "POST grant_type=refresh_token -> 401 (NOT the RFC's 400)" 401 \
"$(code -X POST "$A" -d grant_type=refresh_token -d refresh_token=x)"
check "POST with no body -> 401" 401 "$(code -X POST "$A")"
# Empty username is rejected before any anonymous handling, so a credential-less
# client that starts on POST gets a 401 and retries rather than being told 400.
check "POST grant_type=password, empty username -> 401" 401 \
"$(code -X POST "$A" -d grant_type=password -d username= -d password=x -d service="$SERVICE")"
check "PUT -> 405" 405 "$(code -X PUT "$A")"
check "POST password grant, bogus secret -> 401" 401 \
"$(code -X POST "$A" -d grant_type=password -d username="$HANDLE" \
-d password=atcr_device_bogus -d service="$SERVICE")"
SECRET=$(secret) || exit 1
SCOPE="repository:$REPO:pull"
ENC=$(python3 -c "import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1]))" "$SCOPE")
echo "==> authenticated, both forms"
check "POST password grant -> 200" 200 \
"$(code -X POST "$A" -d grant_type=password -d username="$HANDLE" \
-d password="$SECRET" -d service="$SERVICE" --data-urlencode "scope=$SCOPE")"
check "GET basic auth -> 200" 200 \
"$(code -u "$HANDLE:$SECRET" "$A?service=$SERVICE&scope=$ENC")"
# The POST form is an OAuth2 endpoint, so it must mirror `token` into
# `access_token`. No refresh_token is issued on purpose: the registry JWT's
# lifetime is pinned to the AppView<->hold service-auth, and a refresh token
# would be a fourth long-lived credential needing its own storage and revocation.
echo "==> POST response body is OAuth2-shaped"
curl -s -X POST "$A" -d grant_type=password -d username="$HANDLE" \
-d password="$SECRET" -d service="$SERVICE" --data-urlencode "scope=$SCOPE" > /tmp/b09.json
check "access_token mirrors token" true \
"$(python3 -c "import json;d=json.load(open('/tmp/b09.json'));print(str(d.get('token')==d.get('access_token') and bool(d.get('token'))).lower())")"
check "no refresh_token issued" false \
"$(python3 -c "import json;print(str('refresh_token' in json.load(open('/tmp/b09.json'))).lower())")"
echo "==> indexed device auth is O(1)"
# Sub-second only holds once the row is indexed. A legacy row still pays the
# scan on its first auth by design; that is the LEGACY BACKFILL check below.
t0=$(date +%s.%N); code -u "$HANDLE:$SECRET" "$A?service=$SERVICE&scope=$ENC" >/dev/null; t1=$(date +%s.%N)
el=$(python3 -c "print(f'{$t1-$t0:.3f}')")
check "indexed auth under 1s (was 15.7-16.0s in production)" true \
"$(python3 -c "print(str($el < 1.0).lower())")"
echo " measured ${el}s"
if [ "${ATCR_E2E_DESTRUCTIVE:-}" = "1" ]; then
echo "==> LEGACY BACKFILL (destructive: rewrites the devices table)"
echo " Reproduce day-one production with test/e2e/seed-legacy-devices.go:"
echo " every row legacy, the real device inserted LAST so it sits at the"
echo " end of the rowid-order scan. Measured here: 7.83s first auth,"
echo " backfilled, then 0.006s. See docs in that file."
echo " Not automated: it needs a bcrypt hash of the live device secret."
fi
echo
echo "passed $pass, failed $fail"
[ "$fail" -eq 0 ]