Files
summercms/modules/surf/streaming_smoke_test.go
Jakub Zych 5c15dea569 feat(19.7-01): streaming routes, MCP mount hook, configurable authorize scopes
- surf.Router.MountStream and recoverStream: a raw, unbuffered route kind
  whose http.Flusher reaches the client; panic after the first byte only
  aborts the body
- surf.MCPBuildTag and the mountPier hook: mcp.enabled is inert without the
  summercms_mcp tag until it is true, then boot fails naming the tag
  (pier_off.go is the untagged side)
- wristband parseAuthorizeScopes takes the server's allowed set built from
  Options.ScopesSupported; the default set is unchanged
- cooler.KeyPrefix exported for the MCP module's Redis keys
- smoke tests TestStreamingRouteSmoke, TestPierMountSmoke, TestMCPScopesSmoke
- surf, wristband and cooler READMEs, docs/services/{routing,oauth-server}.md
2026-10-22 16:11:37 +02:00

185 lines
5.7 KiB
Go

package surf
import (
"bufio"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
// Smoke test for the streaming route kind of phase 19.7 (RESEARCH Pitfall 1):
// a MountStream route flushes to the client while the handler still runs, a
// buffered route does not, and a panic is recovered without rewriting a
// response that has started.
// streamPlugin registers one buffered route; its stream routes are mounted
// through the mountPier hook, as the MCP module would.
type streamPlugin struct {
release chan struct{}
}
func (streamPlugin) ID() string { return "acme.stream" }
func (streamPlugin) Requires() []string { return nil }
func (streamPlugin) Register(*backpack.App) error { return nil }
func (streamPlugin) Boot(*backpack.App) error { return nil }
func (p streamPlugin) events(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "event: a\n\n")
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-p.release
_, _ = io.WriteString(w, "event: b\n\n")
}
func (p streamPlugin) Routes(r pact.Router) error {
r.Get("/acme/buffered", p.events)
return nil
}
// firstLine reads one line of the body in the background and reports it on
// the returned channel.
func firstLine(t *testing.T, res *http.Response) <-chan string {
t.Helper()
out := make(chan string, 1)
go func() {
line, err := bufio.NewReader(res.Body).ReadString('\n')
if err != nil {
line = "error: " + err.Error()
}
out <- strings.TrimSpace(line)
}()
return out
}
func TestStreamingRouteSmoke(t *testing.T) {
plugin := streamPlugin{release: make(chan struct{})}
panicAfterFlush := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "event: a\n\n")
w.(http.Flusher).Flush()
panic("after flush")
})
panicBeforeWrite := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("before write")
})
mountPier = func(r *Router, _ *backpack.App, _ []party.Plugin) error {
r.BindPlugin("summercms.pier")
if err := r.MountStream("GET", "/mcp/events", http.HandlerFunc(plugin.events)); err != nil {
return err
}
if err := r.MountStream("GET", "/mcp/panic-late", panicAfterFlush); err != nil {
return err
}
if err := r.MountStream("GET", "/mcp/panic-early", panicBeforeWrite); err != nil {
return err
}
// A stream route may not replace a plugin route.
if err := r.MountStream("GET", "/acme/buffered", panicBeforeWrite); err == nil || !strings.Contains(err.Error(), "acme.stream") {
t.Errorf("MountStream over a plugin route: %v", err)
}
return nil
}
defer func() { mountPier = nil }()
app := transportApp(t, "development", map[string]string{
"http.yaml": transportBodyLimits,
"mcp.yaml": "enabled: true\n",
})
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(h)
defer srv.Close()
client := srv.Client()
// The stream route delivers the first event before the handler returns.
stream, err := client.Get(srv.URL + "/mcp/events")
if err != nil {
t.Fatal(err)
}
defer stream.Body.Close()
if stream.StatusCode != http.StatusOK {
t.Fatalf("stream status = %d", stream.StatusCode)
}
select {
case line := <-firstLine(t, stream):
if line != "event: a" {
t.Fatalf("stream first line = %q", line)
}
case <-time.After(5 * time.Second):
t.Fatal("stream route did not flush its first event while the handler was running")
}
// The buffered route delivers nothing, not even its headers, until the
// handler returns: the whole GET stays pending until the release.
type bufferedResult struct {
status int
body string
err error
}
bufferedDone := make(chan bufferedResult, 1)
go func() {
res, err := client.Get(srv.URL + "/acme/buffered")
if err != nil {
bufferedDone <- bufferedResult{err: err}
return
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
bufferedDone <- bufferedResult{status: res.StatusCode, body: string(body), err: err}
}()
select {
case got := <-bufferedDone:
t.Fatalf("buffered route answered before the handler returned: %+v", got)
case <-time.After(300 * time.Millisecond):
}
close(plugin.release)
select {
case got := <-bufferedDone:
if got.err != nil || got.status != http.StatusOK || got.body != "event: a\n\nevent: b\n\n" {
t.Fatalf("buffered route after release = %+v", got)
}
case <-time.After(5 * time.Second):
t.Fatal("buffered route never answered")
}
rest, err := io.ReadAll(stream.Body)
if err != nil || !strings.Contains(string(rest), "event: b") {
t.Fatalf("stream rest = %q, %v", rest, err)
}
// A panic after the first flush ends the body; the 200 already sent stays.
late, err := client.Get(srv.URL + "/mcp/panic-late")
if err != nil {
t.Fatal(err)
}
defer late.Body.Close()
if late.StatusCode != http.StatusOK {
t.Fatalf("panic after flush status = %d", late.StatusCode)
}
body, _ := io.ReadAll(late.Body)
if !strings.HasPrefix(string(body), "event: a") || strings.Contains(string(body), "500") || strings.Contains(string(body), "after flush") {
t.Fatalf("panic after flush body = %q", body)
}
// A panic before any write answers the opaque 500.
early, err := client.Get(srv.URL + "/mcp/panic-early")
if err != nil {
t.Fatal(err)
}
defer early.Body.Close()
body, _ = io.ReadAll(early.Body)
if early.StatusCode != http.StatusInternalServerError || strings.Contains(string(body), "before write") {
t.Fatalf("panic before write = %d %q", early.StatusCode, body)
}
}