diff --git a/ociregistry/error.go b/ociregistry/error.go index 6dc5b0a..34e811b 100644 --- a/ociregistry/error.go +++ b/ociregistry/error.go @@ -15,15 +15,34 @@ package ociregistry import ( - "bytes" "encoding/json" "errors" + "fmt" + "log" "net/http" "strconv" "strings" "unicode" ) +var errorStatuses = map[string]int{ + ErrBlobUnknown.Code(): http.StatusNotFound, + ErrBlobUploadInvalid.Code(): http.StatusRequestedRangeNotSatisfiable, + ErrBlobUploadUnknown.Code(): http.StatusNotFound, + ErrDigestInvalid.Code(): http.StatusBadRequest, + ErrManifestBlobUnknown.Code(): http.StatusNotFound, + ErrManifestInvalid.Code(): http.StatusBadRequest, + ErrManifestUnknown.Code(): http.StatusNotFound, + ErrNameInvalid.Code(): http.StatusBadRequest, + ErrNameUnknown.Code(): http.StatusNotFound, + ErrSizeInvalid.Code(): http.StatusBadRequest, + ErrUnauthorized.Code(): http.StatusUnauthorized, + ErrDenied.Code(): http.StatusForbidden, + ErrUnsupported.Code(): http.StatusBadRequest, + ErrTooManyRequests.Code(): http.StatusTooManyRequests, + ErrRangeInvalid.Code(): http.StatusRequestedRangeNotSatisfiable, +} + // WireErrors is the JSON format used for error responses in // the OCI HTTP API. It should always contain at least one // error. @@ -71,24 +90,16 @@ func (e *WireError) Is(err error) bool { // Error implements the [error] interface. func (e *WireError) Error() string { var buf strings.Builder - for _, r := range e.Code_ { - if r == '_' { - buf.WriteByte(' ') - } else { - buf.WriteRune(unicode.ToLower(r)) - } - } - if buf.Len() == 0 { - buf.WriteString("(no code)") - } + writeErrorCodePrefix(&buf, e.Code_) + if e.Message != "" { buf.WriteString(": ") buf.WriteString(e.Message) } - if len(e.Detail_) != 0 && !bytes.Equal(e.Detail_, []byte("null")) { - buf.WriteString("; detail: ") - buf.Write(e.Detail_) - } + // TODO: it would be nice to have some way to surface the detail + // in a message, but it's awkward to do so here because we don't + // really want the detail to be duplicated in the "message" + // and "detail" fields. return buf.String() } @@ -225,6 +236,83 @@ func (e *httpError) ResponseBody() []byte { return e.body } +// MarshalError marshals the given error as JSON according +// to the OCI distribution specification. It also returns +// the associated HTTP status code, or [http.StatusInternalServerError] +// if no specific code can be found. +// +// If err is or wraps [Error], that code will be used for the "code" +// field in the marshaled error. +// +// If err wraps [HTTPError] and no HTTP status code is known +// for the error code, [HTTPError.StatusCode] will be used. +func MarshalError(err error) (errorBody []byte, httpStatus int) { + _err := err + defer func() { + log.Printf("MarshalError %#v -> %s, %d", _err, errorBody, httpStatus) + }() + var e WireError + // TODO perhaps we should iterate through all the + // errors instead of just choosing one. + // See https://github.com/golang/go/issues/66455 + var ociErr Error + if errors.As(err, &ociErr) { + e.Code_ = ociErr.Code() + e.Detail_ = ociErr.Detail() + } + if e.Code_ == "" { + // This is contrary to spec, but it's what the Docker registry + // does, so it can't be too bad. + e.Code_ = "UNKNOWN" + } + // Use the HTTP status code from the error only when there isn't + // one implied from the error code. This means that the HTTP status + // is always consistent with the error code, but still allows a registry + // to choose custom HTTP status codes for other codes. + httpStatus = http.StatusInternalServerError + if status, ok := errorStatuses[e.Code_]; ok { + httpStatus = status + } else { + var httpErr HTTPError + if errors.As(err, &httpErr) { + httpStatus = httpErr.StatusCode() + } + } + // Prevent the message from containing a redundant + // error code prefix by stripping it before sending over the + // wire. This won't always work, but is enough to prevent + // adjacent stuttering of code prefixes when a client + // creates a WireError from an error response. + e.Message = trimErrorCodePrefix(err, httpStatus, e.Code_) + data, err := json.Marshal(WireErrors{ + Errors: []WireError{e}, + }) + if err != nil { + panic(fmt.Errorf("cannot marshal error: %v", err)) + } + return data, httpStatus +} + +// trimErrorCodePrefix returns err's string +// with any prefix codes added by [HTTPError] +// or [WireError] removed. +func trimErrorCodePrefix(err error, httpStatus int, errorCode string) string { + msg := err.Error() + var buf strings.Builder + if httpStatus != 0 { + writeHTTPStatusPrefix(&buf, httpStatus) + buf.WriteString(": ") + msg = strings.TrimPrefix(msg, buf.String()) + } + if errorCode != "" { + buf.Reset() + writeErrorCodePrefix(&buf, errorCode) + buf.WriteString(": ") + msg = strings.TrimPrefix(msg, buf.String()) + } + return msg +} + // The following values represent the known error codes. var ( ErrBlobUnknown = NewError("blob unknown to registry", "BLOB_UNKNOWN", nil) @@ -252,6 +340,26 @@ var ( ErrRangeInvalid = NewError("invalid content range", "RANGE_INVALID", nil) ) +func writeHTTPStatusPrefix(buf *strings.Builder, statusCode int) { + buf.WriteString(strconv.Itoa(statusCode)) + buf.WriteString(" ") + buf.WriteString(http.StatusText(statusCode)) +} + +func writeErrorCodePrefix(buf *strings.Builder, code string) { + if code == "" { + buf.WriteString("(no code)") + return + } + for _, r := range code { + if r == '_' { + buf.WriteByte(' ') + } else { + buf.WriteRune(unicode.ToLower(r)) + } + } +} + func ref[T any](x T) *T { return &x } diff --git a/ociregistry/error_test.go b/ociregistry/error_test.go new file mode 100644 index 0000000..3c4ba17 --- /dev/null +++ b/ociregistry/error_test.go @@ -0,0 +1,94 @@ +package ociregistry + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/go-quicktest/qt" +) + +var errorTests = []struct { + testName string + err error + wantMsg string + wantMarshalData rawJSONMessage + wantMarshalHTTPStatus int +}{{ + testName: "RegularGoError", + err: fmt.Errorf("unknown error"), + wantMsg: "unknown error", + wantMarshalData: `{"errors":[{"code":"UNKNOWN","message":"unknown error"}]}`, + wantMarshalHTTPStatus: http.StatusInternalServerError, +}, { + testName: "RegistryError", + err: ErrBlobUnknown, + wantMsg: "blob unknown: blob unknown to registry", + wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown to registry"}]}`, + wantMarshalHTTPStatus: http.StatusNotFound, +}, { + testName: "WrappedRegistryErrorWithContextAtStart", + err: fmt.Errorf("some context: %w", ErrBlobUnknown), + wantMsg: "some context: blob unknown: blob unknown to registry", + wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"some context: blob unknown: blob unknown to registry"}]}`, + wantMarshalHTTPStatus: http.StatusNotFound, +}, { + testName: "WrappedRegistryErrorWithContextAtEnd", + err: fmt.Errorf("%w: some context", ErrBlobUnknown), + wantMsg: "blob unknown: blob unknown to registry: some context", + wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"blob unknown to registry: some context"}]}`, + wantMarshalHTTPStatus: http.StatusNotFound, +}, { + testName: "HTTPStatusIgnoredWithKnownCode", + err: NewHTTPError(fmt.Errorf("%w: some context", ErrBlobUnknown), http.StatusUnauthorized, nil, nil), + wantMsg: "401 Unauthorized: blob unknown: blob unknown to registry: some context", + // Note: the "401 Unauthorized" remains intact because it's not redundant with respect + // to the 404 HTTP response code. + wantMarshalData: `{"errors":[{"code":"BLOB_UNKNOWN","message":"401 Unauthorized: blob unknown: blob unknown to registry: some context"}]}`, + wantMarshalHTTPStatus: http.StatusNotFound, +}, { + testName: "HTTPStatusUsedWithUnknownCode", + err: NewHTTPError(NewError("a message with a code", "SOME_CODE", nil), http.StatusUnauthorized, nil, nil), + wantMsg: "401 Unauthorized: some code: a message with a code", + wantMarshalData: `{"errors":[{"code":"SOME_CODE","message":"a message with a code"}]}`, + wantMarshalHTTPStatus: http.StatusUnauthorized, +}, { + testName: "ErrorWithDetail", + err: NewError("a message with some detail", "SOME_CODE", json.RawMessage(`{"foo": true}`)), + wantMsg: `some code: a message with some detail`, + wantMarshalData: `{"errors":[{"code":"SOME_CODE","message":"a message with some detail","detail":{"foo":true}}]}`, + wantMarshalHTTPStatus: http.StatusInternalServerError, +}} + +func TestError(t *testing.T) { + for _, test := range errorTests { + t.Run(test.testName, func(t *testing.T) { + qt.Check(t, qt.ErrorMatches(test.err, test.wantMsg)) + data, httpStatus := MarshalError(test.err) + qt.Check(t, qt.Equals(httpStatus, test.wantMarshalHTTPStatus)) + qt.Check(t, qt.JSONEquals(data, test.wantMarshalData), qt.Commentf("marshal data: %s", data)) + + // Check that the marshaled error unmarshals into WireError OK and + // that the code matches appropriately. + var errs *WireErrors + err := json.Unmarshal(data, &errs) + qt.Assert(t, qt.IsNil(err)) + if ociErr := Error(nil); errors.As(test.err, &ociErr) { + qt.Assert(t, qt.IsTrue(errors.Is(errs, NewError("something", ociErr.Code(), nil)))) + } + }) + } +} + +type rawJSONMessage string + +func (m rawJSONMessage) MarshalJSON() ([]byte, error) { + return []byte(m), nil +} + +func (m *rawJSONMessage) UnmarshalJSON(data []byte) error { + *m = rawJSONMessage(data) + return nil +} diff --git a/ociregistry/ociclient/error.go b/ociregistry/ociclient/error.go index 21a2e9e..21f8901 100644 --- a/ociregistry/ociclient/error.go +++ b/ociregistry/ociclient/error.go @@ -55,6 +55,9 @@ func makeError1(resp *http.Response, bodyData []byte) error { // When we've made a HEAD request, we can't see any of // the actual error, so we'll have to make up something // from the HTTP status. + // TODO would we do better if we interpreted the HTTP status + // relative to the actual method that was called in order + // to come up with a more plausible error? var err error switch resp.StatusCode { case http.StatusNotFound: @@ -71,7 +74,7 @@ func makeError1(resp *http.Response, bodyData []byte) error { // Our caller will turn this into a non-nil error. return nil } - return fmt.Errorf("error response: %v: %w", resp.Status, err) + return err } if ctype := resp.Header.Get("Content-Type"); !isJSONMediaType(ctype) { return fmt.Errorf("non-JSON error response %q; body %q", ctype, bodyData) diff --git a/ociregistry/ociclient/error_test.go b/ociregistry/ociclient/error_test.go index 6454e8c..9726b5a 100644 --- a/ociregistry/ociclient/error_test.go +++ b/ociregistry/ociclient/error_test.go @@ -10,10 +10,39 @@ import ( "testing" "cuelabs.dev/go/oci/ociregistry" + "cuelabs.dev/go/oci/ociregistry/ociserver" "github.com/go-quicktest/qt" "github.com/opencontainers/go-digest" ) +func TestErrorStuttering(t *testing.T) { + // This checks that the stuttering observed in issue #31 + // isn't an issue when ociserver wraps ociclient. + srv := httptest.NewServer(ociserver.New(&ociregistry.Funcs{ + NewError: func(ctx context.Context, methodName, repo string) error { + return ociregistry.ErrManifestUnknown + }, + }, nil)) + defer srv.Close() + + srvURL, _ := url.Parse(srv.URL) + r, err := New(srvURL.Host, &Options{ + Insecure: true, + }) + qt.Assert(t, qt.IsNil(err)) + _, err = r.GetTag(context.Background(), "foo", "sometag") + qt.Check(t, qt.ErrorIs(err, ociregistry.ErrManifestUnknown)) + qt.Check(t, qt.ErrorMatches(err, `404 Not Found: manifest unknown: manifest unknown to registry`)) + + // ResolveTag uses HEAD rather than GET, so here we're testing + // the path where a response with no body gets turned back into + // something vaguely resembling the original error, which is why + // the code and message have changed. + _, err = r.ResolveTag(context.Background(), "foo", "sometag") + qt.Check(t, qt.ErrorIs(err, ociregistry.ErrNameUnknown)) + qt.Check(t, qt.ErrorMatches(err, `404 Not Found: name unknown: repository name not known to registry`)) +} + func TestNonJSONErrorResponse(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { w.WriteHeader(http.StatusTeapot) diff --git a/ociregistry/ociserver/error.go b/ociregistry/ociserver/error.go index 2fcb0e6..126562b 100644 --- a/ociregistry/ociserver/error.go +++ b/ociregistry/ociserver/error.go @@ -15,8 +15,6 @@ package ociserver import ( - "encoding/json" - "errors" "fmt" "net/http" @@ -24,71 +22,12 @@ import ( ) func writeError(resp http.ResponseWriter, err error) { - e := ociregistry.WireError{ - Message: err.Error(), - } - // TODO perhaps we should iterate through all the - // errors instead of just choosing one. - // See https://github.com/golang/go/issues/66455 - var ociErr ociregistry.Error - if errors.As(err, &ociErr) { - e.Code_ = ociErr.Code() - if detail := ociErr.Detail(); detail != nil { - data, err := json.Marshal(detail) - if err != nil { - panic(fmt.Errorf("cannot marshal error detail: %v", err)) - } - e.Detail_ = json.RawMessage(data) - } - } else { - // This is contrary to spec, but it's what the Docker registry - // does, so it can't be too bad. - e.Code_ = "UNKNOWN" - } - - // Use the HTTP status code from the error only when there isn't - // one implied from the error code. This means that the HTTP status - // is always consistent with the error code, but still allows a registry - // to return custom HTTP status codes for other codes. - httpStatus := http.StatusInternalServerError - if status, ok := errorStatuses[e.Code_]; ok { - httpStatus = status - } else { - var httpErr ociregistry.HTTPError - if errors.As(err, &httpErr) { - httpStatus = httpErr.StatusCode() - } - } + data, httpStatus := ociregistry.MarshalError(err) resp.Header().Set("Content-Type", "application/json") resp.WriteHeader(httpStatus) - - data, err := json.Marshal(ociregistry.WireErrors{ - Errors: []ociregistry.WireError{e}, - }) - if err != nil { - // TODO log - } resp.Write(data) } -var errorStatuses = map[string]int{ - ociregistry.ErrBlobUnknown.Code(): http.StatusNotFound, - ociregistry.ErrBlobUploadInvalid.Code(): http.StatusRequestedRangeNotSatisfiable, - ociregistry.ErrBlobUploadUnknown.Code(): http.StatusNotFound, - ociregistry.ErrDigestInvalid.Code(): http.StatusBadRequest, - ociregistry.ErrManifestBlobUnknown.Code(): http.StatusNotFound, - ociregistry.ErrManifestInvalid.Code(): http.StatusBadRequest, - ociregistry.ErrManifestUnknown.Code(): http.StatusNotFound, - ociregistry.ErrNameInvalid.Code(): http.StatusBadRequest, - ociregistry.ErrNameUnknown.Code(): http.StatusNotFound, - ociregistry.ErrSizeInvalid.Code(): http.StatusBadRequest, - ociregistry.ErrUnauthorized.Code(): http.StatusUnauthorized, - ociregistry.ErrDenied.Code(): http.StatusForbidden, - ociregistry.ErrUnsupported.Code(): http.StatusBadRequest, - ociregistry.ErrTooManyRequests.Code(): http.StatusTooManyRequests, - ociregistry.ErrRangeInvalid.Code(): http.StatusRequestedRangeNotSatisfiable, -} - func withHTTPCode(statusCode int, err error) error { return ociregistry.NewHTTPError(err, statusCode, nil, nil) } diff --git a/ociregistry/ociserver/error_test.go b/ociregistry/ociserver/error_test.go index c99b64f..8a26b68 100644 --- a/ociregistry/ociserver/error_test.go +++ b/ociregistry/ociserver/error_test.go @@ -16,7 +16,7 @@ package ociserver import ( "context" - "io/ioutil" + "io" "net/http" "net/http/httptest" "testing" @@ -40,7 +40,7 @@ func TestHTTPStatusOverriddenByErrorCode(t *testing.T) { resp, err := http.Get(s.URL + "/v2/foo/manifests/sometag") qt.Assert(t, qt.IsNil(err)) defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) + body, _ := io.ReadAll(resp.Body) qt.Assert(t, qt.Equals(resp.StatusCode, http.StatusNotFound)) qt.Assert(t, qt.JSONEquals(body, &ociregistry.WireErrors{ Errors: []ociregistry.WireError{{ @@ -64,12 +64,12 @@ func TestHTTPStatusUsedForUnknownErrorCode(t *testing.T) { resp, err := http.Get(s.URL + "/v2/foo/manifests/sometag") qt.Assert(t, qt.IsNil(err)) defer resp.Body.Close() - body, _ := ioutil.ReadAll(resp.Body) + body, _ := io.ReadAll(resp.Body) qt.Assert(t, qt.Equals(resp.StatusCode, http.StatusTeapot)) qt.Assert(t, qt.JSONEquals(body, &ociregistry.WireErrors{ Errors: []ociregistry.WireError{{ Code_: "SOMECODE", - Message: "418 I'm a teapot: somecode: foo", + Message: "foo", }}, })) } diff --git a/ociregistry/ociserver/registry_test.go b/ociregistry/ociserver/registry_test.go index 6b5ff7c..5f219a7 100644 --- a/ociregistry/ociserver/registry_test.go +++ b/ociregistry/ociserver/registry_test.go @@ -88,14 +88,14 @@ func TestCalls(t *testing.T) { URL: "/v2/bad", WantCode: http.StatusNotFound, WantHeader: map[string]string{"Docker-Distribution-API-Version": "registry/2.0"}, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"404 Not Found: page not found"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"page not found"}]}`, }, { Description: "GET_non_existent_blob", Method: "GET", URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"name unknown: repository name not known to registry"}]}`, + WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`, }, { Description: "HEAD_non_existent_blob", @@ -108,7 +108,7 @@ func TestCalls(t *testing.T) { Method: "GET", URL: "/v2/foo/blobs/sha256:asd", WantCode: http.StatusBadRequest, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"400 Bad Request: badly formed digest"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`, }, { Description: "HEAD_bad_digest", @@ -121,7 +121,7 @@ func TestCalls(t *testing.T) { Method: "FOO", URL: "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", WantCode: http.StatusMethodNotAllowed, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"405 Method Not Allowed: method not allowed"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"method not allowed"}]}`, }, { Description: "GET_containerless_blob", @@ -214,7 +214,7 @@ func TestCalls(t *testing.T) { Method: "GET", URL: "/v2/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"404 Not Found: page not found"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"page not found"}]}`, }, { Description: "uploadurl", @@ -235,7 +235,7 @@ func TestCalls(t *testing.T) { Method: "PUT", URL: "/v2/foo/blobs/uploads/MQ", WantCode: http.StatusBadRequest, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"400 Bad Request: badly formed digest"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`, }, { Description: "monolithic_upload_good_digest", @@ -251,7 +251,7 @@ func TestCalls(t *testing.T) { URL: "/v2/foo/blobs/uploads?digest=sha256:fake", Body: "foo", WantCode: http.StatusBadRequest, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"400 Bad Request: badly formed digest"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`, }, { Description: "upload_good_digest", @@ -267,7 +267,7 @@ func TestCalls(t *testing.T) { URL: "/v2/foo/blobs/uploads/MQ?digest=sha256:baddigest", WantCode: http.StatusBadRequest, Body: "foo", - WantBody: `{"errors":[{"code":"UNKNOWN","message":"400 Bad Request: badly formed digest"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`, }, { Description: "stream_upload", @@ -305,7 +305,7 @@ func TestCalls(t *testing.T) { Method: "GET", URL: "/v2/foo/manifests/latest", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"name unknown: repository name not known to registry"}]}`, + WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`, }, { Description: "head_missing_manifest", @@ -319,7 +319,7 @@ func TestCalls(t *testing.T) { Method: "GET", URL: "/v2/foo/manifests/bar", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown: manifest unknown to registry"}]}`, + WantBody: `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown to registry"}]}`, }, { Description: "head_missing_manifest_good_container", @@ -396,7 +396,7 @@ func TestCalls(t *testing.T) { Method: "BAR", URL: "/v2/foo/manifests/latest", WantCode: http.StatusMethodNotAllowed, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"405 Method Not Allowed: method not allowed"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"method not allowed"}]}`, }, { Description: "Chunk_upload_start", @@ -418,7 +418,7 @@ func TestCalls(t *testing.T) { // TODO the original had 405 response here. Which is correct? WantCode: http.StatusBadRequest, Body: "foo", - WantBody: `{"errors":[{"code":"UNSUPPORTED","message":"unsupported: we don't understand your Content-Range"}]}`, + WantBody: `{"errors":[{"code":"UNSUPPORTED","message":"we don't understand your Content-Range"}]}`, }, { Description: "Chunk_upload_overlaps_previous_data", @@ -448,7 +448,7 @@ func TestCalls(t *testing.T) { Method: "DELETE", URL: "/v2/test/honk/manifests/latest", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"name unknown: repository name not known to registry"}]}`, + WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`, }, { Description: "DELETE_Unknown_manifest", @@ -456,7 +456,7 @@ func TestCalls(t *testing.T) { Method: "DELETE", URL: "/v2/honk/manifests/tag-honk", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown: manifest unknown to registry: tag does not exist"}]}`, + WantBody: `{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown to registry: tag does not exist"}]}`, }, { Description: "DELETE_existing_manifest", @@ -501,7 +501,7 @@ func TestCalls(t *testing.T) { Method: "GET", URL: "/v2/foo/tags/list?n=1000", WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"name unknown: repository name not known to registry"}]}`, + WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`, }, { Description: "list_repos", @@ -548,14 +548,14 @@ func TestCalls(t *testing.T) { Method: "GET", URL: "/v2/does-not-exist/referrers/" + digestOf("foo"), WantCode: http.StatusNotFound, - WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"name unknown: repository name not known to registry"}]}`, + WantBody: `{"errors":[{"code":"NAME_UNKNOWN","message":"repository name not known to registry"}]}`, }, { Description: "fetch_references,_bad_target_(tag_vs._digest)", Method: "GET", URL: "/v2/foo/referrers/latest", WantCode: http.StatusBadRequest, - WantBody: `{"errors":[{"code":"UNKNOWN","message":"400 Bad Request: badly formed digest"}]}`, + WantBody: `{"errors":[{"code":"UNKNOWN","message":"badly formed digest"}]}`, }, { skip: true, @@ -659,7 +659,7 @@ func TestCalls(t *testing.T) { if string(body) != tc.WantBody { t.Logf("\n WantBody: `%s`,", body) - t.Errorf("Incorrect response body, got %q, want %q", body, tc.WantBody) + t.Errorf("Incorrect response body.\ngot:\n\t%q\n\twant:\n\t%q", body, tc.WantBody) } } t.Run(tc.Description, testf)