diff --git a/internal/handler/conan_test.go b/internal/handler/conan_test.go
new file mode 100644
index 0000000..291eb0c
--- /dev/null
+++ b/internal/handler/conan_test.go
@@ -0,0 +1,473 @@
+package handler
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func conanTestProxy() *Proxy {
+ return &Proxy{
+ Logger: slog.Default(),
+ }
+}
+
+func TestConanShouldCacheFile(t *testing.T) {
+ h := &ConanHandler{}
+
+ tests := []struct {
+ filename string
+ want bool
+ }{
+ {"conan_sources.tgz", true},
+ {"conan_export.tgz", true},
+ {"conan_package.tgz", true},
+ {"conanfile.py", false},
+ {"conanmanifest.txt", false},
+ {"conaninfo.txt", false},
+ {"random.tgz", false},
+ {"", false},
+ }
+
+ for _, tt := range tests {
+ got := h.shouldCacheFile(tt.filename)
+ if got != tt.want {
+ t.Errorf("shouldCacheFile(%q) = %v, want %v", tt.filename, got, tt.want)
+ }
+ }
+}
+
+func TestConanPingV1(t *testing.T) {
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ proxyURL: "http://localhost:8080",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v1/ping", nil)
+ w := httptest.NewRecorder()
+
+ h.handlePing(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ caps := w.Header().Get("X-Conan-Server-Capabilities")
+ if caps != "revisions" {
+ t.Errorf("X-Conan-Server-Capabilities = %q, want %q", caps, "revisions")
+ }
+}
+
+func TestConanPingV2(t *testing.T) {
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ proxyURL: "http://localhost:8080",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/ping", nil)
+ w := httptest.NewRecorder()
+
+ h.handlePing(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ caps := w.Header().Get("X-Conan-Server-Capabilities")
+ if caps != "revisions" {
+ t.Errorf("X-Conan-Server-Capabilities = %q, want %q", caps, "revisions")
+ }
+}
+
+func TestConanProxyUpstream(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/v2/conans/search" {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ if r.URL.Query().Get("q") != "zlib" {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"results":["zlib/1.2.13"]}`))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/search?q=zlib", 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, "zlib/1.2.13") {
+ t.Errorf("response body does not contain expected result: %s", body)
+ }
+}
+
+func TestConanProxyUpstreamNotFound(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/nonexistent", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusNotFound)
+ }
+}
+
+func TestConanProxyUpstreamCopiesHeaders(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Custom-Header", "test-value")
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{}`))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Header().Get("X-Custom-Header") != "test-value" {
+ t.Errorf("X-Custom-Header = %q, want %q", w.Header().Get("X-Custom-Header"), "test-value")
+ }
+}
+
+func TestConanProxyUpstreamForwardsAuthHeader(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ if auth != "Bearer mytoken" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"ok":true}`))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
+ req.Header.Set("Authorization", "Bearer mytoken")
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+}
+
+func TestConanProxyUpstreamBadUpstream(t *testing.T) {
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: "http://127.0.0.1:1", // unreachable
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusBadGateway {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusBadGateway)
+ }
+}
+
+func TestConanRecipeFileNonCacheable(t *testing.T) {
+ // When a recipe file is not cacheable (e.g. conanfile.py), it should be proxied upstream.
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("conanfile content"))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/files/zlib/1.2.13/_/_/abc123/recipe/conanfile.py", nil)
+ req.SetPathValue("name", "zlib")
+ req.SetPathValue("version", "1.2.13")
+ req.SetPathValue("user", "_")
+ req.SetPathValue("channel", "_")
+ req.SetPathValue("revision", "abc123")
+ req.SetPathValue("filename", "conanfile.py")
+
+ w := httptest.NewRecorder()
+ h.handleRecipeFile(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ body := w.Body.String()
+ if body != "conanfile content" {
+ t.Errorf("body = %q, want %q", body, "conanfile content")
+ }
+}
+
+func TestConanPackageFileNonCacheable(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/plain")
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("conaninfo content"))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/files/zlib/1.2.13/_/_/abc123/package/pkgref1/pkgrev1/conaninfo.txt", nil)
+ req.SetPathValue("name", "zlib")
+ req.SetPathValue("version", "1.2.13")
+ req.SetPathValue("user", "_")
+ req.SetPathValue("channel", "_")
+ req.SetPathValue("revision", "abc123")
+ req.SetPathValue("pkgref", "pkgref1")
+ req.SetPathValue("pkgrev", "pkgrev1")
+ req.SetPathValue("filename", "conaninfo.txt")
+
+ w := httptest.NewRecorder()
+ h.handlePackageFile(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ body := w.Body.String()
+ if body != "conaninfo content" {
+ t.Errorf("body = %q, want %q", body, "conaninfo content")
+ }
+}
+
+func TestConanRoutes(t *testing.T) {
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: "http://localhost:1", // won't be called for ping
+ proxyURL: "http://proxy.local",
+ }
+
+ routes := h.Routes()
+
+ tests := []struct {
+ path string
+ wantStatus int
+ }{
+ {"/v1/ping", http.StatusOK},
+ {"/v2/ping", http.StatusOK},
+ }
+
+ for _, tt := range tests {
+ req := httptest.NewRequest(http.MethodGet, tt.path, nil)
+ w := httptest.NewRecorder()
+ routes.ServeHTTP(w, req)
+
+ if w.Code != tt.wantStatus {
+ t.Errorf("GET %s: status = %d, want %d", tt.path, w.Code, tt.wantStatus)
+ }
+ }
+}
+
+func TestConanProxyUpstreamPreservesQueryString(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Query().Get("q") != "boost" && r.URL.Query().Get("page") != "2" {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`ok`))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/search?q=boost&page=2", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+}
+
+func TestConanProxyUpstreamLargeResponse(t *testing.T) {
+ largeBody := strings.Repeat("x", 1024*1024)
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(largeBody))
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ if w.Body.Len() != len(largeBody) {
+ t.Errorf("body length = %d, want %d", w.Body.Len(), len(largeBody))
+ }
+}
+
+func TestNewConanHandler(t *testing.T) {
+ proxy := conanTestProxy()
+ h := NewConanHandler(proxy, "http://localhost:8080/")
+
+ if h.proxy != proxy {
+ t.Error("proxy not set correctly")
+ }
+ if h.upstreamURL != conanUpstream {
+ t.Errorf("upstreamURL = %q, want %q", h.upstreamURL, conanUpstream)
+ }
+ if h.proxyURL != "http://localhost:8080" {
+ t.Errorf("proxyURL = %q, want %q (trailing slash should be trimmed)", h.proxyURL, "http://localhost:8080")
+ }
+}
+
+func TestNewConanHandlerNoTrailingSlash(t *testing.T) {
+ proxy := conanTestProxy()
+ h := NewConanHandler(proxy, "http://localhost:8080")
+
+ if h.proxyURL != "http://localhost:8080" {
+ t.Errorf("proxyURL = %q, want %q", h.proxyURL, "http://localhost:8080")
+ }
+}
+
+func TestConanProxyUpstreamNoAuthHeaderWhenNotProvided(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ auth := r.Header.Get("Authorization")
+ if auth != "" {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte("unexpected auth header"))
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer upstream.Close()
+
+ h := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/test", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+}
+
+func TestConanProxyUpstreamCopiesBody(t *testing.T) {
+ expected := `{"name":"zlib","version":"1.2.13","user":"_","channel":"_"}`
+ 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 := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/conans/zlib/1.2.13/_/_/latest", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ got, _ := io.ReadAll(w.Body)
+ if string(got) != expected {
+ t.Errorf("body = %q, want %q", string(got), expected)
+ }
+}
+
+func TestConanProxyUpstreamPreservesStatusCodes(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 := &ConanHandler{
+ proxy: conanTestProxy(),
+ upstreamURL: upstream.URL,
+ proxyURL: "http://proxy.local",
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/v2/test", nil)
+ w := httptest.NewRecorder()
+ h.proxyUpstream(w, req)
+
+ if w.Code != code {
+ t.Errorf("status = %d, want %d", w.Code, code)
+ }
+
+ upstream.Close()
+ }
+}
diff --git a/internal/handler/nuget_test.go b/internal/handler/nuget_test.go
new file mode 100644
index 0000000..1dfcab6
--- /dev/null
+++ b/internal/handler/nuget_test.go
@@ -0,0 +1,769 @@
+package handler
+
+import (
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func nugetTestProxy() *Proxy {
+ return &Proxy{
+ Logger: slog.Default(),
+ }
+}
+
+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 TestNuGetShouldRewriteService(t *testing.T) {
+ h := &NuGetHandler{}
+
+ rewriteTypes := []string{
+ "PackageBaseAddress/3.0.0",
+ "RegistrationsBaseUrl/3.6.0",
+ "RegistrationsBaseUrl/Versioned",
+ "SearchQueryService",
+ "SearchQueryService/3.0.0-rc",
+ "SearchQueryService/3.5.0",
+ "SearchAutocompleteService",
+ "SearchAutocompleteService/3.5.0",
+ }
+
+ for _, stype := range rewriteTypes {
+ if !h.shouldRewriteService(stype) {
+ t.Errorf("shouldRewriteService(%q) = false, want true", stype)
+ }
+ }
+
+ noRewriteTypes := []string{
+ "SomeOtherService/1.0.0",
+ "PackagePublish/2.0.0",
+ "",
+ "SearchQueryService/99.0.0",
+ }
+
+ for _, stype := range noRewriteTypes {
+ if h.shouldRewriteService(stype) {
+ t.Errorf("shouldRewriteService(%q) = true, want false", stype)
+ }
+ }
+}
+
+func TestNuGetRewriteURL(t *testing.T) {
+ h := &NuGetHandler{
+ proxyURL: "http://localhost:8080",
+ }
+
+ tests := []struct {
+ input string
+ want string
+ }{
+ {
+ "https://api.nuget.org/v3-flatcontainer/",
+ "http://localhost:8080/nuget/v3-flatcontainer/",
+ },
+ {
+ "https://api.nuget.org/v3/registration5-gz-semver2/",
+ "http://localhost:8080/nuget/v3/registration5-gz-semver2/",
+ },
+ {
+ "https://azuresearch-usnc.nuget.org/query",
+ "http://localhost:8080/nuget/query",
+ },
+ {
+ "https://azuresearch-usnc.nuget.org/autocomplete",
+ "http://localhost:8080/nuget/autocomplete",
+ },
+ {
+ "https://example.com/unknown",
+ "https://example.com/unknown",
+ },
+ }
+
+ for _, tt := range tests {
+ got := h.rewriteNuGetURL(tt.input)
+ if got != tt.want {
+ t.Errorf("rewriteNuGetURL(%q) = %q, want %q", tt.input, 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)
+
+ if w.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want %d", w.Code, http.StatusInternalServerError)
+ }
+}
+
+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("test"))
+ }))
+ 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 != "test" {
+ 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",
+ }
+
+ 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",
+ }
+
+ // 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",
+ }
+
+ 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)
+ }
+}