mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
9d4ad84(read-only app password -> 403) ande6959e6(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
105 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|