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/composer_download_lookup_test.go

356 lines
13 KiB
Go
Raw Permalink Normal View History

package handler
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/git-pkgs/cooldown"
registryclient "github.com/git-pkgs/registries/client"
"github.com/git-pkgs/registries/fetch"
)
// composerLookupUpstream serves v/p's metadata listing versions and the
// archives they point at, counts metadata and archive requests, and fails
// every request while down is set, with downStatus or 503. A version listed in
// published carries that publish time.
type composerLookupUpstream struct {
h http.Handler
handler *ComposerHandler
proxy *Proxy
versions atomic.Value // []string
published map[string]time.Time
down atomic.Bool
downStatus atomic.Int32
metadata atomic.Int32
archives atomic.Int32
routeURL string
}
func setupComposerLookup(t *testing.T, cacheMetadata bool, versions ...string) *composerLookupUpstream {
t.Helper()
u := &composerLookupUpstream{published: map[string]time.Time{}}
u.versions.Store(versions)
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if u.down.Load() {
status := int(u.downStatus.Load())
if status == 0 {
status = http.StatusServiceUnavailable
}
w.Header().Set("Retry-After", "30")
http.Error(w, "down", status)
return
}
switch {
case r.URL.Path == "/p2/v/p.json":
u.metadata.Add(1)
var entries []string
for _, v := range u.versions.Load().([]string) {
var published string
if at, ok := u.published[v]; ok {
published = fmt.Sprintf(`"time":%q,`, at.Format(time.RFC3339))
}
entries = append(entries, fmt.Sprintf(`{"version":%q,%s"dist":{"url":%q,"type":"zip"}}`, v, published, srv.URL+"/archives/"+v+".zip"))
}
_, _ = fmt.Fprintf(w, `{"packages":{"v/p":[%s]}}`, strings.Join(entries, ","))
case r.URL.Path == "/p2/v/p~dev.json":
u.metadata.Add(1)
http.NotFound(w, r)
case strings.HasPrefix(r.URL.Path, "/archives/"):
u.archives.Add(1)
_, _ = io.WriteString(w, "archive "+r.URL.Path)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
proxy, _, _, _ := setupTestProxy(t)
proxy.CacheMetadata = cacheMetadata
proxy.MetadataTTL = time.Hour
fetcher := fetch.NewFetcher(fetch.WithHTTPClient(srv.Client()), fetch.WithMaxRetries(0))
proxy.Fetcher = fetcher
t.Cleanup(func() { _ = fetcher.Close() })
u.proxy = proxy
u.handler = NewComposerHandlerWithUpstreams(proxy, "http://proxy", srv.URL, srv.URL)
u.h = u.handler.Routes()
u.routeURL = srv.URL
return u
}
// route sends v/p to a custom repository, which is the same server under
// another name.
func (u *composerLookupUpstream) route() {
u.h = u.handler.WithPackageRoutes(map[string]string{"v/*": u.routeURL}).Routes()
}
func (u *composerLookupUpstream) get(t *testing.T, target string) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
u.h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
return w
}
func (u *composerLookupUpstream) download(t *testing.T, version string) {
t.Helper()
w := u.get(t, "/files/v/p/"+version+"/"+version+".zip")
if want := "archive /archives/" + version + ".zip"; w.Code != http.StatusOK || w.Body.String() != want {
t.Fatalf("download %s: status %d, body %q, want 200 %q", version, w.Code, w.Body.String(), want)
}
}
// TestComposerDownloadResolvesFromCachedMetadata checks that downloads find
// their archive in the metadata Composer was just served, instead of fetching
// it from upstream again for every archive.
func TestComposerDownloadResolvesFromCachedMetadata(t *testing.T) {
u := setupComposerLookup(t, true, "1.0.0")
if w := u.get(t, "/p2/v/p.json"); w.Code != http.StatusOK {
t.Fatalf("metadata: status %d", w.Code)
}
u.download(t, "1.0.0")
u.download(t, "1.0.0")
if n := u.metadata.Load(); n != 1 {
t.Errorf("upstream metadata requests = %d, want 1 (the metadata request itself)", n)
}
if n := u.archives.Load(); n != 1 {
t.Errorf("upstream archive requests = %d, want 1", n)
}
}
// TestComposerCachedArchiveServedWhileUpstreamDown checks that a cached
// archive stays installable when upstream fails, rather than failing on the
// metadata lookup that only names where to fetch it from.
func TestComposerCachedArchiveServedWhileUpstreamDown(t *testing.T) {
u := setupComposerLookup(t, true, "1.0.0")
u.download(t, "1.0.0")
u.down.Store(true)
u.download(t, "1.0.0")
}
// TestComposerDownloadOfVersionNewerThanCachedMetadata checks that a version
// published after the metadata was cached, which a lock file can name before
// the cached copy expires, is still looked up upstream.
func TestComposerDownloadOfVersionNewerThanCachedMetadata(t *testing.T) {
u := setupComposerLookup(t, true, "1.0.0")
u.download(t, "1.0.0")
u.versions.Store([]string{"1.0.0", "1.1.0"})
u.download(t, "1.1.0")
}
// TestComposerDownloadWithoutMetadataCache checks that without cache_metadata
// a download asks upstream for the metadata once, as it always has.
func TestComposerDownloadWithoutMetadataCache(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
u.download(t, "1.0.0")
if n := u.metadata.Load(); n != 1 {
t.Errorf("upstream metadata requests = %d, want 1", n)
}
}
// TestComposerDownloadLookupUpstreamError checks that a download whose
// metadata lookup fails upstream answers 502, or 503 while upstream holds
// requests, and not 404: Composer treats 404 as the version being gone.
func TestComposerDownloadLookupUpstreamError(t *testing.T) {
for _, status := range []int{http.StatusInternalServerError, http.StatusServiceUnavailable} {
t.Run(http.StatusText(status), func(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
u.downStatus.Store(int32(status))
u.down.Store(true)
if w := u.get(t, "/files/v/p/1.0.0/1.0.0.zip"); w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want 502", w.Code)
}
})
}
t.Run("rate limited", func(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
gate := registryclient.NewRateLimitGate(http.DefaultTransport)
u.proxy.HTTPClient = &http.Client{Transport: gate}
u.proxy.RateLimits = gate
u.downStatus.Store(http.StatusTooManyRequests)
u.down.Store(true)
w := u.get(t, "/files/v/p/1.0.0/1.0.0.zip")
if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
t.Errorf("status = %d, Retry-After %q; want 503 with Retry-After", w.Code, w.Header().Get("Retry-After"))
}
})
t.Run("not listed", func(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
if w := u.get(t, "/files/v/p/2.0.0/2.0.0.zip"); w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
})
}
// storedPublishTime returns the publish time recorded for v/p at version.
func (u *composerLookupUpstream) storedPublishTime(t *testing.T, version string) (time.Time, bool) {
t.Helper()
ver, err := u.proxy.DB.GetVersionByPURL(canonicalVersionPURL("composer", "v/p", version))
if err != nil {
t.Fatalf("reading versions row: %v", err)
}
if ver == nil || !ver.PublishedAt.Valid {
return time.Time{}, false
}
return ver.PublishedAt.Time, true
}
func (u *composerLookupUpstream) wantMetadataRequests(t *testing.T, want int32) {
t.Helper()
if n := u.metadata.Load(); n != want {
t.Errorf("upstream metadata requests = %d, want %d", n, want)
}
}
// TestComposerCachedReleaseSkipsMetadata checks that a tagged release whose
// archive is cached is served without asking upstream for the metadata
// again, with and without cache_metadata, and with cooldown, which takes the
// publish time recorded when the archive was first resolved.
func TestComposerCachedReleaseSkipsMetadata(t *testing.T) {
for _, tt := range []struct {
name string
cacheMetadata bool
cooldown bool
}{
{"metadata not cached", false, false},
{"metadata cached", true, false},
{"cooldown", false, true},
} {
t.Run(tt.name, func(t *testing.T) {
u := setupComposerLookup(t, tt.cacheMetadata, "1.0.0")
u.published["1.0.0"] = time.Now().Add(-30 * 24 * time.Hour)
if tt.cooldown {
u.proxy.Cooldown = &cooldown.Config{Default: "3d"}
}
u.download(t, "1.0.0")
// With cache_metadata the cached copy expires; the archive must
// not need it.
u.proxy.MetadataTTL = 0
u.download(t, "1.0.0")
u.download(t, "1.0.0")
u.wantMetadataRequests(t, 1)
if n := u.archives.Load(); n != 1 {
t.Errorf("upstream archive requests = %d, want 1", n)
}
})
}
}
// TestComposerDownloadStoresPublishTime checks that resolving a tagged
// release records its publish time, so later downloads can check cooldown
// without the metadata.
func TestComposerDownloadStoresPublishTime(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
published := time.Date(2024, 5, 6, 7, 8, 9, 0, time.UTC)
u.published["1.0.0"] = published
u.download(t, "1.0.0")
got, ok := u.storedPublishTime(t, "1.0.0")
if !ok || !got.Equal(published) {
t.Errorf("stored publish time = %v, %v, want %v", got, ok, published)
}
}
// TestComposerCachedReleaseCooldownFromStoredTime checks that a cached
// archive whose stored publish time is still inside the cooldown window is
// withheld without the metadata, including when cooldown was enabled or
// raised after the archive was cached.
func TestComposerCachedReleaseCooldownFromStoredTime(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0", "2.0.0")
u.published["1.0.0"] = time.Now().Add(-5 * 24 * time.Hour)
u.published["2.0.0"] = time.Now().Add(-1 * time.Hour)
u.download(t, "2.0.0")
u.proxy.Cooldown = &cooldown.Config{Default: "3d"}
u.download(t, "1.0.0")
if w := u.get(t, "/files/v/p/2.0.0/2.0.0.zip"); w.Code != http.StatusNotFound {
t.Errorf("2.0.0 after enabling cooldown: status %d, want 404", w.Code)
}
u.proxy.Cooldown = &cooldown.Config{Default: "7d"}
if w := u.get(t, "/files/v/p/1.0.0/1.0.0.zip"); w.Code != http.StatusNotFound {
t.Errorf("1.0.0 after raising cooldown: status %d, want 404", w.Code)
}
u.wantMetadataRequests(t, 2)
}
// TestComposerCachedReleaseWithoutStoredTime checks that with cooldown a
// cached archive the proxy recorded no publish time for, such as one cached
// by an older version, is checked against the metadata, which then records
// the time.
func TestComposerCachedReleaseWithoutStoredTime(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
u.published["1.0.0"] = time.Now().Add(-1 * time.Hour)
u.download(t, "1.0.0")
if _, err := u.proxy.DB.Exec(`UPDATE versions SET published_at = NULL`); err != nil {
t.Fatal(err)
}
u.proxy.Cooldown = &cooldown.Config{Default: "3d"}
if w := u.get(t, "/files/v/p/1.0.0/1.0.0.zip"); w.Code != http.StatusNotFound {
t.Errorf("status %d, want 404", w.Code)
}
u.wantMetadataRequests(t, 2)
if _, ok := u.storedPublishTime(t, "1.0.0"); !ok {
t.Error("publish time not recorded from the metadata")
}
}
// TestComposerCachedDevVersionUsesMetadata checks that a branch, whose
// archive and publish time change with every push, is still resolved from
// the metadata on each download and records no publish time.
func TestComposerCachedDevVersionUsesMetadata(t *testing.T) {
u := setupComposerLookup(t, false, "dev-main")
u.published["dev-main"] = time.Now().Add(-30 * 24 * time.Hour)
u.download(t, "dev-main")
before := u.metadata.Load()
u.download(t, "dev-main")
if u.metadata.Load() == before {
t.Error("cached branch archive served without consulting the metadata")
}
if _, ok := u.storedPublishTime(t, "dev-main"); ok {
t.Error("publish time recorded for a branch")
}
}
// TestComposerCachedRoutedReleaseUsesMetadata checks that a routed package
// keeps resolving every download from its route's metadata: the versions
// table is keyed by a PURL that does not say which repository the version
// came from, so it neither stores nor trusts a publish time for it.
func TestComposerCachedRoutedReleaseUsesMetadata(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
u.published["1.0.0"] = time.Now().Add(-1 * time.Hour)
u.route()
u.download(t, "1.0.0")
u.download(t, "1.0.0")
u.wantMetadataRequests(t, 2)
if _, ok := u.storedPublishTime(t, "1.0.0"); ok {
t.Error("publish time recorded for a routed package")
}
// A time stored for the public package of the same name is not used.
if err := u.proxy.DB.SetVersionPublishedAt(canonicalVersionPURL("composer", "v/p", "1.0.0"),
canonicalPackagePURL("composer", "v/p"), time.Now().Add(-30*24*time.Hour)); err != nil {
t.Fatal(err)
}
u.proxy.Cooldown = &cooldown.Config{Default: "3d"}
if w := u.get(t, "/files/v/p/1.0.0/1.0.0.zip"); w.Code != http.StatusNotFound {
t.Errorf("routed release in cooldown: status %d, want 404", w.Code)
}
}
// TestComposerCachedReleaseDenied checks that the denylist still blocks a
// cached release served without the metadata.
func TestComposerCachedReleaseDenied(t *testing.T) {
u := setupComposerLookup(t, false, "1.0.0")
u.download(t, "1.0.0")
setTestDenylist(t, u.proxy, "pkg:composer/v/p@1.0.0")
if w := u.get(t, "/files/v/p/1.0.0/1.0.0.zip"); w.Code != http.StatusForbidden {
t.Errorf("status %d, want 403", w.Code)
}
}