- 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)
151 lines
6.2 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|