test(19.7-05): framework unit tests for every validation row and the gate's admin stage

- pier: TestCatalog, TestDispatch, TestGuardRules, TestSessions (Redis
  across two instances and the memory store), TestFanOut; the store clock
  and stateless-server seams in export_test.go
- surf: TestStreamingRoute (throttle, CORS profile and access-log pattern on
  a stream route) and TestPierMount in both builds; the streaming smoke
  restores the mount hook it replaces
- wristband TestMCPScopes; cabana TestAccountContributions (Postgres part:
  transaction and unchanged My account payloads), TestRecordActionPrompt,
  TestScopeLimitForMethods; TestRecordActionPromptRun pins the empty Value
  of an action without a prompt
- vitest: 403 and failed-load cases for My account contributions, the
  cancelled prompt
- gate: every name required (tagged runs included), account.go unchanged
  since 51d088a, admin stage live
This commit is contained in:
Jakub Zych
2026-10-23 14:04:16 +02:00
parent 1111828e8a
commit f14eae96dd
18 changed files with 1696 additions and 10 deletions

View File

@@ -0,0 +1,212 @@
package pier_test
import (
"context"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// noContent answers 204 for the catalog's routes (nothing is called).
func noContent(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }
// guarded is one route behind acme.auth.
func guarded(method, path string) testRoute {
return testRoute{method: method, path: path, mw: []string{"acme.auth"}, h: noContent}
}
type itemInput struct {
ID int `json:"id" path:"id"`
}
type listInput struct {
Page int `json:"page,omitempty" query:"page"`
}
func boolPtr(b bool) *bool { return &b }
// catalogMount mounts pier for plugins on a fresh app with the acme guard
// and answers the mount error.
func catalogMount(t *testing.T, plugins ...party.Plugin) (*instance, error) {
t.Helper()
app := newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}})
host, err := mountPier(t, app, plugins)
if err != nil {
return nil, err
}
return &instance{app: app, host: host}, nil
}
// TestCatalog (SC-2, D-10..D-12, D-14): tool and prompt names are prefixed
// with the plugin's short code; every boot error names what is wrong
// (colliding short codes and duplicate names name both owners, a long or
// dashed name, a route nobody registers, a route without a token guard and
// no Guard, a non-struct Input, a path tag without its segment, a body field
// on a GET); annotations follow the HTTP method with declared overrides.
func TestCatalog(t *testing.T) {
list := testRoute{method: http.MethodGet, path: "/acme/list", mw: []string{"acme.auth"}, h: noContent}
listTool := pact.MCPTool{Name: "list", Description: "Lists.", Method: "GET", Path: "/acme/list", Input: listInput{}}
t.Run("prefix is the plugin's short code", func(t *testing.T) {
for id, want := range map[string]string{"acme.blog": "blog_list", "acme.My-Blog": "my_blog_list", "Vendor.Shop2": "shop2_list"} {
in, err := catalogMount(t, routePlugin{id: id, routes: []testRoute{list}, tools: []pact.MCPTool{listTool}})
if err != nil {
t.Fatalf("%s: %v", id, err)
}
if got := in.pier().ToolNames(); !slices.Equal(got, []string{want}) {
t.Fatalf("%s exposes %v, want [%s]", id, got, want)
}
}
})
refusals := []struct {
name string
plugins []party.Plugin
want []string
}{
{"same short code in two plugins", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}},
routePlugin{id: "other.blog", guard: "other", tools: []pact.MCPTool{listTool}},
}, []string{"other.blog", "acme.blog", "blog_list"}},
{"duplicate name in one plugin", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool, listTool}},
}, []string{"acme.blog", "blog_list", "already taken"}},
{"name over 128 characters", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: strings.Repeat("x", 124), Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"acme.blog", "must match"}},
{"name with a dash", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list-all", Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"blog_list-all", "must match"}},
{"unregistered route", []party.Plugin{
routePlugin{id: "acme.blog", tools: []pact.MCPTool{{Name: "gone", Method: "post", Path: "/acme/gone", Input: struct{}{}}}},
}, []string{"POST /acme/gone", "no plugin registers"}},
{"route without a token guard and no Guard", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/open", h: noContent}}, tools: []pact.MCPTool{{Name: "open", Method: "GET", Path: "/acme/open", Input: struct{}{}}}},
}, []string{"acme.blog", "not token-guarded"}},
{"unknown declared guard", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Guard: "nobody", Method: "GET", Path: "/acme/list", Input: listInput{}}}},
}, []string{"unknown guard \"nobody\""}},
{"input is not a struct", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: "page"}}},
}, []string{"Input must be a struct"}},
{"input is nil", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list"}}},
}, []string{"Input is nil"}},
{"path tag without its segment", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: itemInput{}}}},
}, []string{`path:"id"`, "no {id} segment"}},
{"segment without an input field", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}")}, tools: []pact.MCPTool{{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: struct{}{}}}},
}, []string{"segment {id}", "no input field"}},
{"body field on a GET", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{{Name: "list", Method: "GET", Path: "/acme/list", Input: struct {
Title string `json:"title"`
}{}}}},
}, []string{`"title"`, "GET route takes only path and query fields"}},
{"prompt named like a tool", []party.Plugin{
routePlugin{id: "acme.blog", routes: []testRoute{list}, tools: []pact.MCPTool{listTool}, prompts: []pact.MCPPrompt{{Name: "list", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}},
}, []string{"prompt \"list\"", "blog_list", "already taken"}},
{"prompt without a guard", []party.Plugin{
routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) { return nil, nil }}}},
}, []string{"prompt \"draft\"", "must name its Guard"}},
}
for _, c := range refusals {
t.Run(c.name, func(t *testing.T) {
_, err := catalogMount(t, c.plugins...)
if err == nil {
t.Fatal("mount succeeded, want a boot error")
}
for _, w := range c.want {
if !strings.Contains(err.Error(), w) {
t.Fatalf("err = %v, want it to name %q", err, w)
}
}
})
}
t.Run("a declared backend guard binds a route without a token guard", func(t *testing.T) {
in, err := catalogMount(t, routePlugin{id: "acme.blog", routes: []testRoute{{method: http.MethodGet, path: "/acme/posts", h: noContent}},
tools: []pact.MCPTool{{Name: "posts", Guard: "backend", Method: "GET", Path: "/acme/posts", Input: struct{}{}}}})
if err != nil {
t.Fatal(err)
}
if got := in.pier().ToolNames(); !slices.Equal(got, []string{"blog_posts"}) {
t.Fatalf("exposed %v", got)
}
})
t.Run("annotations follow the method", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
plugin := routePlugin{id: "acme.blog",
routes: []testRoute{guarded(http.MethodGet, "/acme/items/{id}"), guarded(http.MethodPut, "/acme/items/{id}"), guarded(http.MethodDelete, "/acme/items/{id}"), guarded(http.MethodPost, "/acme/items"), list},
tools: []pact.MCPTool{
{Name: "show", Method: "GET", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "replace", Method: "PUT", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "remove", Method: "DELETE", Path: "/acme/items/{id}", Input: itemInput{}},
{Name: "create", Method: "POST", Path: "/acme/items", Input: struct {
Title string `json:"title"`
}{}},
{Name: "export", Method: "GET", Path: "/acme/list", Input: listInput{}, ReadOnly: boolPtr(false), OpenWorld: boolPtr(true)},
}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatal(err)
}
type hints struct{ read, idem, destr, open bool }
want := map[string]hints{
"blog_show": {read: true, idem: true},
"blog_replace": {idem: true},
"blog_remove": {destr: true},
"blog_create": {},
"blog_export": {idem: true, open: true},
}
if len(list.Tools) != len(want) {
t.Fatalf("listed %v", toolNamesOf(list))
}
for _, tl := range list.Tools {
a := tl.Annotations
if a == nil || a.DestructiveHint == nil || a.OpenWorldHint == nil {
t.Fatalf("%s annotations = %+v, want explicit destructive and open-world hints", tl.Name, a)
}
got := hints{read: a.ReadOnlyHint, idem: a.IdempotentHint, destr: *a.DestructiveHint, open: *a.OpenWorldHint}
if got != want[tl.Name] {
t.Fatalf("%s hints = %+v, want %+v", tl.Name, got, want[tl.Name])
}
}
var schema map[string]any
for _, tl := range list.Tools {
if tl.Name == "blog_show" {
schema, _ = tl.InputSchema.(map[string]any)
}
}
if props, _ := schema["properties"].(map[string]any); props["id"] == nil {
t.Fatalf("blog_show input schema = %+v, want an id property", schema)
}
})
t.Run("prompts are prefixed", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
plugin := routePlugin{id: "acme.blog", prompts: []pact.MCPPrompt{{Name: "draft", Guard: "acme", Render: func(context.Context, map[string]string) ([]pact.MCPPromptMessage, error) {
return []pact.MCPPromptMessage{{Role: "user", Text: "Draft."}}, nil
}}}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{}}}), plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})
prompts, err := cs.ListPrompts(ctx, nil)
if err != nil || len(prompts.Prompts) != 1 || prompts.Prompts[0].Name != "blog_draft" {
t.Fatalf("prompts/list = %+v, %v", prompts, err)
}
if _, err := cs.GetPrompt(ctx, &mcp.GetPromptParams{Name: "draft"}); err == nil {
t.Fatal("the unprefixed prompt name answered")
}
})
}

View File

@@ -0,0 +1,173 @@
package pier_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
type thingInput struct {
ID string `json:"id" path:"id"`
Tags []string `json:"tags,omitempty" query:"tag"`
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
const invalidBody = `{"message":"The given data was invalid.","errors":{"title":["The title field is required."]}}`
// TestDispatch (D-11, D-13, D-14): a call's path and query fields fill the
// URL and the body is the agent's JSON minus them, without the fields the
// agent omitted (Pitfall 6); a multi-segment value for a single segment is
// refused before dispatch; the outer Authorization and Cookie never reach
// the route while the client address, X-Forwarded-For and Accept-Language
// do; a non-2xx answer is a tool error carrying the route's exact body as
// text and structured content; a 2xx array is wrapped as {"data": [...]};
// an answer above mcp.max_result_bytes is a fixed tool error; the per-call
// timeout cancels a hanging handler.
func TestDispatch(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var thingHits atomic.Int64
hangErr := make(chan error, 1)
echo := func(w http.ResponseWriter, r *http.Request) {
thingHits.Add(1)
raw, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": r.PathValue("id"),
"tags": r.URL.Query()["tag"],
"body": string(raw),
"auth": r.Header.Get("Authorization"),
"cookie": r.Header.Get("Cookie"),
"xff": r.Header.Get("X-Forwarded-For"),
"lang": r.Header.Get("Accept-Language"),
"remote": r.RemoteAddr,
})
}
plugin := routePlugin{id: "test.disp",
routes: []testRoute{
{method: http.MethodPost, path: "/disp/things/{id}", mw: []string{"acme.auth"}, h: echo},
{method: http.MethodPost, path: "/disp/invalid", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusUnprocessableEntity, invalidBody)},
{method: http.MethodGet, path: "/disp/list", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `[1,2,3]`)},
{method: http.MethodGet, path: "/disp/big", mw: []string{"acme.auth"}, h: jsonRoute(http.StatusOK, `"`+strings.Repeat("x", 2048)+`"`)},
{method: http.MethodGet, path: "/disp/hang", mw: []string{"acme.auth"}, h: func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
hangErr <- r.Context().Err()
w.WriteHeader(http.StatusServiceUnavailable)
}},
},
tools: []pact.MCPTool{
{Name: "thing", Method: "POST", Path: "/disp/things/{id}", Input: thingInput{}},
{Name: "invalid", Method: "POST", Path: "/disp/invalid", Input: struct{}{}},
{Name: "list", Method: "GET", Path: "/disp/list", Input: struct{}{}},
{Name: "big", Method: "GET", Path: "/disp/big", Input: struct{}{}},
{Name: "hang", Method: "GET", Path: "/disp/hang", Input: struct{}{}},
}}
app := newApp(t, appSpec{mcp: "enabled: false\nmax_result_bytes: 1024\ncall_timeout: 1s\n", guards: []pact.MCPGuard{&fakeGuard{}}})
in := startInstance(t, app, plugin)
cs := sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{headers: map[string]string{
"Cookie": "summer_session=outer; summer_admin=outer",
"X-Forwarded-For": "203.0.113.9",
"Accept-Language": "pl",
}})
call := func(name string, args map[string]any) *mcp.CallToolResult {
t.Helper()
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: name, Arguments: args})
if err != nil {
t.Fatalf("%s: %v", name, err)
}
return res
}
t.Run("argument split and request shape", func(t *testing.T) {
res := call("disp_thing", map[string]any{"id": "abc", "tags": []string{"a", "b"}, "title": "T"})
if res.IsError {
t.Fatalf("isError: %s", resultText(t, res))
}
got := structured(t, res)
if got["id"] != "abc" || got["body"] != `{"title":"T"}` {
t.Fatalf("path and body = %v, %v", got["id"], got["body"])
}
if tags, _ := got["tags"].([]any); len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
t.Fatalf("query tags = %v", got["tags"])
}
if got["auth"] != "" || got["cookie"] != "" {
t.Fatalf("outer credentials reached the route: auth %q cookie %q", got["auth"], got["cookie"])
}
if got["xff"] != "203.0.113.9" || got["lang"] != "pl" || !strings.HasPrefix(got["remote"].(string), "127.0.0.1:") {
t.Fatalf("forwarded request facts = xff %v lang %v remote %v", got["xff"], got["lang"], got["remote"])
}
if text := resultText(t, res); !strings.Contains(text, `"id":"abc"`) {
t.Fatalf("text content = %s", text)
}
})
t.Run("multi-segment value refused before dispatch", func(t *testing.T) {
before := thingHits.Load()
res := call("disp_thing", map[string]any{"id": "a/b", "title": "T"})
if !res.IsError || !strings.Contains(resultText(t, res), "one non-empty path segment") {
t.Fatalf("a/b = %+v", res)
}
res = call("disp_thing", map[string]any{"id": "abc"})
if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") {
t.Fatalf("missing required title = %+v", res)
}
if thingHits.Load() != before {
t.Fatal("a refused call reached the route")
}
})
t.Run("route error is a tool error with its body", func(t *testing.T) {
res := call("disp_invalid", nil)
if !res.IsError || resultText(t, res) != invalidBody {
t.Fatalf("422 = %+v %q", res, resultText(t, res))
}
got := structured(t, res)
if got["message"] != "The given data was invalid." || got["errors"] == nil {
t.Fatalf("422 structuredContent = %+v", got)
}
})
t.Run("2xx array wrapped as data", func(t *testing.T) {
res := call("disp_list", nil)
data, _ := structured(t, res)["data"].([]any)
if res.IsError || len(data) != 3 || data[0] != 1.0 || resultText(t, res) != "[1,2,3]" {
t.Fatalf("array = %+v", res)
}
})
t.Run("result above max_result_bytes", func(t *testing.T) {
res := call("disp_big", nil)
if !res.IsError || resultText(t, res) != "pier: the route's answer exceeds mcp.max_result_bytes" || strings.Contains(resultText(t, res), "xxxx") {
t.Fatalf("big = %+v", res)
}
})
t.Run("call timeout cancels the handler", func(t *testing.T) {
start := time.Now()
res := call("disp_hang", nil)
if took := time.Since(start); took > 10*time.Second || took < 900*time.Millisecond {
t.Fatalf("hang returned after %v, want about the 1s call_timeout", took)
}
if !res.IsError {
t.Fatalf("hang = %+v, want a tool error", res)
}
select {
case err := <-hangErr:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("handler context ended with %v, want the deadline", err)
}
case <-time.After(5 * time.Second):
t.Fatal("the hanging handler never saw its context end")
}
})
}

View File

@@ -2,6 +2,7 @@ package pier
import (
"context"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bouncer"
@@ -23,3 +24,25 @@ func (p *Pier) Resolve(ctx context.Context, raw string) (Identity, error) {
// Ceiling reports whether the read/write ceiling applies to the identity.
func (id Identity) Ceiling() bool { return id.ceiling }
// AdvanceStoreClock moves the memory session store's clock forward by d, so
// a test can expire idle sessions without waiting; a Redis store is moved
// with miniredis.FastForward instead.
func (p *Pier) AdvanceStoreClock(d time.Duration) bool {
ms, ok := p.store.(*memoryStore)
if !ok {
return false
}
ms.mu.Lock()
base := ms.now
ms.now = func() time.Time { return base().Add(d) }
ms.mu.Unlock()
return true
}
// StatelessServers counts the per-identity servers of the stateless path.
func (p *Pier) StatelessServers() int {
p.stateless.mu.Lock()
defer p.stateless.mu.Unlock()
return len(p.stateless.entries)
}

135
modules/pier/fanout_test.go Normal file
View File

@@ -0,0 +1,135 @@
package pier_test
import (
"context"
"net/http"
"slices"
"sync/atomic"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// waitGone polls until a session's calls fail (its server closed it).
func waitGone(t *testing.T, ctx context.Context, cs *mcp.ClientSession) {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
if _, err := cs.ListTools(ctx, nil); err != nil {
return
}
if time.Now().After(deadline) {
t.Fatal("the session survived the event")
}
time.Sleep(50 * time.Millisecond)
}
}
// TestFanOut (D-26, D-27): subject events fired on one instance reach the
// sessions of the other through the Redis store. A permissions change sends
// tools/list_changed and the next list drops the tool; a link change makes
// the backend tools appear and disappear (and drops the 5 s link cache at
// once); revocation and deletion end the subject's sessions (404 next) and
// evict its stateless servers.
func TestFanOut(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var deny atomic.Bool
front := &fakeGuard{allow: func(s pact.MCPSubject, _ pact.MCPRequirements, perms []string) bool {
return !(deny.Load() && slices.Contains(perms, "acme.echo"))
}}
back := &fakeGuard{name: "backend", tokenMW: "backend"}
links := newFakeLinks()
verifier := newFakeVerifier(map[string]pact.MCPGrant{"oas_at_admin": grant(10, "c4", 4, "read", "write", "backend")})
acme := routePlugin{id: "test.acme",
routes: []testRoute{guarded(http.MethodPost, "/acme/echo/{id}"), guarded(http.MethodGet, "/acme/items/{id}")},
tools: []pact.MCPTool{
{Name: "echo", Method: "POST", Path: "/acme/echo/{id}", Input: echoInput{}, Permissions: []string{"acme.echo"}},
{Name: "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: noContent}},
tools: []pact.MCPTool{{Name: "posts", Method: "GET", Path: "/office/posts", Input: struct{}{}}}}
a, b, _ := twoInstances(t, appSpec{guards: []pact.MCPGuard{front, back}, verifier: verifier, links: links}, acme, office)
t.Run("permissions change", func(t *testing.T) {
changed := make(chan struct{}, 4)
cs := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{changed: changed})
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_echo", "acme_show"}) {
t.Fatalf("before = %v", got)
}
deny.Store(true)
defer deny.Store(false)
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectPermissions)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Equal(got, []string{"acme_show"}) {
t.Fatalf("after = %v", got)
}
})
t.Run("link change", func(t *testing.T) {
changed := make(chan struct{}, 4)
cs := sdkClient(t, ctx, b.url(), "oas_at_admin", clientOpts{changed: changed})
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("backend tool listed without a link: %v", got)
}
links.set(10, 42)
fireSubjectChanged(t, a.app, "acme", 10, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); !slices.Contains(got, "office_posts") {
t.Fatalf("after linking = %v", got)
}
// Unlinked without an event: the instance keeps its cached answer for
// up to LinkCacheTTL; the link event drops it at once.
links.remove(10)
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend == nil {
t.Fatalf("cached link within the TTL = %+v, %v", id.Backend, err)
}
fireSubjectChanged(t, a.app, "backend", 42, pact.MCPSubjectLink)
waitChanged(t, changed)
if got := listNames(t, ctx, cs); slices.Contains(got, "office_posts") {
t.Fatalf("after unlinking = %v", got)
}
if id, err := b.pier().Resolve(ctx, "oas_at_admin"); err != nil || id.Backend != nil {
t.Fatalf("link cache after the event = %+v, %v", id.Backend, err)
}
})
t.Run("revocation ends sessions and evicts stateless servers", func(t *testing.T) {
stateful := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{})
stateless := sdkClient(t, ctx, b.url(), "oas_at_test", clientOpts{version: "newest"})
if stateless.ID() != "" {
t.Fatal("the newest client got a session")
}
listNames(t, ctx, stateless)
if n := b.pier().StatelessServers(); n == 0 {
t.Fatal("no stateless server for the binding")
}
sid := stateful.ID()
fireSubjectChanged(t, a.app, "acme", 7, pact.MCPSubjectRevoked)
waitGone(t, ctx, stateful)
if raw := rpc(t, ctx, b.url(), "oas_at_test", sid, callBody); raw.status != http.StatusNotFound {
t.Fatalf("call after revocation = %d %s", raw.status, raw.body)
}
deadline := time.Now().Add(10 * time.Second)
for b.pier().StatelessServers() != 0 {
if time.Now().After(deadline) {
t.Fatalf("stateless servers left after revocation: %d", b.pier().StatelessServers())
}
time.Sleep(50 * time.Millisecond)
}
})
t.Run("deletion ends sessions", func(t *testing.T) {
cs := sdkClient(t, ctx, b.url(), "oas_at_other", clientOpts{})
sid := cs.ID()
fireSubjectChanged(t, a.app, "acme", 8, pact.MCPSubjectDeleted)
waitGone(t, ctx, cs)
if raw := rpc(t, ctx, a.url(), "oas_at_other", sid, callBody); raw.status != http.StatusNotFound {
t.Fatalf("call after deletion = %d %s", raw.status, raw.body)
}
})
}

View File

@@ -0,0 +1,138 @@
package pier_test
import (
"context"
"errors"
"net/http"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// permGuard parses acme.perm:<code> middleware into required permission
// codes and allows a subject only when its granted codes cover the route's
// codes and the tool's (the union, D-16).
func permGuard(granted map[uint][]string) *fakeGuard {
return &fakeGuard{
parse: func(mw []string) (pact.MCPRequirements, error) {
var req pact.MCPRequirements
for _, m := range mw {
if code, ok := strings.CutPrefix(m, "acme.perm:"); ok {
if code == "" {
return pact.MCPRequirements{}, errors.New("acme.perm names no code")
}
req.Permissions = append(req.Permissions, code)
}
}
return req, nil
},
allow: func(s pact.MCPSubject, req pact.MCPRequirements, perms []string) bool {
for _, code := range append(slices.Clone(req.Permissions), perms...) {
if !slices.Contains(granted[s.UserID], code) {
return false
}
}
return true
},
}
}
// methodDenyGuard is a method-aware provider that refuses one method, as an
// admin token whose scopes allow GET only does.
type methodDenyGuard struct {
methodGuard
deny string
}
func (g methodDenyGuard) MCPAllowsMethod(ctx context.Context, s pact.MCPSubject, req pact.MCPRequirements, perms []string, method string) (bool, error) {
ok, err := g.methodGuard.MCPAllowsMethod(ctx, s, req, perms, method)
return ok && method != g.deny, err
}
// TestGuardRules (D-15, D-16, D-22): the route's middleware requirements and
// the tool's Permissions are both handed to the provider (the union decides);
// a provider that rejects a route's middleware fails boot; a route the
// provider does not call token-guarded binds only with a declared Guard; a
// method-aware provider is asked with each tool's own method, so a GET-only
// scope hides write tools (the built-in backend provider does this with
// cabana.ScopeLimitFor, pinned by cabana's TestScopeLimitForMethods).
func TestGuardRules(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rules := testRoute{method: http.MethodGet, path: "/rules/a", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}
t.Run("requirements union with tool permissions", func(t *testing.T) {
guard := permGuard(map[uint][]string{7: {"p.a", "p.b"}, 8: {"p.a"}})
plugin := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{rules},
tools: []pact.MCPTool{
{Name: "both", Method: "GET", Path: "/rules/a", Input: struct{}{}, Permissions: []string{"p.b"}},
}}
plain := routePlugin{id: "test.plain", guard: "plain", routes: []testRoute{{method: http.MethodGet, path: "/rules/plain", mw: []string{"acme.auth", "acme.perm:p.a"}, h: noContent}},
tools: []pact.MCPTool{{Name: "route_only", Guard: "acme", Method: "GET", Path: "/rules/plain", Input: struct{}{}}}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin, plain)
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"plain_route_only", "rules_both"}) {
t.Fatalf("user 7 (p.a, p.b) lists %v", got)
}
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_other", clientOpts{})); !slices.Equal(got, []string{"plain_route_only"}) {
t.Fatalf("user 8 (p.a) lists %v", got)
}
var saw bool
for _, c := range guard.recorded() {
if slices.Equal(c.req.Permissions, []string{"p.a"}) && slices.Equal(c.perms, []string{"p.b"}) {
saw = true
}
}
if !saw {
t.Fatalf("the provider never saw the route's p.a with the tool's p.b: %+v", guard.recorded())
}
})
t.Run("rejected middleware fails boot", func(t *testing.T) {
guard := permGuard(nil)
bad := routePlugin{id: "test.rules", factory: "acme.perm", routes: []testRoute{{method: http.MethodGet, path: "/rules/bad", mw: []string{"acme.auth", "acme.perm:"}, h: noContent}},
tools: []pact.MCPTool{{Name: "bad", Method: "GET", Path: "/rules/bad", Input: struct{}{}}}}
_, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{bad})
if err == nil || !strings.Contains(err.Error(), "rejects the route's middleware") || !strings.Contains(err.Error(), "names no code") {
t.Fatalf("err = %v", err)
}
})
t.Run("not token-guarded needs a declared guard", func(t *testing.T) {
guard := &fakeGuard{notToken: true}
route := testRoute{method: http.MethodGet, path: "/rules/session", mw: []string{"acme.auth"}, h: noContent}
implicit := routePlugin{id: "test.rules", routes: []testRoute{route}, tools: []pact.MCPTool{{Name: "s", Method: "GET", Path: "/rules/session", Input: struct{}{}}}}
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), []party.Plugin{implicit}); err == nil || !strings.Contains(err.Error(), "not token-guarded") {
t.Fatalf("implicit binding: err = %v", err)
}
declared := implicit
declared.tools = []pact.MCPTool{{Name: "s", Guard: "acme", Method: "GET", Path: "/rules/session", Input: struct{}{}}}
if _, err := mountPier(t, newApp(t, appSpec{guards: []pact.MCPGuard{&fakeGuard{notToken: true}}}), []party.Plugin{declared}); err != nil {
t.Fatalf("declared guard: %v", err)
}
})
t.Run("method-aware provider sees each tool's method", func(t *testing.T) {
guard := methodDenyGuard{methodGuard: methodGuard{fakeGuard: &fakeGuard{}}, deny: http.MethodPost}
plugin := routePlugin{id: "test.rules",
routes: []testRoute{guarded(http.MethodGet, "/rules/items"), guarded(http.MethodPost, "/rules/items")},
tools: []pact.MCPTool{
{Name: "list", Method: "GET", Path: "/rules/items", Input: struct{}{}},
{Name: "create", Method: "POST", Path: "/rules/items", Input: struct{}{}},
}}
in := startInstance(t, newApp(t, appSpec{guards: []pact.MCPGuard{guard}}), plugin)
if got := listNames(t, ctx, sdkClient(t, ctx, in.url(), "oas_at_test", clientOpts{})); !slices.Equal(got, []string{"rules_list"}) {
t.Fatalf("lists %v, want the GET tool only", got)
}
methods := map[string]bool{}
for _, c := range guard.recorded() {
methods[c.method] = true
}
if !methods[http.MethodGet] || !methods[http.MethodPost] || methods[""] {
t.Fatalf("provider asked with methods %v", methods)
}
})
}

View File

@@ -647,6 +647,18 @@ type routePlugin struct {
routes []testRoute
tools []pact.MCPTool
prompts []pact.MCPPrompt
// factory, when set, registers a pass-through middleware factory under
// that name (for <factory>:<param> requirement middleware).
factory string
}
func (p routePlugin) MiddlewareFactories() map[string]func(string) pact.Middleware {
if p.factory == "" {
return nil
}
return map[string]func(string) pact.Middleware{p.factory: func(string) pact.Middleware {
return func(next http.Handler) http.Handler { return next }
}}
}
func (p routePlugin) ID() string { return p.id }

View File

@@ -0,0 +1,112 @@
package pier_test
import (
"context"
"net/http"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
)
// deleteSession sends DELETE for a session with a bearer.
func deleteSession(t *testing.T, ctx context.Context, url, token, sid string) int {
t.Helper()
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Mcp-Session-Id", sid)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
return resp.StatusCode
}
// sessionCase is one store under test: the instance that opens sessions,
// the one that serves them next and the store's clock.
type sessionCase struct {
name string
a, b *instance
advance func(time.Duration)
}
// TestSessions (D-25): on the Redis store shared by two instances and on
// the memory store of one instance, a session is bound to the identity that
// opened it (another binding is 403 on POST and DELETE), is served by
// whichever instance receives it (rehydrated from the stored protocol state,
// never from a stored identity: the call runs as the request's bearer),
// DELETE ends it everywhere (404 next), an idle session expires after
// mcp.sessions.idle_ttl (404, and the client initialises a new one) and
// mcp.sessions.max_per_binding caps a binding's open sessions (429).
func TestSessions(t *testing.T) {
cfgTail := " idle_ttl: 30m\n max_per_binding: 2\n"
plugin := acmePlugin{tools: []pact.MCPTool{echoTool()}}
var cases []sessionCase
{
a, b, mr := twoInstances(t, appSpec{mcp: "enabled: false\nsessions:\n store: redis\n" + cfgTail, guards: []pact.MCPGuard{&acmeGuard{}}}, plugin)
cases = append(cases, sessionCase{"redis across two instances", a, b, mr.FastForward})
}
{
one := startInstance(t, newApp(t, appSpec{mcp: "enabled: false\nsessions:\n store: memory\n" + cfgTail, guards: []pact.MCPGuard{&acmeGuard{}}}), plugin)
cases = append(cases, sessionCase{"memory on one instance", one, one, func(d time.Duration) {
if !one.pier().AdvanceStoreClock(d) {
t.Fatal("the memory instance has no memory store")
}
}})
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
sid := openSession(t, ctx, c.a, "oas_at_test")
call := rpc(t, ctx, c.b.url(), "oas_at_test", sid, callBody)
if call.status != http.StatusOK || !strings.Contains(call.body, `\"principal\":7`) || strings.Contains(call.body, `"isError":true`) {
t.Fatalf("call on the second instance = %d %s", call.status, call.body)
}
if other := rpc(t, ctx, c.b.url(), "oas_at_other", sid, callBody); other.status != http.StatusForbidden || !strings.Contains(other.body, "session_binding") {
t.Fatalf("another binding = %d %s", other.status, other.body)
}
if code := deleteSession(t, ctx, c.b.url(), "oas_at_other", sid); code != http.StatusForbidden {
t.Fatalf("DELETE by another binding = %d", code)
}
if code := deleteSession(t, ctx, c.b.url(), "oas_at_test", sid); code != http.StatusNoContent {
t.Fatalf("DELETE = %d", code)
}
for _, in := range []*instance{c.a, c.b} {
if gone := rpc(t, ctx, in.url(), "oas_at_test", sid, callBody); gone.status != http.StatusNotFound {
t.Fatalf("call after DELETE = %d %s", gone.status, gone.body)
}
}
if code := deleteSession(t, ctx, c.a.url(), "oas_at_test", sid); code != http.StatusNotFound {
t.Fatalf("second DELETE = %d", code)
}
idle := openSession(t, ctx, c.a, "oas_at_test")
c.advance(31 * time.Minute)
if expired := rpc(t, ctx, c.b.url(), "oas_at_test", idle, callBody); expired.status != http.StatusNotFound {
t.Fatalf("call after the idle TTL = %d %s", expired.status, expired.body)
}
fresh := openSession(t, ctx, c.b, "oas_at_test")
if again := rpc(t, ctx, c.a.url(), "oas_at_test", fresh, callBody); again.status != http.StatusOK {
t.Fatalf("call on the re-initialised session = %d %s", again.status, again.body)
}
openSession(t, ctx, c.a, "oas_at_test")
third := rpc(t, ctx, c.b.url(), "oas_at_test", "", initializeBody)
if third.status != http.StatusTooManyRequests || !strings.Contains(third.body, "too_many_sessions") {
t.Fatalf("session over max_per_binding = %d %s", third.status, third.body)
}
// Another binding has its own allowance.
openSession(t, ctx, c.b, "oas_at_other")
})
}
}