mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
The lookup took any non-200 metadata response for a version that isn't listed, so a Packagist outage or rate limit answered the archive request with 404. Composer treats that as the version being gone. Only 404 and 410 now mean the document is missing. A 429 is passed on as 503 with Retry-After while the upstream is held, and any other status answers 502.
356 lines
13 KiB
Go
356 lines
13 KiB
Go
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)
|
|
}
|
|
}
|