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/rewrite_cache_test.go
simon chrzanowski e74e5abc79
Serve cached npm and Composer metadata gzip-compressed
- Keep a gzip copy of each rewrite cache entry when it is smaller, made once
  per rewrite and counted against metadata_rewrite_cache_size
- Serve it with Content-Encoding: gzip to clients that accept gzip, with an
  ETag of its own ("<sha256>-gzip") and Vary: Accept-Encoding
- Rewrites made outside the cache (cache disabled, cooldown on) stay
  uncompressed
- rewriteCache.get and rewrite return rewrittenMetadata
2026-10-15 12:46:55 +02:00

263 lines
8.5 KiB
Go

package handler
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"github.com/git-pkgs/cooldown"
)
// countingRewrite upper-cases its input and counts calls. If entered is set it
// is closed when the first call starts, and the call then waits on release.
type countingRewrite struct {
calls atomic.Int64
entered chan struct{}
release chan struct{}
once sync.Once
}
func (r *countingRewrite) fn(in []byte) ([]byte, error) {
r.calls.Add(1)
if r.entered != nil {
r.once.Do(func() { close(r.entered) })
<-r.release
}
return []byte(strings.ToUpper(string(in))), nil
}
func (r *countingRewrite) keeping(in []byte) ([]byte, []string, error) {
out, err := r.fn(in)
return out, nil, err
}
func TestRewriteCache_RepeatServesCachedRewrite(t *testing.T) {
c := newRewriteCache(1 << 20)
var r countingRewrite
key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte("doc"))
for range 3 {
m, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn)
if err != nil || string(m.body) != "DOC" {
t.Fatalf("rewrite = %q, %v", m.body, err)
}
}
if got := r.calls.Load(); got != 1 {
t.Errorf("rewrites = %d, want 1", got)
}
}
func TestRewriteCache_NewUpstreamBytesRewriteAgain(t *testing.T) {
c := newRewriteCache(1 << 20)
var r countingRewrite
for _, doc := range []string{"v1", "v2"} {
key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte(doc))
m, err := c.rewrite(context.Background(), key, []byte(doc), r.fn)
if err != nil || string(m.body) != strings.ToUpper(doc) {
t.Fatalf("rewrite(%s) = %q, %v", doc, m.body, err)
}
}
if got := r.calls.Load(); got != 2 {
t.Errorf("rewrites = %d, want 2", got)
}
}
// TestRewriteCache_ConcurrentRequestsShareRewrite asserts that requests
// arriving while a document is being rewritten wait for that rewrite instead
// of running their own. Callers that arrive after it finishes hit the cache,
// so the count holds however the goroutines are scheduled.
func TestRewriteCache_ConcurrentRequestsShareRewrite(t *testing.T) {
c := newRewriteCache(1 << 20)
r := countingRewrite{entered: make(chan struct{}), release: make(chan struct{})}
key := rewriteCacheKey("npm", "http://proxy", "typescript", []byte("doc"))
const n = 10
outs := make(chan string, n)
run := func() {
m, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn)
if err != nil {
t.Error(err)
}
outs <- string(m.body)
}
go run()
<-r.entered
for range n - 1 {
go run()
}
close(r.release)
for range n {
if out := <-outs; out != "DOC" {
t.Errorf("out = %q", out)
}
}
if got := r.calls.Load(); got != 1 {
t.Errorf("rewrites = %d, want 1", got)
}
}
// TestRewriteCache_WaiterLeavingKeepsRewrite asserts that a caller waiting on
// another's rewrite returns its own context error when its client leaves,
// while the rewrite completes and is cached for the next request.
func TestRewriteCache_WaiterLeavingKeepsRewrite(t *testing.T) {
c := newRewriteCache(1 << 20)
r := countingRewrite{entered: make(chan struct{}), release: make(chan struct{})}
key := rewriteCacheKey("npm", "http://proxy", "typescript", []byte("doc"))
first := make(chan error, 1)
go func() {
_, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn)
first <- err
}()
<-r.entered
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := c.rewrite(ctx, key, []byte("doc"), r.fn); !errors.Is(err, context.Canceled) {
t.Errorf("waiter err = %v, want context.Canceled", err)
}
close(r.release)
if err := <-first; err != nil {
t.Fatal(err)
}
if m, err := c.rewrite(context.Background(), key, []byte("doc"), r.fn); err != nil || string(m.body) != "DOC" {
t.Errorf("cached rewrite = %q, %v", m.body, err)
}
if got := r.calls.Load(); got != 1 {
t.Errorf("rewrites = %d, want 1", got)
}
}
func TestRewriteCache_EvictsLeastRecentlyUsed(t *testing.T) {
c := newRewriteCache(6) // room for two three-byte outputs
var r countingRewrite
keyFor := func(doc string) string { return rewriteCacheKey("npm", "http://proxy", doc, []byte(doc)) }
for _, doc := range []string{"aaa", "bbb"} {
_, _ = c.rewrite(context.Background(), keyFor(doc), []byte(doc), r.fn)
}
_, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), r.fn) // aaa is now the most recent
_, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), r.fn) // evicts bbb
before := r.calls.Load()
_, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), r.fn)
_, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), r.fn)
if got := r.calls.Load() - before; got != 0 {
t.Errorf("aaa and ccc rewritten %d times, want both still cached", got)
}
_, _ = c.rewrite(context.Background(), keyFor("bbb"), []byte("bbb"), r.fn)
if got := r.calls.Load() - before; got != 1 {
t.Errorf("bbb should have been evicted and rewritten")
}
}
func TestRewriteCache_OutputLargerThanCacheIsNotStored(t *testing.T) {
c := newRewriteCache(2)
var r countingRewrite
key := rewriteCacheKey("npm", "http://proxy", "big", []byte("big"))
_, _ = c.rewrite(context.Background(), key, []byte("big"), r.fn)
if c.len() != 0 {
t.Errorf("cached %d entries, want 0", c.len())
}
}
func TestRewriteCache_ErrorsAreNotCached(t *testing.T) {
c := newRewriteCache(1 << 20)
var calls int
failing := func([]byte) ([]byte, error) { calls++; return nil, errors.New("bad document") }
key := rewriteCacheKey("npm", "http://proxy", "broken", []byte("doc"))
for range 2 {
if _, err := c.rewrite(context.Background(), key, []byte("doc"), failing); err == nil {
t.Fatal("expected the rewrite error")
}
}
if calls != 2 {
t.Errorf("rewrites = %d, want 2", calls)
}
}
func TestCachedRewrite_DisabledRewritesEveryTime(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.SetMetadataRewriteCacheSize(0)
var r countingRewrite
for range 2 {
_, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
}
if got := r.calls.Load(); got != 2 {
t.Errorf("rewrites = %d, want 2", got)
}
}
// TestCachedRewrite_CooldownBypassesCache asserts that with cooldown on every
// request is rewritten: cooldown filtering depends on the current time, so a
// cached rewrite could keep hiding a version after its cooldown ends.
func TestCachedRewrite_CooldownBypassesCache(t *testing.T) {
proxy, _, _, _ := setupTestProxy(t)
proxy.SetMetadataRewriteCacheSize(1 << 20)
proxy.Cooldown = &cooldown.Config{Default: "3d"}
var r countingRewrite
for range 2 {
_, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping)
}
if got := r.calls.Load(); got != 2 {
t.Errorf("rewrites = %d, want 2", got)
}
}
// TestMetadataHandlers_ServeCachedRewrite asserts that repeated requests for
// the same npm or Composer metadata reuse one rewrite and return the same
// bytes, with the download URLs pointing at the proxy.
func TestMetadataHandlers_ServeCachedRewrite(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/left-pad":
_, _ = w.Write([]byte(`{"name":"left-pad","versions":{"1.3.0":{"dist":{"tarball":"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz"}}}}`))
case "/p2/vendor/pkg.json":
_, _ = w.Write([]byte(`{"packages":{"vendor/pkg":[{"version":"1.0.0","dist":{"type":"zip","url":"https://example.com/pkg-1.0.0.zip"}}]}}`))
default:
http.NotFound(w, r)
}
}))
defer upstream.Close()
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
proxy.SetMetadataRewriteCacheSize(1 << 20)
cases := []struct {
name string
handler http.Handler
path string
}{
{"npm", NewNPMHandler(proxy, "http://proxy.example", upstream.URL).Routes(), "/left-pad"},
{"composer", NewComposerHandlerWithUpstreams(proxy, "http://proxy.example", upstream.URL, upstream.URL).Routes(), "/p2/vendor/pkg.json"},
}
for i, c := range cases {
var bodies []string
for range 2 {
rec := httptest.NewRecorder()
c.handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, c.path, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d", c.name, rec.Code)
}
bodies = append(bodies, rec.Body.String())
}
if bodies[0] != bodies[1] || !strings.Contains(bodies[0], "http://proxy.example") {
t.Errorf("%s: bodies %q and %q", c.name, bodies[0], bodies[1])
}
if got := proxy.rewrites.len(); got != i+1 {
t.Errorf("%s: cached rewrites = %d, want %d", c.name, got, i+1)
}
}
}