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) } }