//go:build sync && scrub && metrics && search && lint && mgmt package api_test import ( "bytes" "context" "encoding/json" "io" "mime" "mime/multipart" "net/http" "net/http/httptest" "strconv" "strings" "sync/atomic" "testing" "github.com/google/uuid" "github.com/gorilla/mux" godigest "github.com/opencontainers/go-digest" "github.com/opencontainers/image-spec/specs-go" ispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/project-zot/mockoidc" . "github.com/smartystreets/goconvey/convey" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zitadel/oidc/v3/pkg/client/rp" "github.com/zitadel/oidc/v3/pkg/oidc" "golang.org/x/oauth2" zerr "zotregistry.dev/zot/v2/errors" "zotregistry.dev/zot/v2/pkg/api" "zotregistry.dev/zot/v2/pkg/api/config" "zotregistry.dev/zot/v2/pkg/api/constants" apiErr "zotregistry.dev/zot/v2/pkg/api/errors" "zotregistry.dev/zot/v2/pkg/log" mTypes "zotregistry.dev/zot/v2/pkg/meta/types" reqCtx "zotregistry.dev/zot/v2/pkg/requestcontext" storageTypes "zotregistry.dev/zot/v2/pkg/storage/types" test "zotregistry.dev/zot/v2/pkg/test/common" "zotregistry.dev/zot/v2/pkg/test/mocks" ) const sessionStr = "session" func TestRoutes(t *testing.T) { Convey("Make a new controller", t, func() { conf := config.New() conf.HTTP.Port = "0" username, seedUser := test.GenerateRandomString() password, seedPass := test.GenerateRandomString() htpasswdPath := test.MakeHtpasswdFileFromString(t, test.GetBcryptCredString(username, password)) mockOIDCServer, err := mockoidc.Run() if err != nil { panic(err) } defer func() { err := mockOIDCServer.Shutdown() if err != nil { panic(err) } }() mockOIDCConfig := mockOIDCServer.Config() defaultVal := true conf.HTTP.Auth = &config.AuthConfig{ HTPasswd: config.AuthHTPasswd{ Path: htpasswdPath, }, OpenID: &config.OpenIDConfig{ Providers: map[string]config.OpenIDProviderConfig{ "oidc": { ClientID: mockOIDCConfig.ClientID, ClientSecret: mockOIDCConfig.ClientSecret, KeyPath: "", Issuer: mockOIDCConfig.Issuer, Scopes: []string{"openid", "email"}, }, }, }, APIKey: defaultVal, } ctlr := api.NewController(conf) ctlr.Log.Info().Int64("seedUser", seedUser).Int64("seedPass", seedPass).Msg("random seed for username & password") ctlr.Config.Storage.RootDirectory = t.TempDir() ctlr.Config.Storage.Commit = true cm := test.NewControllerManager(ctlr) cm.StartAndWait(conf.HTTP.Port) defer cm.StopServer() baseURL := test.GetBaseURL(strconv.Itoa(ctlr.GetPort())) rthdlr := api.NewRouteHandler(ctlr) // NOTE: the url or method itself doesn't matter below since we are calling the handlers directly, // so path routing is bypassed Convey("Test GithubCodeExchangeCallback", func() { callback := rthdlr.GithubCodeExchangeCallback() ctx := context.TODO() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response := httptest.NewRecorder() tokens := &oidc.Tokens[*oidc.IDTokenClaims]{} relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{}) So(err, ShouldBeNil) callback(response, request, tokens, "state", relyingParty) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) }) Convey("Test OpenIDCodeExchangeCallback", func() { callback := rthdlr.OpenIDCodeExchangeCallback() ctx := context.TODO() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response := httptest.NewRecorder() tokens := &oidc.Tokens[*oidc.IDTokenClaims]{ IDTokenClaims: &oidc.IDTokenClaims{ Claims: map[string]any{ "groups": []any{"group1", "group3"}, }, }, } relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{}) So(err, ShouldBeNil) userinfo := &oidc.UserInfo{ Subject: "sub", Claims: map[string]any{ "email": "test@test.com", "groups": []any{"group1", "group2"}, }, UserInfoEmail: oidc.UserInfoEmail{Email: "test@test.com"}, } callback(response, request, tokens, "state", relyingParty, userinfo) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusUnauthorized) }) Convey("Test OpenIDCodeExchangeCallback with claim mapping", func() { authConfig := conf.HTTP.Auth.OpenID.Providers["oidc"] authConfig.ClaimMapping = &config.ClaimMapping{ Username: "preferred_username", Groups: "roles", } conf.HTTP.Auth.OpenID.Providers["oidc"] = authConfig var capturedGroups []string ctlr.MetaDB = mocks.MetaDBMock{ SetUserGroupsFn: func(ctx context.Context, groups []string) error { capturedGroups = append(capturedGroups, groups...) return nil }, } callback := rthdlr.OpenIDCodeExchangeCallbackWithProvider("oidc") ctx := context.TODO() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response := httptest.NewRecorder() state := uuid.New().String() session, _ := ctlr.CookieStore.Get(request, "statecookie") session.Values["state"] = state So(session.Save(request, response), ShouldBeNil) tokens := &oidc.Tokens[*oidc.IDTokenClaims]{ IDTokenClaims: &oidc.IDTokenClaims{ Claims: map[string]any{ "groups": []any{"ignored-token-group"}, "roles": []any{"ops", "admin"}, }, }, } relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{}) So(err, ShouldBeNil) userinfo := &oidc.UserInfo{ Subject: "sub", UserInfoProfile: oidc.UserInfoProfile{PreferredUsername: "mapped-user"}, Claims: map[string]any{ "email": "test@test.com", "groups": []any{"ignored-userinfo-group"}, "roles": []any{"dev", "ops"}, }, UserInfoEmail: oidc.UserInfoEmail{Email: "test@test.com"}, } callback(response, request, tokens, state, relyingParty, userinfo) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusCreated) So(capturedGroups, ShouldResemble, []string{"admin", "dev", "ops"}) userAc, err := reqCtx.UserAcFromContext(request.Context()) So(err, ShouldBeNil) So(userAc.GetUsername(), ShouldEqual, "mapped-user") So(userAc.GetGroups(), ShouldResemble, []string{"admin", "dev", "ops"}) }) Convey("Test OpenIDCodeExchangeCallback falls back to email when mapped username is missing", func() { authConfig := conf.HTTP.Auth.OpenID.Providers["oidc"] authConfig.ClaimMapping = &config.ClaimMapping{ Username: "missing_username", Groups: "roles", } conf.HTTP.Auth.OpenID.Providers["oidc"] = authConfig ctlr.MetaDB = mocks.MetaDBMock{ SetUserGroupsFn: func(ctx context.Context, groups []string) error { return nil }, } callback := rthdlr.OpenIDCodeExchangeCallbackWithProvider("oidc") ctx := context.TODO() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response := httptest.NewRecorder() state := uuid.New().String() session, _ := ctlr.CookieStore.Get(request, "statecookie") session.Values["state"] = state So(session.Save(request, response), ShouldBeNil) tokens := &oidc.Tokens[*oidc.IDTokenClaims]{ IDTokenClaims: &oidc.IDTokenClaims{ Claims: map[string]any{ "roles": []any{"admin"}, }, }, } relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{}) So(err, ShouldBeNil) userinfo := &oidc.UserInfo{ Subject: "sub", Claims: map[string]any{ "roles": []any{"dev"}, }, UserInfoEmail: oidc.UserInfoEmail{Email: "fallback@test.com"}, } callback(response, request, tokens, state, relyingParty, userinfo) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusCreated) userAc, err := reqCtx.UserAcFromContext(request.Context()) So(err, ShouldBeNil) So(userAc.GetUsername(), ShouldEqual, "fallback@test.com") So(userAc.GetGroups(), ShouldResemble, []string{"admin", "dev"}) }) Convey("Test OpenIDCodeExchangeCallback continues when mapped groups are missing", func() { authConfig := conf.HTTP.Auth.OpenID.Providers["oidc"] authConfig.ClaimMapping = &config.ClaimMapping{ Username: "preferred_username", Groups: "roles", } conf.HTTP.Auth.OpenID.Providers["oidc"] = authConfig var capturedGroups []string ctlr.MetaDB = mocks.MetaDBMock{ SetUserGroupsFn: func(ctx context.Context, groups []string) error { capturedGroups = append(capturedGroups, groups...) return nil }, } callback := rthdlr.OpenIDCodeExchangeCallbackWithProvider("oidc") ctx := context.TODO() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response := httptest.NewRecorder() state := uuid.New().String() session, _ := ctlr.CookieStore.Get(request, "statecookie") session.Values["state"] = state So(session.Save(request, response), ShouldBeNil) tokens := &oidc.Tokens[*oidc.IDTokenClaims]{ IDTokenClaims: &oidc.IDTokenClaims{ Claims: map[string]any{}, }, } relyingParty, err := rp.NewRelyingPartyOAuth(&oauth2.Config{}) So(err, ShouldBeNil) userinfo := &oidc.UserInfo{ Subject: "sub", UserInfoProfile: oidc.UserInfoProfile{PreferredUsername: "mapped-user"}, Claims: map[string]any{}, UserInfoEmail: oidc.UserInfoEmail{Email: "mapped@test.com"}, } callback(response, request, tokens, state, relyingParty, userinfo) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusCreated) So(capturedGroups, ShouldBeEmpty) userAc, err := reqCtx.UserAcFromContext(request.Context()) So(err, ShouldBeNil) So(userAc.GetUsername(), ShouldEqual, "mapped-user") So(userAc.GetGroups(), ShouldBeEmpty) }) Convey("Test OAuth2Callback errors", func() { ctx := context.TODO() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response := httptest.NewRecorder() _, err := api.OAuth2Callback(ctlr, response, request, "state", "email", "", []string{"group"}) So(err, ShouldEqual, zerr.ErrInvalidStateCookie) session, _ := ctlr.CookieStore.Get(request, "statecookie") session.Options.Secure = true session.Options.HttpOnly = true session.Options.SameSite = http.SameSiteDefaultMode state := uuid.New().String() session.Values["state"] = state // let the session set its own id err = session.Save(request, response) So(err, ShouldBeNil) _, err = api.OAuth2Callback(ctlr, response, request, "state", "email", "", []string{"group"}) So(err, ShouldEqual, zerr.ErrInvalidStateCookie) }) Convey("List repositories authz error", func() { var invalid struct{} uacKey := reqCtx.GetContextKey() ctx := context.WithValue(context.Background(), uacKey, invalid) request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, map[string]string{ "name": "test", "reference": "b8b1231908844a55c251211c7a67ae3c809fb86a081a8eeb4a715e6d7d65625c", }) response := httptest.NewRecorder() rthdlr.ListRepositories(response, request) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("Delete manifest authz error", func() { var invalid struct{} uacKey := reqCtx.GetContextKey() ctx := context.WithValue(context.Background(), uacKey, invalid) request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, map[string]string{ "name": "test", "reference": "b8b1231908844a55c251211c7a67ae3c809fb86a081a8eeb4a715e6d7d65625c", }) response := httptest.NewRecorder() rthdlr.DeleteManifest(response, request) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("Get manifest", func() { // overwrite controller storage ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{ GetImageManifestFn: func(repo string, reference string) ([]byte, godigest.Digest, string, error) { return []byte{}, "", "", zerr.ErrRepoBadVersion }, } request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, map[string]string{ "name": "test", "reference": "b8b1231908844a55c251211c7a67ae3c809fb86a081a8eeb4a715e6d7d65625c", }) response := httptest.NewRecorder() rthdlr.GetManifest(response, request) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.Header.Get("Access-Control-Allow-Credentials"), ShouldEqual, "") So(resp.StatusCode, ShouldEqual, http.StatusNotFound) }) Convey("Get manifest with explicit AllowOrigin emits credentials header", func() { ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{ GetImageManifestFn: func(repo string, reference string) ([]byte, godigest.Digest, string, error) { return []byte{}, "", "", zerr.ErrRepoBadVersion }, } originalAllowOrigin := ctlr.Config.HTTP.AllowOrigin ctlr.Config.HTTP.AllowOrigin = "https://example.com" defer func() { ctlr.Config.HTTP.AllowOrigin = originalAllowOrigin }() request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, map[string]string{ "name": "test", "reference": "b8b1231908844a55c251211c7a67ae3c809fb86a081a8eeb4a715e6d7d65625c", }) response := httptest.NewRecorder() rthdlr.GetManifest(response, request) resp := response.Result() defer resp.Body.Close() So(resp, ShouldNotBeNil) So(resp.Header.Get("Access-Control-Allow-Credentials"), ShouldEqual, "true") So(resp.StatusCode, ShouldEqual, http.StatusNotFound) }) Convey("UpdateManifest ", func() { testUpdateManifest := func(urlVars map[string]string, ism *mocks.MockedImageStore) int { ctlr.StoreController.DefaultStore = ism str := []byte("test") request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewBuffer(str)) request = mux.SetURLVars(request, urlVars) request.Header.Add("Content-Type", ispec.MediaTypeImageManifest) response := httptest.NewRecorder() rthdlr.UpdateManifest(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } Convey("body exceeds MaxManifestBodySize returns 413 with MANIFEST_INVALID error payload", func() { ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{} oversized := make([]byte, constants.MaxManifestBodySize+1) request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPut, baseURL, bytes.NewReader(oversized)) request = mux.SetURLVars(request, map[string]string{"name": "test", "reference": "v1"}) request.Header.Add("Content-Type", ispec.MediaTypeImageManifest) response := httptest.NewRecorder() rthdlr.UpdateManifest(response, request) So(response.Code, ShouldEqual, http.StatusRequestEntityTooLarge) var errList apiErr.ErrorList err := json.NewDecoder(response.Body).Decode(&errList) So(err, ShouldBeNil) So(errList.Errors, ShouldHaveLength, 1) So(errList.Errors[0].Code, ShouldEqual, apiErr.MANIFEST_INVALID.String()) So(errList.Errors[0].Detail["reason"], ShouldContainSubstring, "exceeds maximum allowed size") }) // repo not found statusCode := testUpdateManifest( map[string]string{ "name": "test", "reference": "reference", }, &mocks.MockedImageStore{ PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest, godigest.Digest, error, ) { return "", "", zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrManifestNotFound statusCode = testUpdateManifest( map[string]string{ "name": "test", "reference": "reference", }, &mocks.MockedImageStore{ PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest, godigest.Digest, error, ) { return "", "", zerr.ErrManifestNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrBadManifest statusCode = testUpdateManifest( map[string]string{ "name": "test", "reference": "reference", }, &mocks.MockedImageStore{ PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest, godigest.Digest, error, ) { return "", "", zerr.ErrBadManifest }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) // ErrBlobNotFound statusCode = testUpdateManifest( map[string]string{ "name": "test", "reference": "reference", }, &mocks.MockedImageStore{ PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest, godigest.Digest, error, ) { return "", "", zerr.ErrBlobNotFound }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) // ErrRepoBadVersion statusCode = testUpdateManifest( map[string]string{ "name": "test", "reference": "reference", }, &mocks.MockedImageStore{ PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest, godigest.Digest, error, ) { return "", "", zerr.ErrRepoBadVersion }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("UpdateManifest digest query tags with MetaDB", func() { defer func() { ctlr.MetaDB = nil }() configBlob := []byte(`{"architecture":"amd64","os":"linux"}`) configDigest := godigest.FromBytes(configBlob) manifest := ispec.Manifest{ Versioned: specs.Versioned{SchemaVersion: 2}, Config: ispec.Descriptor{ MediaType: ispec.MediaTypeImageConfig, Digest: configDigest, Size: int64(len(configBlob)), }, Layers: []ispec.Descriptor{}, } mcontent, mErr := json.Marshal(manifest) So(mErr, ShouldBeNil) manifestDigest := godigest.FromBytes(mcontent) digestRef := manifestDigest.String() ism := &mocks.MockedImageStore{ PutImageManifestFn: func(repo, reference, mediaType string, body []byte, extraTags []string) ( godigest.Digest, godigest.Digest, error, ) { So(extraTags, ShouldResemble, []string{"meta-a", "meta-b"}) So(string(body), ShouldEqual, string(mcontent)) return manifestDigest, godigest.Digest(""), nil }, GetBlobContentFn: func(repo string, digest godigest.Digest) ([]byte, error) { if digest == configDigest { return configBlob, nil } return nil, zerr.ErrBlobNotFound }, } ctlr.StoreController.DefaultStore = ism runDigestMultiTag := func(metaDB mTypes.MetaDB) *httptest.ResponseRecorder { ctlr.MetaDB = metaDB reqURL := baseURL + "?tag=meta-a&tag=meta-b" request, reqErr := http.NewRequestWithContext(context.Background(), http.MethodPut, reqURL, bytes.NewBuffer(mcontent)) So(reqErr, ShouldBeNil) request = mux.SetURLVars(request, map[string]string{ "name": "test", "reference": digestRef, }) request.Header.Add("Content-Type", ispec.MediaTypeImageManifest) response := httptest.NewRecorder() rthdlr.UpdateManifest(response, request) return response } Convey("SetRepoReference succeeds", func() { rec := runDigestMultiTag(mocks.MetaDBMock{ SetRepoReferenceFn: func(ctx context.Context, repo, reference string, imageMeta mTypes.ImageMeta) error { return nil }, }) So(rec.Code, ShouldEqual, http.StatusCreated) So(rec.Header().Values(constants.OCITagResponseKey), ShouldResemble, []string{"meta-a", "meta-b"}) So(rec.Header().Get(constants.DistContentDigestKey), ShouldEqual, manifestDigest.String()) }) Convey("SetRepoReference fails for a later tag returns 500", func() { var calls int rec := runDigestMultiTag(mocks.MetaDBMock{ SetRepoReferenceFn: func(ctx context.Context, repo, reference string, imageMeta mTypes.ImageMeta) error { calls++ if reference == "meta-b" { return ErrUnexpectedError } return nil }, }) So(calls, ShouldEqual, 2) So(rec.Code, ShouldEqual, http.StatusInternalServerError) }) }) Convey("DeleteManifest", func() { testDeleteManifest := func(headers map[string]string, urlVars map[string]string, ism *mocks.MockedImageStore) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.Background(), http.MethodDelete, baseURL, nil) request = mux.SetURLVars(request, urlVars) for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.DeleteManifest(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // ErrRepoNotFound statusCode := testDeleteManifest( map[string]string{}, map[string]string{ "name": "ErrManifestNotFound", "reference": "reference", }, &mocks.MockedImageStore{ DeleteImageManifestFn: func(repo, reference string, detectCollision bool) error { return zerr.ErrRepoNotFound }, }, ) So(statusCode, ShouldEqual, http.StatusBadRequest) // ErrManifestNotFound statusCode = testDeleteManifest( map[string]string{}, map[string]string{ "name": "ErrManifestNotFound", "reference": "reference", }, &mocks.MockedImageStore{ DeleteImageManifestFn: func(repo, reference string, detectCollision bool) error { return zerr.ErrManifestNotFound }, }, ) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrUnexpectedError statusCode = testDeleteManifest( map[string]string{}, map[string]string{ "name": "ErrUnexpectedError", "reference": "reference", }, &mocks.MockedImageStore{ DeleteImageManifestFn: func(repo, reference string, detectCollision bool) error { return ErrUnexpectedError }, }, ) So(statusCode, ShouldEqual, http.StatusInternalServerError) // ErrBadManifest statusCode = testDeleteManifest( map[string]string{}, map[string]string{ "name": "ErrBadManifest", "reference": "reference", }, &mocks.MockedImageStore{ DeleteImageManifestFn: func(repo, reference string, detectCollision bool) error { return zerr.ErrBadManifest }, }, ) So(statusCode, ShouldEqual, http.StatusBadRequest) }) Convey("DeleteBlob", func() { testDeleteBlob := func(urlVars map[string]string, ism *mocks.MockedImageStore) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodDelete, baseURL, nil) request = mux.SetURLVars(request, urlVars) response := httptest.NewRecorder() rthdlr.DeleteBlob(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // ErrUnexpectedError statusCode := testDeleteBlob( map[string]string{ "name": "ErrUnexpectedError", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }, &mocks.MockedImageStore{ DeleteBlobFn: func(repo string, digest godigest.Digest) error { return ErrUnexpectedError }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) statusCode = testDeleteBlob( map[string]string{ "name": "ErrBadBlobDigest", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }, &mocks.MockedImageStore{ DeleteBlobFn: func(repo string, digest godigest.Digest) error { return zerr.ErrBadBlobDigest }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) // ErrBlobNotFound statusCode = testDeleteBlob( map[string]string{ "name": "ErrBlobNotFound", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }, &mocks.MockedImageStore{ DeleteBlobFn: func(repo string, digest godigest.Digest) error { return zerr.ErrBlobNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrRepoNotFound statusCode = testDeleteBlob( map[string]string{ "name": "ErrRepoNotFound", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }, &mocks.MockedImageStore{ DeleteBlobFn: func(repo string, digest godigest.Digest) error { return zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) }) // Check Blob Convey("CheckBlob", func() { testCheckBlob := func(urlVars map[string]string, ism *mocks.MockedImageStore) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodHead, baseURL, nil) request = mux.SetURLVars(request, urlVars) response := httptest.NewRecorder() rthdlr.CheckBlob(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // ErrBadBlobDigest statusCode := testCheckBlob( map[string]string{ "name": "ErrBadBlobDigest", "digest": "1234", }, &mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, zerr.ErrBadBlobDigest }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) // ErrRepoNotFound statusCode = testCheckBlob( map[string]string{ "name": "ErrRepoNotFound", "digest": "1234", }, &mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrBlobNotFound statusCode = testCheckBlob( map[string]string{ "name": "ErrBlobNotFound", "digest": "1234", }, &mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, zerr.ErrBlobNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrUnexpectedError statusCode = testCheckBlob( map[string]string{ "name": "ErrUnexpectedError", "digest": "1234", }, &mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, ErrUnexpectedError }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) // Error Check Blob is not ok statusCode = testCheckBlob( map[string]string{ "name": "Check Blob Not Ok", "digest": "1234", }, &mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return false, 0, nil }, }) So(statusCode, ShouldEqual, http.StatusNotFound) }) Convey("GetBlob", func() { testGetBlob := func(urlVars map[string]string, ism *mocks.MockedImageStore) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, urlVars) response := httptest.NewRecorder() rthdlr.GetBlob(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // ErrRepoNotFound statusCode := testGetBlob( map[string]string{ "name": "ErrRepoNotFound", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }, &mocks.MockedImageStore{ GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) { return io.NopCloser(bytes.NewBufferString("")), 0, zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrRepoNotFound statusCode = testGetBlob( map[string]string{ "name": "ErrRepoNotFound", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }, &mocks.MockedImageStore{ GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) { return io.NopCloser(bytes.NewBufferString("")), 0, zerr.ErrBadBlobDigest }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) }) Convey("CreateBlobUpload", func() { testCreateBlobUpload := func( query []struct{ k, v string }, headers map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPost, baseURL, nil) request = mux.SetURLVars(request, map[string]string{ "name": "test", "mount": "1234", }) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.CreateBlobUpload(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // ErrRepoNotFound statusCode := testCreateBlobUpload( []struct{ k, v string }{ {"mount", "1234"}, }, map[string]string{}, &mocks.MockedImageStore{ NewBlobUploadFn: func(repo string) (string, error) { return "", zerr.ErrRepoNotFound }, CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // a full blob upload if multiple digests are present statusCode = testCreateBlobUpload( []struct{ k, v string }{ {"digest", "1234"}, {"digest", "5234"}, }, map[string]string{}, &mocks.MockedImageStore{ NewBlobUploadFn: func(repo string) (string, error) { return "", zerr.ErrRepoNotFound }, CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) // a full blob upload if content type is wrong statusCode = testCreateBlobUpload( []struct{ k, v string }{ {"digest", "1234"}, }, map[string]string{ "Content-Type": "badContentType", }, &mocks.MockedImageStore{ NewBlobUploadFn: func(repo string) (string, error) { return "", zerr.ErrRepoNotFound }, CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 0, zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusUnsupportedMediaType) // digest prezent imgStore err statusCode = testCreateBlobUpload( []struct{ k, v string }{ {"digest", "1234"}, }, map[string]string{ "Content-Type": constants.BinaryMediaType, "Content-Length": "100", }, &mocks.MockedImageStore{ FullBlobUploadFn: func(repo string, body io.Reader, digest godigest.Digest) (string, int64, error) { return sessionStr, 0, zerr.ErrBadBlobDigest }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) // digest prezent bad length statusCode = testCreateBlobUpload( []struct{ k, v string }{ {"digest", "1234"}, }, map[string]string{ "Content-Type": constants.BinaryMediaType, "Content-Length": "100", }, &mocks.MockedImageStore{ FullBlobUploadFn: func(repo string, body io.Reader, digest godigest.Digest) (string, int64, error) { return sessionStr, 20, nil }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) // newBlobUpload not found statusCode = testCreateBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Type": constants.BinaryMediaType, "Content-Length": "100", }, &mocks.MockedImageStore{ NewBlobUploadFn: func(repo string) (string, error) { return "", zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // newBlobUpload unexpected error statusCode = testCreateBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Type": constants.BinaryMediaType, "Content-Length": "100", }, &mocks.MockedImageStore{ NewBlobUploadFn: func(repo string) (string, error) { return "", ErrUnexpectedError }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("GetBlobUpload", func() { testGetBlobUpload := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.GetBlobUpload(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // ErrBadBlobDigest statusCode := testGetBlobUpload( []struct{ k, v string }{ {"mount", "1234"}, }, map[string]string{}, map[string]string{ "name": "test", "session_id": "1234", }, &mocks.MockedImageStore{ GetBlobUploadFn: func(repo, uuid string) (int64, error) { return 0, zerr.ErrBadBlobDigest }, }) So(statusCode, ShouldEqual, http.StatusBadRequest) // ErrRepoNotFound statusCode = testGetBlobUpload( []struct{ k, v string }{ {"mount", "1234"}, }, map[string]string{}, map[string]string{ "name": "test", "session_id": "1234", }, &mocks.MockedImageStore{ GetBlobUploadFn: func(repo, uuid string) (int64, error) { return 0, zerr.ErrRepoNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrUploadNotFound statusCode = testGetBlobUpload( []struct{ k, v string }{ {"mount", "1234"}, }, map[string]string{}, map[string]string{ "name": "test", "session_id": "1234", }, &mocks.MockedImageStore{ GetBlobUploadFn: func(repo, uuid string) (int64, error) { return 0, zerr.ErrUploadNotFound }, }) So(statusCode, ShouldEqual, http.StatusNotFound) // ErrUploadNotFound statusCode = testGetBlobUpload( []struct{ k, v string }{ {"mount", "1234"}, }, map[string]string{}, map[string]string{ "name": "test", "session_id": "1234", }, &mocks.MockedImageStore{ GetBlobUploadFn: func(repo, uuid string) (int64, error) { return 0, ErrUnexpectedError }, }) So(statusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("PatchBlobUpload", func() { testPatchBlobUpload := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.PatchBlobUpload(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } status := testPatchBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Length": "abc", "Content-Range": "abc", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{}, ) So(status, ShouldEqual, http.StatusBadRequest) status = testPatchBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Length": "100", "Content-Range": "1-50", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{}, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) status = testPatchBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 100, zerr.ErrRepoNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testPatchBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 100, zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testPatchBlobUpload( []struct{ k, v string }{}, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 100, ErrUnexpectedError }, DeleteBlobUploadFn: func(repo, uuid string) error { return ErrUnexpectedError }, }, ) So(status, ShouldEqual, http.StatusInternalServerError) }) Convey("UpdateBlobUpload", func() { testUpdateBlobUpload := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.UpdateBlobUpload(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } status := testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "", "Content-Range": "", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{}, ) So(status, ShouldEqual, http.StatusBadRequest) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "100", "Content-Range": "badRange", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{}, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) // Malformed Content-Range (no hyphen): must return 416, not panic. status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "100", "Content-Range": "100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{}, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 0, zerr.ErrBadUploadRange }, }, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 0, zerr.ErrRepoNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 0, zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "100", "Content-Range": "1-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ PutBlobChunkFn: func(repo, uuid string, from, to int64, body io.Reader) (int64, error) { return 0, ErrUnexpectedError }, DeleteBlobUploadFn: func(repo, uuid string) error { return ErrUnexpectedError }, }, ) So(status, ShouldEqual, http.StatusInternalServerError) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrBadBlobDigest }, }, ) So(status, ShouldEqual, http.StatusBadRequest) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrBadUploadRange }, }, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrRepoNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return ErrUnexpectedError }, DeleteBlobUploadFn: func(repo, uuid string) error { return ErrUnexpectedError }, }, ) So(status, ShouldEqual, http.StatusInternalServerError) }) Convey("DeleteBlobUpload", func() { testDeleteBlobUpload := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.DeleteBlobUpload(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } status := testDeleteBlobUpload( []struct{ k, v string }{}, map[string]string{}, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ DeleteBlobUploadFn: func(repo, uuid string) error { return zerr.ErrRepoNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testDeleteBlobUpload( []struct{ k, v string }{}, map[string]string{}, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ DeleteBlobUploadFn: func(repo, uuid string) error { return zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusNotFound) status = testDeleteBlobUpload( []struct{ k, v string }{}, map[string]string{}, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ DeleteBlobUploadFn: func(repo, uuid string) error { return ErrUnexpectedError }, }, ) So(status, ShouldEqual, http.StatusInternalServerError) }) Convey("ListRepositories", func() { testListRepositoriesWithSubstores := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism ctlr.StoreController.SubStore = map[string]storageTypes.ImageStore{ "test": &mocks.MockedImageStore{ GetRepositoriesFn: func() ([]string, error) { return []string{}, ErrUnexpectedError }, }, } request, _ := http.NewRequestWithContext(context.TODO(), http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.ListRepositories(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } testListRepositories := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism ctlr.StoreController.SubStore = map[string]storageTypes.ImageStore{} request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.ListRepositories(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } // with substores status := testListRepositoriesWithSubstores( []struct{ k, v string }{}, map[string]string{}, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ GetNextRepositoriesFn: func(lastRepo string, maxEntries int, fn storageTypes.FilterRepoFunc, ) ([]string, bool, error) { return []string{}, false, ErrUnexpectedError }, }, ) So(status, ShouldEqual, http.StatusInternalServerError) status = testListRepositories( []struct{ k, v string }{}, map[string]string{}, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ GetNextRepositoriesFn: func(lastRepo string, maxEntries int, fn storageTypes.FilterRepoFunc, ) ([]string, bool, error) { return []string{}, false, ErrUnexpectedError }, }, ) So(status, ShouldEqual, http.StatusInternalServerError) }) Convey("ListRepositories with Authz", func() { ctlr.StoreController.DefaultStore = &mocks.MockedImageStore{ GetNextRepositoriesFn: func(lastRepo string, maxEntries int, fn storageTypes.FilterRepoFunc, ) ([]string, bool, error) { return []string{"repo"}, false, nil }, } ctlr.StoreController.SubStore = map[string]storageTypes.ImageStore{ "test1": &mocks.MockedImageStore{ GetNextRepositoriesFn: func(lastRepo string, maxEntries int, fn storageTypes.FilterRepoFunc, ) ([]string, bool, error) { return []string{"repo1"}, false, nil }, }, "test2": &mocks.MockedImageStore{ GetNextRepositoriesFn: func(lastRepo string, maxEntries int, fn storageTypes.FilterRepoFunc, ) ([]string, bool, error) { return []string{"repo2"}, false, nil }, }, } // make the user an admin // acCtx := api.NewAccessControlContext(map[string]bool{}, true) // ctx := context.WithValue(context.Background(), "ctx", acCtx) ctx := context.Background() request, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) request = mux.SetURLVars(request, map[string]string{ "name": "repo", "session_id": "test", }) response := httptest.NewRecorder() rthdlr.ListRepositories(response, request) resp := response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusOK) }) Convey("Test API keys", func() { Convey("CreateAPIKey invalid access control context", func() { var invalid struct{} uacKey := reqCtx.GetContextKey() ctx := context.WithValue(context.Background(), uacKey, invalid) request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) response := httptest.NewRecorder() rthdlr.CreateAPIKey(response, request) resp := response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) request, _ = http.NewRequestWithContext(ctx, http.MethodGet, baseURL, nil) response = httptest.NewRecorder() rthdlr.GetAPIKeys(response, request) resp = response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("CreateAPIKey body exceeds MaxAPIKeyBodySize returns 413", func() { userAc := reqCtx.NewUserAccessControl() userAc.SetUsername("test") ctx := userAc.DeriveContext(context.Background()) oversized := make([]byte, constants.MaxAPIKeyBodySize+1) request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(oversized)) response := httptest.NewRecorder() rthdlr.CreateAPIKey(response, request) resp := response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusRequestEntityTooLarge) }) Convey("CreateAPIKey bad request body", func() { userAc := reqCtx.NewUserAccessControl() userAc.SetUsername("test") ctx := userAc.DeriveContext(context.Background()) request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader([]byte{})) response := httptest.NewRecorder() rthdlr.CreateAPIKey(response, request) resp := response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusBadRequest) }) Convey("CreateAPIKey error on AddUserAPIKey", func() { userAc := reqCtx.NewUserAccessControl() userAc.SetUsername("test") ctx := userAc.DeriveContext(context.Background()) payload := api.APIKeyPayload{ Label: "test", Scopes: []string{"test"}, } reqBody, err := json.Marshal(payload) So(err, ShouldBeNil) request, _ := http.NewRequestWithContext(ctx, http.MethodPost, baseURL, bytes.NewReader(reqBody)) response := httptest.NewRecorder() ctlr.MetaDB = mocks.MetaDBMock{ AddUserAPIKeyFn: func(ctx context.Context, hashedKey string, apiKeyDetails *mTypes.APIKeyDetails) error { return ErrUnexpectedError }, } rthdlr.CreateAPIKey(response, request) resp := response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) }) Convey("Revoke error on DeleteUserAPIKeyFn", func() { userAc := reqCtx.NewUserAccessControl() userAc.SetUsername("test") ctx := userAc.DeriveContext(context.Background()) request, _ := http.NewRequestWithContext(ctx, http.MethodDelete, baseURL, bytes.NewReader([]byte{})) response := httptest.NewRecorder() q := request.URL.Query() q.Add("id", "apikeyid") request.URL.RawQuery = q.Encode() ctlr.MetaDB = mocks.MetaDBMock{ DeleteUserAPIKeyFn: func(ctx context.Context, id string) error { return ErrUnexpectedError }, } rthdlr.RevokeAPIKey(response, request) resp := response.Result() defer resp.Body.Close() So(resp.StatusCode, ShouldEqual, http.StatusInternalServerError) }) }) Convey("Helper functions", func() { testUpdateBlobUpload := func( query []struct{ k, v string }, headers map[string]string, vars map[string]string, ism *mocks.MockedImageStore, ) int { ctlr.StoreController.DefaultStore = ism request, _ := http.NewRequestWithContext(context.TODO(), http.MethodPatch, baseURL, nil) request = mux.SetURLVars(request, vars) q := request.URL.Query() for _, qe := range query { q.Add(qe.k, qe.v) } request.URL.RawQuery = q.Encode() for k, v := range headers { request.Header.Add(k, v) } response := httptest.NewRecorder() rthdlr.UpdateBlobUpload(response, request) resp := response.Result() defer resp.Body.Close() return resp.StatusCode } status := testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "a-100", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "20-a", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) status = testUpdateBlobUpload( []struct{ k, v string }{ {"digest", "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621"}, }, map[string]string{ "Content-Length": "0", "Content-Range": "20-1", }, map[string]string{ "name": "repo", "session_id": "test", }, &mocks.MockedImageStore{ FinishBlobUploadFn: func(repo, uuid string, body io.Reader, digest godigest.Digest) error { return zerr.ErrUploadNotFound }, }, ) So(status, ShouldEqual, http.StatusRequestedRangeNotSatisfiable) }) }) } type readerThatFails struct{} func (r readerThatFails) Read(p []byte) (int, error) { return 0, zerr.ErrInjected } func TestWriteDataFromReader(t *testing.T) { Convey("", t, func() { response := httptest.NewRecorder() api.WriteDataFromReader(response, 200, 100, ispec.MediaTypeImageManifest, readerThatFails{}, log.NewTestLogger()) So(response.Code, ShouldEqual, 200) }) } // Descriptor-aware Content-Type tests for blob HEAD/GET. // // The blob endpoints derive the response Content-Type from the OCI // descriptor associated with the blob (via the repo's index/manifest // chain), and fall back to application/octet-stream when no such // descriptor is available. These tests use mock image stores to drive // both branches independently of the on-disk storage layer. // descriptorTestDigests returns deterministic layer, manifest, and // config digests (in that order) used by the descriptor-aware // Content-Type tests. func descriptorTestDigests() (godigest.Digest, godigest.Digest, godigest.Digest) { return godigest.FromString("layer"), godigest.FromString("manifest"), godigest.FromString("config") } // newBlobTestRouteHandler returns a fresh RouteHandler whose default // store is the supplied mock. It does not start a server; handlers are // invoked directly via httptest. The Router is initialized manually // because NewRouteHandler->SetupRoutes dereferences it but the server // (which would normally do that) is never started here. func newBlobTestRouteHandler(t *testing.T, store mocks.MockedImageStore) *api.RouteHandler { t.Helper() ctlr := api.NewController(config.New()) ctlr.Router = mux.NewRouter() ctlr.StoreController.DefaultStore = store return api.NewRouteHandler(ctlr) } // descriptorFixture builds a minimal index -> manifest -> layer chain // that resolves the layer digest from descriptorTestDigests to // MediaTypeImageLayerGzip. func descriptorFixture(t *testing.T) ([]byte, []byte) { t.Helper() layerDigest, manifestDigest, configDigest := descriptorTestDigests() manifest := ispec.Manifest{ Config: ispec.Descriptor{ MediaType: ispec.MediaTypeImageConfig, Digest: configDigest, Size: 1, }, Layers: []ispec.Descriptor{ { MediaType: ispec.MediaTypeImageLayerGzip, Digest: layerDigest, Size: 4, }, }, } manifest.SchemaVersion = 2 manifestJSON, err := json.Marshal(manifest) require.NoError(t, err) index := ispec.Index{ Manifests: []ispec.Descriptor{ { MediaType: ispec.MediaTypeImageManifest, Digest: manifestDigest, Size: int64(len(manifestJSON)), Annotations: map[string]string{ ispec.AnnotationRefName: "latest", }, }, }, } index.SchemaVersion = 2 indexJSON, err := json.Marshal(index) require.NoError(t, err) return indexJSON, manifestJSON } // descriptorStore returns a mock store backed by descriptorFixture. // Looking up the layer digest from descriptorTestDigests resolves to a // layer with media type MediaTypeImageLayerGzip via the index walk; // other digests fall through to the binary fallback. func descriptorStore(t *testing.T) mocks.MockedImageStore { t.Helper() indexJSON, manifestJSON := descriptorFixture(t) layerDigest, manifestDigest, _ := descriptorTestDigests() return mocks.MockedImageStore{ RootDirFn: func() string { return t.TempDir() }, CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { if digest == layerDigest { return true, 4, nil } return true, 0, nil }, GetIndexContentFn: func(repo string) ([]byte, error) { return indexJSON, nil }, GetBlobContentFn: func(repo string, digest godigest.Digest) ([]byte, error) { require.Equal(t, manifestDigest, digest, "unexpected blob content lookup") return manifestJSON, nil }, } } func TestCheckBlobUsesDescriptorContentType(t *testing.T) { store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 42, nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodHead, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Accept", "application/vnd.oci.image.layer.v1.tar+gzip, */*") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.CheckBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, ispec.MediaTypeImageLayerGzip, resp.Header.Get("Content-Type")) assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges")) assert.Equal(t, layerDigest.String(), resp.Header.Get(constants.DistContentDigestKey)) } func TestCheckBlobFallsBackToBinaryContentType(t *testing.T) { // No index/manifest at all: descriptor lookup fails and the handler // must fall back to application/octet-stream so OCI clients get a // well-formed Content-Type. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 1024, nil }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodHead, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Accept", "*/*") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.CheckBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, constants.BinaryMediaType, resp.Header.Get("Content-Type")) } func TestGetBlobUsesDescriptorContentType(t *testing.T) { store := descriptorStore(t) store.GetBlobFn = func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) { // The mediaType argument forwarded to the storage layer is a // hint and is currently ignored; we still feed it the resolved // value so the surface stays consistent. assert.Equal(t, ispec.MediaTypeImageLayerGzip, mediaType) return io.NopCloser(strings.NewReader("blob")), 4, nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) // Wildcard / mixed Accept must not leak into the response. req.Header.Set("Accept", "application/vnd.oci.image.layer.v1.tar+gzip, */*") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, ispec.MediaTypeImageLayerGzip, resp.Header.Get("Content-Type")) } func TestGetBlobFallsBackOnInvalidDescriptorContentType(t *testing.T) { // Descriptor media types are user-supplied and may be invalid as HTTP // header values. resolveBlobResponseMediaType must sanitize/validate // and fall back to application/octet-stream on parse failure. store := descriptorStore(t) store.GetBlobFn = func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) { assert.Equal(t, constants.BinaryMediaType, mediaType) return io.NopCloser(strings.NewReader("blob")), 4, nil } // Force descriptor lookup success but with an invalid media type string. store.GetBlobContentFn = func(repo string, digest godigest.Digest) ([]byte, error) { _, manifestJSON := descriptorFixture(t) var manifest ispec.Manifest require.NoError(t, json.Unmarshal(manifestJSON, &manifest)) require.Len(t, manifest.Layers, 1) manifest.Layers[0].MediaType = "bad\r\nvalue" out, err := json.Marshal(manifest) require.NoError(t, err) return out, nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, constants.BinaryMediaType, resp.Header.Get("Content-Type")) } func TestGetBlobFallsBackToBinaryContentType(t *testing.T) { // Repository has no index/manifest: full GET must respond with // application/octet-stream rather than echoing Accept. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ GetBlobFn: func(repo string, digest godigest.Digest, mediaType string) (io.ReadCloser, int64, error) { assert.Equal(t, constants.BinaryMediaType, mediaType) return io.NopCloser(strings.NewReader("blob")), 4, nil }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) // Comma-separated Accept must not produce a malformed Content-Type. req.Header.Set("Accept", "typeA, typeB") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, constants.BinaryMediaType, resp.Header.Get("Content-Type")) } func TestGetBlobPartialUsesDescriptorContentType(t *testing.T) { store := descriptorStore(t) store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { assert.Equal(t, ispec.MediaTypeImageLayerGzip, mediaType) assert.Equal(t, int64(0), from) assert.Equal(t, int64(1), to) return io.NopCloser(strings.NewReader("bl")), 2, 4, nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) assert.Equal(t, ispec.MediaTypeImageLayerGzip, resp.Header.Get("Content-Type")) assert.Equal(t, "bytes 0-1/4", resp.Header.Get("Content-Range")) assert.Equal(t, layerDigest.String(), resp.Header.Get(constants.DistContentDigestKey)) } func TestGetBlobPartialFallsBackToBinaryContentType(t *testing.T) { // Single-range request for a blob whose repo has no index — same // fallback behaviour as the full-GET case. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 4, nil }, GetBlobPartialFn: func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { assert.Equal(t, constants.BinaryMediaType, mediaType) return io.NopCloser(strings.NewReader("bl")), 2, 4, nil }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req.Header.Set("Accept", "application/vnd.oci.image.layer.v1.tar+gzip, */*") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) assert.Equal(t, constants.BinaryMediaType, resp.Header.Get("Content-Type")) } // TestGetBlobMultipartPartHasDescriptorContentType verifies that each // part of a multipart/byteranges response carries the descriptor- // derived Content-Type alongside the per-part Content-Range. func TestGetBlobMultipartPartHasDescriptorContentType(t *testing.T) { const blobBody = "0123456789" store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, int64(len(blobBody)), nil } store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { assert.Equal(t, ispec.MediaTypeImageLayerGzip, mediaType) return io.NopCloser(strings.NewReader(blobBody[from : to+1])), to - from + 1, int64(len(blobBody)), nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1,5-7") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) contentType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) require.NoError(t, err) require.Equal(t, "multipart/byteranges", contentType) require.NotEmpty(t, params["boundary"]) reader := multipart.NewReader(resp.Body, params["boundary"]) expected := []struct { body string contentRange string }{ {body: "01", contentRange: "bytes 0-1/10"}, {body: "567", contentRange: "bytes 5-7/10"}, } for i, want := range expected { part, err := reader.NextPart() require.NoError(t, err, "read part %d", i) assert.Equal(t, want.contentRange, part.Header.Get("Content-Range"), "part %d content-range", i) assert.Equal(t, ispec.MediaTypeImageLayerGzip, part.Header.Get("Content-Type"), "part %d content-type", i) body, err := io.ReadAll(part) require.NoError(t, err, "read part %d body", i) assert.Equal(t, want.body, string(body), "part %d body", i) } _, err = reader.NextPart() require.ErrorIs(t, err, io.EOF) assert.Equal(t, layerDigest.String(), resp.Header.Get(constants.DistContentDigestKey)) } // Streaming-multipart tests for the lazy-fan-out path. // // The multipart 206 response is written from a producer goroutine that // opens range readers one at a time, with the response Content-Length // precomputed up front. These tests cover: // - Content-Length matches the actual body length on the wire. // - At most one range reader is ever open at any instant (the // fan-out improvement that motivated the rewrite). // - A reader-error mid-stream truncates the body (since the 206 // headers have already been flushed) and is logged. // partialReaderOpenTracker records how many partial-blob readers are open at once and // the peak concurrent count. The multipart test's GetBlobPartial mock calls NewReadCloser // per range; overlapping opens show up as PeakOpens() > 1. type partialReaderOpenTracker struct { live atomic.Int32 peak atomic.Int32 } // NewReadCloser returns a reader that registers in the tracker until Close. func (t *partialReaderOpenTracker) NewReadCloser(body string) io.ReadCloser { t.beginOpen() return &partialReaderReadCloser{ Reader: strings.NewReader(body), tracker: t, } } func (t *partialReaderOpenTracker) LiveOpens() int32 { return t.live.Load() } func (t *partialReaderOpenTracker) PeakOpens() int32 { return t.peak.Load() } func (t *partialReaderOpenTracker) endClose() { t.live.Add(-1) } // beginOpen increments the live-open count and sets peak := max(peak, newLiveCount). // // The for loop retries when CompareAndSwap fails: another goroutine can change peak // after Load but before CompareAndSwap, so one attempt is not enough under contention. func (t *partialReaderOpenTracker) beginOpen() { cur := t.live.Add(1) for { observedPeak := t.peak.Load() if cur <= observedPeak { return } if t.peak.CompareAndSwap(observedPeak, cur) { return } } } // partialReaderReadCloser wraps a strings.Reader and only notifies the tracker on Close. type partialReaderReadCloser struct { *strings.Reader tracker *partialReaderOpenTracker closed bool } func (r *partialReaderReadCloser) Close() error { if r.closed { return nil } r.closed = true r.tracker.endClose() return nil } // drainResponseBody reads until EOF and returns the bytes plus any // non-EOF error that occurred. The httptest recorder's body is fully // buffered so this never blocks. func drainResponseBody(t *testing.T, resp *http.Response) []byte { t.Helper() body, err := io.ReadAll(resp.Body) require.NoError(t, err) return body } func TestGetBlobMultipartContentLengthMatchesBody(t *testing.T) { const blobBody = "0123456789abcdef" // 16 bytes store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, int64(len(blobBody)), nil } store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { return io.NopCloser(strings.NewReader(blobBody[from : to+1])), to - from + 1, int64(len(blobBody)), nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1,5-7,12-15") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) contentType, params, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) require.NoError(t, err) require.Equal(t, "multipart/byteranges", contentType) require.NotEmpty(t, params["boundary"]) advertisedLen, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) require.NoError(t, err, "Content-Length must be a valid integer") require.Positive(t, advertisedLen, "Content-Length must be set on multipart responses") body := drainResponseBody(t, resp) assert.Equal(t, advertisedLen, int64(len(body)), "advertised Content-Length must match the actual body length") // Sanity-check the multipart structure is parseable end-to-end so // the byte count above isn't masking a malformed body. multipartReader := multipart.NewReader(bytes.NewReader(body), params["boundary"]) const wantParts = 3 for i := range wantParts { part, err := multipartReader.NextPart() require.NoError(t, err, "part %d", i) _, err = io.Copy(io.Discard, part) require.NoError(t, err, "part %d body", i) } _, err = multipartReader.NextPart() require.ErrorIs(t, err, io.EOF) } func TestGetBlobMultipartOpensOneReaderAtATime(t *testing.T) { const blobBody = "0123456789abcdef0123456789abcdef" // 32 bytes var opens partialReaderOpenTracker store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, int64(len(blobBody)), nil } store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { // opens tracks live readers; Close decrements. writeMultipartRanges should fully // consume each reader before opening the next. reader := opens.NewReadCloser(blobBody[from : to+1]) return reader, to - from + 1, int64(len(blobBody)), nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) // Four non-coalescing ranges so the producer must open four // distinct readers in sequence. req.Header.Set("Range", "bytes=0-3,8-11,16-19,24-27") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) // Drain the body so the producer goroutine completes and decrements // the open counter on every reader. _ = drainResponseBody(t, resp) assert.Equal(t, int32(0), opens.LiveOpens(), "all readers must be closed by the time the body is drained") assert.Equal(t, int32(1), opens.PeakOpens(), "writeMultipartRanges must open at most one range reader at a time") } func TestGetBlobMultipartTruncatesOnReaderError(t *testing.T) { const blobBody = "0123456789abcdef" // 16 bytes var calls atomic.Int32 store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, int64(len(blobBody)), nil } store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { // First range succeeds, second fails. The 206 status and // Content-Length have already been written by the time the // producer hits the failure, so we expect a truncated body // rather than a 5xx. if calls.Add(1) == 1 { return io.NopCloser(strings.NewReader(blobBody[from : to+1])), to - from + 1, int64(len(blobBody)), nil } return nil, 0, 0, ErrUnexpectedError } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1,5-7") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() // 206 was already in flight when the 2nd-range error fired; the // connection just truncates. require.Equal(t, http.StatusPartialContent, resp.StatusCode) advertisedLen, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) require.NoError(t, err) body := drainResponseBody(t, resp) assert.Less(t, int64(len(body)), advertisedLen, "body must be truncated relative to the advertised Content-Length on mid-stream error") } func TestGetBlobRangeUnsatisfiable(t *testing.T) { // A Range header that lies entirely past the end of the blob must // produce 416 with `Content-Range: bytes */` so clients can // retry with a valid range. parseRangeHeader rejects the header // before the handler reaches GetBlobPartial. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 4, nil }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=999-1000") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusRequestedRangeNotSatisfiable, resp.StatusCode) assert.Equal(t, "bytes */4", resp.Header.Get("Content-Range")) } func TestGetBlobRangeCheckBlobError(t *testing.T) { // CheckBlob returning a non-zerr error must surface as 500 via // writeBlobError's default branch. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return false, 0, ErrUnexpectedError }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusInternalServerError, resp.StatusCode) } func TestGetBlobRangeCheckBlobMissing(t *testing.T) { // CheckBlob succeeding with ok=false (e.g. a deleted blob whose // repo still exists) must short-circuit to 404 BLOB_UNKNOWN before // any range parsing or descriptor lookup. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return false, 0, nil }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) var errList apiErr.ErrorList require.NoError(t, json.NewDecoder(resp.Body).Decode(&errList)) require.Len(t, errList.Errors, 1) assert.Equal(t, apiErr.BLOB_UNKNOWN.String(), errList.Errors[0].Code) } func TestGetBlobSingleRangePartialBlobNotFound(t *testing.T) { // Single-range path: GetBlobPartial returning ErrBlobNotFound after // a successful CheckBlob (a blob deleted between the two calls) // must surface as 404 with the BLOB_UNKNOWN error body. CheckBlob // has already returned ok=true so we get past the length check; // the response is still recoverable because no body bytes have // been written yet. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 4, nil }, GetBlobPartialFn: func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { return nil, 0, 0, zerr.ErrBlobNotFound }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusNotFound, resp.StatusCode) var errList apiErr.ErrorList require.NoError(t, json.NewDecoder(resp.Body).Decode(&errList)) require.Len(t, errList.Errors, 1) assert.Equal(t, apiErr.BLOB_UNKNOWN.String(), errList.Errors[0].Code) } func TestGetBlobSingleRangePartialUnexpectedError(t *testing.T) { // Single-range path: GetBlobPartial returning a non-zerr error // hits writeBlobError's default branch and produces a 500. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 4, nil }, GetBlobPartialFn: func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { return nil, 0, 0, ErrUnexpectedError }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusInternalServerError, resp.StatusCode) assert.Empty(t, resp.Header.Get(constants.DistContentDigestKey), "Docker-Content-Digest must not be set on error responses") } func TestGetBlobSingleRangeLengthMismatch(t *testing.T) { // Single-range path: storage returns a reader claiming a different // length than the request asked for. The handler must reject this // with 500 rather than streaming an under- or over-sized body, // since on the single-range path the headers haven't been flushed // yet and 5xx is still possible. handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return true, 4, nil }, GetBlobPartialFn: func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { // Caller asked for [0,1] (2 bytes); we hand back a reader // claiming 3 bytes. blen != rng.length() so the handler // should bail out with 500. return io.NopCloser(strings.NewReader("xyz")), 3, 4, nil }, GetIndexContentFn: func(repo string) ([]byte, error) { return nil, zerr.ErrManifestNotFound }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusInternalServerError, resp.StatusCode) } func TestGetBlobMultipartShortReaderTruncates(t *testing.T) { // Multipart path: the second range's reader is short — it claims // rng.length() bytes but EOFs after one. io.CopyN inside the // producer goroutine returns ErrUnexpectedEOF, which the handler // surfaces as a truncated body (the 206 is already on the wire). // This exercises the copyErr branch of writeMultipartRanges, // distinct from the openRange-error path covered above. const blobBody = "0123456789abcdef" // 16 bytes var calls atomic.Int32 store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, int64(len(blobBody)), nil } store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { if calls.Add(1) == 1 { return io.NopCloser(strings.NewReader(blobBody[from : to+1])), to - from + 1, int64(len(blobBody)), nil } // Second range: announce the requested length but only deliver // 1 byte. io.CopyN will return ErrUnexpectedEOF. return io.NopCloser(strings.NewReader("x")), to - from + 1, int64(len(blobBody)), nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1,5-7") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) advertisedLen, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) require.NoError(t, err) body := drainResponseBody(t, resp) assert.Less(t, int64(len(body)), advertisedLen, "a short reader on the second range must truncate the body") } func TestGetBlobRangeCheckBlobNamedErrors(t *testing.T) { // CheckBlob is the first storage call on the range branch and the // only place where named storage errors can be turned into proper // 4xx OCI error responses (once the 206 is in flight on the // multipart path it's too late). Each case in the table maps a // zerr.* return to the OCI status code + error code the handler // must produce via writeBlobError. type expect struct { status int code string } cases := map[string]struct { err error expect expect }{ "bad digest": { err: zerr.ErrBadBlobDigest, expect: expect{status: http.StatusBadRequest, code: apiErr.DIGEST_INVALID.String()}, }, "repo not found": { err: zerr.ErrRepoNotFound, expect: expect{status: http.StatusNotFound, code: apiErr.NAME_UNKNOWN.String()}, }, "blob not found": { err: zerr.ErrBlobNotFound, expect: expect{status: http.StatusNotFound, code: apiErr.BLOB_UNKNOWN.String()}, }, } for name, testCase := range cases { t.Run(name, func(t *testing.T) { handler := newBlobTestRouteHandler(t, mocks.MockedImageStore{ CheckBlobFn: func(repo string, digest godigest.Digest) (bool, int64, error) { return false, 0, testCase.err }, }) req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": "sha256:7b8437f04f83f084b7ed68ad8c4a4947e12fc4e1b006b38129bac89114ec3621", }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, testCase.expect.status, resp.StatusCode) var errList apiErr.ErrorList require.NoError(t, json.NewDecoder(resp.Body).Decode(&errList)) require.Len(t, errList.Errors, 1) assert.Equal(t, testCase.expect.code, errList.Errors[0].Code) }) } } // erroringCloseReader wraps an io.Reader and returns a fixed error // from Close(). It exists to exercise the closeErr branch of // writeMultipartRanges' producer goroutine, which the recent deferred- // CloseWithError refactor introduced as a distinct code path. type erroringCloseReader struct { io.Reader err error } func (e *erroringCloseReader) Close() error { return e.err } func TestGetBlobMultipartReaderCloseError(t *testing.T) { // A range reader whose Close() errors after a full read must // still truncate the body — the 206 is on the wire, so we can // only tear the pipe down. This drives the closeErr branch of // writeMultipartRanges; the open/copy paths already succeeded. const blobBody = "0123456789abcdef" // 16 bytes var calls atomic.Int32 store := descriptorStore(t) store.CheckBlobFn = func(repo string, digest godigest.Digest) (bool, int64, error) { return true, int64(len(blobBody)), nil } store.GetBlobPartialFn = func( repo string, digest godigest.Digest, mediaType string, from, to int64, ) (io.ReadCloser, int64, int64, error) { // First range: clean reader; second range: a reader whose // content is fine but Close() errors. body := blobBody[from : to+1] if calls.Add(1) == 1 { return io.NopCloser(strings.NewReader(body)), to - from + 1, int64(len(blobBody)), nil } return &erroringCloseReader{ Reader: strings.NewReader(body), err: ErrUnexpectedError, }, to - from + 1, int64(len(blobBody)), nil } handler := newBlobTestRouteHandler(t, store) layerDigest, _, _ := descriptorTestDigests() req := httptest.NewRequestWithContext( context.Background(), http.MethodGet, "http://example.com/v2/test/blobs/sha256:test", http.NoBody, ) req.Header.Set("Range", "bytes=0-1,5-7") req = mux.SetURLVars(req, map[string]string{ "name": "test", "digest": layerDigest.String(), }) rec := httptest.NewRecorder() handler.GetBlob(rec, req) resp := rec.Result() defer resp.Body.Close() require.Equal(t, http.StatusPartialContent, resp.StatusCode) advertisedLen, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) require.NoError(t, err) body := drainResponseBody(t, resp) assert.Less(t, int64(len(body)), advertisedLen, "a Close() error on the second range must truncate the body") }