Files
at-container-registry/pkg/auth/timeout_test.go
T
Evan JarrettandClaude Opus 5 ce01e47ba6 auth: cover the two batch-09 commits that shipped without tests
9d4ad84 (read-only app password -> 403) and e6959e6 (bounded HTTP clients on
the token path) both landed with no test at all. These are the ones a
regression would be silent in: a revert of either leaves every existing test
green.

Each test was mutation-verified against the defect it claims to catch, in a
throwaway worktree, and required to fail:

  * revert ResolveHoldDID to http.DefaultClient  -> SlowHoldIsCutOff fails
  * revert getServiceAuth to http.DefaultClient  -> SlowPDSIsCutOff fails
  * NewSessionValidator back to &http.Client{}   -> ClientsAreBounded fails
  * drop the InsufficientScope classification    -> IsClassified fails
  * drop the handler's errors.Is branch          -> Returns403 fails, and the
    body it returns is the exact retry-inviting 503 UNAVAILABLE the commit
    exists to remove

The slow-path tests wait on an outer deadline rather than on the call itself.
With an unbounded client these calls never return, so a test that simply
awaited the result would hang the suite instead of failing it, and a hung
suite reports nothing.

The client caps are asserted twice on purpose: once as a field value, which
guards the production 10s/15s numbers, and once functionally, which proves the
call site routes through the bounded client rather than merely declaring one.
Neither half catches the other's regression.

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

105 lines
3.9 KiB
Go

package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// Both of these ran on http.DefaultClient, which has no timeout, so a slow or
// unreachable PDS could hold /auth/token open indefinitely — past Docker's own
// token-fetch deadline, with no way to shed the request.
//
// The OAuth refresh path is deliberately excluded: its POSTs go through
// refreshDetachTransport and cancelling one mid-rotation strands a rotated
// refresh token. Do not "fix" that one by symmetry.
func TestTokenPathHTTPClientsAreBounded(t *testing.T) {
if appPasswordServiceAuthClient.Timeout != 10*time.Second {
t.Errorf("appPasswordServiceAuthClient.Timeout = %v, want 10s", appPasswordServiceAuthClient.Timeout)
}
if got := NewSessionValidator().httpClient.Timeout; got != 15*time.Second {
t.Errorf("SessionValidator.httpClient.Timeout = %v, want 15s — createSession on an unreachable PDS otherwise never returns", got)
}
}
// Proves the getServiceAuth call actually routes through the bounded client.
// Reverting the call site to http.DefaultClient keeps the field assertion above
// green and fails this.
func TestAppPasswordServiceToken_SlowPDSIsCutOff(t *testing.T) {
release := make(chan struct{})
pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
_, _ = w.Write([]byte(`{"token":"never-arrives"}`))
}))
t.Cleanup(func() { close(release); pds.Close() })
restore := appPasswordServiceAuthClient
appPasswordServiceAuthClient = &http.Client{Timeout: 100 * time.Millisecond}
t.Cleanup(func() { appPasswordServiceAuthClient = restore })
did := "did:plc:slowpds"
GetGlobalTokenCache().Set(did, "app-password-access-token", time.Hour)
t.Cleanup(func() { GetGlobalTokenCache().Delete(did) })
done := make(chan callResult, 1)
go func() {
start := time.Now()
_, err := GetOrFetchServiceTokenWithAppPassword(
context.Background(), did, "did:web:hold.example.com", pds.URL,
)
done <- callResult{err, time.Since(start)}
}()
assertCutOff(t, done, "GetOrFetchServiceTokenWithAppPassword", "appPasswordServiceAuthClient")
}
// Same, for createSession. The client here is a struct field rather than a
// package var, so the swap goes through the validator.
func TestSessionValidator_SlowPDSIsCutOff(t *testing.T) {
release := make(chan struct{})
pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-release
_, _ = w.Write([]byte(`{"did":"did:plc:alice","handle":"alice.test","accessJwt":"x","refreshJwt":"y"}`))
}))
t.Cleanup(func() { close(release); pds.Close() })
v := NewSessionValidator()
v.httpClient = &http.Client{Timeout: 100 * time.Millisecond}
done := make(chan callResult, 1)
go func() {
start := time.Now()
_, err := v.createSession(context.Background(), pds.URL, "alice.test", "app-password")
done <- callResult{err, time.Since(start)}
}()
assertCutOff(t, done, "createSession", "v.httpClient")
}
type callResult struct {
err error
elapsed time.Duration
}
// assertCutOff waits for a call that should have been cut off by a 100ms client
// cap. The outer deadline is what makes an unbounded client fail the test rather
// than hang it: on http.DefaultClient these calls never return on their own, and
// a hung suite reports nothing.
func assertCutOff(t *testing.T, done <-chan callResult, call, client string) {
t.Helper()
select {
case res := <-done:
if res.err == nil {
t.Fatalf("%s: expected a timeout error from an unresponsive PDS, got none", call)
}
// A whole second is ten times the configured cap, so this separates "the
// bounded client was used" from "the request ran to completion" without
// being flaky under load.
if res.elapsed > time.Second {
t.Errorf("%s took %v with a 100ms client cap — the call is not going through %s", call, res.elapsed, client)
}
case <-time.After(2 * time.Second):
t.Fatalf("%s never returned against a PDS that never answers — the call is not going through %s", call, client)
}
}