mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 16:26:56 +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
93 lines
3.1 KiB
Go
93 lines
3.1 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A read-only app password authenticates fine through createSession but cannot
|
|
// call com.atproto.server.getServiceAuth, which is privileged: the PDS answers
|
|
// 403 InsufficientScope. Before this was classified it fell through to the
|
|
// generic non-200 branch and surfaced as a retryable 503, so clients looped on
|
|
// a request that can never succeed.
|
|
//
|
|
// The classification is deliberately narrow — status 403 AND the atproto error
|
|
// name — because the sentinel drives a permanent 403 at /auth/token. Widening it
|
|
// would turn a transient outage into a dead end for the user.
|
|
func TestAppPasswordServiceToken_InsufficientScopeIsClassified(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantSentinel bool
|
|
}{
|
|
{
|
|
name: "403 InsufficientScope is the read-only app password",
|
|
status: http.StatusForbidden,
|
|
body: `{"error":"InsufficientScope","message":"Bad token scope"}`,
|
|
wantSentinel: true,
|
|
},
|
|
{
|
|
name: "403 with an unrelated error name stays generic",
|
|
status: http.StatusForbidden,
|
|
body: `{"error":"AccountTakedown","message":"Account has been taken down"}`,
|
|
wantSentinel: false,
|
|
},
|
|
{
|
|
name: "InsufficientScope on a non-403 status stays generic",
|
|
status: http.StatusBadRequest,
|
|
body: `{"error":"InsufficientScope"}`,
|
|
wantSentinel: false,
|
|
},
|
|
{
|
|
name: "503 from the PDS stays generic and retryable",
|
|
status: http.StatusServiceUnavailable,
|
|
body: `{"error":"UpstreamFailure"}`,
|
|
wantSentinel: 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, "app-password-access-token", time.Hour)
|
|
t.Cleanup(func() { GetGlobalTokenCache().Delete(did) })
|
|
|
|
_, err := GetOrFetchServiceTokenWithAppPassword(
|
|
context.Background(), did, holdDID, pds.URL,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected an error from a non-200 PDS response")
|
|
}
|
|
|
|
gotSentinel := errors.Is(err, ErrAppPasswordInsufficientScope)
|
|
if gotSentinel != tt.wantSentinel {
|
|
t.Errorf("errors.Is(err, ErrAppPasswordInsufficientScope) = %v, want %v (err = %v)",
|
|
gotSentinel, tt.wantSentinel, err)
|
|
}
|
|
|
|
// The bearer token is valid — it is only scoped too narrowly — so it
|
|
// must survive. Evicting it would force a re-authentication that
|
|
// produces another read-only token, which is the loop this change
|
|
// exists to stop.
|
|
if _, stillCached := GetGlobalTokenCache().Get(did); tt.wantSentinel && !stillCached {
|
|
t.Error("the app-password token was evicted on an under-scoped grant; it is valid and re-authenticating cannot widen it")
|
|
}
|
|
})
|
|
}
|
|
}
|