mirror of
https://github.com/git-pkgs/proxy.git
synced 2026-11-11 00:29:15 -05:00
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
1519 lines
51 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|