carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package edge
2
3import (
4 "bufio"
5 "compress/gzip"
6 "context"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "net/http/httptest"
12 "path/filepath"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/carlosframework/platform/internal/registry"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// TestAcceptsGzip pins the q-value rules. The two that matter in production
22// are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies
23// and old curl invocations send it) and a bare `*`, which admits gzip only
24// when its own q is non-zero. A specific token always outranks the wildcard,
25// so `gzip;q=0, *` is a refusal however generous the wildcard is.
26func TestAcceptsGzip(t *testing.T) {
27 for _, tc := range []struct {
28 ae string
29 want bool
30 }{
31 {"", false},
32 {"gzip", true},
33 {"GZIP", true},
34 {"x-gzip", true},
35 {"gzip, deflate, br", true},
36 {" gzip ; q=0.5 ", true},
37 {"gzip;q=0", false},
38 {"gzip;q=0.0", false},
39 {"gzip;q=0, *", false},
40 {"identity", false},
41 {"deflate, br", false},
42 {"*", true},
43 {"*;q=0", false},
44 {"deflate, *;q=0.1", true},
45 } {
46 h := http.Header{}
47 if tc.ae != "" {
48 h.Set("Accept-Encoding", tc.ae)
49 }
50 if got := acceptsGzip(h); got != tc.want {
51 t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want)
52 }
53 }
54}
55
56// TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines
57// and that is identical in meaning to one comma-joined line (RFC 9110 §5.3).
58// Reading it with Header.Get would see only the first, which is the exact bug
59// joinHeader exists to prevent for Vary and Cache-Control.
60func TestAcceptsGzipReadsEveryLine(t *testing.T) {
61 h := http.Header{}
62 h.Add("Accept-Encoding", "deflate")
63 h.Add("Accept-Encoding", "gzip")
64 if !acceptsGzip(h) {
65 t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get")
66 }
67}
68
69func TestQValue(t *testing.T) {
70 for _, tc := range []struct {
71 params string
72 want float64
73 }{
74 {"", 1},
75 {"q=1", 1},
76 {"q=0", 0},
77 {"q=0.5", 0.5},
78 {" Q = 0.25 ", 0.25},
79 {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal
80 {"charset=utf-8", 1},
81 } {
82 if got := qValue(tc.params); got != tc.want {
83 t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want)
84 }
85 }
86}
87
88// TestCompressibleEligibility walks the response-side gate. Each false case
89// is a response that would be WRONG or pointless to compress, not merely
90// unprofitable — a 206 range body, an already-encoded body, an origin that
91// said no-transform.
92func TestCompressibleEligibility(t *testing.T) {
93 base := func() http.Header {
94 h := http.Header{}
95 h.Set("Content-Type", "text/css")
96 return h
97 }
98 for _, tc := range []struct {
99 name string
100 mutate func(http.Header)
101 status int
102 want bool
103 }{
104 {"plain css", func(http.Header) {}, 200, true},
105 {"html error page", func(h http.Header) { h.Set("Content-Type", "text/html; charset=utf-8") }, 404, true},
106 {"json api", func(h http.Header) { h.Set("Content-Type", "application/json") }, 200, true},
107 {"svg", func(h http.Header) { h.Set("Content-Type", "image/svg+xml") }, 200, true},
108 {"long body", func(h http.Header) { h.Set("Content-Length", "4096") }, 200, true},
109 {"unknown length", func(h http.Header) { h.Del("Content-Length") }, 200, true},
110
111 {"no content type", func(h http.Header) { h.Del("Content-Type") }, 200, false},
112 {"png", func(h http.Header) { h.Set("Content-Type", "image/png") }, 200, false},
113 {"event stream", func(h http.Header) { h.Set("Content-Type", "text/event-stream") }, 200, false},
114 {"already encoded", func(h http.Header) { h.Set("Content-Encoding", "gzip") }, 200, false},
115 {"no-transform", func(h http.Header) { h.Set("Cache-Control", "max-age=60, no-transform") }, 200, false},
116 {"short body", func(h http.Header) { h.Set("Content-Length", "512") }, 200, false},
117 {"exactly at threshold", func(h http.Header) { h.Set("Content-Length", "1024") }, 200, true},
118 {"partial content", func(h http.Header) { h.Set("Content-Range", "bytes 0-99/4096") }, 206, false},
119 {"no content", func(http.Header) {}, 204, false},
120 {"reset content", func(http.Header) {}, 205, false},
121 {"not modified", func(http.Header) {}, 304, false},
122 } {
123 h := base()
124 tc.mutate(h)
125 if got := compressible(h, tc.status); got != tc.want {
126 t.Errorf("%s: compressible(%v, %d) = %v, want %v", tc.name, h, tc.status, got, tc.want)
127 }
128 }
129}
130
131// TestCompressibleTypeSuffixes: a media type we have never heard of still
132// compresses when its structured suffix says it is text underneath.
133func TestCompressibleTypeSuffixes(t *testing.T) {
134 for _, ct := range []string{
135 "application/manifest+json", "application/atom+xml", "application/ld+json",
136 "application/vnd.api+json", "text/plain", "application/javascript",
137 "application/wasm", "application/xml", "text/css;charset=UTF-8",
138 } {
139 if !compressibleType(ct) {
140 t.Errorf("compressibleType(%q) = false, want true", ct)
141 }
142 }
143 for _, ct := range []string{
144 "", "image/png", "video/mp4", "application/octet-stream",
145 "application/zip", "font/woff2", "text/event-stream",
146 } {
147 if compressibleType(ct) {
148 t.Errorf("compressibleType(%q) = true, want false", ct)
149 }
150 }
151}
152
153// TestWeakenETag: a gzipped body is a different representation, so its
154// validator must not claim byte-equality with the identity one. Weakening
155// keeps conditional requests working — the stdlib's If-None-Match check
156// compares weakly for GET — while dropping the strong-equality claim a
157// Range request would rely on.
158func TestWeakenETag(t *testing.T) {
159 for _, tc := range []struct{ in, want string }{
160 {`"abc123"`, `W/"abc123"`},
161 {`W/"abc123"`, `W/"abc123"`}, // already weak: untouched
162 {"", ""}, // absent: nothing to weaken
163 } {
164 h := http.Header{}
165 if tc.in != "" {
166 h.Set("ETag", tc.in)
167 }
168 weakenETag(h)
169 if got := h.Get("ETag"); got != tc.want {
170 t.Errorf("weakenETag(%q) -> %q, want %q", tc.in, got, tc.want)
171 }
172 }
173}
174
175// hijackTrackingRecorder is edge_test.go's hijackableRecorder plus a record of
176// whether the call actually arrived — this test needs to prove the wrapper
177// FORWARDED the hijack, not merely that it did not error.
178type hijackTrackingRecorder struct {
179 *httptest.ResponseRecorder
180 hijacked bool
181}
182
183func (h *hijackTrackingRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
184 h.hijacked = true
185 return nil, nil, nil
186}
187
188// gzipReq builds a request that accepts gzip.
189func gzipReq(method, url string) *http.Request {
190 r := httptest.NewRequest(method, url, nil)
191 r.Header.Set("Accept-Encoding", "gzip")
192 return r
193}
194
195// TestGzipWriterKeepsCompressionHeadersOffTheCallerMap is THE test this design
196// exists for. Every writer wrapper in this package embeds the writer below, so
197// Header() returns one shared map all the way down — and maybeStore caches
198// rec.Header().Clone(). If the compression headers landed on that shared map,
199// the cache would store an IDENTITY body labelled Content-Encoding: gzip and
200// serve it to every later visitor as an undecodable response.
201func TestGzipWriterKeepsCompressionHeadersOffTheCallerMap(t *testing.T) {
202 rec := httptest.NewRecorder()
203 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/tokens.css"))
204 if g == nil {
205 t.Fatal("newGzipWriter returned nil for a gzip-accepting GET")
206 }
207 g.Header().Set("Content-Type", "text/css")
208 g.Header().Set("ETag", `"abc123"`)
209 g.Header().Set("Content-Length", "4000")
210 g.WriteHeader(http.StatusOK)
211 body := strings.Repeat("a{color:red}\n", 400)
212 io.WriteString(g, body)
213 g.finish()
214
215 if got := g.Header().Get("Content-Encoding"); got != "" {
216 t.Errorf("caller-visible Content-Encoding = %q, want empty — the recorder above would cache an identity body labelled gzip", got)
217 }
218 if got := g.Header().Get("ETag"); got != `"abc123"` {
219 t.Errorf("caller-visible ETag = %q, want the strong tag untouched (the cached body IS the identity one)", got)
220 }
221 if got := g.Header().Get("Content-Length"); got != "4000" {
222 t.Errorf("caller-visible Content-Length = %q, want it untouched", got)
223 }
224
225 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
226 t.Errorf("client Content-Encoding = %q, want gzip", got)
227 }
228 if got := rec.Header().Get("Content-Length"); got != "" {
229 t.Errorf("client Content-Length = %q, want it dropped — it described the identity body", got)
230 }
231 if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
232 t.Errorf("client Vary = %q, want Accept-Encoding", got)
233 }
234 if got := rec.Header().Get("ETag"); got != `W/"abc123"` {
235 t.Errorf("client ETag = %q, want the weakened form", got)
236 }
237
238 zr, err := gzip.NewReader(rec.Body)
239 if err != nil {
240 t.Fatalf("client body is not gzip: %v", err)
241 }
242 got, err := io.ReadAll(zr)
243 if err != nil {
244 t.Fatalf("read gzip body: %v", err)
245 }
246 if string(got) != body {
247 t.Fatalf("decompressed body differs from what the handler wrote")
248 }
249 if rec.Body.Len() >= len(body) {
250 t.Fatalf("compressed size %d >= identity size %d", rec.Body.Len(), len(body))
251 }
252}
253
254// TestGzipWriterNilForRequestsThatCanNeverCompress: construction is where the
255// request-side questions are settled, once. A nil wrapper means ServeHTTP
256// leaves the writer stack exactly as it is today.
257func TestGzipWriterNilForRequestsThatCanNeverCompress(t *testing.T) {
258 if g := newGzipWriter(httptest.NewRecorder(), httptest.NewRequest("GET", "http://app.test/", nil)); g != nil {
259 t.Error("no Accept-Encoding must yield a nil wrapper")
260 }
261 if g := newGzipWriter(httptest.NewRecorder(), gzipReq("HEAD", "http://app.test/big.css")); g != nil {
262 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")
263 }
264}
265
266// TestGzipWriterPassesThroughIneligibleResponses: an ineligible response must
267// be byte-identical to the unwrapped one, headers included.
268func TestGzipWriterPassesThroughIneligibleResponses(t *testing.T) {
269 rec := httptest.NewRecorder()
270 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/logo.png"))
271 g.Header().Set("Content-Type", "image/png")
272 g.Header().Set("Content-Length", "9")
273 g.WriteHeader(http.StatusOK)
274 io.WriteString(g, "PNG-BYTES")
275 g.finish()
276
277 if got := rec.Header().Get("Content-Encoding"); got != "" {
278 t.Errorf("Content-Encoding = %q on an image, want empty", got)
279 }
280 if got := rec.Header().Get("Content-Length"); got != "9" {
281 t.Errorf("Content-Length = %q, want it preserved on a pass-through", got)
282 }
283 if got := rec.Body.String(); got != "PNG-BYTES" {
284 t.Errorf("body = %q, want the identity bytes", got)
285 }
286}
287
288// TestGzipWriterSilentHandlerStillDeliversHeaders: the handler set headers and
289// returned without ever writing. Those headers live in the PRIVATE map, so
290// without a sync in finish() the stdlib would emit a bare 200 with none of
291// them — X-Carlos-Version included.
292func TestGzipWriterSilentHandlerStillDeliversHeaders(t *testing.T) {
293 rec := httptest.NewRecorder()
294 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/"))
295 g.Header().Set("X-Carlos-Version", "abc123")
296 g.finish()
297
298 if got := rec.Header().Get("X-Carlos-Version"); got != "abc123" {
299 t.Fatalf("X-Carlos-Version = %q, want abc123 — a handler that wrote nothing still lost every header it set", got)
300 }
301}
302
303// TestGzipWriter1xxDoesNotLatch mirrors recordingResponseWriter.WriteHeader: a
304// 103 Early Hints block precedes the final status. Latching it would freeze
305// the compression decision on a status that has no body, and the real response
306// would then be delivered uncompressed — or worse, with the hints lost, since
307// they live in the private map.
308func TestGzipWriter1xxDoesNotLatch(t *testing.T) {
309 rec := httptest.NewRecorder()
310 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/"))
311 g.Header().Set("Link", "</tokens.css>; rel=preload")
312 g.WriteHeader(http.StatusEarlyHints)
313
314 if got := rec.Header().Get("Link"); got != "</tokens.css>; rel=preload" {
315 t.Errorf("early-hints Link = %q, want it forwarded — it was set on the private map", got)
316 }
317
318 g.Header().Del("Link")
319 g.Header().Set("Content-Type", "text/html")
320 g.WriteHeader(http.StatusOK)
321 io.WriteString(g, strings.Repeat("<p>hi</p>", 200))
322 g.finish()
323
324 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
325 t.Errorf("Content-Encoding = %q after a 103, want gzip — the 1xx latched the decision", got)
326 }
327 if got := rec.Header().Get("Link"); got != "" {
328 t.Errorf("Link = %q, want empty — the final header block replaces the informational one", got)
329 }
330}
331
332// TestGzipWriterFlushReachesTheClient: a flushed response must actually leave
333// the compressor, not sit in its buffer until the handler returns. SSE never
334// compresses (content type), but a streamed HTML page does.
335func TestGzipWriterFlushReachesTheClient(t *testing.T) {
336 rec := httptest.NewRecorder()
337 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/stream"))
338 g.Header().Set("Content-Type", "text/html")
339 g.WriteHeader(http.StatusOK)
340 io.WriteString(g, strings.Repeat("<p>chunk</p>", 100))
341 g.Flush()
342
343 if rec.Body.Len() == 0 {
344 t.Fatal("Flush left the bytes inside the compressor — a streaming response would hang until the handler returned")
345 }
346 if !rec.Flushed {
347 t.Fatal("Flush did not reach the writer below")
348 }
349 g.finish()
350}
351
352// TestGzipWriterForwardsHijack: the upgrade path returns before this wrapper
353// is ever built, so this is not a hot path — it exists so the wrapper is never
354// itself the reason a hijack fails, matching the convention countingWriter and
355// recordingResponseWriter already follow.
356func TestGzipWriterForwardsHijack(t *testing.T) {
357 rec := &hijackTrackingRecorder{ResponseRecorder: httptest.NewRecorder()}
358 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/"))
359 if _, _, err := g.Hijack(); err != nil {
360 t.Fatalf("Hijack: %v", err)
361 }
362 if !rec.hijacked {
363 t.Fatal("Hijack did not reach the writer below")
364 }
365}
366
367// edgeWithBackend builds an Edge with one proxied route, "app.test", pointed
368// at h. Driven with httptest.NewRecorder + httptest.NewRequest rather than a
369// real client: http.Transport adds its own Accept-Encoding and transparently
370// decompresses what comes back, which would hide exactly what these tests are
371// checking.
372func edgeWithBackend(t *testing.T, h http.Handler) *Edge {
373 t.Helper()
374 backend := httptest.NewServer(h)
375 t.Cleanup(backend.Close)
376 reg := openReg(t)
377 if err := reg.Upsert(registry.Route{
378 Host: "app.test", Kind: "service", Status: "ready",
379 Addr: backend.Listener.Addr().String(),
380 }); err != nil {
381 t.Fatalf("upsert: %v", err)
382 }
383 e, err := New(reg, nil)
384 if err != nil {
385 t.Fatalf("New: %v", err)
386 }
387 return e
388}
389
390// cssBackend answers every request with a cacheable stylesheet of n bytes.
391func cssBackend(n int) http.Handler {
392 body := strings.Repeat("a{color:red}\n", n/13+1)[:n]
393 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
394 w.Header().Set("Content-Type", "text/css")
395 w.Header().Set("Cache-Control", "max-age=600")
396 io.WriteString(w, body)
397 })
398}
399
400// TestEdgeCompressesProxiedAssets is the payoff: an app ships identity bytes
401// and the edge puts them on the wire compressed.
402func TestEdgeCompressesProxiedAssets(t *testing.T) {
403 e := edgeWithBackend(t, cssBackend(4096))
404 rec := httptest.NewRecorder()
405 e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css"))
406
407 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
408 t.Fatalf("Content-Encoding = %q, want gzip", got)
409 }
410 if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") {
411 t.Errorf("Vary = %q, want it to name Accept-Encoding", got)
412 }
413 zr, err := gzip.NewReader(rec.Body)
414 if err != nil {
415 t.Fatalf("body is not gzip: %v", err)
416 }
417 got, err := io.ReadAll(zr)
418 if err != nil {
419 t.Fatalf("read gzip body: %v", err)
420 }
421 if len(got) != 4096 {
422 t.Fatalf("decompressed %d bytes, want 4096", len(got))
423 }
424}
425
426// TestEdgeCacheStoresIdentityBodies is the invariant the whole writer ordering
427// exists to hold. The recorder sits ABOVE the compressor, so what lands in the
428// cache is the identity body with the app's own headers — one variant, not one
429// per encoding — and a client that cannot decode gzip is still served correctly
430// from that same entry.
431func TestEdgeCacheStoresIdentityBodies(t *testing.T) {
432 e := edgeWithBackend(t, cssBackend(4096))
433 e.EnableCache(NewCache(10, 1<<20))
434
435 rec := httptest.NewRecorder()
436 e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css")) // miss
437 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
438 t.Fatalf("miss: Content-Encoding = %q, want gzip", got)
439 }
440
441 stored, ok := e.cache.get(CacheKey{Host: "app.test", Method: "GET", Path: "/tokens.css"})
442 if !ok {
443 t.Fatal("the response was not cached — this test cannot say anything about what was stored")
444 }
445 if ce := stored.Header.Get("Content-Encoding"); ce != "" {
446 t.Errorf("cached entry carries Content-Encoding %q: an identity body labelled gzip, served to every later visitor as an undecodable response", ce)
447 }
448 if v := stored.Header.Get("Vary"); strings.Contains(v, "Accept-Encoding") {
449 t.Errorf("cached entry carries Vary %q: the edge's own Vary belongs below the recorder, not in the cache key", v)
450 }
451 if len(stored.Body) != 4096 {
452 t.Errorf("cached body is %d bytes, want the 4096-byte identity body", len(stored.Body))
453 }
454
455 hit := httptest.NewRecorder()
456 e.ServeHTTP(hit, gzipReq("GET", "http://app.test/tokens.css"))
457 if got := hit.Header().Get("Content-Encoding"); got != "gzip" {
458 t.Fatalf("cache hit: Content-Encoding = %q, want gzip — a hit is a delivery like any other", got)
459 }
460 zr, err := gzip.NewReader(hit.Body)
461 if err != nil {
462 t.Fatalf("cache hit body is not gzip: %v", err)
463 }
464 if b, err := io.ReadAll(zr); err != nil || len(b) != 4096 {
465 t.Fatalf("cache hit decompressed to %d bytes (err %v), want 4096", len(b), err)
466 }
467
468 plain := httptest.NewRecorder()
469 e.ServeHTTP(plain, httptest.NewRequest("GET", "http://app.test/tokens.css", nil))
470 if got := plain.Header().Get("Content-Encoding"); got != "" {
471 t.Errorf("identity client got Content-Encoding %q from the shared entry", got)
472 }
473 if plain.Body.Len() != 4096 {
474 t.Errorf("identity client got %d bytes, want the 4096-byte identity body", plain.Body.Len())
475 }
476}
477
478// TestEdgeMetersCompressedBytes: a tenant is billed for what crossed the wire.
479// The counter sits BELOW the compressor and finish() runs before Record — the
480// equality below is what pins that defer order, since gzip buffers until Close
481// and a reversed order would meter a fraction of the bytes.
482func TestEdgeMetersCompressedBytes(t *testing.T) {
483 e := edgeWithBackend(t, cssBackend(8192))
484 m := NewUsageMeter()
485 e.EnableUsage(m)
486
487 rec := httptest.NewRecorder()
488 e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css"))
489
490 row, ok := m.Drain()["app.test"]
491 if !ok {
492 t.Fatal("no usage row for app.test")
493 }
494 if row.BytesOut != int64(rec.Body.Len()) {
495 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())
496 }
497 if row.BytesOut >= 8192 {
498 t.Fatalf("metered %d bytes for an 8192-byte identity body — the meter is counting bytes the edge did not send", row.BytesOut)
499 }
500}
501
502// TestEdgeGzipKillSwitch: CARLOS_EDGE_GZIP=off is the rollback that needs no
503// redeploy, so it must produce today's bytes exactly.
504func TestEdgeGzipKillSwitch(t *testing.T) {
505 t.Setenv("CARLOS_EDGE_GZIP", "off")
506 e := edgeWithBackend(t, cssBackend(4096))
507 rec := httptest.NewRecorder()
508 e.ServeHTTP(rec, gzipReq("GET", "http://app.test/tokens.css"))
509
510 if got := rec.Header().Get("Content-Encoding"); got != "" {
511 t.Errorf("Content-Encoding = %q with the kill switch off, want empty", got)
512 }
513 if got := rec.Header().Get("Vary"); strings.Contains(got, "Accept-Encoding") {
514 t.Errorf("Vary = %q with the kill switch off — nothing may hint at a variant that is never produced", got)
515 }
516 if rec.Body.Len() != 4096 {
517 t.Errorf("body = %d bytes, want the 4096-byte identity body", rec.Body.Len())
518 }
519}
520
521// TestGzipWriterForwardsTrailers: net/http moves a header set AFTER
522// WriteHeader into the trailer block when it was announced in Trailer. Every
523// such Set lands on the private map, where syncHeader has already been and
524// gone — without a late sync the wrapper silently eats trailers, and
525// purge_header.go's contract names them as a channel apps may use.
526func TestGzipWriterForwardsTrailers(t *testing.T) {
527 rec := httptest.NewRecorder()
528 g := newGzipWriter(rec, gzipReq("GET", "http://app.test/"))
529 g.Header().Set("Trailer", "Carlos-Purge")
530 g.Header().Set("Content-Type", "text/html")
531 g.Header().Set("ETag", `"strong"`)
532 g.WriteHeader(http.StatusOK)
533 io.WriteString(g, strings.Repeat("<p>hi</p>", 200))
534 g.Header().Set("Carlos-Purge", "/trailed") // after the body: a trailer
535 g.finish()
536
537 if got := rec.Header().Get("Carlos-Purge"); got != "/trailed" {
538 t.Fatalf("trailer Carlos-Purge = %q, want /trailed — the private map swallowed it", got)
539 }
540 // The late sync must not undo the compression edits by copying the
541 // identity representation's fields back down.
542 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
543 t.Errorf("Content-Encoding = %q after the trailer sync, want gzip", got)
544 }
545 if got := rec.Header().Get("ETag"); got != `W/"strong"` {
546 t.Errorf("ETag = %q after the trailer sync, want the weakened form", got)
547 }
548 if got := rec.Header().Get("Content-Length"); got != "" {
549 t.Errorf("Content-Length = %q after the trailer sync, want it still dropped", got)
550 }
551 if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
552 t.Errorf("Vary = %q after the trailer sync, want Accept-Encoding", got)
553 }
554}
555
556// TestUpgradeIsUntouchedByCompression: the wrap site is after the upgrade
557// branch precisely so a handshake keeps the writer stack it has always had.
558// A gzip-accepting client (every browser) must still get its 101, and bytes
559// must still flow both ways after the switch.
560func TestUpgradeIsUntouchedByCompression(t *testing.T) {
561 addr := upgradeEchoBackend(t, "ws")
562 reg := openReg(t)
563 if err := reg.Upsert(registry.Route{
564 Host: "ws.test", Kind: "service", Status: "ready", Addr: addr,
565 }); err != nil {
566 t.Fatalf("upsert: %v", err)
567 }
568 e, err := New(reg, nil)
569 if err != nil {
570 t.Fatalf("New: %v", err)
571 }
572 srv := httptest.NewServer(e)
573 defer srv.Close()
574
575 c, err := net.Dial("tcp", strings.TrimPrefix(srv.URL, "http://"))
576 if err != nil {
577 t.Fatalf("dial edge: %v", err)
578 }
579 defer c.Close()
580 if err := c.SetDeadline(time.Now().Add(10 * time.Second)); err != nil {
581 t.Fatalf("set deadline: %v", err)
582 }
583 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 {
584 t.Fatalf("write handshake: %v", err)
585 }
586 br := bufio.NewReader(c)
587 resp, err := http.ReadResponse(br, nil)
588 if err != nil {
589 t.Fatalf("read handshake response: %v", err)
590 }
591 if resp.StatusCode != http.StatusSwitchingProtocols {
592 t.Fatalf("handshake status = %d, want 101 — an Accept-Encoding header must not divert an upgrade through the compressor", resp.StatusCode)
593 }
594 if _, err := fmt.Fprint(c, "ping\n"); err != nil {
595 t.Fatalf("write post-upgrade frame: %v", err)
596 }
597 line, err := br.ReadString('\n')
598 if err != nil {
599 t.Fatalf("read post-upgrade frame: %v", err)
600 }
601 if line != "ws:ping\n" {
602 t.Fatalf("post-upgrade echo = %q, want %q — bytes must flow both ways after the switch", line, "ws:ping\n")
603 }
604}
605
606// TestAlreadyEncodedUpstreamPassesThrough: an app that compresses for itself
607// must not be compressed again into a body nobody can decode.
608func TestAlreadyEncodedUpstreamPassesThrough(t *testing.T) {
609 e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
610 var buf strings.Builder
611 zw := gzip.NewWriter(&buf)
612 io.WriteString(zw, strings.Repeat("already compressed\n", 200))
613 zw.Close()
614 w.Header().Set("Content-Type", "text/plain")
615 w.Header().Set("Content-Encoding", "gzip")
616 io.WriteString(w, buf.String())
617 }))
618 rec := httptest.NewRecorder()
619 e.ServeHTTP(rec, gzipReq("GET", "http://app.test/pre.txt"))
620
621 if got := rec.Header().Values("Content-Encoding"); len(got) != 1 || got[0] != "gzip" {
622 t.Fatalf("Content-Encoding = %v, want exactly one gzip", got)
623 }
624 zr, err := gzip.NewReader(rec.Body)
625 if err != nil {
626 t.Fatalf("body did not survive as single-encoded gzip: %v", err)
627 }
628 body, err := io.ReadAll(zr)
629 if err != nil {
630 t.Fatalf("read body: %v", err)
631 }
632 if !strings.HasPrefix(string(body), "already compressed") {
633 t.Fatalf("body = %.40q, want the upstream's own gzip decoded in ONE pass", body)
634 }
635}
636
637// TestEventStreamIsNotCompressed: SSE is an open stream consumed event by
638// event. Buffering it inside a compressor is a hang, not a saving.
639func TestEventStreamIsNotCompressed(t *testing.T) {
640 e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
641 w.Header().Set("Content-Type", "text/event-stream")
642 w.WriteHeader(http.StatusOK)
643 for i := 0; i < 3; i++ {
644 fmt.Fprintf(w, "data: %d\n\n", i)
645 if f, ok := w.(http.Flusher); ok {
646 f.Flush()
647 }
648 }
649 }))
650 rec := httptest.NewRecorder()
651 e.ServeHTTP(rec, gzipReq("GET", "http://app.test/events"))
652
653 if got := rec.Header().Get("Content-Encoding"); got != "" {
654 t.Fatalf("Content-Encoding = %q on an event stream, want empty", got)
655 }
656 if !strings.Contains(rec.Body.String(), "data: 0") {
657 t.Fatalf("event stream body = %q, want the plain events", rec.Body.String())
658 }
659}
660
661// TestRangeAndConditionalRequestsSurvive: a 206 must keep its byte offsets
662// meaningful, and a conditional request must still 304 against the weakened
663// validator the client was given.
664func TestRangeAndConditionalRequestsSurvive(t *testing.T) {
665 body := strings.Repeat("a{color:red}\n", 400)
666 e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
667 w.Header().Set("Content-Type", "text/css")
668 w.Header().Set("ETag", `"v1"`)
669 http.ServeContent(w, r, "tokens.css", time.Unix(1, 0), strings.NewReader(body))
670 }))
671
672 rng := gzipReq("GET", "http://app.test/tokens.css")
673 rng.Header.Set("Range", "bytes=0-9")
674 rec := httptest.NewRecorder()
675 e.ServeHTTP(rec, rng)
676 if rec.Code != http.StatusPartialContent {
677 t.Fatalf("range status = %d, want 206", rec.Code)
678 }
679 if got := rec.Header().Get("Content-Encoding"); got != "" {
680 t.Errorf("206 carries Content-Encoding %q — compressing a byte range makes the offsets it names meaningless", got)
681 }
682 if rec.Body.Len() != 10 {
683 t.Errorf("range body = %d bytes, want 10", rec.Body.Len())
684 }
685
686 full := httptest.NewRecorder()
687 e.ServeHTTP(full, gzipReq("GET", "http://app.test/tokens.css"))
688 etag := full.Header().Get("ETag")
689 if !strings.HasPrefix(etag, `W/`) {
690 t.Fatalf("ETag = %q on a compressed body, want the weakened form", etag)
691 }
692 cond := gzipReq("GET", "http://app.test/tokens.css")
693 cond.Header.Set("If-None-Match", etag)
694 nm := httptest.NewRecorder()
695 e.ServeHTTP(nm, cond)
696 if nm.Code != http.StatusNotModified {
697 t.Fatalf("conditional request = %d, want 304 — weakening the validator broke revalidation", nm.Code)
698 }
699 if got := nm.Header().Get("Content-Encoding"); got != "" {
700 t.Errorf("304 carries Content-Encoding %q; a 304 has no body to encode", got)
701 }
702}
703
704// TestStatusHostCompresses: the status class reaches the wrap site through a
705// different door (its own handler, exempt from the shared cache and the usage
706// meter) and must compress like everything else.
707func TestStatusHostCompresses(t *testing.T) {
708 s, h := testEdgeWithStore(t, registry.Route{Host: "status.test", Kind: "status", Status: "ready"})
709 s.Put(context.Background(), "status/fleet.json", []byte(`{"boxes":[`+strings.Repeat(`{"id":"box"},`, 300)+`{}]}`), "")
710 rec := httptest.NewRecorder()
711 h.ServeHTTP(rec, gzipReq("GET", "http://status.test/fleet.json"))
712
713 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
714 t.Fatalf("Content-Encoding = %q on the status host, want gzip", got)
715 }
716 zr, err := gzip.NewReader(rec.Body)
717 if err != nil {
718 t.Fatalf("body is not gzip: %v", err)
719 }
720 got, err := io.ReadAll(zr)
721 if err != nil || !strings.Contains(string(got), `"boxes"`) {
722 t.Fatalf("decompressed status body = %.40q (err %v)", got, err)
723 }
724}
725
726// TestStaticSiteCompresses: the static class is the payoff case — a shipped
727// site tree whose CSS is served straight off disk by http.ServeFile. It
728// reaches the wrap site through Edge.ServeHTTP like every other class, so
729// this pins that the class nobody proxies is covered too.
730func TestStaticSiteCompresses(t *testing.T) {
731 store := release.NewDirStore(filepath.Join(t.TempDir(), "store"))
732 priv, pub := testKeys(t)
733 css := strings.Repeat("a{color:red}\n", 400) // 5200 bytes, well over the threshold
734 shipSite(t, store, "site", "v1", map[string]string{
735 "index.html": "home",
736 "tokens.css": css,
737 })
738 if err := release.Promote(context.Background(), store, priv, release.OpsAccount, "site", "canary/x", "v1"); err != nil {
739 t.Fatalf("Promote: %v", err)
740 }
741
742 reg := openReg(t)
743 if err := reg.Upsert(registry.Route{
744 Host: "site.test", Kind: "static", App: "site", Channel: "canary/x", Status: "ready",
745 }); err != nil {
746 t.Fatalf("upsert: %v", err)
747 }
748 e, err := New(reg, nil)
749 if err != nil {
750 t.Fatalf("New: %v", err)
751 }
752 e.EnableStatic(store, pub, filepath.Join(t.TempDir(), "cache"))
753 if err := e.Reload(); err != nil {
754 t.Fatalf("Reload: %v", err)
755 }
756
757 rec := httptest.NewRecorder()
758 e.ServeHTTP(rec, gzipReq("GET", "http://site.test/tokens.css"))
759 if rec.Code != http.StatusOK {
760 t.Fatalf("status = %d, want 200", rec.Code)
761 }
762 if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
763 t.Fatalf("Content-Encoding = %q on a static asset, want gzip", got)
764 }
765 if got := rec.Header().Get("X-Carlos-Version"); got != "v1" {
766 t.Errorf("X-Carlos-Version = %q, want v1 — compression must not disturb the static class's per-request version stamp", got)
767 }
768 zr, err := gzip.NewReader(rec.Body)
769 if err != nil {
770 t.Fatalf("body is not gzip: %v", err)
771 }
772 got, err := io.ReadAll(zr)
773 if err != nil {
774 t.Fatalf("read gzip body: %v", err)
775 }
776 if string(got) != css {
777 t.Fatalf("decompressed static asset differs from the shipped file")
778 }
779 if rec.Body.Len() >= len(css) {
780 t.Fatalf("compressed %d bytes >= identity %d", rec.Body.Len(), len(css))
781 }
782}
783
784// TestHeadKeepsItsContentLength: a HEAD has no body to compress, and its
785// truthful Content-Length is the whole value of the response. The backend
786// sets the length explicitly — Go only infers one when the whole body fits
787// its 2048-byte buffer, so a 4096-byte handler would otherwise chunk and this
788// test would be asserting the stdlib's buffering, not the edge's behaviour.
789func TestHeadKeepsItsContentLength(t *testing.T) {
790 e := edgeWithBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
791 w.Header().Set("Content-Type", "text/css")
792 w.Header().Set("Content-Length", "4096")
793 w.WriteHeader(http.StatusOK)
794 if r.Method != http.MethodHead {
795 io.WriteString(w, strings.Repeat("x", 4096))
796 }
797 }))
798 rec := httptest.NewRecorder()
799 e.ServeHTTP(rec, gzipReq("HEAD", "http://app.test/tokens.css"))
800
801 if got := rec.Header().Get("Content-Encoding"); got != "" {
802 t.Errorf("HEAD Content-Encoding = %q, want empty", got)
803 }
804 if got := rec.Header().Get("Content-Length"); got != "4096" {
805 t.Errorf("HEAD Content-Length = %q, want 4096", got)
806 }
807}
808