package handler import ( "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) const testReferrersIndex = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[` + `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","size":10,"artifactType":"application/spdx+json"},` + `{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222","size":10,"artifactType":"application/vnd.cncf.notary.signature"}]}` var testReferrersSubject = "sha256:" + sha256Hex("subject manifest") func newReferrersTestHandler(t *testing.T, upstream *httptest.Server) (*ContainerHandler, *Proxy) { t.Helper() proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() return &ContainerHandler{proxy: proxy, registryURL: upstream.URL, proxyURL: "http://proxy.example.test"}, proxy } func serveReferrersRequest(h *ContainerHandler, method, target string) *httptest.ResponseRecorder { recorder := httptest.NewRecorder() h.Routes().ServeHTTP(recorder, httptest.NewRequest(method, target, nil)) return recorder } func TestContainerHandler_ReferrersCachesIndexForEveryFilter(t *testing.T) { upstreamAvailable := true upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamRequests++ if r.URL.Path != "/v2/library/nginx/referrers/"+testReferrersSubject { http.NotFound(w, r) return } if r.URL.RawQuery != "" { t.Errorf("upstream query = %q, want artifactType stripped", r.URL.RawQuery) } if got := r.Header.Get("Accept"); got != containerReferrersMediaType { t.Errorf("upstream Accept = %q, want %q", got, containerReferrersMediaType) } if !upstreamAvailable { http.Error(w, "upstream unavailable", http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", containerReferrersMediaType) w.Header().Set("OCI-Filters-Applied", "artifactType") _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() h, proxy := newReferrersTestHandler(t, upstream) proxy.MetadataTTL = time.Hour base := "/library/nginx/referrers/" + testReferrersSubject first := serveReferrersRequest(h, http.MethodGet, base+"?artifactType=application/spdx%2Bjson") if first.Code != http.StatusOK { t.Fatalf("first status = %d, want 200: %s", first.Code, first.Body.String()) } if first.Body.String() != testReferrersIndex { t.Errorf("first body = %q, want upstream index", first.Body.String()) } if got := first.Header().Get("Content-Type"); got != containerReferrersMediaType { t.Errorf("first Content-Type = %q, want %q", got, containerReferrersMediaType) } if got := first.Header().Get("OCI-Filters-Applied"); got != "" { t.Errorf("OCI-Filters-Applied = %q, want absent so clients filter", got) } other := serveReferrersRequest(h, http.MethodGet, base+"?artifactType=application/vnd.cncf.notary.signature") if other.Code != http.StatusOK || other.Body.String() != testReferrersIndex { t.Fatalf("other filter = %d %q, want cached full index", other.Code, other.Body.String()) } if upstreamRequests != 1 { t.Fatalf("upstream requests after fresh hit = %d, want 1", upstreamRequests) } proxy.MetadataTTL = 0 upstreamAvailable = false stale := serveReferrersRequest(h, http.MethodGet, base) if stale.Code != http.StatusOK || stale.Body.String() != testReferrersIndex { t.Fatalf("stale = %d %q, want cached index", stale.Code, stale.Body.String()) } if got := stale.Header().Get("Content-Type"); got != containerReferrersMediaType { t.Errorf("stale Content-Type = %q, want %q", got, containerReferrersMediaType) } if got := stale.Header().Get("Warning"); got != containerStaleWarning { t.Errorf("stale Warning = %q, want stale warning", got) } if upstreamRequests != 2 { t.Errorf("upstream requests after stale fallback = %d, want 2", upstreamRequests) } } func TestContainerHandler_ReferrersRevalidatesWithETag(t *testing.T) { upstreamRequests := 0 notModified := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamRequests++ if r.Header.Get("If-None-Match") == `"referrers-etag"` { notModified++ w.WriteHeader(http.StatusNotModified) return } w.Header().Set("Content-Type", containerReferrersMediaType) w.Header().Set("ETag", `"referrers-etag"`) _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() h, proxy := newReferrersTestHandler(t, upstream) proxy.MetadataTTL = time.Hour target := "/library/nginx/referrers/" + testReferrersSubject if first := serveReferrersRequest(h, http.MethodGet, target); first.Code != http.StatusOK { t.Fatalf("first status = %d, want 200", first.Code) } // Age the row past the TTL so the next request has to revalidate. cacheKey := h.containerManifestCacheKey(upstream.URL, "library/nginx", testReferrersSubject, "") entry, err := proxy.DB.GetMetadataCache(containerReferrersCacheEcosystem, cacheKey) if err != nil || entry == nil { t.Fatalf("cached row = %v, %v, want one", entry, err) } entry.FetchedAt.Time = time.Now().Add(-2 * time.Hour) if err := proxy.DB.UpsertMetadataCache(entry); err != nil { t.Fatalf("ageing cached row: %v", err) } second := serveReferrersRequest(h, http.MethodGet, target) if second.Code != http.StatusOK || second.Body.String() != testReferrersIndex { t.Fatalf("revalidated = %d %q, want cached index", second.Code, second.Body.String()) } if got := second.Header().Get("Warning"); got != "" { t.Errorf("Warning = %q, want none after 304", got) } // The 304 starts the TTL again, so this one stays in the cache. if third := serveReferrersRequest(h, http.MethodGet, target); third.Code != http.StatusOK { t.Fatalf("third status = %d, want 200", third.Code) } if upstreamRequests != 2 || notModified != 1 { t.Errorf("upstream requests = %d with %d answered 304, want 2 with 1", upstreamRequests, notModified) } } func TestContainerHandler_ReferrersDefaultsMissingContentType(t *testing.T) { upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { upstreamRequests++ // A nil value stops net/http from sniffing a Content-Type. w.Header()["Content-Type"] = nil _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() h, proxy := newReferrersTestHandler(t, upstream) proxy.MetadataTTL = time.Hour target := "/library/nginx/referrers/" + testReferrersSubject // oras-go only accepts the exact image index type, both fresh and cached. for _, label := range []string{"fetched", "cached"} { got := serveReferrersRequest(h, http.MethodGet, target) if got.Code != http.StatusOK { t.Fatalf("%s status = %d, want 200", label, got.Code) } if ct := got.Header().Get("Content-Type"); ct != containerReferrersMediaType { t.Errorf("%s Content-Type = %q, want %q", label, ct, containerReferrersMediaType) } } if upstreamRequests != 1 { t.Errorf("upstream requests = %d, want 1", upstreamRequests) } } func TestContainerHandler_ReferrersRelaysUpstreamErrorsWithoutCaching(t *testing.T) { tests := []struct { name string status int header string value string wantError string }{ {name: "no referrers API", status: http.StatusNotFound, header: "Content-Type", value: "application/json", wantError: "NOT_FOUND"}, {name: "repository unknown", status: http.StatusNotFound, header: "Content-Type", value: "application/json", wantError: "NAME_UNKNOWN"}, {name: "auth challenge", status: http.StatusUnauthorized, header: "WWW-Authenticate", value: `Bearer realm="https://auth.example.test/token"`, wantError: "UNAUTHORIZED"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { upstreamRequests++ w.Header().Set(tt.header, tt.value) w.WriteHeader(tt.status) _, _ = io.WriteString(w, `{"errors":[{"code":"`+tt.wantError+`"}]}`) })) defer upstream.Close() h, proxy := newReferrersTestHandler(t, upstream) proxy.MetadataTTL = time.Hour target := "/library/nginx/referrers/" + testReferrersSubject for range 2 { got := serveReferrersRequest(h, http.MethodGet, target) if got.Code != tt.status { t.Fatalf("status = %d, want %d", got.Code, tt.status) } if !strings.Contains(got.Body.String(), tt.wantError) { t.Errorf("body = %q, want upstream error %s", got.Body.String(), tt.wantError) } if got.Header().Get(tt.header) != tt.value { t.Errorf("%s = %q, want %q", tt.header, got.Header().Get(tt.header), tt.value) } } if upstreamRequests != 2 { t.Errorf("upstream requests = %d, want 2 (error must not be cached)", upstreamRequests) } }) } } func TestContainerHandler_ReferrersReportsUpstreamFailureWithoutCache(t *testing.T) { tests := []struct { name string handler http.HandlerFunc wantStatus int wantRetryAfter string }{ {name: "upstream 503", wantStatus: http.StatusServiceUnavailable, wantRetryAfter: "120", handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Retry-After", "120") http.Error(w, "unavailable", http.StatusServiceUnavailable) }}, {name: "upstream 429", wantStatus: http.StatusTooManyRequests, wantRetryAfter: "60", handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Retry-After", "60") http.Error(w, "slow down", http.StatusTooManyRequests) }}, {name: "upstream not JSON", wantStatus: http.StatusBadGateway, handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html") _, _ = io.WriteString(w, "captive portal") }}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamRequests++ tt.handler(w, r) })) defer upstream.Close() h, proxy := newReferrersTestHandler(t, upstream) proxy.MetadataTTL = time.Hour target := "/library/nginx/referrers/" + testReferrersSubject for range 2 { got := serveReferrersRequest(h, http.MethodGet, target) if got.Code != tt.wantStatus { t.Fatalf("status = %d, want %d: %s", got.Code, tt.wantStatus, got.Body.String()) } if got.Header().Get("Retry-After") != tt.wantRetryAfter { t.Errorf("Retry-After = %q, want %q", got.Header().Get("Retry-After"), tt.wantRetryAfter) } } if upstreamRequests != 2 { t.Errorf("upstream requests = %d, want 2 (failure must not be cached)", upstreamRequests) } }) } t.Run("upstream unreachable", func(t *testing.T) { upstream := httptest.NewServer(http.NotFoundHandler()) h, _ := newReferrersTestHandler(t, upstream) upstream.Close() got := serveReferrersRequest(h, http.MethodGet, "/library/nginx/referrers/"+testReferrersSubject) if got.Code != http.StatusBadGateway { t.Fatalf("status = %d, want 502 so clients keep using the referrers API: %s", got.Code, got.Body.String()) } }) } func TestContainerHandler_ReferrersNamedRegistryRewritesLink(t *testing.T) { upstreamRequests := 0 var upstream *httptest.Server upstream = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamRequests++ if r.URL.Path != "/v2/owner/img/referrers/"+testReferrersSubject { http.NotFound(w, r) return } w.Header().Set("Content-Type", containerReferrersMediaType) switch r.URL.Query().Get("last") { case "": w.Header().Add("Link", `; rel="next"`) w.Header().Add("Link", `; rel="help"`) case "abc": w.Header().Set("Link", `<`+upstream.URL+`/v2/owner/img/referrers/`+testReferrersSubject+`?last=def>; rel="next"`) } _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() proxy.MetadataTTL = time.Hour // The default registry is the same upstream, so upstream/test/owner/img // and owner/img share one cache row. h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL, map[string]string{"test": upstream.URL}) routes := http.StripPrefix("/v2", h.Routes()) first := httptest.NewRecorder() routes.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/v2/upstream/test/owner/img/referrers/"+testReferrersSubject+"?artifactType=application/spdx%2Bjson", nil)) if first.Code != http.StatusOK { t.Fatalf("first page status = %d, want 200: %s", first.Code, first.Body.String()) } wantLink := `; rel="next", ; rel="help"` if got := first.Header().Get("Link"); got != wantLink { t.Fatalf("Link = %q, want %q", got, wantLink) } shared := httptest.NewRecorder() routes.ServeHTTP(shared, httptest.NewRequest(http.MethodGet, "/v2/owner/img/referrers/"+testReferrersSubject, nil)) if shared.Code != http.StatusOK { t.Fatalf("shared row status = %d, want 200: %s", shared.Code, shared.Body.String()) } wantSharedLink := `; rel="next", ; rel="help"` if got := shared.Header().Get("Link"); got != wantSharedLink { t.Errorf("shared row Link = %q, want it rewritten for the second path %q", got, wantSharedLink) } if upstreamRequests != 1 { t.Fatalf("upstream requests after shared row hit = %d, want 1", upstreamRequests) } nextURL := strings.TrimPrefix(strings.SplitN(first.Header().Get("Link"), ">", 2)[0], "<") next := httptest.NewRecorder() routes.ServeHTTP(next, httptest.NewRequest(http.MethodGet, nextURL, nil)) if next.Code != http.StatusOK { t.Fatalf("next page status = %d, want 200: %s", next.Code, next.Body.String()) } wantNextLink := `; rel="next"` if got := next.Header().Get("Link"); got != wantNextLink { t.Errorf("next page Link = %q, want absolute upstream link rewritten to %q", got, wantNextLink) } if upstreamRequests != 2 { t.Errorf("upstream requests = %d, want 2 (pages are separate rows)", upstreamRequests) } } func TestContainerHandler_ReferrersRejectsInvalidRequests(t *testing.T) { upstreamRequests := 0 upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { upstreamRequests++ w.WriteHeader(http.StatusInternalServerError) })) defer upstream.Close() proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", upstream.URL) tests := []struct { name string method string path string wantStatus int wantCode string }{ {name: "post", method: http.MethodPost, path: "/library/nginx/referrers/" + testReferrersSubject, wantStatus: http.StatusMethodNotAllowed}, {name: "short digest", method: http.MethodGet, path: "/library/nginx/referrers/sha256:abc", wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, {name: "trailing characters", method: http.MethodGet, path: "/library/nginx/referrers/" + testReferrersSubject + "x", wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, {name: "leading characters", method: http.MethodGet, path: "/library/nginx/referrers/x" + testReferrersSubject, wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, {name: "unknown algorithm", method: http.MethodGet, path: "/library/nginx/referrers/md5:" + strings.Repeat("a", 32), wantStatus: http.StatusBadRequest, wantCode: "DIGEST_INVALID"}, {name: "unknown named upstream", method: http.MethodGet, path: "/upstream/missing/owner/img/referrers/" + testReferrersSubject, wantStatus: http.StatusNotFound, wantCode: "NAME_UNKNOWN"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := serveReferrersRequest(h, tt.method, tt.path) if got.Code != tt.wantStatus { t.Fatalf("status = %d, want %d: %s", got.Code, tt.wantStatus, got.Body.String()) } if tt.wantCode != "" && !strings.Contains(got.Body.String(), tt.wantCode) { t.Errorf("body = %q, want %s", got.Body.String(), tt.wantCode) } }) } if upstreamRequests != 0 { t.Errorf("upstream requests = %d, want 0", upstreamRequests) } } func TestContainerHandler_ReferrersRouteKeepsManifestPaths(t *testing.T) { tagSchema := "sha256-" + strings.TrimPrefix(testReferrersSubject, "sha256:") var upstreamPaths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamPaths = append(upstreamPaths, r.URL.Path) // Both handlers build the same upstream path here, so tell them apart // by Accept: only the referrers handler asks for the index type alone. if r.Header.Get("Accept") == containerReferrersMediaType { http.Error(w, "reached the referrers handler", http.StatusBadRequest) return } w.Header().Set("Content-Type", containerReferrersMediaType) _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() h, _ := newReferrersTestHandler(t, upstream) for _, path := range []string{ // A repository may contain a "referrers" component; manifest paths // must keep reaching the manifest handler. "/foo/referrers/app/manifests/latest", // Clients without a referrers API read the tag schema as a manifest. "/library/nginx/manifests/" + tagSchema, } { if got := serveReferrersRequest(h, http.MethodGet, path); got.Code != http.StatusOK { t.Fatalf("GET %s status = %d, want 200: %s", path, got.Code, got.Body.String()) } } want := []string{ "/v2/foo/referrers/app/manifests/latest", "/v2/library/nginx/manifests/" + tagSchema, } if strings.Join(upstreamPaths, "\n") != strings.Join(want, "\n") { t.Errorf("upstream paths = %q, want %q", upstreamPaths, want) } } func TestContainerHandler_ReferrersRouteAllowsEndpointNamesInRepository(t *testing.T) { var upstreamPaths []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { upstreamPaths = append(upstreamPaths, r.URL.Path) // The manifest handler would forward the same path, so only answer // the referrers handler, which asks for the index type alone. if r.Header.Get("Accept") != containerReferrersMediaType { http.Error(w, "reached the manifest handler", http.StatusBadRequest) return } w.Header().Set("Content-Type", containerReferrersMediaType) _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() h, _ := newReferrersTestHandler(t, upstream) for _, name := range []string{"owner/tags/list", "owner/manifests/app"} { upstreamPaths = nil got := serveReferrersRequest(h, http.MethodGet, "/"+name+"/referrers/"+testReferrersSubject) if got.Code != http.StatusOK { t.Fatalf("%s: status = %d, want 200: %s", name, got.Code, got.Body.String()) } want := "/v2/" + name + "/referrers/" + testReferrersSubject if len(upstreamPaths) != 1 || upstreamPaths[0] != want { t.Errorf("%s: upstream paths = %q, want [%q]", name, upstreamPaths, want) } } } func TestContainerHandler_ReferrersFollowsNamespace(t *testing.T) { var upstreamQueries []string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v2/owner/img/referrers/"+testReferrersSubject { http.NotFound(w, r) return } upstreamQueries = append(upstreamQueries, r.URL.RawQuery) w.Header().Set("Content-Type", containerReferrersMediaType) w.Header().Set("Link", `; rel="next"`) _, _ = io.WriteString(w, testReferrersIndex) })) defer upstream.Close() proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = upstream.Client() proxy.MetadataTTL = time.Hour h := NewContainerHandlerWithRegistry(proxy, "http://proxy.example.test", "", map[string]string{"test": upstream.URL}) routes := http.StripPrefix("/v2", h.Routes()) host := strings.TrimPrefix(upstream.URL, "http://") target := "/v2/owner/img/referrers/" + testReferrersSubject withNS := httptest.NewRecorder() routes.ServeHTTP(withNS, httptest.NewRequest(http.MethodGet, target+"?ns="+url.QueryEscape(host), nil)) if withNS.Code != http.StatusOK { t.Fatalf("ns request status = %d, want 200: %s", withNS.Code, withNS.Body.String()) } wantLink := `; rel="next"` if got := withNS.Header().Get("Link"); got != wantLink { t.Errorf("Link = %q, want next page kept on the ns route %q", got, wantLink) } // The prefix route reaches the same registry and shares the row. prefixed := httptest.NewRecorder() routes.ServeHTTP(prefixed, httptest.NewRequest(http.MethodGet, "/v2/upstream/test/owner/img/referrers/"+testReferrersSubject, nil)) if prefixed.Code != http.StatusOK { t.Fatalf("prefix request status = %d, want 200: %s", prefixed.Code, prefixed.Body.String()) } if len(upstreamQueries) != 1 || upstreamQueries[0] != "" { t.Errorf("upstream queries = %q, want one request without ns", upstreamQueries) } } // Docker looks up a sha256- referrers tag on every pull. With the // upstream down and nothing cached, answering it as unknown lets a pull of a // cached image go ahead; other tags still report the failure. func TestContainerHandler_ReferrersTagIsUnknownWhenUpstreamFails(t *testing.T) { referrersTag := "sha256-" + strings.Repeat("ab", 32) tests := []struct { name string reference string upstream func(t *testing.T) string want int }{ {"referrers tag, upstream 503", referrersTag, upstreamAnswering(http.StatusServiceUnavailable), http.StatusNotFound}, {"referrers tag, upstream unreachable", referrersTag, unreachableUpstream, http.StatusNotFound}, {"referrers tag, upstream 404", referrersTag, upstreamAnswering(http.StatusNotFound), http.StatusNotFound}, {"other tag, upstream 503", "latest", upstreamAnswering(http.StatusServiceUnavailable), http.StatusServiceUnavailable}, {"other tag, upstream unreachable", "latest", unreachableUpstream, http.StatusBadGateway}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { proxy, _, _, _ := setupTestProxy(t) proxy.HTTPClient = &http.Client{} h := &ContainerHandler{proxy: proxy, registryURL: tt.upstream(t), proxyURL: "http://localhost:8080"} for _, method := range []string{http.MethodGet, http.MethodHead} { w := httptest.NewRecorder() h.Routes().ServeHTTP(w, httptest.NewRequest(method, "/library/nginx/manifests/"+tt.reference, nil)) if w.Code != tt.want { t.Errorf("%s status = %d, want %d; body: %s", method, w.Code, tt.want, w.Body.String()) } } }) } } func upstreamAnswering(status int) func(t *testing.T) string { return func(t *testing.T) string { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { http.Error(w, http.StatusText(status), status) })) t.Cleanup(srv.Close) return srv.URL } } func unreachableUpstream(t *testing.T) string { srv := httptest.NewServer(http.NotFoundHandler()) srv.Close() return srv.URL }