Files
summercms/modules/pier/tools_list_test.go
Jakub Zych c37c6ce50f fix(19.7): RC-WR-01 a backend grant acts only as the administrator it was consented for
pact.MCPGrant gains BackendUserID, the backend account the consent page
showed. pier gives an OAuth identity a backend subject (on resolution and
after a link event) only while the user's link names that account; a link
to anyone else, or no recorded account, gives none, so a link changed after
the consent never re-targets the grant.
2026-10-23 20:51:57 +02:00

174 lines
7.4 KiB
Go

package pier_test
import (
"context"
"encoding/json"
"net/http"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// listFixture is the two-guard application of TestToolsList: the frontend
// guard "acme" (user 8 lacks acme.echo) with a write and a read tool, the
// "backend" guard with one read tool, the verifier's grants for users 7, 8,
// 9 (read only), 10 (backend, linked to admin 42, plus grants consented for
// admin 43 and for none) and 11 (backend, no link), and the hit counters of the write route and the backend route.
type listFixture struct {
in *instance
echoHits *atomic.Int64
officeHits *atomic.Int64
}
func newListFixture(t *testing.T) listFixture {
t.Helper()
echoHits, officeHits := &atomic.Int64{}, &atomic.Int64{}
front := &fakeGuard{allow: func(s pact.MCPSubject, _ pact.MCPRequirements, perms []string) bool {
return !(slices.Contains(perms, "acme.echo") && s.UserID == 8)
}}
back := &fakeGuard{name: "backend", tokenMW: "backend"}
links := newFakeLinks()
links.set(10, 42)
verifier := newFakeVerifier(map[string]pact.MCPGrant{
"oas_at_admin": grant(10, "c4", 4, "read", "write", "backend"),
"oas_at_nolink": grant(11, "c5", 5, "read", "write", "backend"),
// User 10's grants consented for another administrator and for
// none (RC-WR-01).
"oas_at_rebound": boundGrant(10, "c8", 8, 43, "read", "write", "backend"),
"oas_at_unbound": boundGrant(10, "c9", 9, 0, "read", "write", "backend"),
})
principalOf := func(w http.ResponseWriter, r *http.Request) {
user, _ := bouncer.User(r.Context())
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"principal": user.ID, "backend": user.Backend})
}
acme := routePlugin{
id: "test.acme",
routes: []testRoute{
{method: http.MethodPost, path: "/acme/echo/{id}", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) {
echoHits.Add(1)
principalOf(w, r)
}},
{method: http.MethodGet, path: "/acme/items/{id}", mw: []string{"acme.auth"}, h: principalOf},
},
tools: []pact.MCPTool{
{Name: "echo", Description: "Echo.", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}, Permissions: []string{"acme.echo"}},
{Name: "show", Description: "Show.", Method: "GET", Path: "/acme/items/{id}", Input: showInput{}},
},
}
office := routePlugin{
id: "test.office", guard: "backend", middleware: "backend",
routes: []testRoute{{method: http.MethodGet, path: "/office/posts", mw: []string{"backend"}, h: func(w http.ResponseWriter, r *http.Request) {
officeHits.Add(1)
principalOf(w, r)
}}},
tools: []pact.MCPTool{{Name: "posts", Description: "Lists posts.", Method: "GET", Path: "/office/posts", Input: struct{}{}}},
}
app := newApp(t, appSpec{guards: []pact.MCPGuard{front, back}, verifier: verifier, links: links})
return listFixture{in: startInstance(t, app, []party.Plugin{acme, office}...), echoHits: echoHits, officeHits: officeHits}
}
// TestToolsList (SC-3, D-20, D-23): tools/list is computed per principal;
// a read-only grant sees only read-only tools; a tool whose guard has no
// subject for the caller is absent; calling a tool outside the caller's
// list is a JSON-RPC error that never reaches the route; a backend grant
// whose consented administrator is not the linked one gets no backend
// subject (RC-WR-01); an identity with both a frontend and a backend
// subject sees the union.
func TestToolsList(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
f := newListFixture(t)
t.Run("per principal", func(t *testing.T) {
seven := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), "oas_at_test", clientOpts{}))
eight := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), "oas_at_other", clientOpts{}))
if !slices.Equal(seven, []string{"acme_echo", "acme_show"}) || !slices.Equal(eight, []string{"acme_show"}) {
t.Fatalf("user 7 lists %v, user 8 lists %v", seven, eight)
}
})
t.Run("read grant lists read-only tools", func(t *testing.T) {
cs := sdkClient(t, ctx, f.in.url(), "oas_at_read", clientOpts{})
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
if got := toolNamesOf(list); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("read grant lists %v", got)
}
for _, tl := range list.Tools {
if tl.Annotations == nil || !tl.Annotations.ReadOnlyHint {
t.Fatalf("read grant lists %s without readOnlyHint", tl.Name)
}
}
})
t.Run("no subject under the guard", func(t *testing.T) {
for _, token := range []string{"oas_at_test", "oas_at_nolink"} {
if got := listNames(t, ctx, sdkClient(t, ctx, f.in.url(), token, clientOpts{})); slices.Contains(got, "office_posts") {
t.Fatalf("%s lists the backend tool: %v", token, got)
}
}
})
t.Run("a backend grant acts only as the administrator it was consented for", func(t *testing.T) {
before := f.officeHits.Load()
for _, token := range []string{"oas_at_rebound", "oas_at_unbound"} {
cs := sdkClient(t, ctx, f.in.url(), token, clientOpts{})
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("%s (user 10, linked to admin 42) lists %v, want the frontend tools only", token, got)
}
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "office_posts"})
if err == nil || !strings.Contains(err.Error(), "not available to this caller") {
t.Fatalf("%s calling office_posts: %+v, %v", token, res, err)
}
}
if f.officeHits.Load() != before {
t.Fatalf("a grant consented for another administrator reached the backend route")
}
})
t.Run("unlisted call never reaches the route", func(t *testing.T) {
before, office := f.echoHits.Load(), f.officeHits.Load()
for token, tool := range map[string]string{"oas_at_other": "acme_echo", "oas_at_read": "acme_echo", "oas_at_test": "office_posts"} {
cs := sdkClient(t, ctx, f.in.url(), token, clientOpts{})
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: tool, Arguments: map[string]any{"id": 1, "text": "x"}})
if err == nil || !strings.Contains(err.Error(), "not available to this caller") {
t.Fatalf("%s calling %s: %+v, %v", token, tool, res, err)
}
}
if f.echoHits.Load() != before || f.officeHits.Load() != office {
t.Fatalf("a refused call reached a route: echo %d -> %d, office %d -> %d", before, f.echoHits.Load(), office, f.officeHits.Load())
}
})
t.Run("union of both guards", func(t *testing.T) {
cs := sdkClient(t, ctx, f.in.url(), "oas_at_admin", clientOpts{})
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show", "office_posts"}) {
t.Fatalf("frontend and backend subject lists %v", got)
}
front, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_show", Arguments: map[string]any{"id": 3}})
if err != nil || front.IsError {
t.Fatalf("acme_show = %+v, %v", front, err)
}
if got := structured(t, front); got["principal"] != 10.0 || got["backend"] != false {
t.Fatalf("acme_show ran as %+v, want frontend user 10", got)
}
back, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "office_posts"})
if err != nil || back.IsError {
t.Fatalf("office_posts = %+v, %v", back, err)
}
if got := structured(t, back); got["principal"] != 42.0 || got["backend"] != true {
t.Fatalf("office_posts ran as %+v, want linked admin 42", got)
}
})
}