Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 08:09:17 -05:00
pkg-proxy/internal/handler/metadata_gzip_test.go
simon chrzanowski cb566d5df6
Expire cooldown rewrites when a withheld version becomes available (#638)
A cached rewrite made with cooldown on stays right until a version it held
back leaves cooldown, so it now expires then instead of after a fixed
time. Handlers ask cooldown through Proxy.cooldown, which reports each
withheld version to the rewrites running at that moment. A rewrite that
held nothing back is kept for the shortest cooldown, at most a day.
2026-10-30 14:06:42 +00:00

237 lines
8.9 KiB
Go

package handler
import (
"bytes"
"compress/gzip"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/cooldown"
)
// compressibleDocuments sets upstream documents with enough versions that
// gzip makes them smaller.
func compressibleDocuments(u *validatorUpstream) {
var npm, composer []string
for i := range 50 {
v := fmt.Sprintf("1.0.%d", i)
npm = append(npm, `"`+v+`":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-`+v+`.tgz"}}`)
composer = append(composer, `{"version":"`+v+`","dist":{"type":"zip","url":"https://example.com/pkg-`+v+`.zip"}}`)
}
u.set(
`{"name":"left-pad","versions":{`+strings.Join(npm, ",")+`}}`,
`{"packages":{"vendor/pkg":[`+strings.Join(composer, ",")+`]}}`,
)
}
func gunzip(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes()))
if err != nil {
t.Fatalf("body is not gzip: %v", err)
}
body, err := io.ReadAll(zr)
if err != nil {
t.Fatalf("reading gzip body: %v", err)
}
return string(body)
}
var acceptGzip = http.Header{"Accept-Encoding": {"gzip, deflate, br"}}
// TestRewrittenMetadataGzip checks that a client accepting gzip gets the
// cached compressed copy of rewritten metadata, on the first fetch and on
// the rewrite fast path, with an ETag of its own so it never matches the
// uncompressed representation.
func TestRewrittenMetadataGzip(t *testing.T) {
for _, c := range validatorCases {
for _, ttl := range []time.Duration{time.Hour, 0} {
t.Run(c.ecosystem+"/ttl="+ttl.String(), func(t *testing.T) {
u := newValidatorUpstream(t)
compressibleDocuments(u)
handler := c.handler(validatorProxy(t, u, ttl), u)
identity := serve(handler, c.path, nil)
for range 2 {
expectGzipOf(t, serve(handler, c.path, acceptGzip), identity)
}
})
}
}
}
// expectGzipOf checks that rec is the gzip representation of the
// uncompressed response identity.
func expectGzipOf(t *testing.T, rec, identity *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "gzip" {
t.Fatalf("status %d, Content-Encoding %q; want 200 with gzip", rec.Code, rec.Header().Get("Content-Encoding"))
}
if rec.Body.Len() >= identity.Body.Len() {
t.Errorf("gzip body is %d bytes, uncompressed %d", rec.Body.Len(), identity.Body.Len())
}
if got := gunzip(t, rec); got != identity.Body.String() {
t.Errorf("decompressed body differs from the uncompressed response")
}
if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
t.Errorf("Vary = %q, want Accept-Encoding", got)
}
if etag := rec.Header().Get("ETag"); etag == "" || etag == identity.Header().Get("ETag") {
t.Errorf("gzip ETag %q must differ from the uncompressed ETag %q", etag, identity.Header().Get("ETag"))
}
if rec.Header().Get("Last-Modified") != identity.Header().Get("Last-Modified") {
t.Errorf("Last-Modified %q, want %q", rec.Header().Get("Last-Modified"), identity.Header().Get("Last-Modified"))
}
}
// TestRewrittenMetadataGzipRevalidation checks that each representation
// revalidates with its own ETag, also when a reverse proxy weakened it.
func TestRewrittenMetadataGzipRevalidation(t *testing.T) {
for _, c := range validatorCases {
t.Run(c.ecosystem, func(t *testing.T) {
u := newValidatorUpstream(t)
compressibleDocuments(u)
handler := c.handler(validatorProxy(t, u, time.Hour), u)
gzipETag := serve(handler, c.path, acceptGzip).Header().Get("ETag")
identityETag := serve(handler, c.path, nil).Header().Get("ETag")
for _, ifNoneMatch := range []string{gzipETag, "W/" + gzipETag} {
rec := serve(handler, c.path, http.Header{"Accept-Encoding": {"gzip"}, "If-None-Match": {ifNoneMatch}})
expectNotModified(t, rec, gzipETag)
if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
t.Errorf("304 Vary = %q, want Accept-Encoding", got)
}
}
if rec := serve(handler, c.path, http.Header{"Accept-Encoding": {"gzip"}, "If-None-Match": {identityETag}}); rec.Code != http.StatusOK {
t.Errorf("gzip request revalidating the uncompressed ETag: status %d, want 200", rec.Code)
}
if rec := serve(handler, c.path, http.Header{"If-None-Match": {gzipETag}}); rec.Code != http.StatusOK {
t.Errorf("uncompressed request revalidating the gzip ETag: status %d, want 200", rec.Code)
}
})
}
}
// TestRewrittenMetadataWithoutGzip checks that clients that do not accept
// gzip get the uncompressed document, which still varies on Accept-Encoding.
func TestRewrittenMetadataWithoutGzip(t *testing.T) {
for _, c := range validatorCases {
t.Run(c.ecosystem, func(t *testing.T) {
u := newValidatorUpstream(t)
compressibleDocuments(u)
handler := c.handler(validatorProxy(t, u, time.Hour), u)
for _, acceptEncoding := range []string{"", "identity", "gzip;q=0", "br"} {
header := http.Header{}
if acceptEncoding != "" {
header.Set("Accept-Encoding", acceptEncoding)
}
rec := serve(handler, c.path, header)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "" {
t.Errorf("Accept-Encoding %q: status %d, Content-Encoding %q; want 200 uncompressed", acceptEncoding, rec.Code, rec.Header().Get("Content-Encoding"))
}
if got := rec.Header().Get("Vary"); got != "Accept-Encoding" {
t.Errorf("Accept-Encoding %q: Vary = %q, want Accept-Encoding", acceptEncoding, got)
}
}
})
}
}
// TestRewrittenMetadataGzipOnlyFromRewriteCache checks that rewrites made
// outside the cache, with it disabled or with cooldown on, stay uncompressed:
// compressing them on every request is better left to a reverse proxy.
func TestRewrittenMetadataGzipOnlyFromRewriteCache(t *testing.T) {
setups := map[string]func(*Proxy){
"cache disabled": func(p *Proxy) { p.SetMetadataRewriteCacheSize(0) },
"cooldown without cache": func(p *Proxy) {
p.SetMetadataRewriteCacheSize(0)
p.Cooldown = &cooldown.Config{Default: "3d"}
},
}
for _, c := range validatorCases {
for name, setup := range setups {
t.Run(c.ecosystem+"/"+name, func(t *testing.T) {
u := newValidatorUpstream(t)
compressibleDocuments(u)
proxy := validatorProxy(t, u, time.Hour)
setup(proxy)
handler := c.handler(proxy, u)
for range 2 {
if rec := serve(handler, c.path, acceptGzip); rec.Code != http.StatusOK || rec.Header().Get("Content-Encoding") != "" {
t.Errorf("status %d, Content-Encoding %q; want 200 uncompressed", rec.Code, rec.Header().Get("Content-Encoding"))
}
}
})
}
}
}
// TestRewriteCache_KeepsGzipCopyOnlyWhenSmaller checks that the cache keeps
// only a compressed copy of a rewrite, counted against its size, unless
// compressing does not make the rewrite smaller, and that the plain document
// is still there for callers that need it.
func TestRewriteCache_KeepsGzipCopyOnlyWhenSmaller(t *testing.T) {
c := newRewriteCache(1 << 20)
identity := func(b []byte) ([]byte, error) { return b, nil }
large := []byte(strings.Repeat(`{"version":"1.0.0"},`, 100))
for _, doc := range [][]byte{large, []byte("abc")} {
key := rewriteCacheKey("npm", "http://proxy", string(doc[:3]), doc)
if _, err := c.rewrite(t.Context(), key, doc, nil, identity); err != nil {
t.Fatal(err)
}
}
m, ok := c.get(rewriteCacheKey("npm", "http://proxy", string(large[:3]), large))
if !ok || m.gzipped == nil {
t.Fatalf("large rewrite: cached %v, gzip copy %d bytes; want a gzip copy", ok, len(m.gzipped))
}
if m.plain != nil {
t.Errorf("large rewrite: cached %d plain bytes besides the gzip copy, want none", len(m.plain))
}
if c.size != int64(len(m.gzipped)+3) {
t.Errorf("cache size = %d, want the gzip copy and the small rewrite (%d)", c.size, len(m.gzipped)+3)
}
if !bytes.Equal(m.content(), large) || m.size != len(large) {
t.Errorf("large rewrite content = %d bytes, size %d; want the %d bytes rewritten", len(m.content()), m.size, len(large))
}
if small, ok := c.get(rewriteCacheKey("npm", "http://proxy", "abc", []byte("abc"))); !ok || small.gzipped != nil || string(small.content()) != "abc" {
t.Errorf("small rewrite: cached %v, gzip copy %d bytes, content %q; want abc uncompressed", ok, len(small.gzipped), small.content())
}
}
func TestAcceptsGzip(t *testing.T) {
cases := map[string]bool{
"": false,
"identity": false,
"br, deflate": false,
"gzip": true,
"GZIP": true,
"x-gzip": true,
"deflate, gzip;q=1.0": true,
"gzip; q=0.5": true,
"gzip;q=0": false,
"gzip;q=0.000": false,
"*": true,
"*;q=0": false,
"br;q=1, *;q=0.5": true,
"gzip;q=0, *": false,
"gzip;q=invalid, deflate": false,
}
for header, want := range cases {
r := httptest.NewRequest(http.MethodGet, "/", nil)
if header != "" {
r.Header.Set("Accept-Encoding", header)
}
if got := acceptsGzip(r); got != want {
t.Errorf("acceptsGzip(%q) = %v, want %v", header, got, want)
}
}
}