package handler import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "regexp" "strings" "sync/atomic" "testing" "time" "github.com/git-pkgs/cooldown" ) // getRewritten requests target and checks the response status. func getRewritten(t *testing.T, h http.Handler, target string, header http.Header, want int) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodGet, target, nil) for k, v := range header { req.Header[k] = v } rec := httptest.NewRecorder() h.ServeHTTP(rec, req) if rec.Code != want { t.Fatalf("GET %s: status %d, want %d: %s", target, rec.Code, want, rec.Body.String()) } return rec } // checkServedFromRewriteCache requests target twice more after a first // request, with storage failing, and checks the repeat is the cached rewrite: // same body and ETag, 304 for the ETag, gzip for a client accepting it. func checkServedFromRewriteCache(t *testing.T, h http.Handler, store *mockStorage, target string, header http.Header) string { t.Helper() first := getRewritten(t, h, target, header, http.StatusOK) etag := first.Header().Get(headerETag) if etag == "" { t.Fatalf("no ETag on rewritten metadata; headers %v", first.Header()) } // The repeat must not read the stored document back. store.mu.Lock() store.openErr = errors.New("storage must not be read") store.mu.Unlock() t.Cleanup(func() { store.mu.Lock() store.openErr = nil store.mu.Unlock() }) second := getRewritten(t, h, target, header, http.StatusOK) if second.Body.String() != first.Body.String() || second.Header().Get(headerETag) != etag { t.Errorf("repeat differs: ETag %q vs %q", second.Header().Get(headerETag), etag) } if got, want := second.Header().Get(headerContentType), first.Header().Get(headerContentType); got != want { t.Errorf("repeat Content-Type = %q, want %q", got, want) } conditional := header.Clone() if conditional == nil { conditional = http.Header{} } conditional.Set("If-None-Match", etag) getRewritten(t, h, target, conditional, http.StatusNotModified) gzipped := header.Clone() if gzipped == nil { gzipped = http.Header{} } gzipped.Set(headerAcceptEncoding, "gzip") compressed := getRewritten(t, h, target, gzipped, http.StatusOK) if got := compressed.Header().Get(headerContentEncoding); got != "gzip" { t.Errorf("Content-Encoding = %q, want gzip", got) } return first.Body.String() } func TestHelmIndexServedFromRewriteCache(t *testing.T) { digest := strings.Repeat("a", 64) var indexRequests atomic.Int32 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { indexRequests.Add(1) w.Header().Set("Content-Type", "application/x-yaml") _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n demo:\n%s", strings.Repeat(fmt.Sprintf( " - digest: %s\n name: demo\n urls: [demo-1.0.0.tgz]\n version: 1.0.0\n", digest), 20)) })) defer upstream.Close() proxy, _, store, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() proxy.CacheMetadata = true proxy.MetadataTTL = time.Hour proxy.SetMetadataRewriteCacheSize(1 << 20) h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL}).Routes() body := checkServedFromRewriteCache(t, h, store, "/stable/index.yaml", nil) if !strings.Contains(body, "http://proxy.example/helm/stable/charts/"+digest+"/demo-1.0.0.tgz") { t.Errorf("index not rewritten: %s", body) } if got := indexRequests.Load(); got != 1 { t.Errorf("upstream requests = %d, want 1", got) } if got := proxy.rewrites.len(); got != 1 { t.Errorf("cached rewrites = %d, want 1", got) } } // TestHelmChartLookupFollowsIndex checks that chart downloads are looked up // in charts kept from the last parse of the same index, and that a changed // index is parsed again. func TestHelmChartLookupFollowsIndex(t *testing.T) { chartA, chartB := []byte("chart a"), []byte("chart b") digestA, digestB := helmSHA256Hex(chartA), helmSHA256Hex(chartB) var withB atomic.Bool upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/index.yaml": w.Header().Set("Content-Type", "application/x-yaml") _, _ = fmt.Fprintf(w, "apiVersion: v1\nentries:\n a:\n - digest: %s\n urls: [a-1.0.0.tgz]\n", digestA) if withB.Load() { _, _ = fmt.Fprintf(w, " b:\n - digest: %s\n urls: [b-1.0.0.tgz]\n", digestB) } case "/a-1.0.0.tgz": _, _ = w.Write(chartA) case "/b-1.0.0.tgz": _, _ = w.Write(chartB) default: http.NotFound(w, r) } })) defer upstream.Close() proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() proxy.CacheMetadata = true proxy.MetadataTTL = time.Hour h := NewHelmHandler(proxy, "http://proxy.example", map[string]string{"stable": upstream.URL}) index := func() metadataResult { return h.fetchIndex(context.Background(), "stable", upstream.URL) } first, err := h.findChartDownload("stable", upstream.URL, index(), digestA, "a-1.0.0.tgz") if err != nil || first != upstream.URL+"/a-1.0.0.tgz" { t.Fatalf("lookup = %q, %v", first, err) } lookup := h.lookups["stable\x00"+upstream.URL] if lookup == nil { t.Fatal("chart lookup was not kept") } if _, err := h.findChartDownload("stable", upstream.URL, index(), digestB, "b-1.0.0.tgz"); !errors.Is(err, errHelmChartNotFound) { t.Fatalf("lookup of unlisted chart: %v", err) } if h.lookups["stable\x00"+upstream.URL] != lookup { t.Error("same index was parsed again") } // A new index from upstream lists a new chart. withB.Store(true) proxy.MetadataTTL = 0 got, err := h.findChartDownload("stable", upstream.URL, index(), digestB, "b-1.0.0.tgz") if err != nil || got != upstream.URL+"/b-1.0.0.tgz" { t.Fatalf("lookup in new index = %q, %v", got, err) } } func TestPyPIMetadataServedFromRewriteCache(t *testing.T) { const files = "https://files.pythonhosted.org/packages/ab/cd/" simpleHTML := `demo-1.0.0.tar.gz
` + `demo-2.0.0.tar.gz
` + strings.Repeat("\n", 20) simpleJSON := `{"meta":{"api-version":"1.1"},"name":"demo","files":[` + `{"filename":"demo-1.0.0.tar.gz","url":"` + files + `demo-1.0.0.tar.gz"},` + `{"filename":"demo-2.0.0.tar.gz","url":"` + files + `demo-2.0.0.tar.gz"}],` + `"padding":"` + strings.Repeat("x", 1000) + `"}` jsonAPI := `{"info":{"name":"demo","version":"1.0.0","description":"` + strings.Repeat("x", 1000) + `"},` + `"releases":{"1.0.0":[{"url":"` + files + `demo-1.0.0.tar.gz"}],"2.0.0":[{"url":"` + files + `demo-2.0.0.tar.gz"}]},` + `"urls":[{"url":"` + files + `demo-1.0.0.tar.gz"}]}` cases := []struct { name, target, accept string }{ {"simple html", "/simple/demo/", ""}, {"simple json", "/simple/demo/", pypiSimpleJSON}, {"json api", "/pypi/demo/json", ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { var upstreamCalls atomic.Int32 proxy, _, store, _ := setupTestProxy(t) proxy.HTTPClient = &http.Client{Transport: pypiRoundTripFunc(func(r *http.Request) (*http.Response, error) { upstreamCalls.Add(1) switch { case r.URL.Path == "/pypi/demo/json": return pypiHTTPResponse(r, "application/json", jsonAPI), nil case r.Header.Get("Accept") == pypiSimpleJSON: return pypiHTTPResponse(r, pypiSimpleJSON, simpleJSON), nil default: return pypiHTTPResponse(r, "text/html", simpleHTML), nil } })} proxy.CacheMetadata = true proxy.MetadataTTL = time.Hour proxy.SetMetadataRewriteCacheSize(1 << 20) setTestDenylist(t, proxy, "pkg:pypi/demo@2.0.0") h := NewPyPIHandlerWithUpstreams(proxy, "http://proxy.test", "https://pypi.test", "") var header http.Header if c.accept != "" { header = http.Header{"Accept": {c.accept}} } body := checkServedFromRewriteCache(t, h.Routes(), store, c.target, header) if !strings.Contains(body, "http://proxy.test/pypi/packages/packages/ab/cd/demo-1.0.0.tar.gz") { t.Errorf("download URL not rewritten: %s", body) } if strings.Contains(body, "demo-2.0.0") { t.Errorf("denied version served: %s", body) } if got := upstreamCalls.Load(); got != 1 { t.Errorf("upstream calls = %d, want 1", got) } if c.target == "/simple/demo/" { rec := getRewritten(t, h.Routes(), c.target, header, http.StatusOK) vary := strings.Join(rec.Header().Values("Vary"), ", ") if !strings.Contains(vary, "Accept-Encoding") || !strings.Contains(strings.ReplaceAll(vary, "Accept-Encoding", ""), "Accept") { t.Errorf("Vary = %q, want Accept and Accept-Encoding", vary) } } }) } } // TestPyPISimpleRewriteFollowsCooldown checks that a cached rewrite of a // simple page is not served once the versions cooldown filters out change, // though the page itself is the same. func TestPyPISimpleRewriteFollowsCooldown(t *testing.T) { now := time.Now() old := now.Add(-30 * 24 * time.Hour).Format(time.RFC3339) var released atomic.Value released.Store(now.Add(-time.Hour).Format(time.RFC3339)) h, proxy := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) { switch r.URL.Path { case "/simple/ruff/": return pypiHTTPResponse(r, "text/html", `ruff-1.0.0.tar.gz`+ `ruff-2.0.0.tar.gz`), nil case "/pypi/ruff/json": return pypiHTTPResponse(r, "application/json", `{"releases":{`+ `"1.0.0":[{"upload_time_iso_8601":"`+old+`"}],`+ `"2.0.0":[{"upload_time_iso_8601":"`+released.Load().(string)+`"}]}}`), nil default: t.Fatalf("unexpected upstream path: %s", r.URL.Path) return nil, nil } }) proxy.Cooldown = &cooldown.Config{Default: "7d"} proxy.SetMetadataRewriteCacheSize(1 << 20) first := getRewritten(t, h.Routes(), "/simple/ruff/", nil, http.StatusOK).Body.String() if strings.Contains(first, "ruff-2.0.0") || !strings.Contains(first, "ruff-1.0.0") { t.Fatalf("first page = %s, want only ruff-1.0.0", first) } released.Store(old) second := getRewritten(t, h.Routes(), "/simple/ruff/", nil, http.StatusOK).Body.String() if !strings.Contains(second, "ruff-2.0.0") { t.Errorf("version out of cooldown still filtered: %s", second) } } // rewriteDownloadHrefsRegexp is the regular expression rewrite that // rewriteDownloadHrefs replaces. func rewriteDownloadHrefsRegexp(h *PyPIHandler, body []byte) []byte { re := regexp.MustCompile(`href="(` + regexp.QuoteMeta(h.downloadURL) + `/packages/[^"]+)"`) return re.ReplaceAllFunc(body, func(match []byte) []byte { submatch := re.FindSubmatch(match) return []byte(`href="` + h.proxyURL + "/pypi/packages" + strings.TrimPrefix(string(submatch[1]), h.downloadURL) + `"`) }) } func FuzzPyPIRewriteDownloadHrefs(f *testing.F) { const dl = "https://files.pythonhosted.org" for _, seed := range []string{ `x`, `empty`, ``, ``, `x`, `x`, `x`, `href="` + dl + `/packages/` + "\n" + `multi line"`, `href="href="` + dl + `/packages/x"`, } { f.Add(seed) } h := NewPyPIHandlerWithUpstreams(testProxy(), "http://proxy.test", "", dl) f.Fuzz(func(t *testing.T, body string) { want := rewriteDownloadHrefsRegexp(h, []byte(body)) if got := h.rewriteDownloadHrefs([]byte(body)); string(got) != string(want) { t.Errorf("rewriteDownloadHrefs(%q)\n = %q\nwant %q", body, got, want) } }) }