| index 08ab2eb52..24db2cfe8 100644 |
| --- a/internal/edge/compress_test.go |
| +++ b/internal/edge/compress_test.go |
| @@ -3,14 +3,19 @@ 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 |
| @@ -547,3 +552,256 @@ func TestGzipWriterForwardsTrailers(t *testing.T) { |
| 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) |
| + } |
| +} |