Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 23:59:16 -05:00
pkg-proxy/internal/handler/cooldown_fail_closed_test.go
Simon Chrzanowski 224bce8f51 Serve cached downloads when cooldown can't read publish times
PyPI and npm downloads look up the publish time before the cache. When
that lookup fails, a file that is already cached is now served, and only
a cache miss fails with 502 or 503. The proxy keeps serving what it has
while the upstream is unreachable, and nothing new is fetched unchecked.

Also drops the unattached comment block in cooldown_fail_closed.go.
2026-10-25 12:31:33 +01:00

313 lines
12 KiB
Go

package handler
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/cooldown"
registryclient "github.com/git-pkgs/registries/client"
"github.com/git-pkgs/registries/fetch"
)
// holdPaths returns a client that answers requests for the given paths with
// the rate limit gate's HeldError and sends the rest to upstream.
func holdPaths(paths ...string) *http.Client {
held := &registryclient.HeldError{Host: "upstream.example", Until: time.Now().Add(30 * time.Second), RetryAfter: 30 * time.Second}
return &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
for _, p := range paths {
if r.URL.Path == p {
return nil, held
}
}
return http.DefaultTransport.RoundTrip(r)
})}
}
func serveGet(t *testing.T, h http.Handler, path string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
return w
}
func wantStatus(t *testing.T, w *httptest.ResponseRecorder, status int, leaked string) {
t.Helper()
if w.Code != status {
t.Errorf("status = %d, want %d: %s", w.Code, status, w.Body.String())
}
if status == http.StatusServiceUnavailable && w.Header().Get("Retry-After") == "" {
t.Error("503 without Retry-After")
}
if leaked != "" && strings.Contains(w.Body.String(), leaked) {
t.Errorf("response lists %q, which cooldown should have withheld", leaked)
}
}
func TestGemCooldownFailsClosed(t *testing.T) {
index := "---\n1.0.0 |checksum:abc\n2.0.0 |checksum:def\n"
versionsStatus := http.StatusInternalServerError
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/v1/versions/") {
w.WriteHeader(versionsStatus)
return
}
_, _ = io.WriteString(w, index)
}))
defer upstream.Close()
t.Run("versions API fails", func(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "3d"}
h := NewGemHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL)
wantStatus(t, serveGet(t, h.Routes(), "/info/testgem"), http.StatusBadGateway, "2.0.0")
})
t.Run("versions API held by rate limit", func(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "3d"}
proxy.HTTPClient = holdPaths("/api/v1/versions/testgem.json")
h := NewGemHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL)
wantStatus(t, serveGet(t, h.Routes(), "/info/testgem"), http.StatusServiceUnavailable, "2.0.0")
})
t.Run("exempt package needs no times", func(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "3d", Packages: map[string]string{"pkg:gem/testgem": "0"}}
h := NewGemHandlerWithUpstream(proxy, "http://proxy.test", upstream.URL)
w := serveGet(t, h.Routes(), "/info/testgem")
if w.Code != http.StatusOK || w.Body.String() != index {
t.Errorf("status = %d, body = %q; want the index unchanged", w.Code, w.Body.String())
}
})
}
func TestHexCooldownFailsClosed(t *testing.T) {
now := time.Now()
proto := buildHexSigned(buildHexPackage("testpkg", []string{testVersion100, "2.0.0"}))
apiJSON, _ := json.Marshal(hexPackageAPI{Releases: []hexRelease{
{Version: testVersion100, InsertedAt: now.Add(-7 * 24 * time.Hour).Format(time.RFC3339Nano)},
{Version: "2.0.0", InsertedAt: now.Add(-time.Hour).Format(time.RFC3339Nano)},
}})
newUpstream := func(t *testing.T, packageBody []byte, apiStatus int) *httptest.Server {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/packages/testpkg":
_, _ = w.Write(packageBody)
case "/api/packages/testpkg":
w.WriteHeader(apiStatus)
_, _ = w.Write(apiJSON)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(srv.Close)
return srv
}
handler := func(t *testing.T, srv *httptest.Server, client *http.Client) http.Handler {
proxy, _, _, _ := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "3d"}
if client != nil {
proxy.HTTPClient = client
}
return NewHexHandlerWithUpstreams(proxy, "http://proxy.test", srv.URL, srv.URL).Routes()
}
t.Run("API fails", func(t *testing.T) {
srv := newUpstream(t, proto, http.StatusInternalServerError)
wantStatus(t, serveGet(t, handler(t, srv, nil), "/packages/testpkg"), http.StatusBadGateway, "")
})
t.Run("API held by rate limit", func(t *testing.T) {
srv := newUpstream(t, proto, http.StatusOK)
w := serveGet(t, handler(t, srv, holdPaths("/api/packages/testpkg")), "/packages/testpkg")
wantStatus(t, w, http.StatusServiceUnavailable, "")
})
t.Run("package can't be filtered", func(t *testing.T) {
srv := newUpstream(t, []byte("not gzip 2.0.0"), http.StatusOK)
wantStatus(t, serveGet(t, handler(t, srv, nil), "/packages/testpkg"), http.StatusBadGateway, "2.0.0")
})
t.Run("filtered", func(t *testing.T) {
srv := newUpstream(t, proto, http.StatusOK)
w := serveGet(t, handler(t, srv, nil), "/packages/testpkg")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
})
}
func TestPyPICooldownFailsClosed(t *testing.T) {
simple := `{"meta":{"api-version":"1.4"},"name":"ruff","files":[` +
`{"filename":"ruff-1.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/ab/ruff-1.0.0.tar.gz"},` +
`{"filename":"ruff-2.0.0.tar.gz","url":"https://files.pythonhosted.org/packages/cd/ruff-2.0.0.tar.gz"}]}`
tests := []struct {
name string
json func(*http.Request) (*http.Response, error)
status int
}{
{"JSON API fails", func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody, Request: r}, nil
}, http.StatusBadGateway},
{"JSON API held by rate limit", func(*http.Request) (*http.Response, error) {
return nil, &registryclient.HeldError{Host: "pypi.test", RetryAfter: 30 * time.Second}
}, http.StatusServiceUnavailable},
{"JSON has no releases", func(r *http.Request) (*http.Response, error) {
return pypiHTTPResponse(r, "application/json", `{"info":{}}`), nil
}, http.StatusBadGateway},
{"JSON API doesn't know the package", func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusNotFound, Body: http.NoBody, Request: r}, nil
}, http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, proxy := setupPyPIHandler(t, func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/pypi/ruff/json" {
return tt.json(r)
}
return pypiHTTPResponse(r, pypiSimpleJSON, simple), nil
})
proxy.Cooldown = &cooldown.Config{Default: "7d"}
req := httptest.NewRequest(http.MethodGet, "/simple/ruff/", nil)
req.Header.Set("Accept", uvPyPIAccept)
w := httptest.NewRecorder()
h.Routes().ServeHTTP(w, req)
leaked := "ruff-2.0.0"
if tt.status == http.StatusOK {
leaked = ""
}
wantStatus(t, w, tt.status, leaked)
})
}
}
func TestPyPIDownloadCooldownFailsClosed(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
proxy, _, _, fetcher := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "7d"}
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("sdist")), ContentType: "application/octet-stream"}
h := &PyPIHandler{proxy: proxy, upstreamURL: upstream.URL, proxyURL: "http://localhost"}
w := serveGet(t, h.Routes(), "/packages/packages/ab/cd/ef0123456789/newpkg-2.0.0.tar.gz")
wantStatus(t, w, http.StatusBadGateway, "")
if fetcher.fetchCalled {
t.Error("fetched a file whose publish time couldn't be checked")
}
}
func TestNPMDownloadCooldownFailsClosed(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
proxy, _, _, fetcher := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "7d"}
fetcher.artifact = &fetch.Artifact{Body: io.NopCloser(strings.NewReader("tarball")), ContentType: "application/octet-stream"}
h := NewNPMHandler(proxy, "http://proxy.test", upstream.URL)
w := serveGet(t, h.Routes(), "/leftpad/-/leftpad-2.0.0.tgz")
wantStatus(t, w, http.StatusBadGateway, "")
if fetcher.fetchCalled {
t.Error("fetched a tarball whose publish time couldn't be checked")
}
}
// TestDownloadCooldownErrorServesCachedFile checks that a file already in the
// cache is still served when its publish time can't be read, while an uncached
// one fails.
func TestDownloadCooldownErrorServesCachedFile(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer upstream.Close()
tests := []struct {
ecosystem, name, version, filename, path string
handler func(*Proxy) http.Handler
}{
{"pypi", "newpkg", "2.0.0", "newpkg-2.0.0.tar.gz", "/packages/packages/ab/cd/ef0123456789/newpkg-2.0.0.tar.gz",
func(p *Proxy) http.Handler {
return (&PyPIHandler{proxy: p, upstreamURL: upstream.URL, downloadURL: upstream.URL, proxyURL: "http://localhost"}).Routes()
}},
{"npm", "leftpad", "2.0.0", "leftpad-2.0.0.tgz", "/leftpad/-/leftpad-2.0.0.tgz",
func(p *Proxy) http.Handler { return NewNPMHandler(p, "http://proxy.test", upstream.URL).Routes() }},
}
for _, tt := range tests {
t.Run(tt.ecosystem, func(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "7d"}
h := tt.handler(proxy)
seedPackage(t, db, store, tt.ecosystem, tt.name, tt.version, tt.filename, "cached file")
w := serveGet(t, h, tt.path)
wantStatus(t, w, http.StatusOK, "")
if w.Body.String() != "cached file" {
t.Errorf("body = %q, want the cached file", w.Body.String())
}
other := strings.ReplaceAll(tt.path, "2.0.0", "3.0.0")
wantStatus(t, serveGet(t, h, other), http.StatusBadGateway, "")
if fetcher.fetchCalled {
t.Error("fetched a file whose publish time couldn't be checked")
}
})
}
}
// TestCooldownRewriteFailureFailsClosed checks that a metadata document the
// cooldown rewrite can't parse is refused rather than passed on, and still
// passed on when cooldown doesn't apply.
func TestCooldownRewriteFailureFailsClosed(t *testing.T) {
const broken = `{"name": "pkg", "versions": [ 2.0.0`
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set(headerContentType, contentTypeJSON)
_, _ = io.WriteString(w, broken)
}))
defer upstream.Close()
tests := []struct {
name string
purl string
handler func(*Proxy) http.Handler
path string
}{
{"npm", "pkg:npm/pkg", func(p *Proxy) http.Handler { return NewNPMHandler(p, "http://proxy.test", upstream.URL).Routes() }, "/pkg"},
{"composer", "pkg:composer/vendor/pkg", func(p *Proxy) http.Handler {
return NewComposerHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, upstream.URL).Routes()
}, "/p2/vendor/pkg.json"},
{"pub", "pkg:pub/pkg", func(p *Proxy) http.Handler {
return NewPubHandlerWithUpstream(p, "http://proxy.test", upstream.URL).Routes()
}, "/api/packages/pkg"},
{"pypi json", "pkg:pypi/pkg", func(p *Proxy) http.Handler {
return NewPyPIHandlerWithUpstreams(p, "http://proxy.test", upstream.URL, "").Routes()
}, "/pypi/pkg/json"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "7d"}
wantStatus(t, serveGet(t, tt.handler(proxy), tt.path), http.StatusBadGateway, "2.0.0")
})
t.Run(tt.name+" exempt", func(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.Cooldown = &cooldown.Config{Default: "7d", Packages: map[string]string{tt.purl: "0"}}
w := serveGet(t, tt.handler(proxy), tt.path)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 for a package cooldown doesn't apply to", w.Code)
}
})
}
}