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