Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 16:19:16 -05:00
pkg-proxy/internal/handler/nuget_test.go
Christian Heim dcfb9a820d Enable storage retention for nuget
The handler stores NuGet ids lowercased, but purl keeps nuget names as written, so package rules get lowercased before they're matched. Case in a rule doesn't matter.

The mirror stores ids as given, so a mixed-case PURL ends up as a copy clients never get. Until that's fixed, the docs ask for lowercase ids in mirror lists.

Refs #306
2026-10-12 15:24:21 +02:00

1092 lines
29 KiB
Go

package handler
import (
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/git-pkgs/cooldown"
"github.com/git-pkgs/purl"
)
func nugetTestProxy() *Proxy {
return &Proxy{
Logger: slog.Default(),
HTTPClient: http.DefaultClient,
}
}
func TestNuGetRewriteServiceIndex(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: nugetUpstream,
proxyURL: "http://localhost:8080",
}
input := `{
"version": "3.0.0",
"resources": [
{
"@id": "https://api.nuget.org/v3-flatcontainer/",
"@type": "PackageBaseAddress/3.0.0"
},
{
"@id": "https://api.nuget.org/v3/registration5-gz-semver2/",
"@type": "RegistrationsBaseUrl/3.6.0"
},
{
"@id": "https://azuresearch-usnc.nuget.org/query",
"@type": "SearchQueryService/3.5.0"
},
{
"@id": "https://azuresearch-usnc.nuget.org/autocomplete",
"@type": "SearchAutocompleteService/3.5.0"
},
{
"@id": "https://example.com/other-service",
"@type": "SomeOtherService/1.0.0"
}
]
}`
output, err := h.rewriteServiceIndex([]byte(input))
if err != nil {
t.Fatalf("rewriteServiceIndex failed: %v", err)
}
var result map[string]any
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
resources := result["resources"].([]any)
if len(resources) != 5 {
t.Fatalf("expected 5 resources, got %d", len(resources))
}
expectations := map[string]string{
"PackageBaseAddress/3.0.0": "http://localhost:8080/nuget/v3-flatcontainer/",
"RegistrationsBaseUrl/3.6.0": "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
"SearchQueryService/3.5.0": "http://localhost:8080/nuget/query",
"SearchAutocompleteService/3.5.0": "http://localhost:8080/nuget/autocomplete",
"SomeOtherService/1.0.0": "https://example.com/other-service",
}
for _, res := range resources {
rmap := res.(map[string]any)
rtype := rmap["@type"].(string)
id := rmap["@id"].(string)
expected, ok := expectations[rtype]
if !ok {
t.Errorf("unexpected resource type: %s", rtype)
continue
}
if id != expected {
t.Errorf("resource %s: @id = %q, want %q", rtype, id, expected)
}
}
}
func TestNuGetRewriteURL(t *testing.T) {
h := &NuGetHandler{
proxyURL: "http://localhost:8080",
}
tests := []struct {
input string
serviceType string
want string
}{
{
"https://api.nuget.org/v3-flatcontainer/",
"PackageBaseAddress/3.0.0",
"http://localhost:8080/nuget/v3-flatcontainer/",
},
{
"https://api.nuget.org/v3/registration5-gz-semver2/",
"RegistrationsBaseUrl/3.6.0",
"http://localhost:8080/nuget/v3/registration5-gz-semver2/",
},
{
"https://api.nuget.org/v3/registration5-gz-semver2/",
"RegistrationsBaseUrl/Versioned",
"http://localhost:8080/nuget/v3/registration5-gz-semver2/",
},
{
"https://azuresearch-usnc.nuget.org/query",
"SearchQueryService",
"http://localhost:8080/nuget/query",
},
{
"https://azuresearch-usnc.nuget.org/query",
"SearchQueryService/3.0.0-rc",
"http://localhost:8080/nuget/query",
},
{
"https://azuresearch-usnc.nuget.org/query",
"SearchQueryService/3.5.0",
"http://localhost:8080/nuget/query",
},
{
"https://azuresearch-usnc.nuget.org/autocomplete",
"SearchAutocompleteService",
"http://localhost:8080/nuget/autocomplete",
},
{
"https://azuresearch-usnc.nuget.org/autocomplete",
"SearchAutocompleteService/3.5.0",
"http://localhost:8080/nuget/autocomplete",
},
{
"https://example.com/unknown",
"SomeOtherService/1.0.0",
"https://example.com/unknown",
},
{
"https://api.nuget.org/v2/package",
"PackagePublish/2.0.0",
"https://api.nuget.org/v2/package",
},
{
"https://azuresearch-usnc.nuget.org/query",
"SearchQueryService/99.0.0",
"https://azuresearch-usnc.nuget.org/query",
},
{
"https://example.com/resource",
"",
"https://example.com/resource",
},
}
for _, tt := range tests {
got := h.rewriteNuGetURL(tt.input, tt.serviceType)
if got != tt.want {
t.Errorf("rewriteNuGetURL(%q, %q) = %q, want %q", tt.input, tt.serviceType, got, tt.want)
}
}
}
func TestNuGetHandleServiceIndex(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v3/index.json" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"version": "3.0.0",
"resources": [
{
"@id": "https://api.nuget.org/v3-flatcontainer/",
"@type": "PackageBaseAddress/3.0.0"
}
]
}`))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
w := httptest.NewRecorder()
h.handleServiceIndex(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want %q", ct, "application/json")
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
resources := result["resources"].([]any)
r0 := resources[0].(map[string]any)
if r0["@id"] != "http://proxy.local/nuget/v3-flatcontainer/" {
t.Errorf("resource @id = %q, want rewritten URL", r0["@id"])
}
}
func TestNuGetHandleServiceIndexUpstreamError(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("internal error"))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
w := httptest.NewRecorder()
h.handleServiceIndex(w, req)
// With metadata caching, upstream 500 is reported as 502 (bad gateway)
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
}
}
func TestNuGetHandleServiceIndexUpstreamUnreachable(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: "http://127.0.0.1:1", // unreachable
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
w := httptest.NewRecorder()
h.handleServiceIndex(w, req)
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
}
}
func TestNuGetHandleServiceIndexInvalidJSON(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("not valid json"))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
w := httptest.NewRecorder()
h.handleServiceIndex(w, req)
// When rewrite fails, the handler falls back to proxying the original body
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d (should fall back to original)", w.Code, http.StatusOK)
}
body := w.Body.String()
if body != "not valid json" {
t.Errorf("body = %q, want original body passed through", body)
}
}
func TestNuGetHandleDownloadEmptyParams(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: "http://localhost:1",
proxyURL: "http://proxy.local",
}
// Missing path values
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer///", nil)
req.SetPathValue("id", "")
req.SetPathValue("version", "")
req.SetPathValue("filename", "")
w := httptest.NewRecorder()
h.handleDownload(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestNuGetHandleDownloadNonNupkg(t *testing.T) {
// Non-.nupkg files should be proxied upstream
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/xml")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("<nuspec>test</nuspec>"))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/newtonsoft.json/13.0.1/newtonsoft.json.nuspec", nil)
req.SetPathValue("id", "newtonsoft.json")
req.SetPathValue("version", "13.0.1")
req.SetPathValue("filename", "newtonsoft.json.nuspec")
w := httptest.NewRecorder()
h.handleDownload(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if body != "<nuspec>test</nuspec>" {
t.Errorf("body = %q, want nuspec content", body)
}
}
func TestNuGetProxyUpstream(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v3-flatcontainer/newtonsoft.json/index.json" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"versions":["13.0.1","13.0.2"]}`))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/newtonsoft.json/index.json", nil)
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
body := w.Body.String()
if !strings.Contains(body, "13.0.1") {
t.Errorf("response body does not contain expected version: %s", body)
}
}
func TestNuGetProxyUpstreamNotFound(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/nonexistent/index.json", nil)
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
}
}
func TestNuGetProxyUpstreamBadUpstream(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: "http://127.0.0.1:1",
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
if w.Code != http.StatusBadGateway {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
}
}
func TestNuGetProxyUpstreamCopiesHeaders(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Custom", "value")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
if w.Header().Get("X-Custom") != "value" {
t.Errorf("X-Custom = %q, want %q", w.Header().Get("X-Custom"), "value")
}
}
func TestNuGetProxyUpstreamForwardsAcceptEncoding(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ae := r.Header.Get("Accept-Encoding")
if ae != "gzip" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte("expected Accept-Encoding: gzip"))
return
}
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
req.Header.Set("Accept-Encoding", "gzip")
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestNuGetBuildUpstreamURL(t *testing.T) {
h := &NuGetHandler{
upstreamURL: "https://api.nuget.org",
searchURL: "https://azuresearch-usnc.nuget.org",
}
tests := []struct {
path string
query string
want string
}{
{
"/v3-flatcontainer/newtonsoft.json/index.json",
"",
"https://api.nuget.org/v3-flatcontainer/newtonsoft.json/index.json",
},
{
"/v3/registration5-gz-semver2/newtonsoft.json/index.json",
"",
"https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json",
},
{
"/query",
"q=json&take=20",
"https://azuresearch-usnc.nuget.org/query?q=json&take=20",
},
{
"/autocomplete",
"q=new&take=10",
"https://azuresearch-usnc.nuget.org/autocomplete?q=new&take=10",
},
}
for _, tt := range tests {
req := httptest.NewRequest(http.MethodGet, tt.path+"?"+tt.query, nil)
got := h.buildUpstreamURL(req)
if got != tt.want {
t.Errorf("buildUpstreamURL(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
func TestNuGetRoutes(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v3/index.json" {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"3.0.0","resources":[]}`))
return
}
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
routes := h.Routes()
req := httptest.NewRequest(http.MethodGet, "/v3/index.json", nil)
w := httptest.NewRecorder()
routes.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("GET /v3/index.json: status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestNewNuGetHandler(t *testing.T) {
proxy := nugetTestProxy()
h := NewNuGetHandler(proxy, "http://localhost:8080/")
if h.proxy != proxy {
t.Error("proxy not set correctly")
}
if h.upstreamURL != nugetUpstream {
t.Errorf("upstreamURL = %q, want %q", h.upstreamURL, nugetUpstream)
}
if h.proxyURL != "http://localhost:8080" {
t.Errorf("proxyURL = %q, want %q (trailing slash should be trimmed)", h.proxyURL, "http://localhost:8080")
}
}
func TestNewNuGetHandlerNoTrailingSlash(t *testing.T) {
proxy := nugetTestProxy()
h := NewNuGetHandler(proxy, "http://localhost:8080")
if h.proxyURL != "http://localhost:8080" {
t.Errorf("proxyURL = %q, want %q", h.proxyURL, "http://localhost:8080")
}
}
func TestNuGetRewriteServiceIndexNoResources(t *testing.T) {
h := &NuGetHandler{
proxyURL: "http://localhost:8080",
}
input := `{"version":"3.0.0"}`
output, err := h.rewriteServiceIndex([]byte(input))
if err != nil {
t.Fatalf("rewriteServiceIndex failed: %v", err)
}
// Should return the body unchanged when no resources key
var result map[string]any
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
if result["version"] != "3.0.0" {
t.Errorf("version = %v, want 3.0.0", result["version"])
}
}
func TestNuGetRewriteServiceIndexAllTypes(t *testing.T) {
h := &NuGetHandler{
proxyURL: "http://localhost:8080",
}
// Test every rewritable service type
resources := []map[string]string{
{"@id": "https://api.nuget.org/v3-flatcontainer/", "@type": "PackageBaseAddress/3.0.0"},
{"@id": "https://api.nuget.org/v3/registration5-gz-semver2/", "@type": "RegistrationsBaseUrl/3.6.0"},
{"@id": "https://api.nuget.org/v3/registration5-gz-semver2/", "@type": "RegistrationsBaseUrl/Versioned"},
{"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService"},
{"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService/3.0.0-rc"},
{"@id": "https://azuresearch-usnc.nuget.org/query", "@type": "SearchQueryService/3.5.0"},
{"@id": "https://azuresearch-usnc.nuget.org/autocomplete", "@type": "SearchAutocompleteService"},
{"@id": "https://azuresearch-usnc.nuget.org/autocomplete", "@type": "SearchAutocompleteService/3.5.0"},
}
inputResources := make([]any, len(resources))
for i, r := range resources {
inputResources[i] = r
}
input, _ := json.Marshal(map[string]any{
"version": "3.0.0",
"resources": inputResources,
})
output, err := h.rewriteServiceIndex(input)
if err != nil {
t.Fatalf("rewriteServiceIndex failed: %v", err)
}
var result map[string]any
if err := json.Unmarshal(output, &result); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
outputResources := result["resources"].([]any)
for _, res := range outputResources {
rmap := res.(map[string]any)
id := rmap["@id"].(string)
// All should be rewritten to proxy URL
if strings.HasPrefix(id, "https://api.nuget.org") || strings.HasPrefix(id, "https://azuresearch-usnc.nuget.org") {
t.Errorf("resource %s was not rewritten: %s", rmap["@type"], id)
}
}
}
func TestNuGetProxyUpstreamPreservesStatusCodes(t *testing.T) {
codes := []int{
http.StatusOK,
http.StatusNotFound,
http.StatusForbidden,
http.StatusInternalServerError,
}
for _, code := range codes {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(code)
}))
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
if w.Code != code {
t.Errorf("status = %d, want %d", w.Code, code)
}
upstream.Close()
}
}
func TestNuGetProxyUpstreamCopiesBody(t *testing.T) {
expected := `{"versions":["1.0.0","2.0.0"]}`
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(expected))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/index.json", nil)
w := httptest.NewRecorder()
h.proxyUpstream(w, req)
got, _ := io.ReadAll(w.Body)
if string(got) != expected {
t.Errorf("body = %q, want %q", string(got), expected)
}
}
func TestNuGetHandleDownloadMissingID(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: "http://localhost:1",
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer//1.0.0/test.nupkg", nil)
req.SetPathValue("id", "")
req.SetPathValue("version", "1.0.0")
req.SetPathValue("filename", "test.nupkg")
w := httptest.NewRecorder()
h.handleDownload(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestNuGetHandleDownloadMissingVersion(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: "http://localhost:1",
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test//test.nupkg", nil)
req.SetPathValue("id", "test")
req.SetPathValue("version", "")
req.SetPathValue("filename", "test.nupkg")
w := httptest.NewRecorder()
h.handleDownload(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestNuGetHandleDownloadMissingFilename(t *testing.T) {
h := &NuGetHandler{
proxy: nugetTestProxy(),
upstreamURL: "http://localhost:1",
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3-flatcontainer/test/1.0.0/", nil)
req.SetPathValue("id", "test")
req.SetPathValue("version", "1.0.0")
req.SetPathValue("filename", "")
w := httptest.NewRecorder()
h.handleDownload(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
}
func TestNuGetBuildUpstreamURLQueryPath(t *testing.T) {
h := &NuGetHandler{
upstreamURL: "https://api.nuget.org",
searchURL: "https://azuresearch-usnc.nuget.org",
}
// Query endpoint should go to azuresearch
req := httptest.NewRequest(http.MethodGet, "/query?q=json&skip=0&take=20", nil)
got := h.buildUpstreamURL(req)
want := "https://azuresearch-usnc.nuget.org/query?q=json&skip=0&take=20"
if got != want {
t.Errorf("buildUpstreamURL for /query = %q, want %q", got, want)
}
}
func TestNuGetBuildUpstreamURLAutocompletePath(t *testing.T) {
h := &NuGetHandler{
upstreamURL: "https://api.nuget.org",
searchURL: "https://azuresearch-usnc.nuget.org",
}
req := httptest.NewRequest(http.MethodGet, "/autocomplete?q=new&take=10", nil)
got := h.buildUpstreamURL(req)
want := "https://azuresearch-usnc.nuget.org/autocomplete?q=new&take=10"
if got != want {
t.Errorf("buildUpstreamURL for /autocomplete = %q, want %q", got, want)
}
}
func TestNuGetBuildUpstreamURLRegularPath(t *testing.T) {
h := &NuGetHandler{
upstreamURL: "https://api.nuget.org",
}
req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/newtonsoft.json/index.json", nil)
got := h.buildUpstreamURL(req)
want := "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json"
if got != want {
t.Errorf("buildUpstreamURL for registration = %q, want %q", got, want)
}
}
func TestNuGetCooldownFiltering(t *testing.T) {
now := time.Now()
oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
registration := map[string]any{
"items": []any{
map[string]any{
"count": 2,
"items": []any{
map[string]any{
"catalogEntry": map[string]any{
"id": "TestPackage",
"version": "1.0.0",
"published": oldTime,
},
},
map[string]any{
"catalogEntry": map[string]any{
"id": "TestPackage",
"version": "2.0.0",
"published": recentTime,
},
},
},
},
},
}
proxy := testProxy()
proxy.Cooldown = &cooldown.Config{
Default: "3d",
}
h := &NuGetHandler{
proxy: proxy,
proxyURL: "http://localhost:8080",
}
if !h.filterNuGetRegistration(registration, "") {
t.Fatal("expected registration items to be retained")
}
pages := registration["items"].([]any)
page := pages[0].(map[string]any)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item after filtering, got %d", len(items))
}
entry := items[0].(map[string]any)["catalogEntry"].(map[string]any)
if entry["version"] != testVersion100 {
t.Errorf("expected version 1.0.0 to survive, got %s", entry["version"])
}
count := page["count"]
if count != 1 {
t.Errorf("expected page count to be 1, got %v", count)
}
}
func TestNuGetCooldownFilteringWithPackageOverride(t *testing.T) {
now := time.Now()
recentTime := now.Add(-2 * time.Hour).Format(time.RFC3339)
registration := map[string]any{
"items": []any{
map[string]any{
"count": 1,
"items": []any{
map[string]any{
"catalogEntry": map[string]any{
"id": "SpecialPackage",
"version": "1.0.0",
"published": recentTime,
},
},
},
},
},
}
proxy := testProxy()
proxy.Cooldown = &cooldown.Config{
Default: "3d",
Packages: map[string]string{"pkg:nuget/specialpackage": "1h"},
}
h := &NuGetHandler{
proxy: proxy,
proxyURL: "http://localhost:8080",
}
if !h.filterNuGetRegistration(registration, "") {
t.Fatal("expected registration items to be retained")
}
pages := registration["items"].([]any)
page := pages[0].(map[string]any)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item (package override allows it), got %d", len(items))
}
}
func TestNuGetCooldownNoCooldownConfig(t *testing.T) {
registration := map[string]any{
"items": []any{
map[string]any{
"count": 1,
"items": []any{
map[string]any{
"catalogEntry": map[string]any{
"id": "Test",
"version": "1.0.0",
"published": time.Now().Format(time.RFC3339),
},
},
},
},
},
}
// No cooldown: all registration items are retained.
h := &NuGetHandler{
proxy: testProxy(),
proxyURL: "http://localhost:8080",
}
if !h.filterNuGetRegistration(registration, "") {
t.Fatal("expected registration items to be retained")
}
pages := registration["items"].([]any)
page := pages[0].(map[string]any)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
}
func TestNuGetCooldownFilteringNuGetTimestamp(t *testing.T) {
// NuGet uses timestamps like "2024-09-07T01:37:52.233+00:00" which
// have fractional seconds - verify these parse correctly
now := time.Now()
oldTime := now.Add(-7 * 24 * time.Hour).Format("2006-01-02T15:04:05.000-07:00")
registration := map[string]any{
"items": []any{
map[string]any{
"count": 1,
"items": []any{
map[string]any{
"catalogEntry": map[string]any{
"id": "Test",
"version": "1.0.0",
"published": oldTime,
},
},
},
},
},
}
proxy := testProxy()
proxy.Cooldown = &cooldown.Config{
Default: "3d",
}
h := &NuGetHandler{
proxy: proxy,
proxyURL: "http://localhost:8080",
}
if !h.filterNuGetRegistration(registration, "") {
t.Fatal("expected registration items to be retained")
}
pages := registration["items"].([]any)
page := pages[0].(map[string]any)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item (old enough to pass cooldown), got %d", len(items))
}
}
func TestNuGetHandleRegistrationWithCooldown(t *testing.T) {
now := time.Now()
oldTime := now.Add(-7 * 24 * time.Hour).Format(time.RFC3339)
recentTime := now.Add(-1 * time.Hour).Format(time.RFC3339)
registrationJSON, _ := json.Marshal(map[string]any{
"items": []any{
map[string]any{
"count": 2,
"items": []any{
map[string]any{
"catalogEntry": map[string]any{
"id": "TestPkg",
"version": "1.0.0",
"published": oldTime,
},
},
map[string]any{
"catalogEntry": map[string]any{
"id": "TestPkg",
"version": "2.0.0",
"published": recentTime,
},
},
},
},
},
})
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(registrationJSON)
}))
defer upstream.Close()
proxy := testProxy()
proxy.Cooldown = &cooldown.Config{
Default: "3d",
}
h := &NuGetHandler{
proxy: proxy,
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/testpkg/index.json", nil)
w := httptest.NewRecorder()
h.handleRegistration(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
pages := result["items"].([]any)
page := pages[0].(map[string]any)
items := page["items"].([]any)
if len(items) != 1 {
t.Fatalf("expected 1 item after cooldown filtering, got %d", len(items))
}
}
func TestNuGetHandleRegistrationWithoutCooldown(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"items":[]}`))
}))
defer upstream.Close()
h := &NuGetHandler{
proxy: nugetTestProxy(), // no cooldown configured
upstreamURL: upstream.URL,
proxyURL: "http://proxy.local",
}
req := httptest.NewRequest(http.MethodGet, "/v3/registration5-gz-semver2/testpkg/index.json", nil)
w := httptest.NewRecorder()
h.handleRegistration(w, req)
// Without cooldown, should proxy directly
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestNuGetRetentionKey(t *testing.T) {
tests := []struct {
key string
want string
ok bool
}{
{"pkg:nuget/Newtonsoft.Json", "pkg:nuget/newtonsoft.json", true},
{"pkg:nuget/serilog", "pkg:nuget/serilog", true},
{"pkg:nuget/serilog/serilog", "", false},
{"pkg:npm/serilog", "", false},
}
for _, tt := range tests {
p, err := purl.Parse(tt.key)
if err != nil {
t.Fatalf("Parse(%s): %v", tt.key, err)
}
got, ok := nugetRetentionKey(p)
if ok != tt.ok || (ok && got != tt.want) {
t.Errorf("nugetRetentionKey(%s) = %q, %v, want %q, %v", tt.key, got, ok, tt.want, tt.ok)
}
}
if _, ok := nugetRetentionKey(nil); ok {
t.Error("nugetRetentionKey(nil) accepted")
}
}