| index e68f92a0c..2e37a3050 100644 |
| --- a/internal/edge/compress_test.go |
| +++ b/internal/edge/compress_test.go |
| @@ -1,7 +1,13 @@ |
| package edge |
| |
| import ( |
| + "bufio" |
| + "compress/gzip" |
| + "io" |
| + "net" |
| "net/http" |
| + "net/http/httptest" |
| + "strings" |
| "testing" |
| ) |
| |
| @@ -158,3 +164,195 @@ func TestWeakenETag(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// hijackTrackingRecorder is edge_test.go's hijackableRecorder plus a record of |
| +// whether the call actually arrived — this test needs to prove the wrapper |
| +// FORWARDED the hijack, not merely that it did not error. |
| +type hijackTrackingRecorder struct { |
| + *httptest.ResponseRecorder |
| + hijacked bool |
| +} |
| + |
| +func (h *hijackTrackingRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { |
| + h.hijacked = true |
| + return nil, nil, nil |
| +} |
| + |
| +// gzipReq builds a request that accepts gzip. |
| +func gzipReq(method, url string) *http.Request { |
| + r := httptest.NewRequest(method, url, nil) |
| + r.Header.Set("Accept-Encoding", "gzip") |
| + return r |
| +} |
| + |
| +// TestGzipWriterKeepsCompressionHeadersOffTheCallerMap is THE test this design |
| +// exists for. Every writer wrapper in this package embeds the writer below, so |
| +// Header() returns one shared map all the way down — and maybeStore caches |
| +// rec.Header().Clone(). If the compression headers landed on that shared map, |
| +// the cache would store an IDENTITY body labelled Content-Encoding: gzip and |
| +// serve it to every later visitor as an undecodable response. |
| +func TestGzipWriterKeepsCompressionHeadersOffTheCallerMap(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/tokens.css")) |
| + if g == nil { |
| + t.Fatal("newGzipWriter returned nil for a gzip-accepting GET") |
| + } |
| + g.Header().Set("Content-Type", "text/css") |
| + g.Header().Set("ETag", `"abc123"`) |
| + g.Header().Set("Content-Length", "4000") |
| + g.WriteHeader(http.StatusOK) |
| + body := strings.Repeat("a{color:red}\n", 400) |
| + io.WriteString(g, body) |
| + g.finish() |
| + |
| + if got := g.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("caller-visible Content-Encoding = %q, want empty — the recorder above would cache an identity body labelled gzip", got) |
| + } |
| + if got := g.Header().Get("ETag"); got != `"abc123"` { |
| + t.Errorf("caller-visible ETag = %q, want the strong tag untouched (the cached body IS the identity one)", got) |
| + } |
| + if got := g.Header().Get("Content-Length"); got != "4000" { |
| + t.Errorf("caller-visible Content-Length = %q, want it untouched", got) |
| + } |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("client Content-Encoding = %q, want gzip", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "" { |
| + t.Errorf("client Content-Length = %q, want it dropped — it described the identity body", got) |
| + } |
| + if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { |
| + t.Errorf("client Vary = %q, want Accept-Encoding", got) |
| + } |
| + if got := rec.Header().Get("ETag"); got != `W/"abc123"` { |
| + t.Errorf("client ETag = %q, want the weakened form", got) |
| + } |
| + |
| + zr, err := gzip.NewReader(rec.Body) |
| + if err != nil { |
| + t.Fatalf("client body is not gzip: %v", err) |
| + } |
| + got, err := io.ReadAll(zr) |
| + if err != nil { |
| + t.Fatalf("read gzip body: %v", err) |
| + } |
| + if string(got) != body { |
| + t.Fatalf("decompressed body differs from what the handler wrote") |
| + } |
| + if rec.Body.Len() >= len(body) { |
| + t.Fatalf("compressed size %d >= identity size %d", rec.Body.Len(), len(body)) |
| + } |
| +} |
| + |
| +// TestGzipWriterNilForRequestsThatCanNeverCompress: construction is where the |
| +// request-side questions are settled, once. A nil wrapper means ServeHTTP |
| +// leaves the writer stack exactly as it is today. |
| +func TestGzipWriterNilForRequestsThatCanNeverCompress(t *testing.T) { |
| + if g := newGzipWriter(httptest.NewRecorder(), httptest.NewRequest("GET", "http://app.test/", nil)); g != nil { |
| + t.Error("no Accept-Encoding must yield a nil wrapper") |
| + } |
| + if g := newGzipWriter(httptest.NewRecorder(), gzipReq("HEAD", "http://app.test/big.css")); g != nil { |
| + t.Error("HEAD must yield a nil wrapper — there is no body to compress, and its truthful Content-Length is the whole value of the response") |
| + } |
| +} |
| + |
| +// TestGzipWriterPassesThroughIneligibleResponses: an ineligible response must |
| +// be byte-identical to the unwrapped one, headers included. |
| +func TestGzipWriterPassesThroughIneligibleResponses(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/logo.png")) |
| + g.Header().Set("Content-Type", "image/png") |
| + g.Header().Set("Content-Length", "9") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, "PNG-BYTES") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "" { |
| + t.Errorf("Content-Encoding = %q on an image, want empty", got) |
| + } |
| + if got := rec.Header().Get("Content-Length"); got != "9" { |
| + t.Errorf("Content-Length = %q, want it preserved on a pass-through", got) |
| + } |
| + if got := rec.Body.String(); got != "PNG-BYTES" { |
| + t.Errorf("body = %q, want the identity bytes", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterSilentHandlerStillDeliversHeaders: the handler set headers and |
| +// returned without ever writing. Those headers live in the PRIVATE map, so |
| +// without a sync in finish() the stdlib would emit a bare 200 with none of |
| +// them — X-Carlos-Version included. |
| +func TestGzipWriterSilentHandlerStillDeliversHeaders(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("X-Carlos-Version", "abc123") |
| + g.finish() |
| + |
| + if got := rec.Header().Get("X-Carlos-Version"); got != "abc123" { |
| + t.Fatalf("X-Carlos-Version = %q, want abc123 — a handler that wrote nothing still lost every header it set", got) |
| + } |
| +} |
| + |
| +// TestGzipWriter1xxDoesNotLatch mirrors recordingResponseWriter.WriteHeader: a |
| +// 103 Early Hints block precedes the final status. Latching it would freeze |
| +// the compression decision on a status that has no body, and the real response |
| +// would then be delivered uncompressed — or worse, with the hints lost, since |
| +// they live in the private map. |
| +func TestGzipWriter1xxDoesNotLatch(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + g.Header().Set("Link", "</tokens.css>; rel=preload") |
| + g.WriteHeader(http.StatusEarlyHints) |
| + |
| + if got := rec.Header().Get("Link"); got != "</tokens.css>; rel=preload" { |
| + t.Errorf("early-hints Link = %q, want it forwarded — it was set on the private map", got) |
| + } |
| + |
| + g.Header().Del("Link") |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>hi</p>", 200)) |
| + g.finish() |
| + |
| + if got := rec.Header().Get("Content-Encoding"); got != "gzip" { |
| + t.Errorf("Content-Encoding = %q after a 103, want gzip — the 1xx latched the decision", got) |
| + } |
| + if got := rec.Header().Get("Link"); got != "" { |
| + t.Errorf("Link = %q, want empty — the final header block replaces the informational one", got) |
| + } |
| +} |
| + |
| +// TestGzipWriterFlushReachesTheClient: a flushed response must actually leave |
| +// the compressor, not sit in its buffer until the handler returns. SSE never |
| +// compresses (content type), but a streamed HTML page does. |
| +func TestGzipWriterFlushReachesTheClient(t *testing.T) { |
| + rec := httptest.NewRecorder() |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/stream")) |
| + g.Header().Set("Content-Type", "text/html") |
| + g.WriteHeader(http.StatusOK) |
| + io.WriteString(g, strings.Repeat("<p>chunk</p>", 100)) |
| + g.Flush() |
| + |
| + if rec.Body.Len() == 0 { |
| + t.Fatal("Flush left the bytes inside the compressor — a streaming response would hang until the handler returned") |
| + } |
| + if !rec.Flushed { |
| + t.Fatal("Flush did not reach the writer below") |
| + } |
| + g.finish() |
| +} |
| + |
| +// TestGzipWriterForwardsHijack: the upgrade path returns before this wrapper |
| +// is ever built, so this is not a hot path — it exists so the wrapper is never |
| +// itself the reason a hijack fails, matching the convention countingWriter and |
| +// recordingResponseWriter already follow. |
| +func TestGzipWriterForwardsHijack(t *testing.T) { |
| + rec := &hijackTrackingRecorder{ResponseRecorder: httptest.NewRecorder()} |
| + g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) |
| + if _, _, err := g.Hijack(); err != nil { |
| + t.Fatalf("Hijack: %v", err) |
| + } |
| + if !rec.hijacked { |
| + t.Fatal("Hijack did not reach the writer below") |
| + } |
| +} |