test: prove websocket upgrade and SSE streaming through forwards
commit 06f4ceb908738d2986a46f68994a8399c1c0c3d1 · parent a052659317
· browse this tree
Ximianth, 2026-08-14 16:36:16 UTC
httputil.ReverseProxy documents both behaviors; these tests demand them over real sockets rather than trusting the docs. The websocket test hand-rolls the Upgrade handshake (the subject is upgrade pass-through, not ws framing -- no ws library needed) and proves bytes flow both directions on the hijacked connection. The SSE test cannot false-pass: the backend holds the stream open until the client confirms the event arrived, so a proxy that buffers to EOF deadlocks into a damage-control timeout instead of passing. No sleeps anywhere; all coordination is channel-gated.
diff --git a/forward_stream_test.go b/forward_stream_test.go new file mode 100644 index 0000000..9b96e82 --- /dev/null +++ b/forward_stream_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// The tests in this file prove streaming behavior through a forward: +// websocket upgrades and SSE both demand more than request/response -- +// bytes must flow while the connection stays open. +// httptest.ResponseRecorder can neither hijack nor observe mid-stream, +// so these run vitrine in a real httptest.Server and speak over real sockets. + +// TestForwardWebsocketUpgrade proves an Upgrade round-trip through the proxy: +// handshake, then bytes both ways on the hijacked connection. +// The handshake is hand-rolled: the subject is upgrade *pass-through*, +// not websocket framing, so no ws library is needed on either end. +func TestForwardWebsocketUpgrade(t *testing.T) { + // The backend hijacks upgrade requests and echoes lines back prefixed. + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + t.Errorf("backend saw no Upgrade header; got %q", r.Header.Get("Upgrade")) + http.Error(w, "expected upgrade", http.StatusBadRequest) + return + } + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Errorf("hijack: %v", err) + return + } + defer conn.Close() + rw.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") + rw.Flush() + for { + line, err := rw.ReadString('\n') + if err != nil { + return + } + rw.WriteString("echo:" + line) + rw.Flush() + } + })) + defer backend.Close() + + vitrine := httptest.NewServer(forwardServer(t, map[string]string{"game": backend.URL})) + defer vitrine.Close() + + // Dial vitrine raw and perform the upgrade handshake through the proxy. + conn, err := net.Dial("tcp", strings.TrimPrefix(vitrine.URL, "http://")) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + fmt.Fprintf(conn, "GET /game/ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n") + br := bufio.NewReader(conn) + resp, err := http.ReadResponse(br, nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("status = %d, want 101", resp.StatusCode) + } + + // The connection is now a two-way pipe through the proxy; prove both directions. + for _, msg := range []string{"hello\n", "worlds\n"} { + if _, err := io.WriteString(conn, msg); err != nil { + t.Fatal(err) + } + line, err := br.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if line != "echo:"+msg { + t.Fatalf("read %q, want %q", line, "echo:"+msg) + } + } +} + +// TestForwardSSEStreamsMidResponse proves the proxy flushes streamed bytes +// while the backend holds the connection open, rather than buffering to EOF. +// The backend sends one event, then blocks until the client confirms receipt +// (channel-gated, no sleeps): if the event arrives at all, it arrived mid-stream. +func TestForwardSSEStreamsMidResponse(t *testing.T) { + received := make(chan struct{}) + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: tick\n\n") + w.(http.Flusher).Flush() + // Hold the response open until the client has read the event. + // Only then may the stream end; a proxy that buffers to EOF deadlocks + // here instead of passing, which is exactly the point. + select { + case <-received: + case <-r.Context().Done(): + t.Error("backend: client gave up before confirming the event") + } + })) + defer backend.Close() + + vitrine := httptest.NewServer(forwardServer(t, map[string]string{"": backend.URL})) + defer vitrine.Close() + + // The client timeout is damage control only, never load-bearing: + // a correctly-flushing proxy delivers the event immediately. + // Without it, a buffering proxy would deadlock the test instead of failing it + // (client blocked on read, backend blocked on the confirmation channel). + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(vitrine.URL + "/events") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" { + t.Fatalf("content-type = %q", ct) + } + br := bufio.NewReader(resp.Body) + line, err := br.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if line != "data: tick\n" { + t.Fatalf("read %q, want the SSE event line", line) + } + close(received) +}