Files
summercms/modules/wristband/mcp_scopes_smoke_test.go
Jakub Zych 63d05e8acb feat(32.1-01): publish one MCP scope list and call context
- pact gains MemberTokenScopeCatalog, MCPAdvertisedScopes and MCPCall
- an empty MCP ScopesSupported resolves to the five fixed names including backend
- pier copies advertised scopes and sets MCPCall beside WithDispatch
2026-10-28 14:32:31 +01:00

95 lines
3.7 KiB
Go

package wristband
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
)
// TestMCPScopesSmoke covers the configurable authorize scope set: a non-empty
// Options.ScopesSupported is the allow-list and the metadata list, so a host
// that lists backend accepts it, while DefaultOptions (the four PHP names,
// which do not include backend) still refuses it. An empty list on the MCP
// flavour resolves to read, write, ai, offline_access and backend.
func TestMCPScopesSmoke(t *testing.T) {
backend := newMemoryBackend()
opts := DefaultOptions()
opts.Issuer = "https://issuer.example.test"
opts.Resource = "https://resource.example.test/mcp"
opts.ScopesSupported = append(opts.ScopesSupported, "backend")
srv := NewServer(opts)
srv.SetBackend(backend)
insertAuthorizeTestClient(backend, "cli-backend", []string{authorizeTestRedirect}, nil)
_, challenge := s256Pair(t)
authorize := func(s *Server, clientID string) map[string]string {
rec := httptest.NewRecorder()
s.Authorize(rec, authorizeRequest(map[string]string{
"client_id": clientID,
"redirect_uri": authorizeTestRedirect,
"response_type": "code",
"code_challenge": challenge,
"code_challenge_method": "S256",
"scope": "read backend",
}))
if rec.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", rec.Code)
}
return queryOf(t, rec.Header().Get("Location"))
}
if q := authorize(srv, "cli-backend"); q["error"] != "" {
t.Fatalf("host listing backend refused it: %v", q)
}
backend.mu.Lock()
if n := len(backend.codes); n != 1 || !slices.Equal(backend.codes[0].Scopes, []string{"read", "backend"}) {
backend.mu.Unlock()
t.Fatalf("pending request scopes = %+v", backend.codes)
}
backend.mu.Unlock()
rec := httptest.NewRecorder()
srv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
var doc struct {
Scopes []string `json:"scopes_supported"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if !slices.Equal(doc.Scopes, []string{"read", "write", "ai", "offline_access", "backend"}) {
t.Fatalf("scopes_supported = %v", doc.Scopes)
}
// The default server (DefaultOptions scopes) still refuses backend.
plain, plainBackend := newAuthorizeTestServer()
insertAuthorizeTestClient(plainBackend, "cli-backend", []string{authorizeTestRedirect}, nil)
q := authorize(plain, "cli-backend")
if q["error"] != "invalid_scope" || q["error_description"] != "scope contains an unsupported value" {
t.Fatalf("default server accepted backend: %v", q)
}
// An empty ScopesSupported resolves to the five fixed names and accepts backend.
emptyBackend := newMemoryBackend()
empty := DefaultOptions()
empty.Issuer, empty.Resource, empty.ScopesSupported = opts.Issuer, opts.Resource, nil
emptySrv := NewServer(empty)
emptySrv.SetBackend(emptyBackend)
insertAuthorizeTestClient(emptyBackend, "cli-empty", []string{authorizeTestRedirect}, nil)
if got := emptySrv.allowedAuthorizeScopes(); !got["read"] || !got["write"] || !got["ai"] || !got["offline_access"] || !got["backend"] || len(got) != 5 {
t.Fatalf("allowed scopes with empty ScopesSupported = %v", got)
}
if q := authorize(emptySrv, "cli-empty"); q["error"] != "" {
t.Fatalf("empty ScopesSupported refused backend: %v", q)
}
rec = httptest.NewRecorder()
emptySrv.Metadata(rec, httptest.NewRequest(http.MethodGet, "/.well-known/oauth-authorization-server", nil))
if err := json.Unmarshal(rec.Body.Bytes(), &doc); err != nil {
t.Fatal(err)
}
if !slices.Equal(doc.Scopes, []string{"read", "write", "ai", "offline_access", "backend"}) {
t.Fatalf("empty scopes_supported = %v", doc.Scopes)
}
}