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