mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 19:24:16 +00:00
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
122 lines
4.0 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|
|
}
|