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,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")
}
})
}