- surf.Router.MountStream and recoverStream: a raw, unbuffered route kind
whose http.Flusher reaches the client; panic after the first byte only
aborts the body
- surf.MCPBuildTag and the mountPier hook: mcp.enabled is inert without the
summercms_mcp tag until it is true, then boot fails naming the tag
(pier_off.go is the untagged side)
- wristband parseAuthorizeScopes takes the server's allowed set built from
Options.ScopesSupported; the default set is unchanged
- cooler.KeyPrefix exported for the MCP module's Redis keys
- smoke tests TestStreamingRouteSmoke, TestPierMountSmoke, TestMCPScopesSmoke
- surf, wristband and cooler READMEs, docs/services/{routing,oauth-server}.md
79 lines
2.8 KiB
Go
79 lines
2.8 KiB
Go
package wristband
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"slices"
|
|
"testing"
|
|
)
|
|
|
|
// TestMCPScopesSmoke covers the configurable authorize scope set of phase
|
|
// 19.7 (RESEARCH Pitfall 5, D-21 framework side): the allow-list is exactly
|
|
// Options.ScopesSupported, so a host that lists backend accepts it, while
|
|
// the default server keeps refusing it.
|
|
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 keeps the PHP default allow-list.
|
|
empty := DefaultOptions()
|
|
empty.Issuer, empty.Resource, empty.ScopesSupported = opts.Issuer, opts.Resource, nil
|
|
if got := NewServer(empty).allowedAuthorizeScopes(); !got["read"] || !got["offline_access"] || got["backend"] || len(got) != 4 {
|
|
t.Fatalf("allowed scopes with empty ScopesSupported = %v", got)
|
|
}
|
|
}
|