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) } }) } }