Files
at-container-registry/pkg/auth/servicetoken_scope_test.go
T
Evan JarrettandClaude Opus 5 ce01e47ba6 auth: cover the two batch-09 commits that shipped without tests
9d4ad84 (read-only app password -> 403) and e6959e6 (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
2026-08-25 16:34:25 -05:00

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