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:
212
modules/pier/catalog_test.go
Normal file
212
modules/pier/catalog_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
173
modules/pier/dispatch_test.go
Normal file
173
modules/pier/dispatch_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
135
modules/pier/fanout_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
138
modules/pier/guard_rules_test.go
Normal file
138
modules/pier/guard_rules_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
112
modules/pier/sessions_test.go
Normal file
112
modules/pier/sessions_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user