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:
100
modules/surf/pier_mount_test.go
Normal file
100
modules/surf/pier_mount_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// TestPierMount (SC-1, D-30, D-33) runs in both builds. Without the
|
||||
// summercms_mcp tag: mcp off leaves the route table exactly as without the
|
||||
// key, and mcp.enabled true fails boot naming the tag. With the tag (the
|
||||
// gate's tagged run): mcp.enabled true mounts POST, GET and DELETE at the
|
||||
// endpoint as stream routes and the two protected resource metadata
|
||||
// documents, and a plugin route already at the endpoint path fails boot
|
||||
// naming its plugin.
|
||||
func TestPierMount(t *testing.T) {
|
||||
plugins := []party.Plugin{dispatchPlugin{}}
|
||||
files := func(mcp string) map[string]string {
|
||||
f := map[string]string{"http.yaml": transportBodyLimits, "app.yaml": "name: mount-test\nurl: http://acme.test\n"}
|
||||
if mcp != "" {
|
||||
f["mcp.yaml"] = mcp
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
t.Run("off leaves the route table unchanged", func(t *testing.T) {
|
||||
base, err := BuildRouter(transportApp(t, "development", files("")), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, mcp := range []string{"enabled: false\n", "path: /agents\nallow_personal_tokens: true\n"} {
|
||||
router, err := BuildRouter(transportApp(t, "development", files(mcp)), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(router.Routes(), base.Routes()) {
|
||||
t.Fatalf("%q changes the route table:\n%+v\n%+v", mcp, router.Routes(), base.Routes())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if mountPier == nil {
|
||||
t.Run("enabled without the tag fails boot", func(t *testing.T) {
|
||||
_, err := BuildRouter(transportApp(t, "development", files("enabled: true\n")), plugins)
|
||||
if err == nil || !strings.Contains(err.Error(), MCPBuildTag) {
|
||||
t.Fatalf("err = %v, want one naming %s", err, MCPBuildTag)
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
t.Run("enabled mounts the endpoint as stream routes", func(t *testing.T) {
|
||||
router, err := BuildRouter(transportApp(t, "development", files("enabled: true\n")), plugins)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var endpoint []string
|
||||
for _, rt := range router.routes {
|
||||
if rt.path == "/mcp" {
|
||||
if !rt.stream || rt.pluginID != "summercms.pier" || !slices.Contains(rt.middleware, "throttle:120,1") {
|
||||
t.Fatalf("%s /mcp = stream %v plugin %q middleware %v", rt.method, rt.stream, rt.pluginID, rt.middleware)
|
||||
}
|
||||
endpoint = append(endpoint, rt.method)
|
||||
}
|
||||
}
|
||||
slices.Sort(endpoint)
|
||||
if !slices.Equal(endpoint, []string{http.MethodDelete, http.MethodGet, http.MethodPost}) {
|
||||
t.Fatalf("endpoint methods = %v", endpoint)
|
||||
}
|
||||
for _, path := range []string{"/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp"} {
|
||||
found := false
|
||||
for _, ri := range router.Routes() {
|
||||
if ri.Method == http.MethodGet && ri.Pattern == path && ri.PluginID == "summercms.pier" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("GET %s is not mounted", path)
|
||||
}
|
||||
}
|
||||
if _, ok := router.Handler(http.MethodPost, "/mcp"); !ok {
|
||||
t.Fatal("no compiled handler for POST /mcp")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a plugin route at the endpoint fails boot", func(t *testing.T) {
|
||||
squatter := routesPluginFunc{id: "acme.squatter", routes: func(r pact.Router) {
|
||||
r.Get("/mcp", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
}}
|
||||
_, err := BuildRouter(transportApp(t, "development", files("enabled: true\n")), append(plugins, squatter))
|
||||
if err == nil || !strings.Contains(err.Error(), "acme.squatter") {
|
||||
t.Fatalf("err = %v, want one naming acme.squatter", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -88,7 +88,8 @@ func TestStreamingRouteSmoke(t *testing.T) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { mountPier = nil }()
|
||||
prev := mountPier
|
||||
defer func() { mountPier = prev }()
|
||||
|
||||
app := transportApp(t, "development", map[string]string{
|
||||
"http.yaml": transportBodyLimits,
|
||||
|
||||
164
modules/surf/streaming_test.go
Normal file
164
modules/surf/streaming_test.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// TestStreamingRoute (SC-1, RESEARCH Pitfall 1): a MountStream route flushes
|
||||
// to the client while its handler runs and a buffered route does not (its
|
||||
// Flush stays a no-op until the handler returns); a panic before the first
|
||||
// byte is the opaque 500; and a stream route keeps everything else a route
|
||||
// gets: its inline throttle, its CORS profile's headers and the route
|
||||
// pattern in the access log.
|
||||
func TestStreamingRoute(t *testing.T) {
|
||||
plugin := streamPlugin{release: make(chan struct{})}
|
||||
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, "event: ok\n\n")
|
||||
})
|
||||
prev := mountPier
|
||||
mountPier = func(r *Router, _ *backpack.App, _ []party.Plugin) error {
|
||||
r.BindPlugin("summercms.pier")
|
||||
for _, m := range []struct {
|
||||
path string
|
||||
h http.Handler
|
||||
mw []string
|
||||
}{
|
||||
{"/mcp/events", http.HandlerFunc(plugin.events), nil},
|
||||
{"/mcp/panic-early", http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("before write") }), nil},
|
||||
{"/mcp/limited", ok, []string{"throttle:2,1"}},
|
||||
{"/mcp/cors", ok, []string{"cors:spa"}},
|
||||
} {
|
||||
if err := r.MountStream(http.MethodGet, m.path, m.h, m.mw...); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
defer func() { mountPier = prev }()
|
||||
|
||||
app := transportApp(t, "development", map[string]string{
|
||||
"http.yaml": transportBodyLimits + `cors_profiles:
|
||||
spa:
|
||||
allowed_origins: ["https://www.example.com"]
|
||||
allowed_methods: ["GET"]
|
||||
allowed_headers: ["Content-Type"]
|
||||
`,
|
||||
"mcp.yaml": "enabled: true\n",
|
||||
})
|
||||
var logs bytes.Buffer
|
||||
if err := app.Publish(slog.New(slog.NewTextHandler(&logs, nil))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := Assemble(app, []party.Plugin{plugin})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := httptest.NewServer(h)
|
||||
defer srv.Close()
|
||||
client := srv.Client()
|
||||
get := func(path string, header map[string]string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, v := range header {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
t.Run("stream flushes, buffered Flush is a no-op", func(t *testing.T) {
|
||||
stream := get("/mcp/events", nil)
|
||||
defer stream.Body.Close()
|
||||
select {
|
||||
case line := <-firstLine(t, stream):
|
||||
if line != "event: a" {
|
||||
t.Fatalf("first line = %q", line)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the stream route did not flush while its handler ran")
|
||||
}
|
||||
buffered := make(chan string, 1)
|
||||
go func() {
|
||||
res, err := client.Get(srv.URL + "/acme/buffered")
|
||||
if err != nil {
|
||||
buffered <- "error: " + err.Error()
|
||||
return
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
buffered <- string(body)
|
||||
}()
|
||||
select {
|
||||
case got := <-buffered:
|
||||
t.Fatalf("the buffered route answered before its handler returned: %q", got)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
close(plugin.release)
|
||||
select {
|
||||
case got := <-buffered:
|
||||
if got != "event: a\n\nevent: b\n\n" {
|
||||
t.Fatalf("buffered body = %q", got)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the buffered route never answered")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("panic before the first byte", func(t *testing.T) {
|
||||
res := get("/mcp/panic-early", nil)
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != http.StatusInternalServerError || strings.Contains(string(body), "before write") {
|
||||
t.Fatalf("panic = %d %q", res.StatusCode, body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("inline throttle", func(t *testing.T) {
|
||||
var codes []int
|
||||
for i := 0; i < 3; i++ {
|
||||
res := get("/mcp/limited", nil)
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
codes = append(codes, res.StatusCode)
|
||||
}
|
||||
if codes[0] != http.StatusOK || codes[1] != http.StatusOK || codes[2] != http.StatusTooManyRequests {
|
||||
t.Fatalf("statuses = %v, want 200 200 429", codes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CORS profile headers", func(t *testing.T) {
|
||||
res := get("/mcp/cors", map[string]string{"Origin": "https://www.example.com"})
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK || res.Header.Get("Access-Control-Allow-Origin") != "https://www.example.com" {
|
||||
t.Fatalf("cors = %d, allow-origin %q", res.StatusCode, res.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
other := get("/mcp/cors", map[string]string{"Origin": "https://evil.example"})
|
||||
defer other.Body.Close()
|
||||
if other.Header.Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatalf("a foreign origin got allow-origin %q", other.Header.Get("Access-Control-Allow-Origin"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("access log pattern", func(t *testing.T) {
|
||||
if !strings.Contains(logs.String(), `pattern="GET /mcp/limited"`) || !strings.Contains(logs.String(), `pattern="GET /mcp/events"`) {
|
||||
t.Fatalf("access log lacks the stream route patterns:\n%s", logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user