package appview import ( "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/go-chi/chi/v5" ) // catalogRouter wires the production registration, mountCatalogRefusal, over a // stand-in for the distribution app, so the test can tell an intercepted request // from one that reached the library. Calling the real helper rather than // restating its routes keeps the test from drifting away from NewServer. func catalogRouter() (chi.Router, *bool) { reachedDistribution := false r := chi.NewRouter() // Registered first on purpose: chi must prefer the static patterns below // even when the wildcard is declared ahead of them. r.Handle("/v2/*", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { reachedDistribution = true w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"repositories":[]}`)) })) mountCatalogRefusal(r) return r, &reachedDistribution } // TestCatalogUnsupported pins the contract: /v2/_catalog answers UNSUPPORTED // with 405 for every parameter shape, so no query string can change the answer. func TestCatalogUnsupported(t *testing.T) { paths := []string{ "/v2/_catalog", "/v2/_catalog?n=1", "/v2/_catalog?n=1000", "/v2/_catalog?n=0", "/v2/_catalog?n=-1", "/v2/_catalog?n=abc", "/v2/_catalog?last=foo", "/v2/_catalog?n=1000&last=foo", "/v2/_catalog/", "/v2/_catalog/?n=1000", } methods := []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodDelete} for _, path := range paths { for _, method := range methods { t.Run(method+" "+path, func(t *testing.T) { router, reached := catalogRouter() rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) if *reached { t.Fatalf("request reached the distribution handler, chi routed %q to the wildcard", path) } if rec.Code != http.StatusMethodNotAllowed { t.Errorf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } var body struct { Errors []struct { Code string `json:"code"` Message string `json:"message"` } `json:"errors"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode body %q: %v", rec.Body.String(), err) } if len(body.Errors) != 1 { t.Fatalf("got %d errors, want 1: %s", len(body.Errors), rec.Body.String()) } if body.Errors[0].Code != "UNSUPPORTED" { t.Errorf("code = %q, want UNSUPPORTED", body.Errors[0].Code) } if body.Errors[0].Message == "" { t.Error("message is empty, it should explain why there is no catalog") } }) } } } // TestCatalogResponseIsIdentical asserts the stronger property: not merely that // every form is UNSUPPORTED, but that every form produces byte-identical output, // so no parameter is left able to influence the answer. func TestCatalogResponseIsIdentical(t *testing.T) { paths := []string{ "/v2/_catalog", "/v2/_catalog?n=1", "/v2/_catalog?n=1000", "/v2/_catalog?n=0", "/v2/_catalog?n=-1", "/v2/_catalog?n=abc", "/v2/_catalog?last=foo", "/v2/_catalog/", } var wantCode int var wantBody string for i, path := range paths { router, _ := catalogRouter() rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) if i == 0 { wantCode, wantBody = rec.Code, rec.Body.String() continue } if rec.Code != wantCode || rec.Body.String() != wantBody { t.Errorf("%s returned %d %q, want %d %q", path, rec.Code, rec.Body.String(), wantCode, wantBody) } } } // TestCatalogDoesNotShadowRepositories guards the interception's blast radius: // real repository paths must still reach the distribution handler. func TestCatalogDoesNotShadowRepositories(t *testing.T) { paths := []string{ "/v2/", "/v2/alice.example.com/nginx/tags/list", "/v2/alice.example.com/_catalog/manifests/latest", "/v2/_catalogue/nginx/tags/list", } for _, path := range paths { t.Run(path, func(t *testing.T) { router, reached := catalogRouter() rec := httptest.NewRecorder() router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) if !*reached { t.Errorf("%s was intercepted, it should reach the distribution handler (got %d %q)", path, rec.Code, rec.Body.String()) } }) } } // TestCatalogSends405Allow pins the Allow header RFC 9110 requires on a 405. // // 15.5.6 and 10.2.1 both state it as a MUST: "An origin server MUST generate an // Allow header field in a 405 (Method Not Allowed) response." UNSUPPORTED maps // to 405, so the header has to be there. The value is empty on purpose — // 10.2.1: "An empty Allow field value indicates that the resource allows no // methods" — which is the truth for a catalog that does not exist. // // Asserted via the header map rather than the raw wire because Go writes an // empty field value as "Allow: " and a client reads it back as present with an // empty string, which is the distinction that matters: present-and-empty is // compliant, absent is not. func TestCatalogSends405Allow(t *testing.T) { r, _ := catalogRouter() for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodPost} { for _, path := range []string{"/v2/_catalog", "/v2/_catalog/", "/v2/_catalog?n=1000"} { req := httptest.NewRequest(method, path, nil) rec := httptest.NewRecorder() r.ServeHTTP(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("%s %s: status = %d, want %d", method, path, rec.Code, http.StatusMethodNotAllowed) } values, present := rec.Result().Header["Allow"] if !present { t.Errorf("%s %s: no Allow header on a 405; RFC 9110 15.5.6 makes it a MUST", method, path) continue } if len(values) != 1 || values[0] != "" { t.Errorf("%s %s: Allow = %q, want exactly one empty value (RFC 9110 10.2.1: the resource allows no methods)", method, path, values) } } } }