mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-09 15:49:16 -05:00
644 lines
21 KiB
Go
644 lines
21 KiB
Go
package handler
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/git-pkgs/cooldown"
|
|
)
|
|
|
|
// cooldownCountingUpstream serves fixed documents by path with ETags, answers a
|
|
// matching If-None-Match with 304, and counts what it was asked. Setting
|
|
// down makes it answer 500, and closing gate holds requests until opened.
|
|
type cooldownCountingUpstream struct {
|
|
*httptest.Server
|
|
mu sync.Mutex
|
|
docs map[string][]byte
|
|
gzipped map[string]bool
|
|
requests map[string]int
|
|
notModified map[string]int
|
|
down map[string]bool
|
|
gate chan struct{}
|
|
}
|
|
|
|
func newCooldownCountingUpstream(t *testing.T) *cooldownCountingUpstream {
|
|
t.Helper()
|
|
u := &cooldownCountingUpstream{
|
|
docs: map[string][]byte{}, gzipped: map[string]bool{},
|
|
requests: map[string]int{}, notModified: map[string]int{}, down: map[string]bool{},
|
|
}
|
|
u.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u.mu.Lock()
|
|
u.requests[r.URL.Path]++
|
|
gate := u.gate
|
|
doc, ok := u.docs[r.URL.Path]
|
|
down := u.down[r.URL.Path]
|
|
gz := u.gzipped[r.URL.Path]
|
|
u.mu.Unlock()
|
|
if gate != nil {
|
|
<-gate
|
|
}
|
|
switch {
|
|
case down:
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
case !ok:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
sum := sha256.Sum256(doc)
|
|
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
|
|
w.Header().Set("ETag", etag)
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
u.mu.Lock()
|
|
u.notModified[r.URL.Path]++
|
|
u.mu.Unlock()
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
if gz && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
|
|
w.Header().Set("Content-Encoding", "gzip")
|
|
zw := gzip.NewWriter(w)
|
|
_, _ = zw.Write(doc)
|
|
_ = zw.Close()
|
|
return
|
|
}
|
|
_, _ = w.Write(doc)
|
|
}))
|
|
t.Cleanup(u.Close)
|
|
return u
|
|
}
|
|
|
|
func (u *cooldownCountingUpstream) set(path string, doc []byte) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.docs[path] = doc
|
|
}
|
|
|
|
func (u *cooldownCountingUpstream) setDown(path string, down bool) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
u.down[path] = down
|
|
}
|
|
|
|
func (u *cooldownCountingUpstream) counts(path string) (requests, notModified int) {
|
|
u.mu.Lock()
|
|
defer u.mu.Unlock()
|
|
return u.requests[path], u.notModified[path]
|
|
}
|
|
|
|
// cooldownCacheProxy returns a proxy with cooldown and the metadata cache on.
|
|
func cooldownCacheProxy(t *testing.T, u *cooldownCountingUpstream) *Proxy {
|
|
t.Helper()
|
|
proxy, _, _, _ := setupTestProxy(t)
|
|
proxy.HTTPClient = u.Client()
|
|
config := &cooldown.Config{Default: "3d"}
|
|
// Config parses itself on first use, which isn't safe to race.
|
|
config.Enabled()
|
|
proxy.Cooldown = config
|
|
proxy.CacheMetadata = true
|
|
proxy.MetadataTTL = time.Hour
|
|
return proxy
|
|
}
|
|
|
|
// expireCachedMetadata moves a metadata cache row's fetch time past the TTL.
|
|
func expireCachedMetadata(t *testing.T, proxy *Proxy, ecosystem, key string) {
|
|
t.Helper()
|
|
entry, err := proxy.DB.GetMetadataCache(ecosystem, key)
|
|
if err != nil || entry == nil {
|
|
t.Fatalf("metadata entry %s/%s: %v", ecosystem, key, err)
|
|
}
|
|
entry.FetchedAt.Time = time.Now().Add(-2 * proxy.MetadataTTL)
|
|
if err := proxy.DB.UpsertMetadataCache(entry); err != nil {
|
|
t.Fatalf("expiring metadata entry: %v", err)
|
|
}
|
|
}
|
|
|
|
func getWith(t *testing.T, h http.Handler, path string, header http.Header) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
for k, vv := range header {
|
|
req.Header[k] = vv
|
|
}
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
return w
|
|
}
|
|
|
|
const (
|
|
gemInfoPath = "/info/testgem"
|
|
gemVersionsPath = "/api/v1/versions/testgem.json"
|
|
)
|
|
|
|
func gemVersionsJSON(t *testing.T, versions map[string]time.Duration) []byte {
|
|
t.Helper()
|
|
var list []gemVersion
|
|
for number, age := range versions {
|
|
list = append(list, gemVersion{Number: number, Platform: "ruby", CreatedAt: time.Now().Add(-age).Format(time.RFC3339)})
|
|
}
|
|
b, err := json.Marshal(list)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// gemCacheSetup serves testgem with 1.0.0 published a week ago and 2.0.0 an
|
|
// hour ago.
|
|
func gemCacheSetup(t *testing.T) (*cooldownCountingUpstream, *Proxy, http.Handler) {
|
|
t.Helper()
|
|
u := newCooldownCountingUpstream(t)
|
|
u.set(gemInfoPath, []byte("---\n1.0.0 |checksum:a\n2.0.0 |checksum:b\n"))
|
|
u.set(gemVersionsPath, gemVersionsJSON(t, map[string]time.Duration{"1.0.0": 7 * 24 * time.Hour, "2.0.0": time.Hour}))
|
|
proxy := cooldownCacheProxy(t, u)
|
|
return u, proxy, NewGemHandlerWithUpstream(proxy, "http://proxy.test", u.URL).Routes()
|
|
}
|
|
|
|
const gemFiltered = "---\n1.0.0 |checksum:a\n"
|
|
|
|
func TestGemCooldownServesFromMetadataCache(t *testing.T) {
|
|
u, _, h := gemCacheSetup(t)
|
|
for i := range 3 {
|
|
w := getWith(t, h, gemInfoPath, nil)
|
|
if w.Code != http.StatusOK || w.Body.String() != gemFiltered {
|
|
t.Fatalf("request %d: status %d, body %q", i, w.Code, w.Body)
|
|
}
|
|
}
|
|
for _, path := range []string{gemInfoPath, gemVersionsPath} {
|
|
if n, _ := u.counts(path); n != 1 {
|
|
t.Errorf("%s requested %d times, want 1 within the TTL", path, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGemCooldownRevalidatesAfterTTL(t *testing.T) {
|
|
u, proxy, h := gemCacheSetup(t)
|
|
getWith(t, h, gemInfoPath, nil)
|
|
expireCachedMetadata(t, proxy, "gem", "info/testgem")
|
|
expireCachedMetadata(t, proxy, "gem", "api/v1/versions/testgem.json")
|
|
|
|
w := getWith(t, h, gemInfoPath, nil)
|
|
if w.Code != http.StatusOK || w.Body.String() != gemFiltered {
|
|
t.Fatalf("status %d, body %q", w.Code, w.Body)
|
|
}
|
|
for _, path := range []string{gemInfoPath, gemVersionsPath} {
|
|
if n, nm := u.counts(path); n != 2 || nm != 1 {
|
|
t.Errorf("%s: %d requests, %d answered 304; want 2 and 1", path, n, nm)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGemCooldownConcurrentRequestsShareFetch(t *testing.T) {
|
|
u, proxy, h := gemCacheSetup(t)
|
|
u.gate = make(chan struct{})
|
|
var release sync.Once
|
|
t.Cleanup(func() { release.Do(func() { close(u.gate) }) })
|
|
|
|
const clients = 5
|
|
var wg sync.WaitGroup
|
|
codes := make([]int, clients)
|
|
bodies := make([]string, clients)
|
|
for i := range clients {
|
|
wg.Go(func() {
|
|
w := getWith(t, h, gemInfoPath, nil)
|
|
codes[i], bodies[i] = w.Code, w.Body.String()
|
|
})
|
|
}
|
|
waitForMetadataWaiters(t, proxy, metadataCoalesceKey("gem", "info/testgem", u.URL+gemInfoPath, "*/*", "identity", false), clients-1)
|
|
waitForMetadataWaiters(t, proxy, metadataCoalesceKey("gem", "api/v1/versions/testgem.json", u.URL+gemVersionsPath, contentTypeJSON, "", true), clients-1)
|
|
release.Do(func() { close(u.gate) })
|
|
wg.Wait()
|
|
|
|
for i := range clients {
|
|
if codes[i] != http.StatusOK || bodies[i] != gemFiltered {
|
|
t.Errorf("client %d: status %d, body %q", i, codes[i], bodies[i])
|
|
}
|
|
}
|
|
for _, path := range []string{gemInfoPath, gemVersionsPath} {
|
|
if n, _ := u.counts(path); n != 1 {
|
|
t.Errorf("%s requested %d times, want 1", path, n)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGemCooldownStaleCopyIsFiltered(t *testing.T) {
|
|
u, proxy, h := gemCacheSetup(t)
|
|
getWith(t, h, gemInfoPath, nil)
|
|
expireCachedMetadata(t, proxy, "gem", "info/testgem")
|
|
expireCachedMetadata(t, proxy, "gem", "api/v1/versions/testgem.json")
|
|
u.setDown(gemInfoPath, true)
|
|
u.setDown(gemVersionsPath, true)
|
|
|
|
w := getWith(t, h, gemInfoPath, nil)
|
|
if w.Code != http.StatusOK || w.Body.String() != gemFiltered {
|
|
t.Fatalf("status %d, body %q; want the stale copy, filtered", w.Code, w.Body)
|
|
}
|
|
if got := w.Header().Get("Warning"); got != staleWarning {
|
|
t.Errorf("Warning = %q, want %q", got, staleWarning)
|
|
}
|
|
}
|
|
|
|
func TestGemCooldownFailsClosedWithoutTimes(t *testing.T) {
|
|
u, _, h := gemCacheSetup(t)
|
|
u.setDown(gemVersionsPath, true)
|
|
|
|
w := getWith(t, h, gemInfoPath, nil)
|
|
if w.Code != http.StatusBadGateway || strings.Contains(w.Body.String(), "1.0.0") {
|
|
t.Fatalf("status %d, body %q; want 502 without the index", w.Code, w.Body)
|
|
}
|
|
// The index alone was cached; it must not be served unfiltered either.
|
|
w = getWith(t, h, gemInfoPath, nil)
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("second request: status %d, want 502", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestGemCooldownClientRevalidates(t *testing.T) {
|
|
_, _, h := gemCacheSetup(t)
|
|
first := getWith(t, h, gemInfoPath, nil)
|
|
etag := first.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatal("filtered index served without an ETag")
|
|
}
|
|
w := getWith(t, h, gemInfoPath, http.Header{"If-None-Match": {etag}})
|
|
if w.Code != http.StatusNotModified {
|
|
t.Fatalf("status %d, want 304", w.Code)
|
|
}
|
|
}
|
|
|
|
// The index can be fetched again while the versions API response is still
|
|
// within its TTL. A version published in between is then in the index but
|
|
// has no publish time, and must not be let through for that.
|
|
func TestGemCooldownRefreshesTimesOlderThanIndex(t *testing.T) {
|
|
for _, timesDown := range []bool{false, true} {
|
|
name := "times refreshed"
|
|
if timesDown {
|
|
name = "times upstream down"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
u, proxy, h := gemCacheSetup(t)
|
|
getWith(t, h, gemInfoPath, nil)
|
|
|
|
u.set(gemInfoPath, []byte("---\n1.0.0 |checksum:a\n2.0.0 |checksum:b\n3.0.0 |checksum:c\n"))
|
|
u.set(gemVersionsPath, gemVersionsJSON(t, map[string]time.Duration{
|
|
"1.0.0": 7 * 24 * time.Hour, "2.0.0": time.Hour, "3.0.0": time.Minute,
|
|
}))
|
|
u.setDown(gemVersionsPath, timesDown)
|
|
expireCachedMetadata(t, proxy, "gem", "info/testgem")
|
|
|
|
w := getWith(t, h, gemInfoPath, nil)
|
|
if w.Code != http.StatusOK || w.Body.String() != gemFiltered {
|
|
t.Fatalf("status %d, body %q; want %q", w.Code, w.Body, gemFiltered)
|
|
}
|
|
if n, _ := u.counts(gemVersionsPath); n != 2 {
|
|
t.Errorf("versions API requested %d times, want 2", n)
|
|
}
|
|
if got := w.Header().Get("Warning"); (got != "") != timesDown {
|
|
t.Errorf("Warning = %q with times upstream down = %v", got, timesDown)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
const (
|
|
hexPackagePath = "/packages/testpkg"
|
|
hexAPIPath = "/api/packages/testpkg"
|
|
)
|
|
|
|
func hexAPIJSON(t *testing.T, releases map[string]time.Duration) []byte {
|
|
t.Helper()
|
|
var api hexPackageAPI
|
|
for version, age := range releases {
|
|
api.Releases = append(api.Releases, hexRelease{Version: version, InsertedAt: time.Now().Add(-age).Format(time.RFC3339Nano)})
|
|
}
|
|
b, err := json.Marshal(api)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func hexCacheSetup(t *testing.T, recent time.Duration) (*cooldownCountingUpstream, *Proxy, http.Handler, []byte) {
|
|
t.Helper()
|
|
u := newCooldownCountingUpstream(t)
|
|
signed := buildHexSigned(buildHexPackage("testpkg", []string{testVersion100, "2.0.0"}))
|
|
u.set(hexPackagePath, signed)
|
|
u.set(hexAPIPath, hexAPIJSON(t, map[string]time.Duration{testVersion100: 7 * 24 * time.Hour, "2.0.0": recent}))
|
|
proxy := cooldownCacheProxy(t, u)
|
|
return u, proxy, NewHexHandlerWithUpstreams(proxy, "http://proxy.test", u.URL, u.URL).Routes(), signed
|
|
}
|
|
|
|
// hexVersions decodes the release versions of a served package.
|
|
func hexVersions(t *testing.T, body []byte) []string {
|
|
t.Helper()
|
|
zr, err := gzip.NewReader(bytes.NewReader(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
signed, err := io.ReadAll(zr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
payload, err := extractProtobufBytes(signed, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
versions, err := packageReleaseVersions(payload)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return versions
|
|
}
|
|
|
|
func TestHexCooldownServesFromMetadataCache(t *testing.T) {
|
|
u, proxy, h, _ := hexCacheSetup(t, time.Hour)
|
|
for i := range 3 {
|
|
w := getWith(t, h, hexPackagePath, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("request %d: status %d", i, w.Code)
|
|
}
|
|
if got := hexVersions(t, w.Body.Bytes()); len(got) != 1 || got[0] != testVersion100 {
|
|
t.Fatalf("request %d: versions %v, want [%s]", i, got, testVersion100)
|
|
}
|
|
if ce := w.Header().Get("Content-Encoding"); ce != "gzip" {
|
|
t.Errorf("Content-Encoding = %q, want gzip", ce)
|
|
}
|
|
}
|
|
for _, path := range []string{hexPackagePath, hexAPIPath} {
|
|
if n, _ := u.counts(path); n != 1 {
|
|
t.Errorf("%s requested %d times, want 1 within the TTL", path, n)
|
|
}
|
|
}
|
|
|
|
expireCachedMetadata(t, proxy, "hex", "packages/testpkg")
|
|
expireCachedMetadata(t, proxy, "hex", hashedCacheKey(u.URL, "api/packages/testpkg"))
|
|
if w := getWith(t, h, hexPackagePath, nil); w.Code != http.StatusOK || len(hexVersions(t, w.Body.Bytes())) != 1 {
|
|
t.Fatalf("after TTL: status %d", w.Code)
|
|
}
|
|
for _, path := range []string{hexPackagePath, hexAPIPath} {
|
|
if n, nm := u.counts(path); n != 2 || nm != 1 {
|
|
t.Errorf("%s: %d requests, %d answered 304; want 2 and 1", path, n, nm)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHexCooldownStaleCopyIsFiltered(t *testing.T) {
|
|
u, proxy, h, _ := hexCacheSetup(t, time.Hour)
|
|
getWith(t, h, hexPackagePath, nil)
|
|
expireCachedMetadata(t, proxy, "hex", "packages/testpkg")
|
|
expireCachedMetadata(t, proxy, "hex", hashedCacheKey(u.URL, "api/packages/testpkg"))
|
|
u.setDown(hexPackagePath, true)
|
|
u.setDown(hexAPIPath, true)
|
|
|
|
w := getWith(t, h, hexPackagePath, nil)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d, want the stale copy", w.Code)
|
|
}
|
|
if got := hexVersions(t, w.Body.Bytes()); len(got) != 1 || got[0] != testVersion100 {
|
|
t.Errorf("versions %v, want [%s]", got, testVersion100)
|
|
}
|
|
if got := w.Header().Get("Warning"); got != staleWarning {
|
|
t.Errorf("Warning = %q, want %q", got, staleWarning)
|
|
}
|
|
}
|
|
|
|
func TestHexCooldownFailsClosedWithoutTimes(t *testing.T) {
|
|
u, _, h, _ := hexCacheSetup(t, time.Hour)
|
|
u.setDown(hexAPIPath, true)
|
|
for i := range 2 {
|
|
if w := getWith(t, h, hexPackagePath, nil); w.Code != http.StatusBadGateway {
|
|
t.Fatalf("request %d: status %d, want 502", i, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHexCooldownUnfilteredKeepsSignature(t *testing.T) {
|
|
_, _, h, signed := hexCacheSetup(t, 7*24*time.Hour)
|
|
w := getWith(t, h, hexPackagePath, nil)
|
|
if w.Code != http.StatusOK || !bytes.Equal(w.Body.Bytes(), signed) {
|
|
t.Fatalf("status %d; want upstream's signed bytes when nothing is in cooldown", w.Code)
|
|
}
|
|
etag := w.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatal("package served without an ETag")
|
|
}
|
|
if w := getWith(t, h, hexPackagePath, http.Header{"If-None-Match": {etag}}); w.Code != http.StatusNotModified {
|
|
t.Fatalf("revalidation: status %d, want 304", w.Code)
|
|
}
|
|
}
|
|
|
|
const condaRepodataPath = "/conda-forge/noarch/repodata.json"
|
|
|
|
func condaCacheSetup(t *testing.T) (*cooldownCountingUpstream, *Proxy, *CondaHandler, http.Handler) {
|
|
t.Helper()
|
|
u := newCooldownCountingUpstream(t)
|
|
repodata, err := json.Marshal(map[string]any{
|
|
"info": map[string]any{"subdir": "noarch"},
|
|
"packages": map[string]any{
|
|
"old-1.0.0-0.tar.bz2": map[string]any{"name": "old", "version": "1.0.0", "timestamp": time.Now().Add(-7 * 24 * time.Hour).UnixMilli()},
|
|
"new-2.0.0-0.tar.bz2": map[string]any{"name": "new", "version": "2.0.0", "timestamp": time.Now().Add(-time.Hour).UnixMilli()},
|
|
},
|
|
"packages.conda": map[string]any{},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.set(condaRepodataPath, repodata)
|
|
u.gzipped[condaRepodataPath] = true
|
|
proxy := cooldownCacheProxy(t, u)
|
|
h := NewCondaHandlerWithUpstream(proxy, "http://proxy.test", u.URL)
|
|
return u, proxy, h, h.Routes()
|
|
}
|
|
|
|
func condaPackages(t *testing.T, w *httptest.ResponseRecorder) []string {
|
|
t.Helper()
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status %d, body %q", w.Code, w.Body)
|
|
}
|
|
var doc struct {
|
|
Packages map[string]json.RawMessage `json:"packages"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &doc); err != nil {
|
|
t.Fatalf("decoding repodata: %v", err)
|
|
}
|
|
var names []string
|
|
for name := range doc.Packages {
|
|
names = append(names, name)
|
|
}
|
|
return names
|
|
}
|
|
|
|
func assertCondaFiltered(t *testing.T, w *httptest.ResponseRecorder) {
|
|
t.Helper()
|
|
if got := condaPackages(t, w); len(got) != 1 || got[0] != "old-1.0.0-0.tar.bz2" {
|
|
t.Fatalf("packages %v, want only old-1.0.0-0.tar.bz2", got)
|
|
}
|
|
}
|
|
|
|
func TestCondaCooldownServesFromMetadataCache(t *testing.T) {
|
|
u, proxy, h, routes := condaCacheSetup(t)
|
|
for range 3 {
|
|
assertCondaFiltered(t, getWith(t, routes, condaRepodataPath, nil))
|
|
}
|
|
if n, _ := u.counts(condaRepodataPath); n != 1 {
|
|
t.Errorf("repodata requested %d times, want 1 within the TTL", n)
|
|
}
|
|
|
|
expireCachedMetadata(t, proxy, "conda", h.metadataCacheKey(condaRepodataPath))
|
|
assertCondaFiltered(t, getWith(t, routes, condaRepodataPath, nil))
|
|
if n, nm := u.counts(condaRepodataPath); n != 2 || nm != 1 {
|
|
t.Errorf("%d requests, %d answered 304; want 2 and 1", n, nm)
|
|
}
|
|
}
|
|
|
|
func TestCondaCooldownStaleCopyIsFiltered(t *testing.T) {
|
|
u, proxy, h, routes := condaCacheSetup(t)
|
|
assertCondaFiltered(t, getWith(t, routes, condaRepodataPath, nil))
|
|
expireCachedMetadata(t, proxy, "conda", h.metadataCacheKey(condaRepodataPath))
|
|
u.setDown(condaRepodataPath, true)
|
|
|
|
w := getWith(t, routes, condaRepodataPath, nil)
|
|
assertCondaFiltered(t, w)
|
|
if got := w.Header().Get("Warning"); got != staleWarning {
|
|
t.Errorf("Warning = %q, want %q", got, staleWarning)
|
|
}
|
|
}
|
|
|
|
func TestCondaCooldownConcurrentRequestsShareFetch(t *testing.T) {
|
|
u, proxy, h, routes := condaCacheSetup(t)
|
|
u.gate = make(chan struct{})
|
|
var release sync.Once
|
|
t.Cleanup(func() { release.Do(func() { close(u.gate) }) })
|
|
|
|
const clients = 5
|
|
var wg sync.WaitGroup
|
|
recorders := make([]*httptest.ResponseRecorder, clients)
|
|
for i := range clients {
|
|
wg.Go(func() { recorders[i] = getWith(t, routes, condaRepodataPath, nil) })
|
|
}
|
|
key := metadataCoalesceKey("conda", h.metadataCacheKey(condaRepodataPath), u.URL+condaRepodataPath, "*/*", "gzip", false)
|
|
waitForMetadataWaiters(t, proxy, key, clients-1)
|
|
release.Do(func() { close(u.gate) })
|
|
wg.Wait()
|
|
|
|
for _, w := range recorders {
|
|
assertCondaFiltered(t, w)
|
|
}
|
|
if n, _ := u.counts(condaRepodataPath); n != 1 {
|
|
t.Errorf("repodata requested %d times, want 1", n)
|
|
}
|
|
}
|
|
|
|
func TestCondaCooldownClientRevalidates(t *testing.T) {
|
|
_, _, h, routes := condaCacheSetup(t)
|
|
// The first copy is streamed as it is filtered, before its ETag is known.
|
|
assertCondaFiltered(t, getWith(t, routes, condaRepodataPath, nil))
|
|
w := getWith(t, routes, condaRepodataPath, nil)
|
|
assertCondaFiltered(t, w)
|
|
etag := w.Header().Get("ETag")
|
|
if etag == "" {
|
|
t.Fatal("filtered repodata served without an ETag")
|
|
}
|
|
if w := getWith(t, routes, condaRepodataPath, http.Header{"If-None-Match": {etag}}); w.Code != http.StatusNotModified {
|
|
t.Fatalf("revalidation: status %d, want 304", w.Code)
|
|
}
|
|
|
|
// Once the entry left out leaves cooldown, the copy changes and so does
|
|
// its ETag.
|
|
key := h.metadataCacheKey(condaRepodataPath)
|
|
h.repodataMu.Lock()
|
|
state := h.repodataStates[key]
|
|
h.repodataMu.Unlock()
|
|
if want := time.Now().Add(-time.Hour + 3*24*time.Hour); state.until.Sub(want).Abs() > time.Minute {
|
|
t.Errorf("ETag kept until %v, want about %v", state.until, want)
|
|
}
|
|
req := httptest.NewRequest(http.MethodGet, condaRepodataPath, nil)
|
|
req.Header.Set("If-None-Match", etag)
|
|
if h.repodataNotModified(httptest.NewRecorder(), req, key, state.digest, state.until) {
|
|
t.Error("ETag still matched after the withheld entry left cooldown")
|
|
}
|
|
}
|
|
|
|
func TestCondaCooldownWithoutMetadataCacheStreams(t *testing.T) {
|
|
u, proxy, _, routes := condaCacheSetup(t)
|
|
proxy.CacheMetadata = false
|
|
for range 2 {
|
|
assertCondaFiltered(t, getWith(t, routes, condaRepodataPath, nil))
|
|
}
|
|
if n, _ := u.counts(condaRepodataPath); n != 2 {
|
|
t.Errorf("repodata requested %d times, want 2 without cache_metadata", n)
|
|
}
|
|
}
|
|
|
|
// A cached repodata.json is filtered as it is read from storage: the
|
|
// response starts while the stored document is still being read.
|
|
func TestCondaCooldownStreamsCachedRepodata(t *testing.T) {
|
|
u, proxy, _, routes := condaCacheSetup(t)
|
|
packages := map[string]any{}
|
|
for i := range 20000 {
|
|
packages[fmt.Sprintf("pkg%d-1.0.0-0.tar.bz2", i)] = map[string]any{
|
|
"name": fmt.Sprintf("pkg%d", i), "version": "1.0.0", "timestamp": time.Now().Add(-7 * 24 * time.Hour).UnixMilli(),
|
|
}
|
|
}
|
|
repodata, err := json.Marshal(map[string]any{"info": map[string]any{}, "packages": packages})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
u.set(condaRepodataPath, repodata)
|
|
// Stored uncompressed, so the document is larger than the part the
|
|
// storage hands out before stalling.
|
|
u.gzipped[condaRepodataPath] = false
|
|
if w := getWith(t, routes, condaRepodataPath, nil); w.Code != http.StatusOK {
|
|
t.Fatalf("populating cache: status %d", w.Code)
|
|
}
|
|
|
|
gate := &stallingStorage{Storage: proxy.Storage, head: 256 << 10, release: make(chan struct{})}
|
|
proxy.Storage = gate
|
|
var release sync.Once
|
|
t.Cleanup(func() { release.Do(func() { close(gate.release) }) })
|
|
srv := httptest.NewServer(routes)
|
|
t.Cleanup(srv.Close)
|
|
|
|
type result struct {
|
|
resp *http.Response
|
|
err error
|
|
}
|
|
started := make(chan result, 1)
|
|
go func() {
|
|
resp, err := http.Get(srv.URL + condaRepodataPath)
|
|
if err == nil {
|
|
_, err = io.ReadFull(resp.Body, make([]byte, 1024))
|
|
}
|
|
started <- result{resp, err}
|
|
}()
|
|
var res result
|
|
select {
|
|
case res = <-started:
|
|
case <-time.After(5 * time.Second):
|
|
release.Do(func() { close(gate.release) })
|
|
if res := <-started; res.resp != nil {
|
|
_ = res.resp.Body.Close()
|
|
}
|
|
t.Fatal("response did not start before the cached document was read to its end")
|
|
}
|
|
release.Do(func() { close(gate.release) })
|
|
if res.err != nil {
|
|
t.Fatal(res.err)
|
|
}
|
|
_, _ = io.Copy(io.Discard, res.resp.Body)
|
|
_ = res.resp.Body.Close()
|
|
if n, _ := u.counts(condaRepodataPath); n != 1 {
|
|
t.Errorf("repodata requested %d times, want 1", n)
|
|
}
|
|
}
|