carlos / platform Public

edge: gzip regression battery + record the wrapper rule

Upgrades still 101 with Accept-Encoding set, an already-encoded
upstream is not double-compressed, SSE and 206 stay identity, static
trees and the status host compress, and a weakened ETag still
revalidates. All seven passed on first run against Task 4's
implementation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc
Paul Campbell 3598e7a963f005bb800826bc0de93c7ecd940383 parent 15c47ac
2 files changed, +270 −0
  • CLAUDE.md +12 −0
  • internal/edge/compress_test.go +258 −0
diff --git a/CLAUDE.md b/CLAUDE.md
index fc66ddc9d..7a7136c11 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -110,6 +110,18 @@ read as a single piece rather than commit by commit.
production route. It refuses any row carrying a column the record cannot
express (`Backing`, `Tunnel`, `Caps`) or owned by another pass (`Platform`,
`Canary`).
+- **A ResponseWriter wrapper that edits headers needs its own header map.**
+ Wrappers in `internal/edge` embed the writer below, so `Header()` is ONE
+ shared map from handler to connection — and `maybeStore` caches
+ `rec.Header().Clone()`. The gzip wrapper (`internal/edge/compress.go`) keeps
+ a private map and writes `Content-Encoding` only to the real writer's; on the
+ shared map it would have stored an identity body labelled gzip and served it
+ to every later visitor. Two corollaries it paid for: a header set AFTER
+ `WriteHeader` is a TRAILER, so the wrapper must sync late additions down or
+ it silently eats them; and canonical for `ETag` is `Etag`, so a raw map-key
+ comparison spelled `"ETag"` matches nothing. Ordering is load-bearing too:
+ recorder ABOVE the compressor (cache stores identity), usage counter BELOW it
+ (tenants are billed for wire bytes), wrap site AFTER the upgrade branch.
- **Console security gates are ordered**: session → CSRF → owner (byte-identical
404) → `requireFreshAuth` LAST. A non-member must never get a redirect that
confirms an account exists.
diff --git a/internal/edge/compress_test.go b/internal/edge/compress_test.go
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)
+ }
+}