Files
at-container-registry/pkg/auth/token/handler_test.go
T
Evan JarrettandClaude Opus 5 a7569a7717 registry: allow anonymous pull of public images
Credential-less pulls of public images. /auth/token issues a pull-only
token with an empty subject when no Basic auth is present; the
destination hold still enforces captain.Public, and push or delete always
challenges.

  - token.IsPullOnlyScope and AuthMethodAnonymous;
    Handler.issueAnonymousToken skips the authorizer gate and the
    service-auth pre-mint, since there is no identity to reconcile and no
    AppView-to-hold service token to bind. The token is still stamped
    with the resolved registry domain, so anonymous pull works on
    secondary front doors whose access controller demands their own
    audience.
  - auth.allow_anonymous_pull (default true) turns it fully off, restoring
    the previous always-challenge behavior. Mirrored into the deploy
    template, since the default means existing deploys pick this up.
  - RegistryContext.Anonymous is plumbed from the middleware.
  - ProxyBlobStore sends no Authorization header when the service token is
    empty, and returns 401 rather than 403 for anonymous denials so Docker
    prompts for credentials, including when a stale captain cache lets the
    request through and the hold says private.
  - BearerChallenge wraps the /v2/ subtree so a 401 raised deep in the
    stack via errcode.ServeJSON still carries WWW-Authenticate.
    Distribution's own scoped challenges are left alone.

IsPullOnlyScope allowlists the pull action instead of denylisting push and
delete. Distribution's actionSet.contains treats "*" as *every* action, so
a scope of `repository:victim/img:*` names neither denied string and would
have handed an unauthenticated caller a token valid for push and delete on
someone else's repository — clearing the authgate entirely, since anonymous
tokens deliberately skip it. Writes would still have failed further down
(no PDS credential), but the gate itself was bypassable. Now every
requested action must be exactly "pull". Covered by new claims tests.

Unresolvable identities return NAME_UNKNOWN instead of a bare error that
distribution renders as 500. This path was previously unreachable without
credentials; anonymous pull opens it to the internet, and a 5xx on
arbitrary input both misreports a bad request as a server fault and sends
clients that retry 5xx into a retry loop. That loop was real: in the auth
matrix, regclient spent 83s on a single case before this fix, and the
suite now runs in 5s.

Stat preserves an authorization verdict from getPresignedURL rather than
flattening it to ErrBlobUnknown. Distribution calls Stat before ServeBlob
on GET and HEAD, so without this an anonymous pull from a private hold
answered 404 and BearerChallenge had no 401 to annotate — the 401 path
above could never actually reach a client.

The auth matrix is updated to match: anonymous pull of the seeded public
repo now succeeds, anonymous push is denied against a real identity's
namespace (rather than an unresolvable one, which was testing name
resolution rather than authorization), and a new case pins the
NAME_UNKNOWN behavior for an unknown identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:14:58 -05:00

1347 lines
42 KiB
Go

package token
import (
"context"
"crypto/tls"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/auth"
)
// Shared test key to avoid generating a new RSA key for each test
// Generating a 2048-bit RSA key takes ~0.15s, so reusing one key saves ~4.5s for 32 tests
var (
sharedTestKeyPath string
sharedTestKeyOnce sync.Once
sharedTestKeyDir string
)
// getSharedTestKey returns a shared RSA key and its file path for all tests
// The key is generated once and reused across all tests in this package
func getSharedTestKey(t *testing.T) string {
sharedTestKeyOnce.Do(func() {
// Create a persistent temp directory for the shared key
var err error
sharedTestKeyDir, err = os.MkdirTemp("", "atcr-test-keys-*")
if err != nil {
t.Fatalf("Failed to create test key directory: %v", err)
}
sharedTestKeyPath = filepath.Join(sharedTestKeyDir, "test-key.pem")
// Generate the key once (this is the expensive operation we want to avoid repeating)
// This will also generate the certificate via NewIssuer
_, err = NewIssuer(sharedTestKeyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("Failed to generate shared test key: %v", err)
}
})
return sharedTestKeyPath
}
// setupTestDeviceStore creates an in-memory SQLite database for testing
func setupTestDeviceStore(t *testing.T) (*db.DeviceStore, *sql.DB) {
testDB, err := db.InitDB(":memory:", db.LibsqlConfig{})
if err != nil {
t.Fatalf("Failed to initialize test database: %v", err)
}
return db.NewDeviceStore(testDB), testDB
}
// createTestDevice creates a device in the test database and returns its secret
// Requires both DeviceStore and sql.DB to insert user record first
func createTestDevice(t *testing.T, store *db.DeviceStore, testDB *sql.DB, did, handle string) string {
// First create a user record (required by foreign key constraint)
user := &db.User{
DID: did,
Handle: handle,
PDSEndpoint: "https://pds.example.com",
}
err := db.UpsertUser(testDB, user)
if err != nil {
t.Fatalf("Failed to create user: %v", err)
}
// Create pending authorization
pending, err := store.CreatePendingAuth("Test Device", "127.0.0.1", "test-agent")
if err != nil {
t.Fatalf("Failed to create pending auth: %v", err)
}
// Approve the pending authorization
secret, err := store.ApprovePending(pending.UserCode, did, handle)
if err != nil {
t.Fatalf("Failed to approve pending auth: %v", err)
}
return secret
}
func TestNewHandler(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
if handler == nil {
t.Fatal("Expected non-nil handler")
}
if handler.issuer == nil {
t.Error("Expected issuer to be set")
}
if handler.validator == nil {
t.Error("Expected validator to be initialized")
}
}
func TestHandler_SetPostAuthCallback(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
handler.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, token string) error {
return nil
})
if handler.postAuthCallback == nil {
t.Error("Expected post-auth callback to be set")
}
}
func TestHandler_ServeHTTP_NoAuth(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// With anonymous pull disabled, a credential-less request is challenged.
handler.SetAllowAnonymousPull(false)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
// Check for WWW-Authenticate header
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("Expected WWW-Authenticate header")
}
}
func TestHandler_ServeHTTP_AnonymousPull(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil) // allowAnonymousPull defaults true
// No credentials, pull-only scope for someone else's repo.
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:pull", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d for anonymous pull, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Fatal("Expected non-empty anonymous token")
}
// The anonymous token carries no subject and the anonymous auth method.
if sub := ExtractSubject(resp.Token); sub != "" {
t.Errorf("Expected empty subject for anonymous token, got %q", sub)
}
if am := ExtractAuthMethod(resp.Token); am != AuthMethodAnonymous {
t.Errorf("Expected auth method %q, got %q", AuthMethodAnonymous, am)
}
access := ExtractAccess(resp.Token)
if len(access) != 1 || access[0].Name != "bob.bsky.social/myapp" {
t.Errorf("Expected pull access for bob.bsky.social/myapp, got %+v", access)
}
}
func TestHandler_ServeHTTP_AnonymousPing(t *testing.T) {
// The /v2/ ping requests a token with no scope (empty access). Anonymous
// issuance must grant it so the ping succeeds without credentials.
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d for anonymous ping, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token for anonymous ping")
}
if len(ExtractAccess(resp.Token)) != 0 {
t.Errorf("Expected empty access for ping token, got %+v", ExtractAccess(resp.Token))
}
}
func TestHandler_ServeHTTP_AnonymousPushChallenged(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
for _, action := range []string{"push", "delete", "pull,push"} {
t.Run(action, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:"+action, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d for anonymous %s, got %d. Body: %s", http.StatusUnauthorized, action, w.Code, w.Body.String())
}
if w.Header().Get("WWW-Authenticate") == "" {
t.Error("Expected WWW-Authenticate header on anonymous write challenge")
}
})
}
}
func TestHandler_ServeHTTP_AnonymousDisabledChallengesPull(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
handler.SetAllowAnonymousPull(false)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:pull", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d when anonymous pull disabled, got %d. Body: %s", http.StatusUnauthorized, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_WrongMethod(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// GET and POST are both real token specs; anything else is not.
req := httptest.NewRequest(http.MethodPut, "/auth/token", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
}
// newOAuthTokenRequest builds a POST request in the OAuth2 token spec's
// form-encoded shape.
func newOAuthTokenRequest(form url.Values) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/auth/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
func TestHandler_ServeHTTP_OAuthPost_UnsupportedGrant(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// We issue no refresh tokens, so the refresh grant is rejected. The status
// must stay 401 and not the RFC's 400: clients that send this grant have no
// username, so 401 is the only answer that still routes them to the GET form
// instead of failing them outright.
req := newOAuthTokenRequest(url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {"whatever"},
"service": {"registry"},
})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
var body oauthError
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("response body is not an OAuth error object: %v", err)
}
if body.Error != "unsupported_grant_type" {
t.Errorf("error = %q, want unsupported_grant_type", body.Error)
}
}
func TestHandler_ServeHTTP_OAuthPost_MissingCredentials(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// 401 is one of the statuses clients retry on the GET form, so an empty
// body must not come back as anything else.
req := newOAuthTokenRequest(url.Values{"grant_type": {"password"}})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestHandler_ServeHTTP_OAuthPost_DeviceAuth_Valid(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Same credentials and scope as the GET device-auth test, delivered in the
// form body instead. The two paths must issue equivalent tokens.
req := newOAuthTokenRequest(url.Values{
"grant_type": {"password"},
"username": {"alice.bsky.social"},
"password": {deviceSecret},
"service": {"registry"},
"scope": {"repository:alice.bsky.social/myapp:pull,push"},
})
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d, got %d (body: %s)", http.StatusOK, w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("Failed to unmarshal response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token")
}
// Docker's OAuth2 client reads access_token, not the legacy token field.
if resp.AccessToken != resp.Token {
t.Error("access_token must mirror token for OAuth2 clients")
}
}
func TestHandler_ServeHTTP_DeviceAuth_Valid(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Create real device store with in-memory database
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Create request with device secret
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull,push", nil)
req.SetBasicAuth("alice.bsky.social", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
t.Logf("Response body: %s", w.Body.String())
}
// Parse response
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token")
}
if resp.AccessToken == "" {
t.Error("Expected non-empty access_token")
}
if resp.ExpiresIn == 0 {
t.Error("Expected non-zero expires_in")
}
// Verify token and access_token are the same
if resp.Token != resp.AccessToken {
t.Error("Expected token and access_token to be the same")
}
}
func TestHandler_ServeHTTP_DeviceAuth_Invalid(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
// Create device store but don't add any devices
deviceStore, _ := setupTestDeviceStore(t)
handler := NewHandler(issuer, deviceStore)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
req.SetBasicAuth("alice", "atcr_device_invalid")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code)
}
}
func TestHandler_ServeHTTP_InvalidScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Invalid scope format (missing colons)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=invalid", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "invalid scope") {
t.Errorf("Expected error message to contain 'invalid scope', got: %s", body)
}
}
func TestHandler_ServeHTTP_AccessDenied(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// Try to push to someone else's repository
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusForbidden {
t.Errorf("Expected status %d, got %d", http.StatusForbidden, w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "access denied") {
t.Errorf("Expected error message to contain 'access denied', got: %s", body)
}
}
func TestHandler_ServeHTTP_WithCallback(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceStore, database := setupTestDeviceStore(t)
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:user123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Set callback to track if it's called
callbackCalled := false
handler.SetPostAuthCallback(func(ctx context.Context, did, handle, pds, token string) error {
callbackCalled = true
// Note: We don't check the values because callback shouldn't be called for device auth
return nil
})
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Note: Callback is only called for app password auth, not device auth
// So callbackCalled should be false for this test
if callbackCalled {
t.Error("Expected callback NOT to be called for device auth")
}
}
func TestHandler_ServeHTTP_MultipleScopes(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// Multiple scopes separated by space (URL encoded)
scopes := "repository%3Aalice.bsky.social%2Fapp1%3Apull+repository%3Aalice.bsky.social%2Fapp2%3Apush"
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope="+scopes, nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_WildcardScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// Wildcard scope should be allowed
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:*:pull,push", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestHandler_ServeHTTP_NoScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// No scope parameter - should still work (empty access)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code)
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if resp.Token == "" {
t.Error("Expected non-empty token even with no scope")
}
}
func TestGetBaseURL(t *testing.T) {
tests := []struct {
name string
host string
headers map[string]string
expectedURL string
}{
{
name: "simple host",
host: "registry.example.com",
headers: map[string]string{},
expectedURL: "http://registry.example.com",
},
{
name: "with TLS",
host: "registry.example.com",
headers: map[string]string{},
expectedURL: "https://registry.example.com", // Would need TLS in request
},
{
name: "with X-Forwarded-Host",
host: "internal-host",
headers: map[string]string{
"X-Forwarded-Host": "registry.example.com",
},
expectedURL: "http://registry.example.com",
},
{
name: "with X-Forwarded-Proto",
host: "registry.example.com",
headers: map[string]string{
"X-Forwarded-Proto": "https",
},
expectedURL: "https://registry.example.com",
},
{
name: "with both forwarded headers",
host: "internal",
headers: map[string]string{
"X-Forwarded-Host": "registry.example.com",
"X-Forwarded-Proto": "https",
},
expectedURL: "https://registry.example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Host = tt.host
for key, value := range tt.headers {
req.Header.Set(key, value)
}
// For TLS test
if tt.expectedURL == "https://registry.example.com" && len(tt.headers) == 0 {
req.TLS = &tls.ConnectionState{} // Non-nil TLS indicates HTTPS
}
baseURL := getBaseURL(req)
if baseURL != tt.expectedURL {
t.Errorf("Expected URL %q, got %q", tt.expectedURL, baseURL)
}
})
}
}
func TestTokenResponse_JSONFormat(t *testing.T) {
resp := TokenResponse{
Token: "jwt_token_here",
AccessToken: "jwt_token_here",
ExpiresIn: 900,
IssuedAt: "2025-01-01T00:00:00Z",
}
data, err := json.Marshal(resp)
if err != nil {
t.Fatalf("Failed to marshal response: %v", err)
}
// Verify JSON structure
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("Failed to unmarshal JSON: %v", err)
}
if decoded["token"] != "jwt_token_here" {
t.Error("Expected token field in JSON")
}
if decoded["access_token"] != "jwt_token_here" {
t.Error("Expected access_token field in JSON")
}
if decoded["expires_in"] != float64(900) {
t.Error("Expected expires_in field in JSON")
}
if decoded["issued_at"] != "2025-01-01T00:00:00Z" {
t.Error("Expected issued_at field in JSON")
}
}
func TestHandler_ServeHTTP_AuthHeader(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
handler := NewHandler(issuer, nil)
// Test with manually constructed auth header
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry", nil)
auth := base64.StdEncoding.EncodeToString([]byte("username:password"))
req.Header.Set("Authorization", "Basic "+auth)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
// Should fail because we don't have valid credentials, but we're testing the header parsing
if w.Code != http.StatusUnauthorized {
t.Logf("Got status %d (this is fine, we're just testing header parsing)", w.Code)
}
}
func TestHandler_ServeHTTP_ContentType(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status %d, got %d", http.StatusOK, w.Code)
}
contentType := w.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Expected Content-Type 'application/json', got %q", contentType)
}
}
func TestHandler_ServeHTTP_ExpiresIn(t *testing.T) {
keyPath := getSharedTestKey(t)
// Create issuer with specific expiration
expiration := 10 * time.Minute
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", expiration)
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)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
expectedExpiresIn := int(expiration.Seconds())
if resp.ExpiresIn != expectedExpiresIn {
t.Errorf("Expected expires_in %d, got %d", expectedExpiresIn, resp.ExpiresIn)
}
}
func TestHandler_ServeHTTP_PullOnlyAccess(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// Pull from someone else's repo should be allowed
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:bob.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d for pull-only access, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}
func TestParseBasicAuthDID(t *testing.T) {
tests := []struct {
name string
username string
password string
wantUsername string
wantPassword string
}{
{
name: "normal handle unchanged",
username: "alice.bsky.social",
password: "mypassword",
wantUsername: "alice.bsky.social",
wantPassword: "mypassword",
},
{
name: "hyphen-encoded did:plc",
username: "did-plc-abc123",
password: "mypassword",
wantUsername: "did:plc:abc123",
wantPassword: "mypassword",
},
{
name: "hyphen-encoded did:web",
username: "did-web-example.com",
password: "mypassword",
wantUsername: "did:web:example.com",
wantPassword: "mypassword",
},
{
name: "raw did:plc mangled by BasicAuth",
username: "did",
password: "plc:abc123:mypassword",
wantUsername: "did:plc:abc123",
wantPassword: "mypassword",
},
{
name: "raw did:web mangled by BasicAuth",
username: "did",
password: "web:example.com:mypassword",
wantUsername: "did:web:example.com",
wantPassword: "mypassword",
},
{
name: "raw did:plc with device secret",
username: "did",
password: "plc:e3kzdezk5gsirzh7eoqplc64:atcr_device_abc123",
wantUsername: "did:plc:e3kzdezk5gsirzh7eoqplc64",
wantPassword: "atcr_device_abc123",
},
{
name: "username did but not a DID method",
username: "did",
password: "something:else",
wantUsername: "did",
wantPassword: "something:else",
},
{
name: "username did with no colon in rest",
username: "did",
password: "plc:abc123",
wantUsername: "did",
wantPassword: "plc:abc123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotUsername, gotPassword := parseBasicAuthDID(tt.username, tt.password)
if gotUsername != tt.wantUsername {
t.Errorf("username = %q, want %q", gotUsername, tt.wantUsername)
}
if gotPassword != tt.wantPassword {
t.Errorf("password = %q, want %q", gotPassword, tt.wantPassword)
}
})
}
}
// stubAuthorizer is a configurable Authorizer for testing the gate.
type stubAuthorizer struct {
called bool
err error
delay time.Duration // optional sleep before returning, for parallelism tests
// Concurrency barrier — when set, Authorize closes `started` then waits on
// `partnerStarted`. Two stubs cross-wired this way both block until both
// goroutines are live, deterministically proving parallel execution.
// If the partner never starts, the wait fails after barrierTimeout.
started chan struct{}
partnerStarted chan struct{}
barrierTimeout time.Duration
}
func (s *stubAuthorizer) Authorize(_ context.Context, _, _ string, _ []auth.AccessEntry) error {
s.called = true
if s.started != nil {
close(s.started)
select {
case <-s.partnerStarted:
case <-time.After(s.barrierTimeout):
return errors.New("partner goroutine did not start — sequential execution")
}
}
if s.delay > 0 {
time.Sleep(s.delay)
}
return s.err
}
func TestHandler_Authorizer_DeniesPushScope(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
stub := &stubAuthorizer{err: errors.New("quota exceeded: 6000000000 / 5368709120 bytes used")}
handler.SetAuthorizer(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 !stub.called {
t.Fatal("expected Authorizer to be called for push-scoped token request")
}
if w.Code != http.StatusForbidden {
t.Errorf("expected 403, got %d. Body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "quota exceeded") {
t.Errorf("expected denial reason in body, got: %s", w.Body.String())
}
// Distribution error JSON shape: {"errors":[{"code":"DENIED","message":"..."}]}
var errBody struct {
Errors []struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(w.Body.Bytes(), &errBody); err != nil {
t.Fatalf("decode distribution error JSON: %v. Body: %s", err, w.Body.String())
}
if len(errBody.Errors) == 0 || errBody.Errors[0].Code != "DENIED" {
t.Errorf("expected errors[0].code = DENIED, got %+v", errBody.Errors)
}
}
func TestHandler_Authorizer_RunsForPullOnly(t *testing.T) {
// Pull-only scopes still go through the gate so first-time CLI users on
// a private hold can have their crew membership reconciled before the
// hold-side read check runs.
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
stub := &stubAuthorizer{} // succeeds
handler.SetAuthorizer(stub)
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("alice", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !stub.called {
t.Error("Authorizer should run for pull-only scopes (drives crew reconciliation)")
}
if w.Code != http.StatusOK {
t.Errorf("expected 200 for pull-only, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestHandler_Authorizer_NilIsBackwardsCompatible(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// No authorizer set.
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.StatusOK {
t.Errorf("expected 200 with no authorizer configured, got %d. Body: %s", w.Code, w.Body.String())
}
}
// stubServiceAuthFetcher is a configurable ServiceAuthFetcher for testing
// the JWT-to-service-auth lifetime binding.
type stubServiceAuthFetcher struct {
called bool
expiresAt time.Time
err error
delay time.Duration // optional sleep before returning, for parallelism tests
// Concurrency barrier — see stubAuthorizer for semantics.
started chan struct{}
partnerStarted chan struct{}
barrierTimeout time.Duration
}
func (s *stubServiceAuthFetcher) Fetch(_ context.Context, _, _ string) (time.Time, error) {
s.called = true
if s.started != nil {
close(s.started)
select {
case <-s.partnerStarted:
case <-time.After(s.barrierTimeout):
return time.Time{}, errors.New("partner goroutine did not start — sequential execution")
}
}
if s.delay > 0 {
time.Sleep(s.delay)
}
return s.expiresAt, s.err
}
func TestHandler_ServiceAuthFetcher_BindsJWTExp(t *testing.T) {
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*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)
// Service-auth expires in 4 minutes; JWT should be capped to that.
stub := &stubServiceAuthFetcher{expiresAt: time.Now().Add(4 * time.Minute)}
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 !stub.called {
t.Fatal("expected ServiceAuthFetcher.Fetch to be called")
}
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// Allow a few seconds of slack for time elapsed during the request.
if resp.ExpiresIn > 240 || resp.ExpiresIn < 230 {
t.Errorf("expected expires_in ≈ 240 (capped to service-auth), got %d", resp.ExpiresIn)
}
}
func TestHandler_ServiceAuthFetcher_NoHoldUsesDefault(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)
// Zero time + nil error = "no hold configured" — fall back to default.
stub := &stubServiceAuthFetcher{expiresAt: time.Time{}}
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.StatusOK {
t.Fatalf("expected 200 with no-hold degradation, got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ExpiresIn != 300 {
t.Errorf("expected expires_in = 300 (issuer default), got %d", resp.ExpiresIn)
}
}
func TestHandler_ServiceAuthFetcher_FailureReturns503(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)
stub := &stubServiceAuthFetcher{err: errors.New("PDS unreachable")}
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.StatusServiceUnavailable {
t.Errorf("expected 503 on fetch failure, got %d. Body: %s", w.Code, w.Body.String())
}
}
func TestHandler_ServiceAuthFetcher_NilUsesIssuerDefault(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)
// No fetcher set — backward-compat path.
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.StatusOK {
t.Fatalf("expected 200 with nil fetcher, got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.ExpiresIn != 300 {
t.Errorf("expected expires_in = 300 (issuer default), got %d", resp.ExpiresIn)
}
}
func TestHandler_GateDenialPreemptsFetchError(t *testing.T) {
// When both branches error, the gate's denial wins: it's drained
// first and returns 403/DENIED before the fetcher's 503/UNAVAILABLE
// can surface. Regression guard for the drain-gate-first contract.
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)
handler.SetAuthorizer(&stubAuthorizer{err: errors.New("crew membership required")})
handler.SetServiceAuthFetcher(&stubServiceAuthFetcher{err: errors.New("PDS unreachable")})
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.Errorf("expected 403 (gate denial wins), got %d. Body: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "crew membership required") {
t.Errorf("expected gate's error message in body, got: %s", w.Body.String())
}
}
func TestHandler_ExpiresInCapZeroFloor(t *testing.T) {
// Regression guard — see follow-up if a non-negative floor is desired.
// When the fetcher reports a service-auth that's already expired, the
// JWT exp-cap arithmetic (`if until < issueExp { issueExp = until }`)
// lets issueExp go negative. We lock in the current observable
// behavior so a future change here is intentional, not silent.
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)
handler.SetServiceAuthFetcher(&stubServiceAuthFetcher{
expiresAt: time.Now().Add(-1 * time.Minute), // already expired
})
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.StatusOK {
t.Fatalf("expected 200 (token still issued, just with short exp), got %d. Body: %s", w.Code, w.Body.String())
}
var resp TokenResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// Current behavior: ExpiresIn is negative (≈ -60). The handler does
// not floor it. If someone introduces a floor, this assertion needs
// updating along with the docstring above.
if resp.ExpiresIn >= 0 {
t.Errorf("expected negative expires_in for already-expired service-auth, got %d", resp.ExpiresIn)
}
}
func TestTokenHandler_DIDBasicAuth(t *testing.T) {
// Test that a DID passed as BasicAuth username works through the full handler
deviceStore, database := setupTestDeviceStore(t)
keyPath := getSharedTestKey(t)
issuer, err := NewIssuer(keyPath, "atcr.io", "registry", 15*time.Minute)
if err != nil {
t.Fatalf("NewIssuer() error = %v", err)
}
deviceSecret := createTestDevice(t, deviceStore, database, "did:plc:abc123", "alice.bsky.social")
handler := NewHandler(issuer, deviceStore)
// Simulate what BasicAuth() does when username is "did:plc:abc123"
// It splits on first colon: username="did", password="plc:abc123:<secret>"
req := httptest.NewRequest(http.MethodGet, "/auth/token?service=registry&scope=repository:alice.bsky.social/myapp:pull", nil)
req.SetBasicAuth("did:plc:abc123", deviceSecret)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected status %d for DID BasicAuth, got %d. Body: %s", http.StatusOK, w.Code, w.Body.String())
}
}