package httpapi import ( "bufio" "bytes" "compress/gzip" "io" "io/fs" "net" "net/http" "net/http/httptest" "strings" "testing" "github.com/mithral/vellum/web" ) // findAsset returns the path (relative to the embedded dist root, no leading // slash) of the first embedded file ending in suffix. Assets are content-hashed // by the build, so tests must discover them rather than hardcode a name. func findAsset(t *testing.T, suffix string) string { t.Helper() sub, err := fs.Sub(web.Dist, "dist") if err != nil { t.Fatalf("fs.Sub: %v", err) } var found string err = fs.WalkDir(sub, ".", func(p string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if found == "" && strings.HasSuffix(p, suffix) { found = p } return nil }) if err != nil { t.Fatalf("walk dist: %v", err) } if found == "" { // A fresh clone has no built frontend: web/dist holds only .gitkeep, so // the Go embed compiles and the server logs "web UI not embedded". The // gzip behaviour cannot be exercised without a real asset, so skip // rather than fail — the suite must pass on a clone that has not run // `pnpm build`. t.Skipf("no embedded asset ending in %q; run `pnpm build` in web/ to exercise this test", suffix) } return found } func readAsset(t *testing.T, name string) []byte { t.Helper() sub, err := fs.Sub(web.Dist, "dist") if err != nil { t.Fatalf("fs.Sub: %v", err) } b, err := fs.ReadFile(sub, name) if err != nil { // Same reason as findAsset: without `pnpm build` the only thing in dist // is .gitkeep, so a test that reads a real asset cannot run. Skip so the // suite is green on a fresh clone. t.Skipf("embedded %s not present; run `pnpm build` in web/ to exercise this test", name) } return b } func gunzip(t *testing.T, body []byte) []byte { t.Helper() zr, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { t.Fatalf("gzip.NewReader: %v", err) } defer zr.Close() out, err := io.ReadAll(zr) if err != nil { t.Fatalf("read gzip body: %v", err) } return out } // TestStaticGzipCompressesJS proves a gzip-capable request for a .js asset is // compressed, carries no stale Content-Length, and round-trips to the original. func TestStaticGzipCompressesJS(t *testing.T) { srv := newTestServer(t) name := findAsset(t, ".js") want := readAsset(t, name) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/"+name, nil) req.Header.Set("Accept-Encoding", "gzip") srv.Handler().ServeHTTP(rec, req) 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, want gzip", got) } if got := rec.Header().Get("Vary"); !strings.Contains(got, "Accept-Encoding") { t.Errorf("Vary = %q, want it to mention Accept-Encoding", got) } // The handler's Content-Length describes the uncompressed body; it must be // gone once the length changes. if got := rec.Header().Get("Content-Length"); got != "" { t.Errorf("Content-Length = %q, want empty on a compressed response", got) } if got := gunzip(t, rec.Body.Bytes()); !bytes.Equal(got, want) { t.Errorf("decompressed body does not match original %s (%d vs %d bytes)", name, len(got), len(want)) } if rec.Body.Len() >= len(want) { t.Errorf("compressed body %d bytes is not smaller than original %d", rec.Body.Len(), len(want)) } } // TestStaticIdentityWithoutAcceptEncoding proves a request with no // Accept-Encoding is served byte-for-byte, uncompressed. func TestStaticIdentityWithoutAcceptEncoding(t *testing.T) { srv := newTestServer(t) name := findAsset(t, ".js") want := readAsset(t, name) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/"+name, nil) srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := rec.Header().Get("Content-Encoding"); got != "" { t.Fatalf("Content-Encoding = %q, want empty for an identity request", got) } if !bytes.Equal(rec.Body.Bytes(), want) { t.Errorf("identity body does not match original %s", name) } } // TestStaticRangeNotCompressed proves a Range request stays identity: seeking // depends on the original byte offsets, which gzip would invalidate. func TestStaticRangeNotCompressed(t *testing.T) { srv := newTestServer(t) name := findAsset(t, ".js") want := readAsset(t, name) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/"+name, nil) req.Header.Set("Accept-Encoding", "gzip") req.Header.Set("Range", "bytes=0-99") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusPartialContent { t.Fatalf("status = %d, want 206", rec.Code) } if got := rec.Header().Get("Content-Encoding"); got != "" { t.Fatalf("Content-Encoding = %q, want empty on a Range response", got) } if !bytes.Equal(rec.Body.Bytes(), want[:100]) { t.Errorf("range body does not match the first 100 bytes of %s", name) } } // TestStaticPNGNotCompressed proves binary assets are passed through even when // the client accepts gzip; re-compressing them wastes CPU. func TestStaticPNGNotCompressed(t *testing.T) { srv := newTestServer(t) name := findAsset(t, ".png") want := readAsset(t, name) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/"+name, nil) req.Header.Set("Accept-Encoding", "gzip") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "image/png") { t.Fatalf("Content-Type = %q, want image/png", got) } if got := rec.Header().Get("Content-Encoding"); got != "" { t.Fatalf("Content-Encoding = %q, want empty for a png", got) } if !bytes.Equal(rec.Body.Bytes(), want) { t.Errorf("png body does not match original %s", name) } } // TestStaticIndexCompressedAndNoCache proves the SPA shell still says no-cache // and is now compressed for gzip clients. func TestStaticIndexCompressedAndNoCache(t *testing.T) { srv := newTestServer(t) want := readAsset(t, "index.html") rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/", nil) req.Header.Set("Accept-Encoding", "gzip") srv.Handler().ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200", rec.Code) } if got := rec.Header().Get("Cache-Control"); got != "no-cache" { t.Errorf("Cache-Control = %q, want no-cache", got) } if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("Content-Encoding = %q, want gzip", got) } if got := gunzip(t, rec.Body.Bytes()); !bytes.Equal(got, want) { t.Errorf("decompressed index does not match original") } } // ── Wrapper interface forwarding ─────────────────────────────────────── // // The wrapper sits between the static handler and statusRecorder, so it must // keep the same interfaces statusRecorder forwards. func TestGzipResponseWriterForwardsFlush(t *testing.T) { rec := &flushRecorder{ResponseRecorder: httptest.NewRecorder()} req := httptest.NewRequest(http.MethodGet, "/x", nil) req.Header.Set("Accept-Encoding", "gzip") gz := newGzipResponseWriter(rec, req) gz.Header().Set("Content-Type", "text/html; charset=utf-8") gz.WriteHeader(http.StatusOK) _, _ = gz.Write([]byte("")) gz.Flush() _ = gz.Close() if !rec.flushed { t.Error("Flush was not forwarded to the underlying writer") } if got := gunzip(t, rec.Body.Bytes()); string(got) != "" { t.Errorf("flushed body = %q, want ", got) } } type hijackRecorder struct { *httptest.ResponseRecorder hijacked bool } func (h *hijackRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { h.hijacked = true return nil, nil, nil } func TestGzipResponseWriterForwardsHijack(t *testing.T) { rec := &hijackRecorder{ResponseRecorder: httptest.NewRecorder()} req := httptest.NewRequest(http.MethodGet, "/x", nil) gz := newGzipResponseWriter(rec, req) if _, _, err := gz.Hijack(); err != nil { t.Fatalf("Hijack: %v", err) } if !rec.hijacked { t.Error("Hijack was not forwarded to the underlying writer") } } func TestGzipResponseWriterForwardsReadFrom(t *testing.T) { rec := &readFromRecorder{ResponseRecorder: httptest.NewRecorder()} req := httptest.NewRequest(http.MethodGet, "/x", nil) // no Accept-Encoding gz := newGzipResponseWriter(rec, req) n, err := gz.ReadFrom(strings.NewReader("payload")) if err != nil { t.Fatalf("ReadFrom: %v", err) } if !rec.readFrom { t.Error("ReadFrom was not forwarded to the underlying writer") } if n != int64(len("payload")) || rec.Body.String() != "payload" { t.Errorf("ReadFrom wrote %d bytes %q, want payload", n, rec.Body.String()) } } func TestGzipResponseWriterReadFromCompresses(t *testing.T) { rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/x", nil) req.Header.Set("Accept-Encoding", "gzip") gz := newGzipResponseWriter(rec, req) gz.Header().Set("Content-Type", "application/javascript") n, err := gz.ReadFrom(strings.NewReader("payload")) if err != nil { t.Fatalf("ReadFrom: %v", err) } if n != int64(len("payload")) { t.Errorf("ReadFrom returned %d, want %d", n, len("payload")) } _ = gz.Close() if got := rec.Header().Get("Content-Encoding"); got != "gzip" { t.Fatalf("Content-Encoding = %q, want gzip", got) } if got := gunzip(t, rec.Body.Bytes()); string(got) != "payload" { t.Errorf("decompressed body = %q, want payload", got) } } // TestCompressibleType and TestAcceptsGzip pin the content-type allow list and // the Accept-Encoding parsing, including the q=0 refusal. func TestCompressibleType(t *testing.T) { compressible := []string{ "text/html", "text/html; charset=utf-8", "text/css", "application/javascript", "text/javascript; charset=utf-8", "application/json", "image/svg+xml", "application/manifest+json", } for _, ct := range compressible { if !compressibleType(ct) { t.Errorf("compressibleType(%q) = false, want true", ct) } } skip := []string{ "image/png", "image/jpeg", "font/woff2", "application/wasm", "application/octet-stream", "", } for _, ct := range skip { if compressibleType(ct) { t.Errorf("compressibleType(%q) = true, want false", ct) } } } func TestAcceptsGzip(t *testing.T) { yes := []string{"gzip", "gzip, deflate", "br, gzip;q=0.5", "GZIP"} for _, h := range yes { if !acceptsGzip(h) { t.Errorf("acceptsGzip(%q) = false, want true", h) } } no := []string{"", "deflate", "br", "gzip;q=0"} for _, h := range no { if acceptsGzip(h) { t.Errorf("acceptsGzip(%q) = true, want false", h) } } }