Files
at-container-registry/pkg/auth/servicetoken_test.go
T
Evan JarrettandClaude Fable 5.1 dcee8f6a62 deps: upgrade every module; gate loopback OAuth tests on testmode
go get -u across the root, scanner, and deploy modules, then tidy. The
credential helpers pin atcr.io v0.1.4 for standalone go install and are
left alone (go work sync tried to strip that pin; reverted). Direct
upgrades in the root: indigo 20260901 to 20260903, aws-sdk-go-v2 core
1.45.1 to 1.47.0 with config, credentials, and s3 alongside, x/crypto
0.55 to 0.57, x/net, x/sync, x/sys, x/image, klauspost/compress 1.20,
go-containerregistry 0.22.1, goldmark 1.8.6, regclient 0.11.6 (pinned
only by the integration-tagged package, so the bulk upgrade skipped it).
Scanner and deploy had no direct updates; their indirect sets moved.

The indigo delta is a hardening series: identity.DefaultDirectory and
oauth.NewClientApp now carry an SSRF-guarded transport that refuses
loopback and private ranges, did:web and well-known bodies are size
capped, all auth-server endpoints must be HTTPS URLs, and MST decoding
validates PrefixLen on untrusted nodes. Production is unaffected. The
testmode seam in pkg/atproto absorbs the rest: a probe confirmed an
untagged build now refuses 127.0.0.1 with indigo's unsafe-address error
and a tagged build dials through.

Two OAuth tests drove the real client against httptest servers on
loopback and failed untagged after the bump; three siblings in the same
fixtures passed only because the refused dial happened to satisfy a
"transient error" assertion. All five, with their fixtures and fake
stores, move under //go:build testmode in sibling files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwYzaG3Yy7uA8FbZ5qk3tQ
2026-09-11 16:41:58 -05:00

122 lines
4.0 KiB
Go

package auth
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestGetOrFetchServiceToken_NilRefresher(t *testing.T) {
ctx := context.Background()
did := "did:plc:test123"
holdDID := "did:web:hold.example.com"
pdsEndpoint := "https://pds.example.com"
// Test with nil refresher - should return error
_, err := GetOrFetchServiceToken(ctx, nil, did, holdDID, pdsEndpoint)
if err == nil {
t.Error("Expected error when refresher is nil")
}
expectedErrMsg := "refresher is nil"
if err.Error() != "refresher is nil (OAuth session required for service tokens)" {
t.Errorf("Expected error message to contain %q, got %q", expectedErrMsg, err.Error())
}
}
// Note: Full tests with mocked OAuth refresher and HTTP client will be added
// in the comprehensive test implementation phase
// ----------------------------------------------------------------------------
// Session-deletion gating tests (refresh-cancellation incident regression)
// ----------------------------------------------------------------------------
func TestIsStaleBearerToken(t *testing.T) {
stale := []string{"ExpiredToken", "InvalidToken", "ExpiredSession", "InvalidSession"}
for _, name := range stale {
if !isStaleBearerToken(name) {
t.Errorf("isStaleBearerToken(%q) = false, want true", name)
}
}
// Anything else must not discard the credential — an unrelated server-side
// failure should not force the user to re-authenticate.
for _, name := range []string{"", "InvalidRequest", "RateLimitExceeded", "InternalServerError"} {
if isStaleBearerToken(name) {
t.Errorf("isStaleBearerToken(%q) = true, want false", name)
}
}
}
// TestAppPasswordServiceToken_EvictsOnStaleToken is the regression test for a
// wedged-account loop: a PDS reporting an expired bearer token as 400 with an
// atproto error name (rather than 401) left the dead token in the cache, so
// every subsequent request replayed it. One account produced 16,110 such errors
// and 4,254 retryable 503s over 33 hours, recovering only on process restart.
func TestAppPasswordServiceToken_EvictsOnStaleToken(t *testing.T) {
tests := []struct {
name string
status int
body string
wantEvicted bool
}{
{
name: "400 with ExpiredToken evicts",
status: http.StatusBadRequest,
body: `{"error":"ExpiredToken","message":"Token has expired"}`,
wantEvicted: true,
},
{
name: "401 evicts (pre-existing path)",
status: http.StatusUnauthorized,
body: `{"error":"AuthMissing"}`,
wantEvicted: true,
},
{
name: "400 with an unrelated error keeps the token",
status: http.StatusBadRequest,
body: `{"error":"InvalidRequest","message":"bad aud"}`,
wantEvicted: false,
},
{
name: "500 keeps the token",
status: http.StatusInternalServerError,
body: `{"error":"InternalServerError"}`,
wantEvicted: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pds := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(tt.status)
_, _ = w.Write([]byte(tt.body))
}))
defer pds.Close()
did := "did:plc:" + strings.ToLower(strings.ReplaceAll(t.Name(), "/", ""))
holdDID := "did:web:hold.example.com"
GetGlobalTokenCache().Set(did, "stale-access-token", time.Hour)
t.Cleanup(func() { GetGlobalTokenCache().Delete(did) })
if _, err := GetOrFetchServiceTokenWithAppPassword(
context.Background(), did, holdDID, pds.URL,
); err == nil {
t.Fatal("expected an error from a non-200 PDS response")
}
_, stillCached := GetGlobalTokenCache().Get(did)
if tt.wantEvicted && stillCached {
t.Error("expected the stale app-password token to be evicted, but it is still cached")
}
if !tt.wantEvicted && !stillCached {
t.Error("token was evicted on an unrelated failure; the user is forced to re-authenticate needlessly")
}
})
}
}