Files
summercms/modules/pier/tracer_smoke_test.go
Jakub Zych 3bbbe6c11e test(19.7-05): phase gate with the go and marker stages, pier test helpers, TestToolsList and TestDispatchMarker
- 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)
2026-10-23 13:31:41 +02:00

146 lines
5.8 KiB
Go

package pier_test
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Tracer smoke test of phase 19.7 plan 02 (Task 2): one tool declared by a
// fake plugin is listed and called end to end by the SDK client through
// pier's stateful path, dispatching in-process through surf's compiled
// guard chain with the bouncer dispatch marker.
//
// The test lives in package pier_test and builds a real surf router with
// surf.BuildRouter, then mounts pier through a tiny in-test Host adapter
// that records the endpoint handler instead of compiling a route, so the
// pier package tests need no build tag (with the tag, surf imports pier,
// which an external test package may still import).
func TestPierTracerSmoke(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
app := pierApp(t, "enabled: false\n")
plugins := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool()}}}
host, err := mountPier(t, app, plugins)
if err != nil {
t.Fatal(err)
}
for _, m := range []string{"POST", "GET", "DELETE"} {
if host.handlers[m] == nil {
t.Fatalf("pier did not mount %s %s", m, host.path)
}
}
srv := httptest.NewServer(host)
defer srv.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "acme-test", Version: "1"}, nil)
transport := &mcp.StreamableClientTransport{
Endpoint: srv.URL + "/mcp",
HTTPClient: &http.Client{Transport: bearerTransport{token: "oas_at_test"}},
}
// Pinned to 2025-11-25: the stateful path with sessions (the newest
// protocol is served statelessly, see TestPierStatelessSmoke).
cs, err := client.Connect(ctx, transport, &mcp.ClientSessionOptions{ProtocolVersion: "2025-11-25"})
if err != nil {
t.Fatalf("connect: %v", err)
}
defer cs.Close()
if cs.ID() == "" {
t.Fatal("the stateful path answered no Mcp-Session-Id")
}
list, err := cs.ListTools(ctx, nil)
if err != nil {
t.Fatalf("tools/list: %v", err)
}
if len(list.Tools) != 1 || list.Tools[0].Name != "acme_echo" {
t.Fatalf("tools/list = %+v, want exactly acme_echo", list.Tools)
}
ann := list.Tools[0].Annotations
if ann == nil || ann.ReadOnlyHint || ann.DestructiveHint == nil || *ann.DestructiveHint {
t.Fatalf("annotations = %+v, want readOnlyHint false and destructiveHint false", ann)
}
res, err := cs.CallTool(ctx, &mcp.CallToolParams{Name: "acme_echo", Arguments: map[string]any{"id": 5, "text": "hi"}})
if err != nil {
t.Fatalf("tools/call: %v", err)
}
if res.IsError {
t.Fatalf("tools/call isError: %+v", res.Content)
}
structured, _ := res.StructuredContent.(map[string]any)
if structured["principal"] != 7.0 || structured["id"] != 5.0 || structured["auth"] != false {
t.Fatalf("structuredContent = %+v, want principal 7, id 5, auth false", structured)
}
body, _ := structured["body"].(map[string]any)
if len(body) != 1 || body["text"] != "hi" {
t.Fatalf("body = %+v, want {text: hi} without id", body)
}
if len(res.Content) != 1 {
t.Fatalf("content = %+v, want one text item", res.Content)
}
if text, ok := res.Content[0].(*mcp.TextContent); !ok || !strings.Contains(text.Text, `"principal":7`) {
t.Fatalf("text content = %+v", res.Content[0])
}
// A bad bearer is a 401 carrying the protected resource metadata challenge.
init := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}`
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init))
req.Header.Set("Authorization", "Bearer oas_at_bad")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
challenge := resp.Header.Get("WWW-Authenticate")
if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(challenge, `resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"`) || !strings.Contains(challenge, `scope="read"`) {
t.Fatalf("bad bearer = %d %q", resp.StatusCode, challenge)
}
// The same tool declared twice fails Mount naming the exposed name.
twice := []party.Plugin{acmePlugin{tools: []pact.MCPTool{echoTool(), echoTool()}}}
if _, err := mountPier(t, pierApp(t, "enabled: false\n"), twice); err == nil || !strings.Contains(err.Error(), "acme_echo") {
t.Fatalf("duplicate tool: err = %v, want one naming acme_echo", err)
}
// A tool bound to a route nobody registers fails Mount naming the route.
nope := echoTool()
nope.Name, nope.Method, nope.Path = "nope", "GET", "/nope"
nope.Input = struct{}{}
missing := []party.Plugin{acmePlugin{tools: []pact.MCPTool{nope}}}
if _, err := mountPier(t, pierApp(t, "enabled: false\n"), missing); err == nil || !strings.Contains(err.Error(), "/nope") {
t.Fatalf("missing route: err = %v, want one naming /nope", err)
}
// A plugin that declares nothing exposes nothing, and mcp.enabled false mounts nothing.
none, err := mountPier(t, pierApp(t, "enabled: false\n"), []party.Plugin{acmePlugin{}})
if err != nil || none.handlers["POST"] == nil {
t.Fatalf("plugin without tools: err = %v, mounted = %v", err, none.handlers["POST"] != nil)
}
off := pierApp(t, "enabled: false\n")
router, err := surf.BuildRouter(off, plugins)
if err != nil {
t.Fatal(err)
}
offHost := &testHost{r: router, handlers: map[string]http.Handler{}}
if err := pier.Mount(offHost, off, plugins); err != nil || len(offHost.handlers) != 0 {
t.Fatalf("mcp.enabled false: err = %v, handlers = %d", err, len(offHost.handlers))
}
tracerIdentityCases(t, ctx)
}