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
77 lines
2.6 KiB
Go
77 lines
2.6 KiB
Go
package token
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/auth"
|
|
)
|
|
|
|
// A read-only app password is a permanent authorization failure, not an outage.
|
|
// Returning the generic 503 tells the client to retry a request that can never
|
|
// succeed and says nothing about what is wrong; the fix is only discoverable by
|
|
// reading appview logs the user does not have.
|
|
//
|
|
// This is the sibling of TestHandler_ServiceAuthFetcher_FailureReturns503: same
|
|
// path, same stub, and the only difference is which error the fetcher reports.
|
|
func TestHandler_ServiceAuthFetcher_InsufficientScopeReturns403(t *testing.T) {
|
|
keyPath := getSharedTestKey(t)
|
|
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 5*time.Minute)
|
|
if err != nil {
|
|
t.Fatalf("NewIssuer() error = %v", err)
|
|
}
|
|
|
|
deviceStore, database := setupTestDeviceStore(t)
|
|
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:alice123", "alice.bsky.social")
|
|
|
|
handler := NewHandler(issuer, deviceStore)
|
|
// Wrapped, not bare: the production path returns
|
|
// fmt.Errorf("%w: %s", ErrAppPasswordInsufficientScope, body), so a handler
|
|
// that compared with == instead of errors.Is would still 503.
|
|
stub := &stubServiceAuthFetcher{
|
|
err: fmt.Errorf("%w: %s", auth.ErrAppPasswordInsufficientScope,
|
|
`{"error":"InsufficientScope","message":"Bad token scope"}`),
|
|
}
|
|
handler.SetServiceAuthFetcher(stub)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
|
|
req.SetBasicAuth("alice", deviceSecret)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("expected 403 for an under-scoped app password, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// The status alone is not the deliverable: the body has to name the remedy,
|
|
// because "app password" and "read-only" are the only words that tell the
|
|
// user what to change.
|
|
var body struct {
|
|
Errors []struct {
|
|
Code string `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"errors"`
|
|
}
|
|
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
|
t.Fatalf("decode error response: %v", err)
|
|
}
|
|
if len(body.Errors) == 0 {
|
|
t.Fatal("expected an OCI error payload, got none")
|
|
}
|
|
if body.Errors[0].Code != "DENIED" {
|
|
t.Errorf("expected error code DENIED, got %q", body.Errors[0].Code)
|
|
}
|
|
msg := strings.ToLower(body.Errors[0].Message)
|
|
for _, want := range []string{"app password", "read-only"} {
|
|
if !strings.Contains(msg, want) {
|
|
t.Errorf("error message does not mention %q, so it does not tell the user what to fix: %q", want, body.Errors[0].Message)
|
|
}
|
|
}
|
|
}
|