Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 07:39:19 -05:00
pkg-proxy/internal/handler/metadata_not_modified_test.go
Simon Chrzanowski 8dcd194c39 Fix npm search routing and abbreviated packuments under cooldown
`npm search` requests /-/v1/search?text=... . The path has no "/-/"
after the leading slash is trimmed, so it fell through to the package
metadata handler: upstream got /-/v1%2Fsearch without the query
string, and the answer was cached under "-/v1/search", so every later
search was served the first one's results. Search and /-/ping (used
by `npm ping`, which lost its query string the same way) are now
relayed to upstream uncached with their query strings, like the
signing-keys endpoint.

Packuments share one metadata cache row whatever format they were
fetched in. Without cooldown the proxy asks for the abbreviated
format, which has no "time" map. After cooldown was turned on, a
still-fresh abbreviated packument kept being served from cache, and
used as the stale copy when upstream failed, so cooldown filtered
nothing and the tarball check let recent versions through. When full
metadata is required (cooldown or npm_full_metadata on), packument
lookups now pass a validator that rejects an abbreviated document
(top-level "modified", no "time"), so it counts as a cache miss, is
refetched in full and is never used as a stale fallback. Behaviour
without cooldown is unchanged.

fetchMetadataDocument takes the validate callback that
fetchOrCacheMetadata already has; other callers pass nil.
2026-10-23 13:22:49 +02:00

112 lines
3.7 KiB
Go

package handler
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/database"
)
// TestMetadataNotModifiedKeepsStoredDocument checks that a 304 from upstream
// refreshes the cache row without writing the unchanged document to storage
// again, and still serves it.
func TestMetadataNotModifiedKeepsStoredDocument(t *testing.T) {
const body = `{"v":1}`
var notModified atomic.Int32
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set(headerETag, `"v1"`)
if r.Header.Get("If-None-Match") == `"v1"` {
notModified.Add(1)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set(headerContentType, contentTypeJSON)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(upstream.Close)
proxy, db, store, _ := setupTestProxy(t)
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Nanosecond // revalidate on every request
proxy.HTTPClient = upstream.Client()
ctx := context.Background()
first := proxy.fetchMetadataDocument(ctx, "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
if first.err != nil {
t.Fatal(first.err)
}
stored, err := db.GetMetadataCache("test", "pkg")
if err != nil || stored == nil {
t.Fatalf("cache row after first fetch: %v, %v", stored, err)
}
for range 2 {
res := proxy.fetchMetadataDocument(ctx, "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
if res.err != nil || string(res.body) != body || res.digest != first.digest {
t.Fatalf("revalidated fetch = %q, digest %q, err %v; want %q, digest %q", res.body, res.digest, res.err, body, first.digest)
}
}
if n := notModified.Load(); n != 2 {
t.Fatalf("upstream answered 304 %d times, want 2", n)
}
if store.stores != 1 {
t.Errorf("document stored %d times, want once", store.stores)
}
refreshed, err := db.GetMetadataCache("test", "pkg")
if err != nil {
t.Fatal(err)
}
if !refreshed.FetchedAt.Time.After(stored.FetchedAt.Time) {
t.Errorf("fetched_at = %v, want later than %v", refreshed.FetchedAt.Time, stored.FetchedAt.Time)
}
if refreshed.ContentDigest != stored.ContentDigest || refreshed.ETag != stored.ETag {
t.Errorf("row validators changed: %+v, want %+v", refreshed, stored)
}
}
// TestMetadataNotModifiedStoresRowWithoutDigest checks that a 304 for a row
// written before digests were recorded stores the document as before, so the
// row gains its digest.
func TestMetadataNotModifiedStoresRowWithoutDigest(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-None-Match") == `"v1"` {
w.WriteHeader(http.StatusNotModified)
return
}
t.Errorf("unconditional upstream request")
}))
t.Cleanup(upstream.Close)
proxy, db, store, _ := setupTestProxy(t)
proxy.CacheMetadata = true
proxy.MetadataTTL = time.Nanosecond
proxy.HTTPClient = upstream.Client()
path := metadataStoragePath("test", "pkg")
store.files[path] = []byte(`{"v":1}`)
if err := db.UpsertMetadataCache(&database.MetadataCacheEntry{
Ecosystem: "test",
Name: "pkg",
StoragePath: path,
ETag: sql.NullString{String: `"v1"`, Valid: true},
FetchedAt: sql.NullTime{Time: time.Now().Add(-time.Hour), Valid: true},
}); err != nil {
t.Fatal(err)
}
res := proxy.fetchMetadataDocument(context.Background(), "test", "pkg", upstream.URL+"/pkg", contentTypeJSON, nil)
if res.err != nil || string(res.body) != `{"v":1}` {
t.Fatalf("fetch = %q, %v", res.body, res.err)
}
if want := "sha256:" + sha256Hex(`{"v":1}`); res.digest != want {
t.Errorf("digest = %q, want %q", res.digest, want)
}
if store.stores != 1 {
t.Errorf("document stored %d times, want once", store.stores)
}
}