package edge import ( "bufio" "compress/gzip" "context" "fmt" "io" "net" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // TestAcceptsGzip pins the q-value rules. The two that matter in production // are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies // and old curl invocations send it) and a bare `*`, which admits gzip only // when its own q is non-zero. A specific token always outranks the wildcard, // so `gzip;q=0, *` is a refusal however generous the wildcard is. func TestAcceptsGzip(t *testing.T) { for _, tc := range []struct { ae string want bool }{ {"", false}, {"gzip", true}, {"GZIP", true}, {"x-gzip", true}, {"gzip, deflate, br", true}, {" gzip ; q=0.5 ", true}, {"gzip;q=0", false}, {"gzip;q=0.0", false}, {"gzip;q=0, *", false}, {"identity", false}, {"deflate, br", false}, {"*", true}, {"*;q=0", false}, {"deflate, *;q=0.1", true}, } { h := http.Header{} if tc.ae != "" { h.Set("Accept-Encoding", tc.ae) } if got := acceptsGzip(h); got != tc.want { t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want) } } } // TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines // and that is identical in meaning to one comma-joined line (RFC 9110 §5.3). // Reading it with Header.Get would see only the first, which is the exact bug // joinHeader exists to prevent for Vary and Cache-Control. func TestAcceptsGzipReadsEveryLine(t *testing.T) { h := http.Header{} h.Add("Accept-Encoding", "deflate") h.Add("Accept-Encoding", "gzip") if !acceptsGzip(h) { t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get") } } func TestQValue(t *testing.T) { for _, tc := range []struct { params string want float64 }{ {"", 1}, {"q=1", 1}, {"q=0", 0}, {"q=0.5", 0.5}, {" Q = 0.25 ", 0.25}, {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal {"charset=utf-8", 1}, } { if got := qValue(tc.params); got != tc.want { t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want) } } } // TestCompressibleEligibility walks the response-side gate. Each false case // is a response that would be WRONG or pointless to compress, not merely // unprofitable — a 206 range body, an already-encoded body, an origin that // said no-transform. func TestCompressibleEligibility(t *testing.T) { base := func() http.Header { h := http.Header{} h.Set("Content-Type", "text/css") return h } for _, tc := range []struct { name string mutate func(http.Header) status int want bool }{ {"plain css", func(http.Header) {}, 200, true}, {"html error page", func(h http.Header) { h.Set("Content-Type", "text/html; charset=utf-8") }, 404, true}, {"json api", func(h http.Header) { h.Set("Content-Type", "application/json") }, 200, true}, {"svg", func(h http.Header) { h.Set("Content-Type", "image/svg+xml") }, 200, true}, {"long body", func(h http.Header) { h.Set("Content-Length", "4096") }, 200, true}, {"unknown length", func(h http.Header) { h.Del("Content-Length") }, 200, true}, {"no content type", func(h http.Header) { h.Del("Content-Type") }, 200, false}, {"png", func(h http.Header) { h.Set("Content-Type", "image/png") }, 200, false}, {"event stream", func(h http.Header) { h.Set("Content-Type", "text/event-stream") }, 200, false}, {"already encoded", func(h http.Header) { h.Set("Content-Encoding", "gzip") }, 200, false}, {"no-transform", func(h http.Header) { h.Set("Cache-Control", "max-age=60, no-transform") }, 200, false}, {"short body", func(h http.Header) { h.Set("Content-Length", "512") }, 200, false}, {"exactly at threshold", func(h http.Header) { h.Set("Content-Length", "1024") }, 200, true}, {"partial content", func(h http.Header) { h.Set("Content-Range", "bytes 0-99/4096") }, 206, false}, {"no content", func(http.Header) {}, 204, false}, {"reset content", func(http.Header) {}, 205, false}, {"not modified", func(http.Header) {}, 304, false}, } { h := base() tc.mutate(h) if got := compressible(h, tc.status); got != tc.want { t.Errorf("%s: compressible(%v, %d) = %v, want %v", tc.name, h, tc.status, got, tc.want) } } } // TestCompressibleTypeSuffixes: a media type we have never heard of still // compresses when its structured suffix says it is text underneath. func TestCompressibleTypeSuffixes(t *testing.T) { for _, ct := range []string{ "application/manifest+json", "application/atom+xml", "application/ld+json", "application/vnd.api+json", "text/plain", "application/javascript", "application/wasm", "application/xml", "text/css;charset=UTF-8", } { if !compressibleType(ct) { t.Errorf("compressibleType(%q) = false, want true", ct) } } for _, ct := range []string{ "", "image/png", "video/mp4", "application/octet-stream", "application/zip", "font/woff2", "text/event-stream", } { if compressibleType(ct) { t.Errorf("compressibleType(%q) = true, want false", ct) } } } // TestWeakenETag: a gzipped body is a different representation, so its // validator must not claim byte-equality with the identity one. Weakening // keeps conditional requests working — the stdlib's If-None-Match check // compares weakly for GET — while dropping the strong-equality claim a // Range request would rely on. func TestWeakenETag(t *testing.T) { for _, tc := range []struct{ in, want string }{ {`"abc123"`, `W/"abc123"`}, {`W/"abc123"`, `W/"abc123"`}, // already weak: untouched {"", ""}, // absent: nothing to weaken } { h := http.Header{} if tc.in != "" { h.Set("ETag", tc.in) } weakenETag(h) if got := h.Get("ETag"); got != tc.want { t.Errorf("weakenETag(%q) -> %q, want %q", tc.in, got, tc.want) } } } // 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", "; rel=preload") g.WriteHeader(http.StatusEarlyHints) if got := rec.Header().Get("Link"); got != "; 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("

hi

", 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("

chunk

", 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") } } // edgeWithBackend builds an Edge with one proxied route, "app.test", pointed // at h. Driven with httptest.NewRecorder + httptest.NewRequest rather than a // real client: http.Transport adds its own Accept-Encoding and transparently // decompresses what comes back, which would hide exactly what these tests are // checking. func edgeWithBackend(t *testing.T, h http.Handler) *Edge { t.Helper() backend := httptest.NewServer(h) t.Cleanup(backend.Close) reg := openReg(t) if err := reg.Upsert(registry.Route{ Host: "app.test", Kind: "service", Status: "ready", Addr: backend.Listener.Addr().String(), }); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } return e } // cssBackend answers every request with a cacheable stylesheet of n bytes. func cssBackend(n int) http.Handler { body := strings.Repeat("a{color:red}\n", n/13+1)[:n] return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "max-age=600") io.WriteString(w, body) }) } // TestEdgeCompressesProxiedAssets is the payoff: an app ships identity bytes // and the edge puts them on the wire compressed. func TestEdgeCompressesProxiedAssets(t *testing.T) { e := edgeWithBackend(t, cssBackend(4096)) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("Content-Encoding = %q, want gzip", got) } if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { t.Errorf("Vary = %q, want it to name Accept-Encoding", got) } zr, err := gzip.NewReader(rec.Body) if err != nil { t.Fatalf("body is not gzip: %v", err) } got, err := io.ReadAll(zr) if err != nil { t.Fatalf("read gzip body: %v", err) } if len(got) != 4096 { t.Fatalf("decompressed %d bytes, want 4096", len(got)) } } // TestEdgeCacheStoresIdentityBodies is the invariant the whole writer ordering // exists to hold. The recorder sits ABOVE the compressor, so what lands in the // cache is the identity body with the app's own headers — one variant, not one // per encoding — and a client that cannot decode gzip is still served correctly // from that same entry. func TestEdgeCacheStoresIdentityBodies(t *testing.T) { e := edgeWithBackend(t, cssBackend(4096)) e.EnableCache(NewCache(10, 1<<20)) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) // miss if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("miss: Content-Encoding = %q, want gzip", got) } stored, ok := e.cache.get(CacheKey{Host: "app.test", Method: "GET", Path: "/tokens.css"}) if !ok { t.Fatal("the response was not cached — this test cannot say anything about what was stored") } if ce := stored.Header.Get("Content-Encoding"); ce != "" { t.Errorf("cached entry carries Content-Encoding %q: an identity body labelled gzip, served to every later visitor as an undecodable response", ce) } if v := stored.Header.Get("Vary"); strings.Contains(v, "Accept-Encoding") { t.Errorf("cached entry carries Vary %q: the edge's own Vary belongs below the recorder, not in the cache key", v) } if len(stored.Body) != 4096 { t.Errorf("cached body is %d bytes, want the 4096-byte identity body", len(stored.Body)) } hit := httptest.NewRecorder() e.ServeHTTP(hit, gzipReq("GET", "http://app.test/tokens.css")) if got := hit.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("cache hit: Content-Encoding = %q, want gzip — a hit is a delivery like any other", got) } zr, err := gzip.NewReader(hit.Body) if err != nil { t.Fatalf("cache hit body is not gzip: %v", err) } if b, err := io.ReadAll(zr); err != nil || len(b) != 4096 { t.Fatalf("cache hit decompressed to %d bytes (err %v), want 4096", len(b), err) } plain := httptest.NewRecorder() e.ServeHTTP(plain, httptest.NewRequest("GET", "http://app.test/tokens.css", nil)) if got := plain.Header().Get("Content-Encoding"); got != "" { t.Errorf("identity client got Content-Encoding %q from the shared entry", got) } if plain.Body.Len() != 4096 { t.Errorf("identity client got %d bytes, want the 4096-byte identity body", plain.Body.Len()) } } // TestEdgeMetersCompressedBytes: a tenant is billed for what crossed the wire. // The counter sits BELOW the compressor and finish() runs before Record — the // equality below is what pins that defer order, since gzip buffers until Close // and a reversed order would meter a fraction of the bytes. func TestEdgeMetersCompressedBytes(t *testing.T) { e := edgeWithBackend(t, cssBackend(8192)) m := NewUsageMeter() e.EnableUsage(m) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) row, ok := m.Drain()["app.test"] if !ok { t.Fatal("no usage row for app.test") } if row.BytesOut != int64(rec.Body.Len()) { t.Fatalf("metered %d bytes, client received %d — finish() must run before Record (defer order) or the trailer is missed", row.BytesOut, rec.Body.Len()) } if row.BytesOut >= 8192 { t.Fatalf("metered %d bytes for an 8192-byte identity body — the meter is counting bytes the edge did not send", row.BytesOut) } } // TestEdgeGzipKillSwitch: CARLOS_EDGE_GZIP=off is the rollback that needs no // redeploy, so it must produce today's bytes exactly. func TestEdgeGzipKillSwitch(t *testing.T) { t.Setenv("CARLOS_EDGE_GZIP", "off") e := edgeWithBackend(t, cssBackend(4096)) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) if got := rec.Header().Get("Content-Encoding"); got != "" { t.Errorf("Content-Encoding = %q with the kill switch off, want empty", got) } if got := rec.Header().Get("Vary"); strings.Contains(got, "Accept-Encoding") { t.Errorf("Vary = %q with the kill switch off — nothing may hint at a variant that is never produced", got) } if rec.Body.Len() != 4096 { t.Errorf("body = %d bytes, want the 4096-byte identity body", rec.Body.Len()) } } // TestGzipWriterForwardsTrailers: net/http moves a header set AFTER // WriteHeader into the trailer block when it was announced in Trailer. Every // such Set lands on the private map, where syncHeader has already been and // gone — without a late sync the wrapper silently eats trailers, and // purge_header.go's contract names them as a channel apps may use. func TestGzipWriterForwardsTrailers(t *testing.T) { rec := httptest.NewRecorder() g := newGzipWriter(rec, gzipReq("GET", "http://app.test/")) g.Header().Set("Trailer", "Carlos-Purge") g.Header().Set("Content-Type", "text/html") g.Header().Set("ETag", `"strong"`) g.WriteHeader(http.StatusOK) io.WriteString(g, strings.Repeat("

hi

", 200)) g.Header().Set("Carlos-Purge", "/trailed") // after the body: a trailer g.finish() if got := rec.Header().Get("Carlos-Purge"); got != "/trailed" { t.Fatalf("trailer Carlos-Purge = %q, want /trailed — the private map swallowed it", got) } // The late sync must not undo the compression edits by copying the // identity representation's fields back down. if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Errorf("Content-Encoding = %q after the trailer sync, want gzip", got) } if got := rec.Header().Get("ETag"); got != `W/"strong"` { t.Errorf("ETag = %q after the trailer sync, want the weakened form", got) } if got := rec.Header().Get("Content-Length"); got != "" { t.Errorf("Content-Length = %q after the trailer sync, want it still dropped", got) } if got := rec.Header().Get("Vary"); got != "Accept-Encoding" { t.Errorf("Vary = %q after the trailer sync, want Accept-Encoding", got) } } // TestUpgradeIsUntouchedByCompression: the wrap site is after the upgrade // branch precisely so a handshake keeps the writer stack it has always had. // A gzip-accepting client (every browser) must still get its 101, and bytes // must still flow both ways after the switch. func TestUpgradeIsUntouchedByCompression(t *testing.T) { addr := upgradeEchoBackend(t, "ws") reg := openReg(t) if err := reg.Upsert(registry.Route{ Host: "ws.test", Kind: "service", Status: "ready", Addr: addr, }); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } srv := httptest.NewServer(e) defer srv.Close() c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://")) if err != nil { t.Fatalf("dial edge: %v", err) } defer c.Close() if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { t.Fatalf("set deadline: %v", err) } if _, err := fmt.Fprint(c, "GET /socket HTTP/1.1\r\nHost: ws.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nAccept-Encoding: gzip\r\n\r\n"); err != nil { t.Fatalf("write handshake: %v", err) } br := bufio.NewReader(c) resp, err := http.ReadResponse(br, nil) if err != nil { t.Fatalf("read handshake response: %v", err) } if resp.StatusCode != http.StatusSwitchingProtocols { t.Fatalf("handshake status = %d, want 101 — an Accept-Encoding header must not divert an upgrade through the compressor", resp.StatusCode) } if _, err := fmt.Fprint(c, "ping\n"); err != nil { t.Fatalf("write post-upgrade frame: %v", err) } line, err := br.ReadString('\n') if err != nil { t.Fatalf("read post-upgrade frame: %v", err) } if line != "ws:ping\n" { t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways after the switch", line, "ws:ping\n") } } // TestAlreadyEncodedUpstreamPassesThrough: an app that compresses for itself // must not be compressed again into a body nobody can decode. func TestAlreadyEncodedUpstreamPassesThrough(t *testing.T) { e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var buf strings.Builder zw := gzip.NewWriter(&buf) io.WriteString(zw, strings.Repeat("already compressed\n", 200)) zw.Close() w.Header().Set("Content-Type", "text/plain") w.Header().Set("Content-Encoding", "gzip") io.WriteString(w, buf.String()) })) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://app.test/pre.txt")) if got := rec.Header().Values("Content-Encoding"); len(got) != 1 || got[0] != "gzip" { t.Fatalf("Content-Encoding = %v, want exactly one gzip", got) } zr, err := gzip.NewReader(rec.Body) if err != nil { t.Fatalf("body did not survive as single-encoded gzip: %v", err) } body, err := io.ReadAll(zr) if err != nil { t.Fatalf("read body: %v", err) } if !strings.HasPrefix(string(body), "already compressed") { t.Fatalf("body = %.40q, want the upstream's own gzip decoded in ONE pass", body) } } // TestEventStreamIsNotCompressed: SSE is an open stream consumed event by // event. Buffering it inside a compressor is a hang, not a saving. func TestEventStreamIsNotCompressed(t *testing.T) { e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.WriteHeader(http.StatusOK) for i := 0; i < 3; i++ { fmt.Fprintf(w, "data: %d\n\n", i) if f, ok := w.(http.Flusher); ok { f.Flush() } } })) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://app.test/events")) if got := rec.Header().Get("Content-Encoding"); got != "" { t.Fatalf("Content-Encoding = %q on an event stream, want empty", got) } if !strings.Contains(rec.Body.String(), "data: 0") { t.Fatalf("event stream body = %q, want the plain events", rec.Body.String()) } } // TestRangeAndConditionalRequestsSurvive: a 206 must keep its byte offsets // meaningful, and a conditional request must still 304 against the weakened // validator the client was given. func TestRangeAndConditionalRequestsSurvive(t *testing.T) { body := strings.Repeat("a{color:red}\n", 400) e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") w.Header().Set("ETag", `"v1"`) http.ServeContent(w, r, "tokens.css", time.Unix(1, 0), strings.NewReader(body)) })) rng := gzipReq("GET", "http://app.test/tokens.css") rng.Header.Set("Range", "bytes=0-9") rec := httptest.NewRecorder() e.ServeHTTP(rec, rng) if rec.Code != http.StatusPartialContent { t.Fatalf("range status = %d, want 206", rec.Code) } if got := rec.Header().Get("Content-Encoding"); got != "" { t.Errorf("206 carries Content-Encoding %q — compressing a byte range makes the offsets it names meaningless", got) } if rec.Body.Len() != 10 { t.Errorf("range body = %d bytes, want 10", rec.Body.Len()) } full := httptest.NewRecorder() e.ServeHTTP(full, gzipReq("GET", "http://app.test/tokens.css")) etag := full.Header().Get("ETag") if !strings.HasPrefix(etag, `W/`) { t.Fatalf("ETag = %q on a compressed body, want the weakened form", etag) } cond := gzipReq("GET", "http://app.test/tokens.css") cond.Header.Set("If-None-Match", etag) nm := httptest.NewRecorder() e.ServeHTTP(nm, cond) if nm.Code != http.StatusNotModified { t.Fatalf("conditional request = %d, want 304 — weakening the validator broke revalidation", nm.Code) } if got := nm.Header().Get("Content-Encoding"); got != "" { t.Errorf("304 carries Content-Encoding %q; a 304 has no body to encode", got) } } // TestStatusHostCompresses: the status class reaches the wrap site through a // different door (its own handler, exempt from the shared cache and the usage // meter) and must compress like everything else. func TestStatusHostCompresses(t *testing.T) { s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"}) s.Put(context.Background(), "status/fleet.json", []byte(`{"boxes":[`+strings.Repeat(`{"id":"box"},`, 300)+`{}]}`), "") rec := httptest.NewRecorder() h.ServeHTTP(rec, gzipReq("GET", "http://status.test/fleet.json")) if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("Content-Encoding = %q on the status host, want gzip", got) } zr, err := gzip.NewReader(rec.Body) if err != nil { t.Fatalf("body is not gzip: %v", err) } got, err := io.ReadAll(zr) if err != nil || !strings.Contains(string(got), `"boxes"`) { t.Fatalf("decompressed status body = %.40q (err %v)", got, err) } } // TestStaticSiteCompresses: the static class is the payoff case — a shipped // site tree whose CSS is served straight off disk by http.ServeFile. It // reaches the wrap site through Edge.ServeHTTP like every other class, so // this pins that the class nobody proxies is covered too. func TestStaticSiteCompresses(t *testing.T) { store := release.NewDirStore(filepath.Join(t.TempDir(), "store")) priv, pub := testKeys(t) css := strings.Repeat("a{color:red}\n", 400) // 5200 bytes, well over the threshold shipSite(t, store, "site", "v1", map[string]string{ "index.html": "home", "tokens.css": css, }) if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil { t.Fatalf("Promote: %v", err) } reg := openReg(t) if err := reg.Upsert(registry.Route{ Host: "site.test", Kind: "static", App: "site", Channel: "canary/x", Status: "ready", }); err != nil { t.Fatalf("upsert: %v", err) } e, err := New(reg, nil) if err != nil { t.Fatalf("New: %v", err) } e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache")) if err := e.Reload(); err != nil { t.Fatalf("Reload: %v", err) } rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("GET", "http://site.test/tokens.css")) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("Content-Encoding = %q on a static asset, want gzip", got) } if got := rec.Header().Get("X-Carlos-Version"); got != "v1" { t.Errorf("X-Carlos-Version = %q, want v1 — compression must not disturb the static class's per-request version stamp", got) } zr, err := gzip.NewReader(rec.Body) if err != nil { t.Fatalf("body is not gzip: %v", err) } got, err := io.ReadAll(zr) if err != nil { t.Fatalf("read gzip body: %v", err) } if string(got) != css { t.Fatalf("decompressed static asset differs from the shipped file") } if rec.Body.Len() >= len(css) { t.Fatalf("compressed %d bytes >= identity %d", rec.Body.Len(), len(css)) } } // TestHeadKeepsItsContentLength: a HEAD has no body to compress, and its // truthful Content-Length is the whole value of the response. The backend // sets the length explicitly — Go only infers one when the whole body fits // its 2048-byte buffer, so a 4096-byte handler would otherwise chunk and this // test would be asserting the stdlib's buffering, not the edge's behaviour. func TestHeadKeepsItsContentLength(t *testing.T) { e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/css") w.Header().Set("Content-Length", "4096") w.WriteHeader(http.StatusOK) if r.Method != http.MethodHead { io.WriteString(w, strings.Repeat("x", 4096)) } })) rec := httptest.NewRecorder() e.ServeHTTP(rec, gzipReq("HEAD", "http://app.test/tokens.css")) if got := rec.Header().Get("Content-Encoding"); got != "" { t.Errorf("HEAD Content-Encoding = %q, want empty", got) } if got := rec.Header().Get("Content-Length"); got != "4096" { t.Errorf("HEAD Content-Length = %q, want 4096", got) } }