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()) } }) }