- TestEndpointGuards: a foreign browser Origin is 403 before the bearer is read, a body over the endpoint cap is 413, a long GET stream ends once its grant stops verifying (T-19.7-18, T-19.7-19) - TestDispatch: an argument key the input struct does not declare is refused before dispatch (T-19.7-17) - the gate requires TestEndpointGuards (untagged and tagged) and runs the demo's deploy check (T-19.7-37)
181 lines
7.1 KiB
Go
181 lines
7.1 KiB
Go
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)
|
|
}
|
|
// The reflected schema refuses keys the input struct does not
|
|
// declare (additionalProperties false), so nothing undeclared
|
|
// reaches the route's body.
|
|
res = call("disp_thing", map[string]any{"id": "abc", "title": "T", "user_id": 1})
|
|
if !res.IsError || !strings.Contains(resultText(t, res), "invalid arguments") {
|
|
t.Fatalf("undeclared key = %+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")
|
|
}
|
|
})
|
|
}
|