Files
summercms/modules/pier/tools_list_test.go
Jakub Zych 3bbbe6c11e test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- scripts/check-phase19.7.sh: fail-closed multi-repo gate (self-test, go,
  marker; plugins, consumers, docker, admin and docs are stubs until the
  later tasks fill them)
- modules/pier/helpers_test.go: the plan 02 fixtures moved out of the smokes
  plus fakeGuard, fakeVerifier, fakeLinkResolver, fakePersonalResolver,
  routePlugin, newApp, twoInstances, sdkClient and fireSubjectChanged
- TestToolsList (pier), TestDispatchMarker (bouncer, cabana with a Postgres
  part through the screens harness, which now keeps its app)
2026-10-23 13:31:41 +02:00

151 lines
6.2 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) 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"),
})
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; 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("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)
}
})
}