Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-11 00:29:15 -05:00
pkg-proxy/internal/handler/handler_test.go
Simon Chrzanowski b131db394d Isolate the recent failure history in tests
The history is process-wide, and tests cleared it only after running, and
not all of them. Records from earlier tests or an earlier -count repetition
reached later assertions: TestRecordUpstreamOperations left a record for
TestRecentFailuresNewestFirst, and the server tests never cleared it.

- Export ResetRecentFailures so tests outside the metrics package can use it
- Clear the history before and after every test that records or reads
  failures directly, in metrics, server and handler
- Assert exactly one failure in the handler tests instead of looking only
  at the newest
2026-10-12 18:44:12 +02:00

1519 lines
51 KiB
Go

package handler
import (
"bytes"
"context"
"crypto/sha256"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/git-pkgs/artifacts"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/proxy/internal/metrics"
"github.com/git-pkgs/proxy/internal/storage"
"github.com/git-pkgs/purl"
"github.com/git-pkgs/registries/fetch"
"github.com/opencontainers/go-digest"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
dto "github.com/prometheus/client_model/go"
)
// mockStorage implements storage.Storage for testing.
type mockStorage struct {
mu sync.Mutex
files map[string][]byte
storeErr error
openErr error
signedURL string
signErr error
seekable bool
}
func newMockStorage() *mockStorage {
return &mockStorage{files: make(map[string][]byte)}
}
func (s *mockStorage) Store(_ context.Context, path string, r io.Reader) (int64, string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.storeErr != nil {
return 0, "", s.storeErr
}
data, err := io.ReadAll(r)
if err != nil {
return 0, "", err
}
s.files[path] = data
return int64(len(data)), sha256Hex(string(data)), nil
}
func (s *mockStorage) Open(_ context.Context, path string) (io.ReadCloser, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.openErr != nil {
return nil, s.openErr
}
data, ok := s.files[path]
if !ok {
return nil, storage.ErrNotFound
}
if s.seekable {
return &mockSeekableReadCloser{Reader: bytes.NewReader(data)}, nil
}
return io.NopCloser(bytes.NewReader(data)), nil
}
type mockSeekableReadCloser struct {
*bytes.Reader
}
func (r *mockSeekableReadCloser) Close() error { return nil }
func (s *mockStorage) Exists(_ context.Context, path string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.files[path]
return ok, nil
}
func (s *mockStorage) Delete(ctx context.Context, path string) error {
// Real backends (S3/GCS SDKs) fail fast on an already-cancelled
// context; mirror that here so tests can catch cleanup calls that
// forgot to detach from a cancelled client context.
if err := ctx.Err(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
delete(s.files, path)
return nil
}
func (s *mockStorage) Size(_ context.Context, path string) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, ok := s.files[path]
if !ok {
return 0, storage.ErrNotFound
}
return int64(len(data)), nil
}
func (s *mockStorage) UsedSpace(_ context.Context) (int64, error) {
s.mu.Lock()
defer s.mu.Unlock()
var total int64
for _, data := range s.files {
total += int64(len(data))
}
return total, nil
}
func (s *mockStorage) SignedURL(_ context.Context, _ string, _ time.Duration) (string, error) {
if s.signErr != nil {
return "", s.signErr
}
if s.signedURL == "" {
return "", storage.ErrSignedURLUnsupported
}
return s.signedURL, nil
}
func (s *mockStorage) URL() string { return "mem://" }
func (s *mockStorage) Close() error { return nil }
// mockFetcher implements fetch.FetcherInterface for testing. Recording is
// locked because coalescing tests call the handler from many goroutines; tests
// read the recorded fields only after those calls have returned.
type mockFetcher struct {
artifact *fetch.Artifact
fetchErr error
fetchErrByURL map[string]error
mu sync.Mutex
fetchCalled bool
fetchedURL string
fetchedHeader http.Header
}
func (f *mockFetcher) Fetch(ctx context.Context, url string) (*fetch.Artifact, error) {
return f.FetchWithHeaders(ctx, url, nil)
}
func (f *mockFetcher) FetchWithHeaders(_ context.Context, url string, headers http.Header) (*fetch.Artifact, error) {
f.mu.Lock()
f.fetchCalled = true
f.fetchedURL = url
f.fetchedHeader = headers.Clone()
f.mu.Unlock()
if f.fetchErrByURL != nil {
if err, ok := f.fetchErrByURL[url]; ok {
return nil, err
}
}
if f.fetchErr != nil {
return nil, f.fetchErr
}
return f.artifact, nil
}
func (f *mockFetcher) Head(_ context.Context, _ string) (int64, string, error) {
return 0, "", nil
}
// setupTestProxy creates a Proxy with a real DB (SQLite in temp dir) and mock storage/fetcher.
func setupTestProxy(t testing.TB) (*Proxy, *database.DB, *mockStorage, *mockFetcher) {
t.Helper()
dir := t.TempDir()
db, err := database.Create(dir + "/test.db")
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
store := newMockStorage()
fetcher := &mockFetcher{}
resolver := fetch.NewResolver()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
proxy := NewProxy(db, store, fetcher, resolver, logger)
return proxy, db, store, fetcher
}
func histogramSampleCount(t testing.TB, observer prometheus.Observer) uint64 {
t.Helper()
metric, ok := observer.(prometheus.Metric)
if !ok {
t.Fatal("observer does not implement prometheus.Metric")
}
value := &dto.Metric{}
if err := metric.Write(value); err != nil {
t.Fatalf("writing Prometheus metric: %v", err)
}
return value.GetHistogram().GetSampleCount()
}
func testArtifact(content, packageURL, filename, mediaType string) artifacts.Artifact {
return artifacts.Artifact{
PURL: packageURL,
Digest: digest.Digest("sha256:" + sha256Hex(content)),
Size: int64(len(content)),
Filename: filename,
MediaType: mediaType,
}
}
// seedPackage creates a package, version, and cached artifact in the test DB and storage.
func seedPackage(t testing.TB, db *database.DB, store *mockStorage, ecosystem, name, version, filename, content string) {
t.Helper()
pkg := &database.Package{
PURL: purl.MakePURLString(ecosystem, name, ""),
Ecosystem: ecosystem,
Name: name,
}
if err := db.UpsertPackage(pkg); err != nil {
t.Fatalf("failed to upsert package: %v", err)
}
versionPURL := purl.MakePURLString(ecosystem, name, version)
ver := &database.Version{
PURL: versionPURL,
PackagePURL: pkg.PURL,
}
if err := db.UpsertVersion(ver); err != nil {
t.Fatalf("failed to upsert version: %v", err)
}
storagePath := storage.ArtifactPath(ecosystem, "", name, version, filename)
store.files[storagePath] = []byte(content)
sharedArtifact := testArtifact(content, versionPURL, filename, "application/octet-stream")
art := &database.Artifact{
VersionPURL: versionPURL,
Filename: filename,
UpstreamURL: "https://example.com/" + filename,
StoragePath: sql.NullString{String: storagePath, Valid: true},
ContentHash: sql.NullString{String: sharedArtifact.Digest.Encoded(), Valid: true},
Size: sql.NullInt64{Int64: int64(len(content)), Valid: true},
ContentType: sql.NullString{String: "application/octet-stream", Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
}
if err := db.UpsertArtifact(art); err != nil {
t.Fatalf("failed to upsert artifact: %v", err)
}
}
// recordedStoragePath returns the storage path the artifact's record points at.
func recordedStoragePath(t testing.TB, db *database.DB, versionPURL, filename string) string {
t.Helper()
art, err := db.GetArtifact(versionPURL, filename)
if err != nil || art == nil || !art.StoragePath.Valid {
t.Fatalf("no storage path recorded for %s %s (err %v)", versionPURL, filename, err)
}
return art.StoragePath.String
}
// pathParseCase holds a single test case for path parsing functions that return
// (name, version, arch).
type pathParseCase struct {
path string
wantName string
wantVersion string
wantArch string
}
// assertPathParser runs table-driven tests for a path parser function that returns
// three strings (name, version, arch).
func assertPathParser(t *testing.T, funcName string, parse func(string) (string, string, string), cases []pathParseCase) {
t.Helper()
for _, tt := range cases {
t.Run(tt.path, func(t *testing.T) {
name, version, arch := parse(tt.path)
if name != tt.wantName {
t.Errorf("%s() name = %q, want %q", funcName, name, tt.wantName)
}
if version != tt.wantVersion {
t.Errorf("%s() version = %q, want %q", funcName, version, tt.wantVersion)
}
if arch != tt.wantArch {
t.Errorf("%s() arch = %q, want %q", funcName, arch, tt.wantArch)
}
})
}
}
// assertRoutesBasics checks that a handler's Routes() returns a non-nil handler,
// rejects POST requests with 405, and rejects path traversal with 400.
func assertRoutesBasics(t *testing.T, handler http.Handler, postPath, traversalPath string) {
t.Helper()
if handler == nil {
t.Fatal("Routes() returned nil")
}
req := httptest.NewRequest(http.MethodPost, postPath, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("POST request: got status %d, want %d", w.Code, http.StatusMethodNotAllowed)
}
req = httptest.NewRequest(http.MethodGet, traversalPath, nil)
w = httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("path traversal: got status %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestGetOrFetchArtifact_CacheHit(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if !result.Cached {
t.Error("expected result to be cached")
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
body, _ := io.ReadAll(result.Reader)
if string(body) != "cached content" {
t.Errorf("got body %q, want %q", body, "cached content")
}
if result.Artifact.MediaType != "application/octet-stream" {
t.Errorf("got content type %q, want %q", result.Artifact.MediaType, "application/octet-stream")
}
if result.Artifact.Digest.Encoded() != sha256Hex("cached content") {
t.Errorf("got digest %q, want %q", result.Artifact.Digest.Encoded(), sha256Hex("cached content"))
}
}
func TestGetCachedArtifactRejectsMalformedIntegrityMetadata(t *testing.T) {
tests := []struct {
name string
malformedHash string
malformedIntegrity string
}{
{name: "content hash", malformedHash: "abc123"},
{name: "native integrity", malformedIntegrity: "sha512-abc123"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assertMalformedCacheRejected(t, test.malformedHash, test.malformedIntegrity)
})
}
}
func assertMalformedCacheRejected(t *testing.T, malformedHash, malformedIntegrity string) {
t.Helper()
proxy, db, store, _ := setupTestProxy(t)
const (
packageName = "broken"
version = "1.0.0"
filename = "broken-1.0.0.tgz"
)
seedPackage(t, db, store, "npm", packageName, version, filename, "cached content")
versionPURL := purl.MakePURLString("npm", packageName, version)
if malformedHash != "" {
artifact, err := db.GetArtifact(versionPURL, filename)
if err != nil {
t.Fatal(err)
}
artifact.ContentHash = sql.NullString{String: malformedHash, Valid: true}
if err := db.UpsertArtifact(artifact); err != nil {
t.Fatal(err)
}
}
if malformedIntegrity != "" {
versionRecord := &database.Version{
PURL: versionPURL,
PackagePURL: purl.MakePURLString("npm", packageName, ""),
Integrity: sql.NullString{String: malformedIntegrity, Valid: true},
}
if err := db.UpsertVersion(versionRecord); err != nil {
t.Fatal(err)
}
}
proxy.DirectServe = true
store.signedURL = "https://cache.example/broken"
result, err := proxy.GetCachedArtifact(context.Background(), "npm", packageName, version, filename)
if err != nil {
t.Fatalf("GetCachedArtifact: %v", err)
}
if result != nil {
t.Errorf("GetCachedArtifact = %+v, want nil", result)
}
artifact, err := db.GetArtifact(versionPURL, filename)
if err != nil {
t.Fatal(err)
}
if artifact.StoragePath.Valid {
t.Error("unusable cache record retained its storage path")
}
assertQueuedForDeletion(t, db, storage.ArtifactPath("npm", "", packageName, version, filename))
}
// TestCorruptCachedArtifactIsQueuedForDeletion streams cached bytes that no
// longer match their recorded digest, which clears the record once the stream
// ends. With each fetch writing its own path, no later fetch overwrites them,
// so they must be queued for deletion.
func TestCorruptCachedArtifactIsQueuedForDeletion(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "corrupt", "1.0.0", "corrupt-1.0.0.tgz", "cached content")
storagePath := storage.ArtifactPath("npm", "", "corrupt", "1.0.0", "corrupt-1.0.0.tgz")
store.files[storagePath] = []byte("tampered bytes")
result, err := proxy.GetCachedArtifact(context.Background(), "npm", "corrupt", "1.0.0", "corrupt-1.0.0.tgz")
if err != nil || result == nil {
t.Fatalf("GetCachedArtifact = %v, %v", result, err)
}
drain(result)
artifact, err := db.GetArtifact("pkg:npm/corrupt@1.0.0", "corrupt-1.0.0.tgz")
if err != nil || artifact == nil || artifact.StoragePath.Valid {
t.Errorf("record = %+v (err %v), want it cleared", artifact, err)
}
assertQueuedForDeletion(t, db, storagePath)
}
// assertQueuedForDeletion fails unless path waits in the pending delete queue.
func assertQueuedForDeletion(t *testing.T, db *database.DB, path string) {
t.Helper()
queued, err := db.GetDuePendingDeletes(time.Now().Add(time.Hour), 100)
if err != nil || !slices.Contains(queued, path) {
t.Errorf("queued %v (err %v), want %q queued for deletion", queued, err, path)
}
}
func TestGetOrFetchArtifact_CacheMiss_NoPackage(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("npm"))
// The resolver will fail because "nonexistent" isn't a real package,
// but we're testing that it tries to fetch (doesn't return from cache).
fetcher.fetchErr = errors.New("upstream unavailable")
_, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "nonexistent", "1.0.0", "nonexistent-1.0.0.tgz")
if err == nil {
t.Fatal("expected error for uncached package")
}
missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("npm"))
if diff := missesAfter - missesBefore; diff != 1 {
t.Errorf("cache misses delta = %.0f, want 1", diff)
}
}
func TestGetOrFetchArtifactFromURL_CacheMiss_StorageMissing(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
// Seed DB but don't put the file in storage
pkg := &database.Package{PURL: "pkg:npm/missing", Ecosystem: "npm", Name: "missing"}
_ = db.UpsertPackage(pkg)
ver := &database.Version{PURL: "pkg:npm/missing@1.0.0", PackagePURL: pkg.PURL}
_ = db.UpsertVersion(ver)
art := &database.Artifact{
VersionPURL: ver.PURL,
Filename: "missing-1.0.0.tgz",
UpstreamURL: "https://example.com/missing.tgz",
StoragePath: sql.NullString{String: "nonexistent/path.tgz", Valid: true},
ContentHash: sql.NullString{String: sha256Hex("missing content"), Valid: true},
Size: sql.NullInt64{Int64: 100, Valid: true},
ContentType: sql.NullString{String: "application/octet-stream", Valid: true},
FetchedAt: sql.NullTime{Time: time.Now(), Valid: true},
}
_ = db.UpsertArtifact(art)
// Storage doesn't have the file, so checkCache should return nil and trigger a refetch.
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("refetched content")),
ContentType: "application/gzip",
}
result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "npm", "missing", "1.0.0", "missing-1.0.0.tgz", "https://example.com/missing.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.Cached {
t.Error("expected cache miss (storage file was missing)")
}
if !fetcher.fetchCalled {
t.Error("fetcher should be called when storage file is missing")
}
// Verify the new content was stored
storagePath := recordedStoragePath(t, db, "pkg:npm/missing@1.0.0", "missing-1.0.0.tgz")
if _, ok := store.files[storagePath]; !ok {
t.Error("refetched artifact should be stored")
}
}
func TestArtifactCacheRejectsUnsupportedPackageIdentity(t *testing.T) {
proxy, _, _, fetcher := setupTestProxy(t)
_, err := proxy.GetCachedArtifact(
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
)
if !errors.Is(err, errUnsupportedPackageIdentity) {
t.Fatalf("GetCachedArtifact() error = %v, want unsupported package identity", err)
}
_, err = proxy.GetOrFetchArtifactFromURL(
context.Background(), "swift", "apple/example", "1.2.3", "example-1.2.3.zip",
"https://registry.example/apple/example/1.2.3.zip",
)
if !errors.Is(err, errUnsupportedPackageIdentity) {
t.Fatalf("GetOrFetchArtifactFromURL() error = %v, want unsupported package identity", err)
}
if fetcher.fetchCalled {
t.Error("unsupported package identity reached the artifact fetcher")
}
}
func TestGetOrFetchArtifact_DirectServe_Redirect(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
proxy.DirectServeTTL = 15 * time.Minute
store.signedURL = "https://bucket.s3.amazonaws.com/npm/lodash?X-Amz-Signature=abc"
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Cached {
t.Error("expected result to be cached")
}
if result.RedirectURL != store.signedURL {
t.Errorf("RedirectURL = %q, want %q", result.RedirectURL, store.signedURL)
}
if result.Reader != nil {
t.Error("Reader should be nil when redirecting")
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
// Hit count should still be recorded on the redirect path.
art, _ := db.GetArtifact("pkg:npm/lodash@4.17.21", "lodash-4.17.21.tgz")
if art == nil || art.HitCount != 1 {
t.Errorf("artifact hit count not recorded: %+v", art)
}
}
func TestGetOrFetchArtifact_DirectServe_BaseURLRewrite(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
proxy.DirectServeBaseURL = "https://cdn.example.com"
store.signedURL = "http://127.0.0.1:9000/bucket/npm/lodash?X-Amz-Signature=abc&X-Amz-Expires=900"
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "https://cdn.example.com/bucket/npm/lodash?X-Amz-Signature=abc&X-Amz-Expires=900"
if result.RedirectURL != want {
t.Errorf("RedirectURL = %q, want %q", result.RedirectURL, want)
}
}
func TestRewriteSignedURLHost(t *testing.T) {
tests := []struct {
name string
signed string
baseURL string
want string
}{
{
"empty base url is no-op",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"",
"http://127.0.0.1:9000/bucket/key?sig=abc",
},
{
"replaces scheme and host",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"https://cdn.example.com",
"https://cdn.example.com/bucket/key?sig=abc",
},
{
"preserves path and query",
"http://minio:9000/bucket/npm/lodash/4.17.21/lodash.tgz?X-Amz-Signature=abc&X-Amz-Date=20260101",
"https://files.example.com",
"https://files.example.com/bucket/npm/lodash/4.17.21/lodash.tgz?X-Amz-Signature=abc&X-Amz-Date=20260101",
},
{
"ignores base url path",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"https://cdn.example.com/ignored",
"https://cdn.example.com/bucket/key?sig=abc",
},
{
"invalid base url is no-op",
"http://127.0.0.1:9000/bucket/key?sig=abc",
"://bad",
"http://127.0.0.1:9000/bucket/key?sig=abc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := rewriteSignedURLHost(tt.signed, tt.baseURL)
if got != tt.want {
t.Errorf("rewriteSignedURLHost(%q, %q) = %q, want %q", tt.signed, tt.baseURL, got, tt.want)
}
})
}
}
func TestGetOrFetchArtifact_DirectServe_FallbackOnUnsupported(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
// store.signedURL is empty so SignedURL returns ErrSignedURLUnsupported.
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.RedirectURL != "" {
t.Errorf("RedirectURL should be empty, got %q", result.RedirectURL)
}
if result.Reader == nil {
t.Fatal("Reader should be set when signing is unsupported")
}
body, _ := io.ReadAll(result.Reader)
if string(body) != "cached content" {
t.Errorf("got body %q, want %q", body, "cached content")
}
}
func TestGetOrFetchArtifact_DirectServe_FallbackOnError(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = true
store.signErr = errors.New("signing failed")
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.RedirectURL != "" {
t.Errorf("RedirectURL should be empty on signing error, got %q", result.RedirectURL)
}
if result.Reader == nil {
t.Fatal("Reader should be set when signing fails")
}
}
func TestGetOrFetchArtifact_DirectServe_DisabledIgnoresSigning(t *testing.T) {
proxy, db, store, _ := setupTestProxy(t)
seedPackage(t, db, store, "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz", "cached content")
proxy.DirectServe = false
store.signedURL = "https://bucket.example/should-not-be-used"
result, err := proxy.GetOrFetchArtifact(context.Background(), "npm", "lodash", "4.17.21", "lodash-4.17.21.tgz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.RedirectURL != "" {
t.Errorf("RedirectURL should be empty when DirectServe is off, got %q", result.RedirectURL)
}
}
func TestServeArtifact_Redirect(t *testing.T) {
w := httptest.NewRecorder()
ServeArtifact(w, &CacheResult{
RedirectURL: "https://bucket.s3.amazonaws.com/file?sig=abc",
Artifact: artifacts.Artifact{
Digest: digest.Digest("sha256:" + strings.Repeat("a", sha256.Size*2)),
},
Cached: true,
})
if w.Code != http.StatusFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusFound)
}
if loc := w.Header().Get("Location"); loc != "https://bucket.s3.amazonaws.com/file?sig=abc" {
t.Errorf("Location = %q", loc)
}
if etag := w.Header().Get("ETag"); etag != `"`+strings.Repeat("a", sha256.Size*2)+`"` {
t.Errorf("ETag = %q", etag)
}
if cl := w.Header().Get("Content-Length"); cl != "" {
t.Errorf("Content-Length should not be set on redirect, got %q", cl)
}
}
func TestServeArtifact_Stream(t *testing.T) {
w := httptest.NewRecorder()
ServeArtifact(w, &CacheResult{
Reader: io.NopCloser(strings.NewReader("payload")),
Artifact: testArtifact(
"payload",
"pkg:npm/example@1.0.0",
"example.tgz",
"application/octet-stream",
),
})
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Body.String() != "payload" {
t.Errorf("body = %q, want %q", w.Body.String(), "payload")
}
if ct := w.Header().Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q", ct)
}
}
func TestServeArtifactRequestRanges(t *testing.T) {
const payload = "hello world"
tests := []struct {
name string
rangeHeader string
secondRange string
wantStatus int
wantRange string
wantLength string
wantBody string
}{
{name: "bounded", rangeHeader: "bytes=1-3", wantStatus: http.StatusPartialContent, wantRange: "bytes 1-3/11", wantLength: "3", wantBody: "ell"},
{name: "open ended", rangeHeader: "bytes=6-", wantStatus: http.StatusPartialContent, wantRange: "bytes 6-10/11", wantLength: "5", wantBody: "world"},
{name: "suffix", rangeHeader: "bytes=-4", wantStatus: http.StatusPartialContent, wantRange: "bytes 7-10/11", wantLength: "4", wantBody: "orld"},
{name: "suffix larger than artifact", rangeHeader: "bytes=-99", wantStatus: http.StatusPartialContent, wantRange: "bytes 0-10/11", wantLength: "11", wantBody: payload},
{name: "unsatisfiable", rangeHeader: "bytes=11-", wantStatus: http.StatusRequestedRangeNotSatisfiable, wantRange: "bytes */11", wantLength: "0"},
{name: "malformed", rangeHeader: "bytes=invalid", wantStatus: http.StatusOK, wantLength: "11", wantBody: payload},
{name: "reversed", rangeHeader: "bytes=4-2", wantStatus: http.StatusOK, wantLength: "11", wantBody: payload},
{name: "multiple ranges", rangeHeader: "bytes=0-1,4-5", wantStatus: http.StatusOK, wantLength: "11", wantBody: payload},
{name: "multiple range fields", rangeHeader: "bytes=0-1", secondRange: "bytes=4-5", wantStatus: http.StatusOK, wantLength: "11", wantBody: payload},
{name: "no range", wantStatus: http.StatusOK, wantLength: "11", wantBody: payload},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/artifact", nil)
if test.rangeHeader != "" {
request.Header.Set("Range", test.rangeHeader)
}
if test.secondRange != "" {
request.Header.Add("Range", test.secondRange)
}
w := httptest.NewRecorder()
ServeArtifactRequest(w, request, newRangeTestResult(payload))
if w.Code != test.wantStatus {
t.Errorf("status = %d, want %d", w.Code, test.wantStatus)
}
if got := w.Header().Get("Content-Range"); got != test.wantRange {
t.Errorf("Content-Range = %q, want %q", got, test.wantRange)
}
if got := w.Header().Get(headerContentLength); got != test.wantLength {
t.Errorf("Content-Length = %q, want %q", got, test.wantLength)
}
if got := w.Body.String(); got != test.wantBody {
t.Errorf("body = %q, want %q", got, test.wantBody)
}
if got := w.Header().Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want bytes", got)
}
})
}
}
func TestServeArtifactRequestIfRange(t *testing.T) {
const payload = "hello world"
tests := []struct {
name string
ifRange string
wantStatus int
wantRange string
wantBody string
}{
{name: "matching etag", ifRange: `"sha256-matching"`, wantStatus: http.StatusPartialContent, wantRange: "bytes 0-4/11", wantBody: "hello"},
{name: "mismatching etag", ifRange: `"sha256-stale"`, wantStatus: http.StatusOK, wantBody: payload},
{name: "weak etag", ifRange: `W/"sha256-matching"`, wantStatus: http.StatusOK, wantBody: payload},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result := newRangeTestResult(payload)
request := httptest.NewRequest(http.MethodGet, "/artifact", nil)
request.Header.Set("Range", "bytes=0-4")
if test.name == "matching etag" {
test.ifRange = `"` + result.Artifact.Digest.Encoded() + `"`
}
request.Header.Set("If-Range", test.ifRange)
w := httptest.NewRecorder()
ServeArtifactRequest(w, request, result)
if w.Code != test.wantStatus {
t.Errorf("status = %d, want %d", w.Code, test.wantStatus)
}
if got := w.Header().Get("Content-Range"); got != test.wantRange {
t.Errorf("Content-Range = %q, want %q", got, test.wantRange)
}
if got := w.Body.String(); got != test.wantBody {
t.Errorf("body = %q, want %q", got, test.wantBody)
}
})
}
}
func TestServeArtifactRequestHeadIgnoresRange(t *testing.T) {
request := httptest.NewRequest(http.MethodHead, "/artifact", nil)
request.Header.Set("Range", "bytes=0-1")
w := httptest.NewRecorder()
ServeArtifactRequest(w, request, newRangeTestResult("hello world"))
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if got := w.Header().Get(headerContentLength); got != "11" {
t.Errorf("Content-Length = %q, want 11", got)
}
if got := w.Header().Get("Content-Range"); got != "" {
t.Errorf("Content-Range = %q, want empty", got)
}
if w.Body.Len() != 0 {
t.Errorf("HEAD body = %q, want empty", w.Body.String())
}
}
func TestServeArtifactRequestWithoutSeekCapability(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/artifact", nil)
request.Header.Set("Range", "bytes=1-2")
w := httptest.NewRecorder()
ServeArtifactRequest(w, request, &CacheResult{
Reader: io.NopCloser(strings.NewReader("payload")),
Artifact: testArtifact("payload", "pkg:npm/example@1.0.0", "example.tgz", "application/gzip"),
})
if w.Code != http.StatusOK || w.Body.String() != "payload" {
t.Errorf("response = %d %q, want 200 with full payload", w.Code, w.Body.String())
}
if got := w.Header().Get("Accept-Ranges"); got != "" {
t.Errorf("Accept-Ranges = %q, want empty", got)
}
}
func TestServeArtifactRequestRedirectDoesNotAdvertiseRanges(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, "/artifact", nil)
request.Header.Set("Range", "bytes=1-2")
w := httptest.NewRecorder()
ServeArtifactRequest(w, request, &CacheResult{RedirectURL: "https://storage.example/artifact"})
if w.Code != http.StatusFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusFound)
}
if got := w.Header().Get("Accept-Ranges"); got != "" {
t.Errorf("Accept-Ranges = %q, want empty", got)
}
}
type rangeTestReadSeeker struct {
*bytes.Reader
}
func (r *rangeTestReadSeeker) Close() error { return nil }
func newRangeTestResult(payload string) *CacheResult {
return &CacheResult{
Reader: &rangeTestReadSeeker{Reader: bytes.NewReader([]byte(payload))},
Artifact: testArtifact(payload, "pkg:npm/example@1.0.0", "example.tgz", "application/gzip"),
}
}
func TestGetOrFetchArtifactFromURL_CacheHit(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
seedPackage(t, db, store, "pypi", "requests", "2.28.0", "requests-2.28.0.tar.gz", "pypi content")
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("pypi"))
result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "requests", "2.28.0", "requests-2.28.0.tar.gz", "https://pypi.org/files/requests-2.28.0.tar.gz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if !result.Cached {
t.Error("expected cache hit")
}
if fetcher.fetchCalled {
t.Error("fetcher should not be called on cache hit")
}
missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("pypi"))
if diff := missesAfter - missesBefore; diff != 0 {
t.Errorf("cache misses delta = %.0f, want 0", diff)
}
}
func TestGetOrFetchArtifactFromURL_CacheMiss(t *testing.T) {
proxy, db, store, fetcher := setupTestProxy(t)
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("pypi"))
fetchesBefore := histogramSampleCount(t, metrics.UpstreamFetchDuration.WithLabelValues("pypi"))
writesBefore := histogramSampleCount(t, metrics.StorageOperationDuration.WithLabelValues("write"))
readsBefore := histogramSampleCount(t, metrics.StorageOperationDuration.WithLabelValues("read"))
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("fetched content")),
ContentType: "application/gzip",
}
result, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "newpkg", "1.0.0", "newpkg-1.0.0.tar.gz", "https://pypi.org/files/newpkg-1.0.0.tar.gz")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer func() { _ = result.Reader.Close() }()
if result.Cached {
t.Error("expected cache miss")
}
if !fetcher.fetchCalled {
t.Error("fetcher should be called on cache miss")
}
if fetcher.fetchedURL != "https://pypi.org/files/newpkg-1.0.0.tar.gz" {
t.Errorf("fetcher called with wrong URL: %s", fetcher.fetchedURL)
}
body, _ := io.ReadAll(result.Reader)
if string(body) != "fetched content" {
t.Errorf("got body %q, want %q", body, "fetched content")
}
if err := result.Artifact.Validate(); err != nil {
t.Errorf("Artifact.Validate() error = %v", err)
}
if result.Artifact.PURL != "pkg:pypi/newpkg@1.0.0" {
t.Errorf("PURL = %q", result.Artifact.PURL)
}
if result.Artifact.Size != int64(len("fetched content")) {
t.Errorf("Size = %d", result.Artifact.Size)
}
if result.Artifact.MediaType != "application/gzip" {
t.Errorf("MediaType = %q", result.Artifact.MediaType)
}
// Verify it was stored
storagePath := recordedStoragePath(t, db, "pkg:pypi/newpkg@1.0.0", "newpkg-1.0.0.tar.gz")
if _, ok := store.files[storagePath]; !ok {
t.Error("artifact was not stored in storage")
}
missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("pypi"))
if diff := missesAfter - missesBefore; diff != 1 {
t.Errorf("cache misses delta = %.0f, want 1", diff)
}
if diff := histogramSampleCount(t, metrics.UpstreamFetchDuration.WithLabelValues("pypi")) - fetchesBefore; diff != 1 {
t.Errorf("upstream fetch observations delta = %d, want 1", diff)
}
if diff := histogramSampleCount(t, metrics.StorageOperationDuration.WithLabelValues("write")) - writesBefore; diff != 1 {
t.Errorf("storage write observations delta = %d, want 1", diff)
}
if diff := histogramSampleCount(t, metrics.StorageOperationDuration.WithLabelValues("read")) - readsBefore; diff != 1 {
t.Errorf("storage read observations delta = %d, want 1", diff)
}
}
// isolateRecentFailures empties the process-wide failure history for the
// test and again when it ends, so neither earlier tests nor repeated runs
// reach its assertions.
func isolateRecentFailures(t *testing.T) {
t.Helper()
metrics.ResetRecentFailures()
t.Cleanup(metrics.ResetRecentFailures)
}
func TestGetOrFetchArtifactFromURL_FetchError(t *testing.T) {
isolateRecentFailures(t)
proxy, _, _, fetcher := setupTestProxy(t)
fetcher.fetchErr = errors.New("connection refused")
errorsBefore := testutil.ToFloat64(metrics.UpstreamErrors.WithLabelValues("pypi", "fetch_failed"))
_, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "fail", "1.0.0", "fail-1.0.0.tar.gz", "https://pypi.org/files/fail-1.0.0.tar.gz")
if err == nil {
t.Fatal("expected error on fetch failure")
}
if !strings.Contains(err.Error(), "fetching from upstream") {
t.Errorf("expected upstream error, got: %v", err)
}
if diff := testutil.ToFloat64(metrics.UpstreamErrors.WithLabelValues("pypi", "fetch_failed")) - errorsBefore; diff != 1 {
t.Errorf("upstream errors delta = %.0f, want 1", diff)
}
recent := metrics.RecentFailures("proxy_upstream_errors_total", map[string]string{"ecosystem": "pypi", "error_type": "fetch_failed"})
if len(recent) != 1 || recent[0].PURL != "pkg:pypi/fail@1.0.0" ||
recent[0].URL != "https://pypi.org/files/fail-1.0.0.tar.gz" || recent[0].Error != "connection refused" {
t.Errorf("upstream failures = %+v, want one: the failed package, its URL and the error", recent)
}
}
func TestGetOrFetchArtifactFromURL_StoreError(t *testing.T) {
isolateRecentFailures(t)
proxy, _, store, fetcher := setupTestProxy(t)
store.storeErr = errors.New("disk full")
errorsBefore := testutil.ToFloat64(metrics.StorageErrors.WithLabelValues("write"))
fetcher.artifact = &fetch.Artifact{
Body: io.NopCloser(strings.NewReader("data")),
ContentType: "application/gzip",
}
_, err := proxy.GetOrFetchArtifactFromURL(context.Background(), "pypi", "fail", "1.0.0", "fail-1.0.0.tar.gz", "https://pypi.org/files/fail.tar.gz")
if err == nil {
t.Fatal("expected error on store failure")
}
if !strings.Contains(err.Error(), "storing artifact") {
t.Errorf("expected storage error, got: %v", err)
}
if diff := testutil.ToFloat64(metrics.StorageErrors.WithLabelValues("write")) - errorsBefore; diff != 1 {
t.Errorf("storage errors delta = %.0f, want 1", diff)
}
recent := metrics.RecentFailures("proxy_storage_errors_total", map[string]string{"operation": "write"})
if len(recent) != 1 || recent[0].PURL != "pkg:pypi/fail@1.0.0" || recent[0].Filename != "fail-1.0.0.tar.gz" ||
recent[0].Path == "" || recent[0].Error != "disk full" {
t.Errorf("storage failures = %+v, want one: the failed package, its storage path and the error", recent)
}
}
func TestServeArtifact(t *testing.T) {
result := &CacheResult{
Reader: io.NopCloser(strings.NewReader("file contents")),
Artifact: testArtifact("file contents", "pkg:npm/example@1.0.0", "example.tgz", "application/gzip"),
Cached: true,
}
w := httptest.NewRecorder()
ServeArtifact(w, result)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Header().Get("Content-Type") != "application/gzip" {
t.Errorf("Content-Type = %q, want %q", w.Header().Get("Content-Type"), "application/gzip")
}
if w.Header().Get("Content-Length") != "13" {
t.Errorf("Content-Length = %q, want %q", w.Header().Get("Content-Length"), "13")
}
wantETag := `"` + result.Artifact.Digest.Encoded() + `"`
if w.Header().Get("ETag") != wantETag {
t.Errorf("ETag = %q, want %q", w.Header().Get("ETag"), wantETag)
}
if w.Body.String() != "file contents" {
t.Errorf("body = %q, want %q", w.Body.String(), "file contents")
}
}
func TestServeArtifact_EmptyFields(t *testing.T) {
result := &CacheResult{
Reader: io.NopCloser(strings.NewReader("data")),
}
w := httptest.NewRecorder()
ServeArtifact(w, result)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if w.Header().Get("Content-Type") != "" {
t.Errorf("Content-Type should be empty, got %q", w.Header().Get("Content-Type"))
}
if w.Header().Get("Content-Length") != "" {
t.Errorf("Content-Length should be empty, got %q", w.Header().Get("Content-Length"))
}
if w.Header().Get("ETag") != "" {
t.Errorf("ETag should be empty, got %q", w.Header().Get("ETag"))
}
}
func TestJSONError(t *testing.T) {
tests := []struct {
status int
message string
}{
{http.StatusBadRequest, "bad request"},
{http.StatusNotFound, "not found"},
{http.StatusInternalServerError, "internal error"},
}
for _, tt := range tests {
w := httptest.NewRecorder()
JSONError(w, tt.status, tt.message)
if w.Code != tt.status {
t.Errorf("status = %d, want %d", w.Code, tt.status)
}
if w.Header().Get("Content-Type") != "application/json" {
t.Errorf("Content-Type = %q, want %q", w.Header().Get("Content-Type"), "application/json")
}
body := w.Body.String()
if !strings.Contains(body, tt.message) {
t.Errorf("body %q should contain %q", body, tt.message)
}
}
}
func TestNewProxy_NilLogger(t *testing.T) {
dir := t.TempDir()
db, err := database.Create(dir + "/test.db")
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
defer func() { _ = db.Close() }()
proxy := NewProxy(db, newMockStorage(), &mockFetcher{}, fetch.NewResolver(), nil)
if proxy.Logger == nil {
t.Error("Logger should be set to default when nil is passed")
}
}
const testLastModified = "Wed, 01 Jan 2025 12:00:00 GMT"
// setupCachedProxy creates a Proxy with CacheMetadata enabled and an upstream
// test server that returns JSON with ETag and Last-Modified headers.
func setupCachedProxy(t *testing.T, upstreamETag, upstreamLastModified string) (*Proxy, *httptest.Server) {
t.Helper()
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if upstreamETag != "" {
w.Header().Set("ETag", upstreamETag)
}
if upstreamLastModified != "" {
w.Header().Set("Last-Modified", upstreamLastModified)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
t.Cleanup(upstream.Close)
proxy, _, _, _ := setupTestProxy(t)
proxy.CacheMetadata = true
proxy.HTTPClient = upstream.Client()
return proxy, upstream
}
func TestProxyCached_SetsETagAndLastModified(t *testing.T) {
lm := testLastModified
proxy, upstream := setupCachedProxy(t, `"abc123"`, lm)
// First request populates the cache
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "test-key")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("ETag"); got != `"abc123"` {
t.Errorf("ETag = %q, want %q", got, `"abc123"`)
}
if got := w.Header().Get("Last-Modified"); got != lm {
t.Errorf("Last-Modified = %q, want %q", got, lm)
}
if got := w.Header().Get("Content-Length"); got != "11" {
t.Errorf("Content-Length = %q, want %q", got, "11")
}
if w.Body.String() != `{"ok":true}` {
t.Errorf("body = %q, want %q", w.Body.String(), `{"ok":true}`)
}
}
func TestProxyCached_IfNoneMatch_Returns304(t *testing.T) {
proxy, upstream := setupCachedProxy(t, `"abc123"`, "")
// Populate cache
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-key")
if w.Code != http.StatusOK {
t.Fatalf("initial request: status = %d, want 200", w.Code)
}
// Conditional request with matching ETag
req = httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("If-None-Match", `"abc123"`)
w = httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-key")
if w.Code != http.StatusNotModified {
t.Errorf("conditional request: status = %d, want 304", w.Code)
}
if w.Body.Len() != 0 {
t.Errorf("304 response should have empty body, got %d bytes", w.Body.Len())
}
}
func TestProxyCached_IfNoneMatch_NonMatching_Returns200(t *testing.T) {
proxy, upstream := setupCachedProxy(t, `"abc123"`, "")
// Populate cache
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-nm-key")
if w.Code != http.StatusOK {
t.Fatalf("initial request: status = %d, want 200", w.Code)
}
// Conditional request with non-matching ETag
req = httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("If-None-Match", `"different"`)
w = httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "etag-nm-key")
if w.Code != http.StatusOK {
t.Errorf("non-matching ETag: status = %d, want 200", w.Code)
}
}
func TestProxyCached_IfModifiedSince_Returns304(t *testing.T) {
lm := testLastModified
proxy, upstream := setupCachedProxy(t, "", lm)
// Populate cache
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-key")
if w.Code != http.StatusOK {
t.Fatalf("initial request: status = %d, want 200", w.Code)
}
// Conditional request with If-Modified-Since equal to Last-Modified
req = httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("If-Modified-Since", lm)
w = httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-key")
if w.Code != http.StatusNotModified {
t.Errorf("conditional request: status = %d, want 304", w.Code)
}
}
func TestProxyCached_IfModifiedSince_OlderDate_Returns200(t *testing.T) {
lm := testLastModified
proxy, upstream := setupCachedProxy(t, "", lm)
// Populate cache
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-old-key")
if w.Code != http.StatusOK {
t.Fatalf("initial request: status = %d, want 200", w.Code)
}
// Conditional request with If-Modified-Since older than Last-Modified
req = httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("If-Modified-Since", "Mon, 01 Dec 2024 12:00:00 GMT")
w = httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "lm-old-key")
if w.Code != http.StatusOK {
t.Errorf("older If-Modified-Since: status = %d, want 200", w.Code)
}
}
func TestProxyCached_NoValidators_OmitsHeaders(t *testing.T) {
proxy, upstream := setupCachedProxy(t, "", "")
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "no-val-key")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("ETag"); got != "" {
t.Errorf("ETag should be empty when upstream has none, got %q", got)
}
if got := w.Header().Get("Last-Modified"); got != "" {
t.Errorf("Last-Modified should be empty when upstream has none, got %q", got)
}
}
func TestFetchOrCacheMetadata_TTL_ServesFreshFromCache(t *testing.T) {
hitsBefore := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("test"))
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("test"))
upstreamHits := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamHits++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"v":1}`))
}))
t.Cleanup(upstream.Close)
proxy, _, _, _ := setupTestProxy(t)
proxy.CacheMetadata = true
proxy.MetadataTTL = 1 * time.Hour
proxy.HTTPClient = upstream.Client()
ctx := context.Background()
// First request populates cache
body, _, err := proxy.FetchOrCacheMetadata(ctx, "test", "ttl-pkg", upstream.URL+"/pkg")
if err != nil {
t.Fatalf("first fetch: %v", err)
}
if string(body) != `{"v":1}` {
t.Errorf("body = %q, want %q", body, `{"v":1}`)
}
if upstreamHits != 1 {
t.Fatalf("expected 1 upstream hit, got %d", upstreamHits)
}
if diff := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("test")) - missesBefore; diff != 1 {
t.Errorf("cache misses delta after first request = %.0f, want 1", diff)
}
if diff := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("test")) - hitsBefore; diff != 0 {
t.Errorf("cache hits delta after first request = %.0f, want 0", diff)
}
// Second request within TTL should serve from cache without hitting upstream
body, _, err = proxy.FetchOrCacheMetadata(ctx, "test", "ttl-pkg", upstream.URL+"/pkg")
if err != nil {
t.Fatalf("second fetch: %v", err)
}
if string(body) != `{"v":1}` {
t.Errorf("body = %q, want %q", body, `{"v":1}`)
}
if upstreamHits != 1 {
t.Errorf("expected upstream to still be hit only once, got %d", upstreamHits)
}
if diff := testutil.ToFloat64(metrics.CacheHits.WithLabelValues("test")) - hitsBefore; diff != 1 {
t.Errorf("cache hits delta after second request = %.0f, want 1", diff)
}
if diff := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("test")) - missesBefore; diff != 1 {
t.Errorf("cache misses delta after second request = %.0f, want 1", diff)
}
}
func TestFetchOrCacheMetadata_TTL_Zero_AlwaysRevalidates(t *testing.T) {
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("test"))
upstreamHits := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
upstreamHits++
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"v":1}`))
}))
t.Cleanup(upstream.Close)
proxy, _, _, _ := setupTestProxy(t)
proxy.CacheMetadata = true
proxy.MetadataTTL = 0 // always revalidate
proxy.HTTPClient = upstream.Client()
ctx := context.Background()
_, _, err := proxy.FetchOrCacheMetadata(ctx, "test", "ttl0-pkg", upstream.URL+"/pkg")
if err != nil {
t.Fatalf("first fetch: %v", err)
}
_, _, err = proxy.FetchOrCacheMetadata(ctx, "test", "ttl0-pkg", upstream.URL+"/pkg")
if err != nil {
t.Fatalf("second fetch: %v", err)
}
if upstreamHits != 2 {
t.Errorf("expected 2 upstream hits with TTL=0, got %d", upstreamHits)
}
missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues("test"))
if diff := missesAfter - missesBefore; diff != 2 {
t.Errorf("cache misses delta = %.0f, want 2", diff)
}
}
func TestFetchOrCacheMetadata_CacheDisabledDoesNotRecordMetrics(t *testing.T) {
const ecosystem = "metadata-disabled"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"v":1}`))
}))
t.Cleanup(upstream.Close)
proxy, _, _, _ := setupTestProxy(t)
proxy.HTTPClient = upstream.Client()
hitsBefore := testutil.ToFloat64(metrics.CacheHits.WithLabelValues(ecosystem))
missesBefore := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues(ecosystem))
_, _, err := proxy.FetchOrCacheMetadata(context.Background(), ecosystem, "pkg", upstream.URL+"/pkg")
if err != nil {
t.Fatalf("fetch metadata: %v", err)
}
hitsAfter := testutil.ToFloat64(metrics.CacheHits.WithLabelValues(ecosystem))
missesAfter := testutil.ToFloat64(metrics.CacheMisses.WithLabelValues(ecosystem))
if diff := hitsAfter - hitsBefore; diff != 0 {
t.Errorf("cache hits delta = %.0f, want 0", diff)
}
if diff := missesAfter - missesBefore; diff != 0 {
t.Errorf("cache misses delta = %.0f, want 0", diff)
}
}
func TestProxyCached_StaleWarningHeader(t *testing.T) {
requestCount := 0
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if requestCount == 1 {
// First request succeeds to populate cache
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"cached":true}`))
return
}
// Subsequent requests fail to simulate upstream outage
w.WriteHeader(http.StatusBadGateway)
}))
t.Cleanup(upstream.Close)
proxy, _, _, _ := setupTestProxy(t)
proxy.CacheMetadata = true
proxy.MetadataTTL = 1 * time.Millisecond // very short TTL so it expires immediately
proxy.HTTPClient = upstream.Client()
// First request populates cache
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "stale-key")
if w.Code != http.StatusOK {
t.Fatalf("initial request: status = %d, want 200", w.Code)
}
// Wait for TTL to expire
time.Sleep(5 * time.Millisecond)
// Second request: upstream fails, should serve stale cache with Warning header
req = httptest.NewRequest(http.MethodGet, "/test", nil)
w = httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "stale-key")
if w.Code != http.StatusOK {
t.Fatalf("stale request: status = %d, want 200", w.Code)
}
if w.Body.String() != `{"cached":true}` {
t.Errorf("body = %q, want %q", w.Body.String(), `{"cached":true}`)
}
if got := w.Header().Get("Warning"); got != `110 - "Response is Stale"` {
t.Errorf("Warning = %q, want %q", got, `110 - "Response is Stale"`)
}
}
func TestProxyCached_FreshResponse_NoWarningHeader(t *testing.T) {
proxy, upstream := setupCachedProxy(t, "", "")
proxy.MetadataTTL = 1 * time.Hour
req := httptest.NewRequest(http.MethodGet, "/test", nil)
w := httptest.NewRecorder()
proxy.ProxyCached(w, req, upstream.URL+"/test", "test-eco", "fresh-key")
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Warning"); got != "" {
t.Errorf("Warning should be empty for fresh response, got %q", got)
}
}
// TestCanonicalPackagePURLMatchesConfig ensures the runtime cooldown lookup key
// agrees with config.CooldownConfig.NormalizedPackages for the same package,
// so a configured override is actually found regardless of how the user wrote it.
func TestCanonicalPackagePURLMatchesConfig(t *testing.T) {
tests := []struct {
ecosystem string
requestName string
configKey string
}{
{"npm", "@babel/core", "pkg:npm/@babel/core"},
{"npm", "@babel/core", "pkg:npm/%40babel/core"},
{"npm", "@typescript/typescript-darwin-arm64", "pkg:npm/@typescript/typescript-darwin-arm64"},
{"pypi", "Django", "pkg:pypi/Django"},
{"pypi", "django", "pkg:pypi/Django"},
{"composer", "symfony/console", "pkg:composer/Symfony/Console"},
{"cargo", "serde", "pkg:cargo/serde"},
}
for _, tt := range tests {
t.Run(tt.ecosystem+"/"+tt.requestName+"<="+tt.configKey, func(t *testing.T) {
cfg := config.CooldownConfig{Packages: map[string]string{tt.configKey: "1d"}}
normalized := cfg.NormalizedPackages()
lookup := canonicalPackagePURL(tt.ecosystem, tt.requestName)
if _, ok := normalized[lookup]; !ok {
t.Errorf("lookup key %q not found in normalized config %v", lookup, normalized)
}
})
}
}