Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/head_cache_hit_test.go
Simon Chrzanowski 23f48c5563 Answer HEAD on a cached artifact without opening it
A HEAD on a cache hit opened the stored object and closed it unread.
On S3 and Azure that is a GET whose body is thrown away, which can also
drop the pooled connection; on GCS it is a GET as well.

The server now marks HEAD requests in the request context, and readStored
answers a marked cache hit from the database record. Size, content type
and ETag already came from the record. Accept-Ranges depended on the
opened reader being seekable, so backends now say whether their readers
are (Blob does, GCS does not) and the HEAD response matches the GET.

A HEAD still counts as a cache hit, as before. DirectServe redirects are
unchanged since they are decided before the object would be opened.
2026-10-25 17:27:05 +01:00

88 lines
3 KiB
Go

package handler
import (
"context"
"fmt"
"maps"
"net/http"
"net/http/httptest"
"testing"
"github.com/git-pkgs/purl"
)
// A HEAD on a cache hit is answered from the artifact's database record. It
// must not open the stored object (a GET on S3 or GCS), and its headers must
// be the ones the GET sends.
func TestHeadCacheHitSkipsStorageOpen(t *testing.T) {
const digest = "sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"
for _, seekable := range []bool{false, true} {
t.Run(fmt.Sprintf("seekable=%t", seekable), func(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
store.seekable = seekable
seedPackage(t, db, store, "oci", "library/nginx", digest, digest, "cached blob")
h := &ContainerHandler{
proxy: proxy,
registryURL: "https://registry.invalid",
proxyURL: "http://localhost:8080",
}
target := "/library/nginx/blobs/" + digest
get := httptest.NewRecorder()
h.Routes().ServeHTTP(get, httptest.NewRequest(http.MethodGet, target, nil))
if get.Code != http.StatusOK || get.Body.String() != "cached blob" {
t.Fatalf("GET: status %d, body %q", get.Code, get.Body.String())
}
opensAfterGet := store.opens
req := httptest.NewRequest(http.MethodHead, target, nil)
req = req.WithContext(ServingHead(req.Context()))
head := httptest.NewRecorder()
h.Routes().ServeHTTP(head, req)
if head.Code != http.StatusOK {
t.Fatalf("HEAD: status %d, body %q", head.Code, head.Body.String())
}
if got := store.opens - opensAfterGet; got != 0 {
t.Errorf("HEAD opened the stored object %d times, want 0", got)
}
if head.Body.Len() != 0 {
t.Errorf("HEAD body length = %d, want 0", head.Body.Len())
}
if !maps.EqualFunc(get.Header(), head.Header(), func(a, b []string) bool {
return fmt.Sprint(a) == fmt.Sprint(b)
}) {
t.Errorf("HEAD headers differ from GET:\n GET %v\n HEAD %v", get.Header(), head.Header())
}
if got := get.Header().Get("Accept-Ranges") == "bytes"; got != seekable {
t.Errorf("GET Accept-Ranges set = %t, want %t", got, seekable)
}
if fetcher.fetchCalled {
t.Error("fetcher called on a cache hit")
}
// A HEAD still counts as a hit, as it did when it opened the object.
art, err := db.GetArtifact(purl.MakePURLString("oci", "library/nginx", digest), digest)
if err != nil || art == nil {
t.Fatalf("artifact record: %v", err)
}
if art.HitCount != 2 {
t.Errorf("hit count = %d, want 2 (GET and HEAD)", art.HitCount)
}
})
}
}
// A request that is not marked as HEAD reads the object as before.
func TestCacheHitWithoutHeadMarkOpensObject(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "demo", "1.0.0", "demo.tgz", "content")
result, err := proxy.GetCachedArtifact(context.Background(), "npm", "demo", "1.0.0", "demo.tgz")
if err != nil || result == nil || result.Reader == nil {
t.Fatalf("result=%+v err=%v; want an open reader", result, err)
}
_ = result.Reader.Close()
if store.opens != 1 {
t.Errorf("opens = %d, want 1", store.opens)
}
}